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 = { 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(); }); }); }); });