diff --git a/src/shared/storage.test.ts b/src/shared/storage.test.ts new file mode 100644 index 00000000..a232090b --- /dev/null +++ b/src/shared/storage.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { z } from "zod"; +import { storage } from "./storage.js"; + +const schema = z.object({ name: z.string(), age: z.number() }); +type Person = z.infer; + +describe("storage", () => { + let store: Map; + + beforeEach(() => { + store = new Map(); + const mockStorage = { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + removeItem: vi.fn((key: string) => { + store.delete(key); + }), + get length() { + return store.size; + }, + key: vi.fn((index: number) => [...store.keys()][index] ?? null), + clear: vi.fn(() => store.clear()), + } satisfies Storage; + + vi.stubGlobal("localStorage", mockStorage); + }); + + it("round-trips get/set with a valid schema", () => { + const value: Person = { name: "Alice", age: 30 }; + storage.set("user", value, schema); + expect(storage.get("user", schema)).toEqual(value); + }); + + it("returns null for a missing key", () => { + expect(storage.get("nonexistent", schema)).toBeNull(); + }); + + it("returns null when stored value fails schema validation", () => { + // Store invalid data directly + localStorage.setItem("afl_bad", JSON.stringify({ name: 123, age: "wrong" })); + expect(storage.get("bad", schema)).toBeNull(); + }); + + it("returns null when stored value is not valid JSON", () => { + localStorage.setItem("afl_corrupt", "not-json{{{"); + expect(storage.get("corrupt", schema)).toBeNull(); + }); + + it("throws when set value does not match schema", () => { + const invalid = { name: 123, age: "wrong" } as unknown as Person; + expect(() => storage.set("user", invalid, schema)).toThrow(); + }); + + it("deletes a key", () => { + storage.set("user", { name: "Alice", age: 30 }, schema); + storage.delete("user"); + expect(storage.get("user", schema)).toBeNull(); + }); + + it("clear removes only afl_-prefixed keys", () => { + storage.set("user", { name: "Alice", age: 30 }, schema); + localStorage.setItem("other_key", "keep me"); + storage.clear(); + expect(storage.get("user", schema)).toBeNull(); + expect(localStorage.getItem("other_key")).toBe("keep me"); + }); + + it("stores keys with afl_ prefix", () => { + storage.set("user", { name: "Alice", age: 30 }, schema); + expect(localStorage.getItem("afl_user")).not.toBeNull(); + expect(localStorage.getItem("user")).toBeNull(); + }); +}); diff --git a/src/shared/storage.ts b/src/shared/storage.ts new file mode 100644 index 00000000..367c9469 --- /dev/null +++ b/src/shared/storage.ts @@ -0,0 +1,43 @@ +import type { ZodSchema } from "zod"; + +const PREFIX = "afl_"; + +function prefixed(key: string): string { + return `${PREFIX}${key}`; +} + +export const storage = { + get(key: string, schema: ZodSchema): T | null { + try { + const raw = localStorage.getItem(prefixed(key)); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + const result = schema.safeParse(parsed); + return result.success ? result.data : null; + } catch { + return null; + } + }, + + set(key: string, value: T, schema: ZodSchema): void { + schema.parse(value); + localStorage.setItem(prefixed(key), JSON.stringify(value)); + }, + + delete(key: string): void { + localStorage.removeItem(prefixed(key)); + }, + + clear(): void { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(PREFIX)) { + keysToRemove.push(k); + } + } + for (const k of keysToRemove) { + localStorage.removeItem(k); + } + }, +};