diff --git a/src/lib/informal-log/access.test.ts b/src/lib/informal-log/access.test.ts new file mode 100644 index 00000000..ada37589 --- /dev/null +++ b/src/lib/informal-log/access.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + canAccessInformalLogModule, + canConvertInformalLog, + canCreateInformalLog, + canDeleteInformalLog, + canViewInformalLogEntry, +} from "./access"; +import type { InformalLogEntry } from "@/types/informal-log"; + +const entry: InformalLogEntry = { + id: "log-1", + unionId: "union-a", + localId: "local-1", + topic: "Hours of work", + channel: "in_person", + summary: "Spoke with the supervisor before filing.", + occurredAt: "2026-08-01T12:00:00.000Z", + loggedById: "steward-1", + loggedByName: "Alex Steward", + createdAt: "2026-08-01T12:00:00.000Z", +}; + +describe("informal log access", () => { + it("lets stewards and elevated officers into the module, not members", () => { + expect(canAccessInformalLogModule(["local_steward"])).toBe(true); + expect(canAccessInformalLogModule(["local_exec"])).toBe(true); + expect(canAccessInformalLogModule(["local_president"])).toBe(true); + expect(canCreateInformalLog(["local_steward"])).toBe(true); + expect(canAccessInformalLogModule(["local_member"])).toBe(false); + expect(canAccessInformalLogModule([])).toBe(false); + }); + + it("blocks local_exec from converting a log into a grievance", () => { + expect(canConvertInformalLog(["local_steward"])).toBe(true); + expect(canConvertInformalLog(["local_president"])).toBe(true); + expect(canConvertInformalLog(["local_exec"])).toBe(false); + expect(canConvertInformalLog(["local_steward", "local_exec"])).toBe(false); + expect(canConvertInformalLog(["local_member"])).toBe(false); + }); + + it("lets the author or an elevated officer delete a log", () => { + expect(canDeleteInformalLog(entry, "steward-1", ["local_steward"])).toBe( + true, + ); + expect(canDeleteInformalLog(entry, "other", ["local_steward"])).toBe(false); + expect(canDeleteInformalLog(entry, "other", ["local_president"])).toBe(true); + expect(canDeleteInformalLog(entry, "other", ["local_exec"])).toBe(true); + }); + + it("never allows a cross-union read, even for platform_admin", () => { + expect( + canViewInformalLogEntry(entry, "union-b", "local-1", ["platform_admin"]), + ).toBe(false); + expect( + canViewInformalLogEntry(entry, undefined, "local-1", ["local_president"]), + ).toBe(false); + }); + + it("scopes stewards to their local and lets elevated roles read other locals", () => { + expect( + canViewInformalLogEntry(entry, "union-a", "local-1", ["local_steward"]), + ).toBe(true); + expect( + canViewInformalLogEntry(entry, "union-a", "local-2", ["local_steward"]), + ).toBe(false); + expect( + canViewInformalLogEntry(entry, "union-a", "local-2", ["union_admin"]), + ).toBe(true); + expect( + canViewInformalLogEntry(entry, "union-a", "local-1", ["local_member"]), + ).toBe(false); + }); +}); diff --git a/src/lib/officer-learning/api-routes.test.ts b/src/lib/officer-learning/api-routes.test.ts new file mode 100644 index 00000000..fd193851 --- /dev/null +++ b/src/lib/officer-learning/api-routes.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserRole } from "@/types/tenant"; + +const { authMock } = vi.hoisted(() => ({ + authMock: vi.fn(), +})); + +vi.mock("@/auth", () => ({ + auth: authMock, +})); + +import { GET as getMe, PUT as putMe } from "@/app/api/officer-learning/me/route"; +import { + GET as getLocalSettings, + PUT as putLocalSettings, +} from "@/app/api/officer-learning/local-settings/route"; +import { GET as getLocalReport } from "@/app/api/officer-learning/local-report/route"; +import { resetOfficerLearningMemoryForTests } from "./memory-adapter"; +import { memoryOfficerLearningStore } from "./memory-adapter"; +import { resetOfficerLearningStoreSingleton } from "./store"; + +function session(input?: { + id?: string; + unionId?: string; + localId?: string; + name?: string; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-1", + name: input?.name ?? "Alex Steward", + unionId: input?.unionId ?? "union-a", + localId: input?.localId ?? "local-1", + roles: input?.roles ?? (["local_steward"] as UserRole[]), + }, + }; +} + +function jsonRequest(body: unknown): Request { + return { + json: async () => body, + } as Request; +} + +function invalidJsonRequest(): Request { + return { + json: async () => { + throw new SyntaxError("Unexpected token"); + }, + } as Request; +} + +const validMeBody = { + displayName: "Alex Steward", + hubSyncEnabled: true, + shareWithLocal: true, + modules: { + "module-1": { + status: "completed" as const, + scrollDepth: 100, + quizPassed: true, + }, + }, +}; + +describe("officer learning API routes", () => { + beforeEach(() => { + resetOfficerLearningMemoryForTests(); + resetOfficerLearningStoreSingleton(); + authMock.mockReset(); + }); + + describe("GET /api/officer-learning/me", () => { + it("returns 401 when the session is missing tenant identity", async () => { + authMock.mockResolvedValue(null); + const res = await getMe(); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "Unauthorized" }); + + authMock.mockResolvedValue({ + user: { id: "user-1", unionId: "union-a" }, + }); + expect((await getMe()).status).toBe(401); + }); + + it("returns an empty personal record when none is stored", async () => { + authMock.mockResolvedValue(session()); + const res = await getMe(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + record: { userId: string; hubSyncEnabled: boolean; modules: unknown }; + }; + expect(body.record.userId).toBe("user-1"); + expect(body.record.hubSyncEnabled).toBe(false); + expect(body.record.modules).toEqual({}); + }); + }); + + describe("PUT /api/officer-learning/me", () => { + it("rejects unauthenticated, invalid JSON, and schema-invalid bodies", async () => { + authMock.mockResolvedValue(null); + expect((await putMe(jsonRequest(validMeBody))).status).toBe(401); + + authMock.mockResolvedValue(session()); + expect((await putMe(invalidJsonRequest())).status).toBe(400); + + const invalid = await putMe( + jsonRequest({ ...validMeBody, hubSyncEnabled: false }), + ); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toMatchObject({ error: "Validation failed" }); + }); + + it("persists under the session tenant and ignores forged unionId in the body", async () => { + authMock.mockResolvedValue(session()); + const res = await putMe( + jsonRequest({ + ...validMeBody, + unionId: "other-union", + userId: "attacker", + }), + ); + expect(res.status).toBe(400); + + const ok = await putMe(jsonRequest(validMeBody)); + expect(ok.status).toBe(200); + + const stored = await memoryOfficerLearningStore.getUser( + "union-a", + "user-1", + ); + expect(stored?.unionId).toBe("union-a"); + expect(stored?.localId).toBe("local-1"); + expect(stored?.modules["module-1"]?.quizPassed).toBe(true); + expect( + await memoryOfficerLearningStore.getUser("other-union", "user-1"), + ).toBeNull(); + }); + }); + + describe("local settings and report", () => { + it("returns 403 when a steward tries to manage or read the local report", async () => { + authMock.mockResolvedValue(session({ roles: ["local_steward"] })); + expect((await getLocalSettings()).status).toBe(403); + expect((await putLocalSettings(jsonRequest({ reportingEnabled: true }))).status).toBe( + 403, + ); + expect((await getLocalReport()).status).toBe(403); + }); + + it("lets a president enable reporting and only lists their union and local", async () => { + await memoryOfficerLearningStore.upsertUser({ + userId: "other-union-user", + unionId: "union-b", + localId: "local-1", + displayName: "Other union", + hubSyncEnabled: true, + shareWithLocal: true, + modules: { + "module-1": { + status: "completed", + scrollDepth: 100, + quizPassed: true, + }, + }, + }); + await memoryOfficerLearningStore.saveLocalSettings({ + unionId: "union-b", + localId: "local-1", + reportingEnabled: true, + updatedById: "pres-b", + updatedAt: new Date().toISOString(), + }); + + authMock.mockResolvedValue( + session({ id: "pres-1", roles: ["local_president"] }), + ); + + const beforeEnable = await getLocalSettings(); + expect(beforeEnable.status).toBe(200); + expect( + ((await beforeEnable.json()) as { settings: { reportingEnabled: boolean } }) + .settings.reportingEnabled, + ).toBe(false); + + const put = await putLocalSettings(jsonRequest({ reportingEnabled: true })); + expect(put.status).toBe(200); + + await memoryOfficerLearningStore.upsertUser({ + userId: "user-1", + unionId: "union-a", + localId: "local-1", + displayName: "Alex Steward", + hubSyncEnabled: true, + shareWithLocal: true, + modules: { + "module-1": { + status: "completed", + scrollDepth: 100, + quizPassed: true, + }, + }, + }); + + const report = await getLocalReport(); + expect(report.status).toBe(200); + const body = (await report.json()) as { + rows: Array<{ userId: string; displayName: string }>; + }; + expect(body.rows).toHaveLength(1); + expect(body.rows[0].userId).toBe("user-1"); + expect(body.rows.map((row) => row.displayName)).not.toContain("Other union"); + }); + + it("returns 401 for local settings without a session", async () => { + authMock.mockResolvedValue(null); + expect((await getLocalSettings()).status).toBe(401); + expect((await getLocalReport()).status).toBe(401); + }); + }); +}); diff --git a/src/lib/officer-learning/hub-store.test.ts b/src/lib/officer-learning/hub-store.test.ts index 555bd35a..22d0ce23 100644 --- a/src/lib/officer-learning/hub-store.test.ts +++ b/src/lib/officer-learning/hub-store.test.ts @@ -7,7 +7,10 @@ import { saveOfficerLearningLocalSettings, upsertOfficerLearningUser, } from "./hub-store"; -import { canManageOfficerLearningReport } from "./access"; +import { + canManageOfficerLearningReport, + canSyncOfficerLearning, +} from "./access"; import { officerLearningDbBackend } from "@/lib/db/backend"; describe("officer learning hub store", () => { @@ -74,13 +77,71 @@ describe("officer learning hub store", () => { ).toBe(false); expect(await getOfficerLearningUser("union-a", "missing")).toBeNull(); }); + + it("never lists completions from another union, even with a matching localId", async () => { + await saveOfficerLearningLocalSettings({ + unionId: "union-a", + localId: "local-1", + reportingEnabled: true, + updatedById: "pres-1", + updatedAt: new Date().toISOString(), + }); + await upsertOfficerLearningUser({ + userId: "u-b", + unionId: "union-b", + localId: "local-1", + displayName: "Other union", + hubSyncEnabled: true, + shareWithLocal: true, + modules: { + "module-1": { status: "completed", scrollDepth: 100, quizPassed: true }, + }, + }); + + expect(await listSharedCompletionsForLocal("union-a", "local-1")).toEqual( + [], + ); + }); + + it("never lists completions from another local in the same union", async () => { + await saveOfficerLearningLocalSettings({ + unionId: "union-a", + localId: "local-1", + reportingEnabled: true, + updatedById: "pres-1", + updatedAt: new Date().toISOString(), + }); + await upsertOfficerLearningUser({ + userId: "u-other-local", + unionId: "union-a", + localId: "local-2", + displayName: "Other local", + hubSyncEnabled: true, + shareWithLocal: true, + modules: { + "module-1": { status: "completed", scrollDepth: 100, quizPassed: true }, + }, + }); + + expect(await listSharedCompletionsForLocal("union-a", "local-1")).toEqual( + [], + ); + }); }); describe("officer learning access", () => { it("allows presidents and execs to manage reports", () => { expect(canManageOfficerLearningReport(["local_president"])).toBe(true); expect(canManageOfficerLearningReport(["local_exec"])).toBe(true); + expect(canManageOfficerLearningReport(["union_admin"])).toBe(true); expect(canManageOfficerLearningReport(["local_steward"])).toBe(false); + expect(canManageOfficerLearningReport([])).toBe(false); + }); + + it("lets any signed-in role sync personal progress", () => { + expect(canSyncOfficerLearning(["local_steward"])).toBe(true); + expect(canSyncOfficerLearning(["local_member"])).toBe(true); + expect(canSyncOfficerLearning([])).toBe(false); }); }); diff --git a/src/lib/officer-learning/hub-sync-client.test.ts b/src/lib/officer-learning/hub-sync-client.test.ts new file mode 100644 index 00000000..65c78b54 --- /dev/null +++ b/src/lib/officer-learning/hub-sync-client.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + hydrateProgressFromHub, + maybePushHubProgressAfterPass, +} from "./hub-sync-client"; +import { + OFFICER_LEARNING_PROGRESS_KEY, + getAllProgress, + replaceAllProgress, +} from "./progress"; + +function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 500) { + return { + ok, + status, + json: async () => body, + } as Response; +} + +describe("officer learning hub sync client", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + window.localStorage.clear(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + it("does not push after a quiz pass unless Hub sync is already enabled", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + record: { + displayName: "Alex", + hubSyncEnabled: false, + shareWithLocal: false, + modules: {}, + }, + }), + ); + + await maybePushHubProgressAfterPass(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1]?.method).toBeUndefined(); + }); + + it("pushes device progress after a pass when Hub sync is on", async () => { + replaceAllProgress({ + "module-1": { status: "completed", scrollDepth: 100, quizPassed: true }, + }); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + record: { + displayName: "Alex", + hubSyncEnabled: true, + shareWithLocal: true, + modules: {}, + }, + }), + ) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + + await maybePushHubProgressAfterPass(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: "PUT" }); + const body = JSON.parse(fetchMock.mock.calls[1][1].body as string) as { + hubSyncEnabled: boolean; + modules: Record; + }; + expect(body.hubSyncEnabled).toBe(true); + expect(body.modules["module-1"]?.quizPassed).toBe(true); + }); + + it("merges Hub progress onto the device and ignores failed fetches", async () => { + replaceAllProgress({ + "module-1": { status: "in_progress", scrollDepth: 20, quizPassed: false }, + }); + fetchMock.mockResolvedValueOnce( + jsonResponse({ + record: { + displayName: "Alex", + hubSyncEnabled: true, + shareWithLocal: false, + modules: { + "module-1": { + status: "completed", + scrollDepth: 100, + quizPassed: true, + }, + }, + }, + }), + ); + + const { progress, record } = await hydrateProgressFromHub(); + expect(record?.hubSyncEnabled).toBe(true); + expect(progress["module-1"]).toMatchObject({ + status: "completed", + quizPassed: true, + scrollDepth: 100, + }); + expect(getAllProgress()["module-1"]?.quizPassed).toBe(true); + + fetchMock.mockRejectedValueOnce(new Error("offline")); + await expect(maybePushHubProgressAfterPass()).resolves.toBeUndefined(); + expect(window.localStorage.getItem(OFFICER_LEARNING_PROGRESS_KEY)).toBeTruthy(); + }); +}); diff --git a/src/lib/officer-learning/modules.test.ts b/src/lib/officer-learning/modules.test.ts new file mode 100644 index 00000000..159c0a39 --- /dev/null +++ b/src/lib/officer-learning/modules.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + OFFICER_LEARNING_MODULES, + getModuleById, + getModuleBySlug, + getNextModuleSlug, +} from "./modules"; + +describe("officer learning catalog", () => { + it("keeps unique sequential ids and slugs", () => { + const ids = OFFICER_LEARNING_MODULES.map((m) => m.id); + const slugs = OFFICER_LEARNING_MODULES.map((m) => m.slug); + expect(new Set(ids).size).toBe(ids.length); + expect(new Set(slugs).size).toBe(slugs.length); + expect(OFFICER_LEARNING_MODULES.map((m) => m.number)).toEqual([ + 1, 2, 3, 4, 5, 6, + ]); + }); + + it("resolves modules by slug and id", () => { + expect(getModuleBySlug("democratic-governance")?.id).toBe("module-4"); + expect(getModuleById("module-4")?.slug).toBe("democratic-governance"); + expect(getModuleBySlug("missing")).toBeUndefined(); + }); + + it("returns the next module slug for quiz navigation", () => { + expect(getNextModuleSlug("contract-enforcement")).toBe( + "progressive-discipline", + ); + expect(getNextModuleSlug("financial-health")).toBe( + "building-collective-power", + ); + expect(getNextModuleSlug("building-collective-power")).toBeNull(); + expect(getNextModuleSlug("not-a-module")).toBeNull(); + }); +}); diff --git a/src/lib/officer-learning/parse-module.test.ts b/src/lib/officer-learning/parse-module.test.ts index f4050b9e..4710eb21 100644 --- a/src/lib/officer-learning/parse-module.test.ts +++ b/src/lib/officer-learning/parse-module.test.ts @@ -75,6 +75,47 @@ describe("parseOfficerLearningModule", () => { } }); + it("classifies French callout prefixes and quiz explanations", () => { + const markdown = `# Module 4: Gouvernance + +## Objectif général +Équiper le président. + +## Objectifs d'apprentissage +* **Know**: One +* **Feel**: Two +* **Be Able To**: Three + +## Section One +Avertissement: rester calme. + +Exercice: essayer ceci. + +Réflexion: y penser. + +## Quiz d'autoévaluation +### Question 1 +Quelle est la première étape? +* A) Écouter +* B) Parler +* C) Voter +* D) Ajourner +**Correct Answer: A** +*Explication*: commencer par écouter. +`; + const parsed = parseOfficerLearningModule("module-4", markdown); + const callouts = parsed.sections + .flatMap((s) => s.blocks) + .filter((b) => b.type === "callout"); + expect(callouts.map((b) => (b.type === "callout" ? b.variant : null))).toEqual( + ["warning", "practice", "reflection"], + ); + expect(callouts[0]?.type === "callout" && callouts[0].text).toBe( + "rester calme.", + ); + expect(parsed.quiz[0]?.explanation).toContain("commencer par écouter"); + }); + it("parses floor checklist task items as checklist blocks", () => { const markdown = fs.readFileSync( path.join(process.cwd(), "src/content/officer-learning", "module-1.md"), diff --git a/src/lib/officer-learning/progress.test.ts b/src/lib/officer-learning/progress.test.ts new file mode 100644 index 00000000..90f57f18 --- /dev/null +++ b/src/lib/officer-learning/progress.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + OFFICER_LEARNING_PROGRESS_EVENT, + OFFICER_LEARNING_PROGRESS_KEY, + getAllProgress, + getModuleProgress, + markModuleOpened, + markQuizPassed, + replaceAllProgress, + resetAllProgress, + statusLabelKey, + updateScrollDepth, +} from "./progress"; + +describe("officer learning progress", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + it("defaults unread modules to not started", () => { + expect(getModuleProgress("module-1")).toEqual({ + status: "not_started", + scrollDepth: 0, + quizPassed: false, + }); + }); + + it("marks a module in progress on open unless the quiz already passed", () => { + const opened = markModuleOpened("module-1"); + expect(opened.status).toBe("in_progress"); + expect(opened.quizPassed).toBe(false); + expect(opened.lastVisitedAt).toEqual(expect.any(String)); + + markQuizPassed("module-1"); + expect(markModuleOpened("module-1").status).toBe("completed"); + }); + + it("clamps scroll depth and never decreases it", () => { + expect(updateScrollDepth("module-2", 150).scrollDepth).toBe(100); + expect(updateScrollDepth("module-2", -4).scrollDepth).toBe(100); + expect(updateScrollDepth("module-2", 40).scrollDepth).toBe(100); + + resetAllProgress(); + expect(updateScrollDepth("module-2", 33.4).scrollDepth).toBe(33); + expect(updateScrollDepth("module-2", 10).scrollDepth).toBe(33); + }); + + it("keeps completed status after a passing quiz even on later scroll", () => { + markQuizPassed("module-3"); + const next = updateScrollDepth("module-3", 12); + expect(next).toMatchObject({ + status: "completed", + quizPassed: true, + scrollDepth: 100, + }); + }); + + it("treats first positive scroll as in progress", () => { + expect(updateScrollDepth("module-4", 0).status).toBe("not_started"); + expect(updateScrollDepth("module-4", 1).status).toBe("in_progress"); + }); + + it("notifies listeners when a quiz is passed", () => { + const listener = vi.fn(); + window.addEventListener(OFFICER_LEARNING_PROGRESS_EVENT, listener); + markQuizPassed("module-1"); + window.removeEventListener(OFFICER_LEARNING_PROGRESS_EVENT, listener); + expect(listener).toHaveBeenCalledTimes(1); + expect(getAllProgress()["module-1"]?.quizPassed).toBe(true); + }); + + it("survives corrupt JSON and quota failures", () => { + window.localStorage.setItem(OFFICER_LEARNING_PROGRESS_KEY, "{not-json"); + expect(getAllProgress()).toEqual({}); + + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + expect( + replaceAllProgress({ + "module-1": { status: "completed", scrollDepth: 100, quizPassed: true }, + }), + ).toBe(false); + }); + + it("maps status labels for the dashboard", () => { + expect(statusLabelKey("completed")).toBe("progress.completed"); + expect(statusLabelKey("in_progress")).toBe("progress.inProgress"); + expect(statusLabelKey("not_started")).toBe("progress.notStarted"); + }); +}); diff --git a/src/lib/officer-learning/quiz-scroll.test.ts b/src/lib/officer-learning/quiz-scroll.test.ts index e5b0e885..4cbfdb70 100644 --- a/src/lib/officer-learning/quiz-scroll.test.ts +++ b/src/lib/officer-learning/quiz-scroll.test.ts @@ -1,18 +1,78 @@ -import { describe, expect, it, vi, afterEach } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { focusQuizStart, scrollQuizIntoView } from "./quiz-scroll"; describe("scrollQuizIntoView", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + it("no-ops when element is null", () => { expect(() => scrollQuizIntoView(null)).not.toThrow(); }); + + it("uses instant scrolling when the reader prefers reduced motion", () => { + const scrollIntoView = vi.fn(); + const element = { scrollIntoView } as unknown as HTMLElement; + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ matches: true }), + ); + + scrollQuizIntoView(element); + + expect(window.matchMedia).toHaveBeenCalledWith( + "(prefers-reduced-motion: reduce)", + ); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "instant", + block: "start", + }); + }); + + it("uses smooth scrolling when reduced motion is off", () => { + const scrollIntoView = vi.fn(); + const element = { scrollIntoView } as unknown as HTMLElement; + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ matches: false }), + ); + + scrollQuizIntoView(element); + + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + }); + }); }); describe("focusQuizStart", () => { afterEach(() => { + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("no-ops when container is null", () => { expect(() => focusQuizStart(null)).not.toThrow(); }); + + it("focuses the first radio without scrolling the page again", () => { + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + const focus = vi.fn(); + const radio = { focus } as unknown as HTMLInputElement; + const container = { + querySelector: vi.fn().mockReturnValue(radio), + } as unknown as HTMLElement; + + focusQuizStart(container); + + expect(container.querySelector).toHaveBeenCalledWith( + 'input[type="radio"]', + ); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + }); }); diff --git a/src/lib/officer-learning/related-resources.test.ts b/src/lib/officer-learning/related-resources.test.ts index ca3458be..5302425b 100644 --- a/src/lib/officer-learning/related-resources.test.ts +++ b/src/lib/officer-learning/related-resources.test.ts @@ -29,4 +29,10 @@ describe("officer learning related resources", () => { "floor-checklist", ]); }); + + it("maps democratic governance to running meetings and rules of order", () => { + const hrefs = getRelatedResources("democratic-governance").map((r) => r.href); + expect(hrefs).toContain("/guide/running-meetings"); + expect(hrefs).toContain("/tools/rules-of-order"); + }); }); diff --git a/src/lib/proposal-tracker/draft.test.ts b/src/lib/proposal-tracker/draft.test.ts new file mode 100644 index 00000000..820e0964 --- /dev/null +++ b/src/lib/proposal-tracker/draft.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + PROPOSAL_TRACKER_STORAGE_KEY, + createEmptyProposalTrackerDraft, + isProposalTrackerDraft, + loadProposalTrackerDraft, + saveProposalTrackerDraft, +} from "./draft"; +import type { ProposalTrackerDraft } from "./types"; + +const validDraft: ProposalTrackerDraft = { + rows: [ + { + id: "row-1", + article: "12.01", + currentLanguage: "Hours of work.", + unionProposal: "Include flex language.", + employerCounter: "No change.", + status: "open", + notes: "Wage package", + }, + ], +}; + +describe("isProposalTrackerDraft", () => { + it("accepts a complete draft", () => { + expect(isProposalTrackerDraft(validDraft)).toBe(true); + expect(isProposalTrackerDraft(createEmptyProposalTrackerDraft())).toBe( + true, + ); + }); + + it("rejects invalid status, missing fields, and non-objects", () => { + expect( + isProposalTrackerDraft({ + rows: [{ ...validDraft.rows[0], status: "won" }], + }), + ).toBe(false); + expect( + isProposalTrackerDraft({ + rows: [{ ...validDraft.rows[0], notes: 12 }], + }), + ).toBe(false); + expect(isProposalTrackerDraft({ rows: "nope" })).toBe(false); + expect(isProposalTrackerDraft(null)).toBe(false); + }); +}); + +describe("proposal tracker draft persistence", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + it("round-trips a valid draft through localStorage", () => { + expect(saveProposalTrackerDraft(validDraft)).toBe(true); + expect(loadProposalTrackerDraft()).toEqual(validDraft); + expect(window.localStorage.getItem(PROPOSAL_TRACKER_STORAGE_KEY)).toContain( + "12.01", + ); + }); + + it("ignores corrupt or schema-invalid stored JSON", () => { + window.localStorage.setItem(PROPOSAL_TRACKER_STORAGE_KEY, "{not-json"); + expect(loadProposalTrackerDraft()).toBeNull(); + + window.localStorage.setItem( + PROPOSAL_TRACKER_STORAGE_KEY, + JSON.stringify({ rows: [{ ...validDraft.rows[0], status: "won" }] }), + ); + expect(loadProposalTrackerDraft()).toBeNull(); + }); + + it("returns false when localStorage throws (private mode / quota)", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Denied", "SecurityError"); + }); + expect(saveProposalTrackerDraft(validDraft)).toBe(false); + }); +}); diff --git a/src/lib/rules-of-order/actions.test.ts b/src/lib/rules-of-order/actions.test.ts index f5763ff0..f70672cc 100644 --- a/src/lib/rules-of-order/actions.test.ts +++ b/src/lib/rules-of-order/actions.test.ts @@ -28,6 +28,14 @@ describe("rules-of-order actions", () => { } }); + it("maps points and meeting actions to their categories", () => { + expect(getCategoryForAction("mainMotion")).toBe("motions"); + expect(getCategoryForAction("pointOfOrder")).toBe("points"); + expect(getCategoryForAction("pointOfPrivilege")).toBe("points"); + expect(getCategoryForAction("adjourn")).toBe("meeting"); + expect(getCategoryForAction("recess")).toBe("meeting"); + }); + it("has matching EN/FR i18n for every action and detail field", () => { const enActions = en.rulesOfOrder.actions as Record< string, diff --git a/src/lib/steward-guides/storage.test.ts b/src/lib/steward-guides/storage.test.ts new file mode 100644 index 00000000..754932ff --- /dev/null +++ b/src/lib/steward-guides/storage.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearJsonDraft, loadJsonDraft, saveJsonDraft } from "./storage"; + +const KEY = "unionops.test.draft"; + +function isCountDraft(v: unknown): v is { count: number } { + return !!v && typeof v === "object" && typeof (v as { count?: unknown }).count === "number"; +} + +describe("steward-guides JSON draft storage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + it("round-trips a validator-approved draft", () => { + expect(saveJsonDraft(KEY, { count: 3 })).toBe(true); + expect(loadJsonDraft(KEY, isCountDraft)).toEqual({ count: 3 }); + }); + + it("returns null for missing, corrupt, or schema-invalid values", () => { + expect(loadJsonDraft(KEY, isCountDraft)).toBeNull(); + + window.localStorage.setItem(KEY, "{not-json"); + expect(loadJsonDraft(KEY, isCountDraft)).toBeNull(); + + window.localStorage.setItem(KEY, JSON.stringify({ count: "three" })); + expect(loadJsonDraft(KEY, isCountDraft)).toBeNull(); + }); + + it("returns false when localStorage throws (private mode / quota)", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + expect(saveJsonDraft(KEY, { count: 1 })).toBe(false); + + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { + throw new DOMException("Denied", "SecurityError"); + }); + expect(clearJsonDraft(KEY)).toBe(false); + }); + + it("clears a stored draft", () => { + expect(saveJsonDraft(KEY, { count: 1 })).toBe(true); + expect(clearJsonDraft(KEY)).toBe(true); + expect(loadJsonDraft(KEY, isCountDraft)).toBeNull(); + }); +}); diff --git a/src/lib/validation/officer-learning.test.ts b/src/lib/validation/officer-learning.test.ts new file mode 100644 index 00000000..5c32e957 --- /dev/null +++ b/src/lib/validation/officer-learning.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { parseJsonBody } from "@/lib/validation/parse"; +import { + officerLearningLocalSettingsPutSchema, + officerLearningMePutSchema, +} from "@/lib/validation/officer-learning"; + +const validMe = { + displayName: "Alex Steward", + hubSyncEnabled: true, + shareWithLocal: true, + modules: { + "module-1": { + status: "completed", + scrollDepth: 100, + quizPassed: true, + }, + }, +}; + +describe("officerLearningMePutSchema", () => { + it("accepts a valid sync payload", () => { + const parsed = parseJsonBody(officerLearningMePutSchema, validMe); + expect(parsed.ok).toBe(true); + }); + + it("rejects tenant identity keys and unknown fields", () => { + const parsed = parseJsonBody(officerLearningMePutSchema, { + ...validMe, + unionId: "other-union", + userId: "forged", + }); + expect(parsed.ok).toBe(false); + }); + + it("requires hub sync before sharing with the local", () => { + const parsed = parseJsonBody(officerLearningMePutSchema, { + ...validMe, + hubSyncEnabled: false, + shareWithLocal: true, + }); + expect(parsed.ok).toBe(false); + }); + + it("rejects out-of-range scroll depth", () => { + const parsed = parseJsonBody(officerLearningMePutSchema, { + ...validMe, + modules: { + "module-1": { + status: "in_progress", + scrollDepth: 140, + quizPassed: false, + }, + }, + }); + expect(parsed.ok).toBe(false); + }); +}); + +describe("officerLearningLocalSettingsPutSchema", () => { + it("accepts reportingEnabled only", () => { + expect( + parseJsonBody(officerLearningLocalSettingsPutSchema, { + reportingEnabled: true, + }).ok, + ).toBe(true); + }); + + it("rejects extra keys such as unionId", () => { + expect( + parseJsonBody(officerLearningLocalSettingsPutSchema, { + reportingEnabled: true, + unionId: "other-union", + }).ok, + ).toBe(false); + }); +});