303 lines
8.5 KiB
TypeScript
303 lines
8.5 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import { LocalStorageWithSchema } from "../../src/ts/utils/local-storage-with-schema";
|
|
|
|
describe("local-storage-with-schema.ts", () => {
|
|
describe("LocalStorageWithSchema", () => {
|
|
const objectSchema = z.object({
|
|
punctuation: z.boolean(),
|
|
mode: z.enum(["words", "time"]),
|
|
fontSize: z.number(),
|
|
});
|
|
|
|
const defaultObject: z.infer<typeof objectSchema> = {
|
|
punctuation: true,
|
|
mode: "words",
|
|
fontSize: 16,
|
|
};
|
|
|
|
let ls = new LocalStorageWithSchema({
|
|
key: "config",
|
|
schema: objectSchema,
|
|
fallback: defaultObject,
|
|
});
|
|
|
|
const getItemMock = vi.fn();
|
|
const setItemMock = vi.fn();
|
|
const removeItemMock = vi.fn();
|
|
|
|
vi.stubGlobal("localStorage", {
|
|
getItem: getItemMock,
|
|
setItem: setItemMock,
|
|
removeItem: removeItemMock,
|
|
});
|
|
|
|
afterEach(() => {
|
|
getItemMock.mockClear();
|
|
setItemMock.mockClear();
|
|
removeItemMock.mockClear();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
ls = new LocalStorageWithSchema({
|
|
key: "config",
|
|
schema: objectSchema,
|
|
fallback: defaultObject,
|
|
});
|
|
});
|
|
|
|
describe("set", () => {
|
|
it("should save to localStorage if schema is correct and return true", () => {
|
|
const res = ls.set(defaultObject);
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
"config",
|
|
JSON.stringify(defaultObject),
|
|
);
|
|
expect(res).toBe(true);
|
|
});
|
|
|
|
it("should fail to save to localStorage if schema is incorrect and return false", () => {
|
|
const obj = {
|
|
hi: "hello",
|
|
};
|
|
|
|
const res = ls.set(obj as any);
|
|
|
|
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
expect(res).toBe(false);
|
|
});
|
|
|
|
it("should update cache on set", () => {
|
|
ls.set(defaultObject);
|
|
|
|
expect(ls.get()).toStrictEqual(defaultObject);
|
|
|
|
const update = { ...defaultObject, fontSize: 5 };
|
|
ls.set(update);
|
|
getItemMock.mockReset();
|
|
|
|
expect(ls.get()).toStrictEqual(update);
|
|
|
|
expect(getItemMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should get last valid value if schema is incorrect", () => {
|
|
ls.set(defaultObject);
|
|
getItemMock.mockReset();
|
|
|
|
ls.set({ hi: "hello" } as any);
|
|
|
|
expect(ls.get()).toEqual(defaultObject);
|
|
|
|
expect(setItemMock).toHaveBeenCalledOnce();
|
|
expect(getItemMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should not set if value has not changed", () => {
|
|
ls.set(defaultObject);
|
|
setItemMock.mockReset();
|
|
|
|
ls.set(defaultObject);
|
|
|
|
expect(setItemMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("get", () => {
|
|
it("should revert to the fallback value if localstorage is null", () => {
|
|
getItemMock.mockReturnValue(null);
|
|
|
|
const res = ls.get();
|
|
|
|
expect(getItemMock).toHaveBeenCalledWith("config");
|
|
expect(setItemMock).not.toHaveBeenCalled();
|
|
expect(res).toEqual(defaultObject);
|
|
|
|
//cache used
|
|
expect(ls.get()).toEqual(res);
|
|
expect(getItemMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should revert to the fallback value if localstorage json is malformed", () => {
|
|
getItemMock.mockReturnValue("badjson");
|
|
|
|
const res = ls.get();
|
|
|
|
expect(getItemMock).toHaveBeenCalledWith("config");
|
|
expect(setItemMock).toHaveBeenCalledWith(
|
|
"config",
|
|
JSON.stringify(defaultObject),
|
|
);
|
|
expect(res).toEqual(defaultObject);
|
|
|
|
//cache used
|
|
expect(ls.get()).toEqual(defaultObject);
|
|
expect(getItemMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should get from localStorage", () => {
|
|
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
|
|
|
|
const res = ls.get();
|
|
|
|
expect(getItemMock).toHaveBeenCalledWith("config");
|
|
expect(setItemMock).not.toHaveBeenCalled();
|
|
expect(res).toEqual(defaultObject);
|
|
|
|
//cache used
|
|
expect(ls.get()).toEqual(res);
|
|
expect(getItemMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should revert to fallback value if no migrate function and schema failed", () => {
|
|
getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" }));
|
|
const ls = new LocalStorageWithSchema({
|
|
key: "config",
|
|
schema: objectSchema,
|
|
fallback: defaultObject,
|
|
});
|
|
|
|
const res = ls.get();
|
|
|
|
expect(getItemMock).toHaveBeenCalledWith("config");
|
|
expect(setItemMock).toHaveBeenCalledWith(
|
|
"config",
|
|
JSON.stringify(defaultObject),
|
|
);
|
|
expect(res).toEqual(defaultObject);
|
|
|
|
//cache used
|
|
expect(ls.get()).toEqual(defaultObject);
|
|
expect(getItemMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should migrate (when function is provided) if schema failed", () => {
|
|
const existingValue = { hi: "hello" };
|
|
|
|
getItemMock.mockReturnValue(JSON.stringify(existingValue));
|
|
|
|
const migrated = {
|
|
punctuation: false,
|
|
mode: "time",
|
|
fontSize: 1,
|
|
};
|
|
|
|
const migrateFnMock = vi.fn(() => migrated as any);
|
|
|
|
const ls = new LocalStorageWithSchema({
|
|
key: "config",
|
|
schema: objectSchema,
|
|
fallback: defaultObject,
|
|
migrate: migrateFnMock,
|
|
});
|
|
|
|
const res = ls.get();
|
|
|
|
expect(getItemMock).toHaveBeenCalledWith("config");
|
|
expect(migrateFnMock).toHaveBeenCalledWith(
|
|
existingValue,
|
|
expect.any(Array),
|
|
);
|
|
expect(setItemMock).toHaveBeenCalledWith(
|
|
"config",
|
|
JSON.stringify(migrated),
|
|
);
|
|
expect(res).toEqual(migrated);
|
|
|
|
//cache used
|
|
expect(ls.get()).toEqual(migrated);
|
|
expect(getItemMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should return a clone so mutating the result does not affect the cache", () => {
|
|
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
|
|
|
|
const first = ls.get();
|
|
first.fontSize = 999;
|
|
first.mode = "time";
|
|
|
|
const second = ls.get();
|
|
expect(second).toEqual(defaultObject);
|
|
// only one call to getItem — second get() used cache
|
|
expect(getItemMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should return a clone of the fallback so mutating it does not affect the cache", () => {
|
|
getItemMock.mockReturnValue(null);
|
|
|
|
const first = ls.get();
|
|
first.punctuation = false;
|
|
first.fontSize = 0;
|
|
|
|
const second = ls.get();
|
|
expect(second).toEqual(defaultObject);
|
|
expect(getItemMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should return a new object reference on each get() call", () => {
|
|
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
|
|
|
|
const first = ls.get();
|
|
const second = ls.get();
|
|
|
|
expect(first).toEqual(second);
|
|
expect(first).not.toBe(second);
|
|
});
|
|
|
|
it("should not skip set() after caller mutates a previously returned value", () => {
|
|
ls.set(defaultObject);
|
|
setItemMock.mockReset();
|
|
|
|
// get a clone, mutate it, then set it back — should detect the change
|
|
const value = ls.get();
|
|
value.fontSize = 42;
|
|
ls.set(value);
|
|
|
|
expect(setItemMock).toHaveBeenCalledWith(
|
|
"config",
|
|
JSON.stringify({ ...defaultObject, fontSize: 42 }),
|
|
);
|
|
});
|
|
|
|
it("should revert to fallback if migration ran but schema still failed", () => {
|
|
const existingValue = { hi: "hello" };
|
|
|
|
getItemMock.mockReturnValue(JSON.stringify(existingValue));
|
|
|
|
const invalidMigrated = {
|
|
punctuation: 1,
|
|
mode: "time",
|
|
fontSize: 1,
|
|
};
|
|
|
|
const migrateFnMock = vi.fn(() => invalidMigrated as any);
|
|
|
|
const ls = new LocalStorageWithSchema({
|
|
key: "config",
|
|
schema: objectSchema,
|
|
fallback: defaultObject,
|
|
migrate: migrateFnMock,
|
|
});
|
|
|
|
const res = ls.get();
|
|
|
|
expect(getItemMock).toHaveBeenCalledWith("config");
|
|
expect(migrateFnMock).toHaveBeenCalledWith(
|
|
existingValue,
|
|
expect.any(Array),
|
|
);
|
|
expect(setItemMock).toHaveBeenCalledWith(
|
|
"config",
|
|
JSON.stringify(defaultObject),
|
|
);
|
|
expect(res).toEqual(defaultObject);
|
|
|
|
//cache used
|
|
expect(ls.get()).toEqual(defaultObject);
|
|
expect(getItemMock).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
});
|
|
});
|