Files
test/frontend/__tests__/utils/local-storage-with-schema.spec.ts
Benjamin Falch 2bc741fb78
Some checks failed
Mark Stale PRs / stale (push) Has been cancelled
adding monkeytype
2026-04-23 13:53:44 +02:00

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