From 1435550a23168f2f04a9b93e26c13a7191bdd749 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 10:06:15 +0000 Subject: [PATCH] test: lock marketplace and grievance QOL tenancy Those Hub routes only had adapter and access-helper coverage. Route tests now prove union isolation, assignment gates, and session-stamped writes so a forged tenant key or unassigned steward write cannot ship unnoticed. Co-authored-by: Ryan Morris --- src/lib/grievance/api-routes.test.ts | 409 +++++++++++++++++++++++++ src/lib/marketplace/api-routes.test.ts | 374 ++++++++++++++++++++++ src/lib/marketplace/memory-adapter.ts | 85 ++--- 3 files changed, 830 insertions(+), 38 deletions(-) create mode 100644 src/lib/grievance/api-routes.test.ts create mode 100644 src/lib/marketplace/api-routes.test.ts diff --git a/src/lib/grievance/api-routes.test.ts b/src/lib/grievance/api-routes.test.ts new file mode 100644 index 00000000..84a7bb58 --- /dev/null +++ b/src/lib/grievance/api-routes.test.ts @@ -0,0 +1,409 @@ +import { afterEach, 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 listCommunications, + POST as createCommunication, +} from "@/app/api/grievances/[id]/communications/route"; +import { + GET as listMeetings, + POST as createMeeting, +} from "@/app/api/grievances/[id]/meetings/route"; +import { POST as createNote } from "@/app/api/grievances/[id]/notes/route"; +import { + GET as getOutcome, + POST as recordOutcome, +} from "@/app/api/grievances/[id]/outcome/route"; +import { + memoryGrievanceStore, + resetGrievanceMemoryForTests, +} from "./memory-adapter"; +import { resetGrievanceStore } from "./store"; + +function session(input?: { + id?: string; + name?: string; + unionId?: string | null; + localId?: string | null; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-president-243", + name: input?.name ?? "Local 243 President", + unionId: + input?.unionId === null ? undefined : (input?.unionId ?? "union-opseu"), + localId: + input?.localId === null ? undefined : (input?.localId ?? "local-243"), + roles: input?.roles ?? (["local_president"] as UserRole[]), + }, + }; +} + +function jsonRequest(body: unknown): Request { + return { + json: async () => body, + } as Request; +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const stewardSession = () => + session({ + id: "user-steward-243", + name: "Local 243 Steward", + roles: ["local_steward"], + }); + +async function seedForeignGrievance() { + return memoryGrievanceStore.create( + { + category: "Other union", + filedAt: "2026-01-01T00:00:00.000Z", + }, + { + unionId: "union-other", + localId: "local-1", + createdById: "user-x", + assignedStewardId: "user-x", + }, + ); +} + +describe("grievance communications / meetings / notes / outcome API", () => { + beforeEach(() => { + resetGrievanceMemoryForTests(); + resetGrievanceStore(); + authMock.mockReset(); + }); + + afterEach(() => { + resetGrievanceMemoryForTests(); + resetGrievanceStore(); + }); + + describe("communications", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect( + (await listCommunications(new Request("http://localhost"), params("grev-001"))) + .status, + ).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listCommunications( + new Request("http://localhost"), + params("grev-001"), + ); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + }); + + it("lets the assigned steward read grev-001 and forbids the other steward's case", async () => { + authMock.mockResolvedValue(stewardSession()); + const own = await listCommunications( + new Request("http://localhost"), + params("grev-001"), + ); + expect(own.status).toBe(200); + const body = (await own.json()) as { + communications: Array<{ id: string; grievanceId: string }>; + }; + expect(body.communications.some((c) => c.id === "comm-001")).toBe(true); + + const other = await listCommunications( + new Request("http://localhost"), + params("grev-002"), + ); + expect(other.status).toBe(403); + }); + + it("returns 404 for a missing id and 403 for another union, including platform_admin", async () => { + const foreign = await seedForeignGrievance(); + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + + expect( + ( + await listCommunications( + new Request("http://localhost"), + params("grev-missing"), + ) + ).status, + ).toBe(404); + + const crossUnion = await listCommunications( + new Request("http://localhost"), + params(foreign.grievance.id), + ); + expect(crossUnion.status).toBe(403); + expect(await crossUnion.json()).toEqual({ error: "Forbidden" }); + }); + + it("lets a president read the local case and not another local", async () => { + authMock.mockResolvedValue(session()); + expect( + ( + await listCommunications( + new Request("http://localhost"), + params("grev-002"), + ) + ).status, + ).toBe(200); + expect( + ( + await listCommunications( + new Request("http://localhost"), + params("grev-003"), + ) + ).status, + ).toBe(403); + }); + + it("rejects incomplete posts then stamps session author and case tenant, ignoring forged keys", async () => { + authMock.mockResolvedValue(stewardSession()); + const missing = await createCommunication( + jsonRequest({ channel: "email" }), + params("grev-001"), + ); + expect(missing.status).toBe(400); + + const created = await createCommunication( + jsonRequest({ + channel: "email", + direction: "outbound", + summary: "Sent Step 1 update", + occurredAt: "2026-09-01T12:00:00.000Z", + unionId: "union-other", + localId: "local-evil", + loggedById: "attacker", + }), + params("grev-001"), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + communication: { + unionId: string; + localId: string; + loggedById: string; + summary: string; + }; + }; + expect(body.communication.unionId).toBe("union-opseu"); + expect(body.communication.localId).toBe("local-243"); + expect(body.communication.loggedById).toBe("user-steward-243"); + expect(body.communication.summary).toBe("Sent Step 1 update"); + }); + + it("forbids a steward from logging on an unassigned case and local_exec from writing", async () => { + authMock.mockResolvedValue(stewardSession()); + expect( + ( + await createCommunication( + jsonRequest({ + channel: "phone", + direction: "inbound", + summary: "Nope", + occurredAt: "2026-09-01T12:00:00.000Z", + }), + params("grev-002"), + ) + ).status, + ).toBe(403); + + authMock.mockResolvedValue(session({ roles: ["local_exec"] })); + expect( + ( + await createCommunication( + jsonRequest({ + channel: "phone", + direction: "inbound", + summary: "Exec write", + occurredAt: "2026-09-01T12:00:00.000Z", + }), + params("grev-001"), + ) + ).status, + ).toBe(403); + }); + }); + + describe("meetings", () => { + it("lets the assigned steward list meetings and forbids another union", async () => { + authMock.mockResolvedValue(stewardSession()); + const own = await listMeetings( + new Request("http://localhost"), + params("grev-001"), + ); + expect(own.status).toBe(200); + const body = (await own.json()) as { + meetings: Array<{ id: string }>; + }; + expect(body.meetings.some((m) => m.id === "meet-001")).toBe(true); + + const foreign = await seedForeignGrievance(); + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const crossUnion = await listMeetings( + new Request("http://localhost"), + params(foreign.grievance.id), + ); + expect(crossUnion.status).toBe(403); + }); + + it("rejects incomplete posts then returns an ICS stamped to the case, not the body tenant", async () => { + authMock.mockResolvedValue(stewardSession()); + const missing = await createMeeting( + jsonRequest({ title: "Only title" }), + params("grev-001"), + ); + expect(missing.status).toBe(400); + + const created = await createMeeting( + jsonRequest({ + title: "Step 1 follow-up", + startsAt: "2026-09-10T14:00:00.000Z", + endsAt: "2026-09-10T15:00:00.000Z", + location: "HR office", + unionId: "union-other", + createdById: "attacker", + }), + params("grev-001"), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + meeting: { + unionId: string; + localId: string; + createdById: string; + title: string; + }; + ics: string; + }; + expect(body.meeting.unionId).toBe("union-opseu"); + expect(body.meeting.localId).toBe("local-243"); + expect(body.meeting.createdById).toBe("user-steward-243"); + expect(body.ics).toContain("BEGIN:VEVENT"); + expect(body.ics).toContain("SUMMARY:Step 1 follow-up"); + expect(body.ics).toContain("LOCATION:HR office"); + }); + + it("forbids a steward from scheduling on an unassigned case", async () => { + authMock.mockResolvedValue(stewardSession()); + const res = await createMeeting( + jsonRequest({ + title: "Wrong case", + startsAt: "2026-09-10T14:00:00.000Z", + endsAt: "2026-09-10T15:00:00.000Z", + }), + params("grev-002"), + ); + expect(res.status).toBe(403); + }); + }); + + describe("notes", () => { + it("rejects an empty body and stamps the session author while ignoring forged keys", async () => { + authMock.mockResolvedValue(stewardSession()); + const missing = await createNote(jsonRequest({ body: " " }), params("grev-001")); + expect(missing.status).toBe(400); + + const created = await createNote( + jsonRequest({ + body: "Member confirmed the timeline.", + authorId: "attacker", + unionId: "union-other", + }), + params("grev-001"), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + note: { authorId: string; authorName: string; body: string }; + }; + expect(body.note.authorId).toBe("user-steward-243"); + expect(body.note.authorName).toBe("Local 243 Steward"); + expect(body.note.body).toBe("Member confirmed the timeline."); + }); + + it("forbids a steward from noting an unassigned case", async () => { + authMock.mockResolvedValue(stewardSession()); + expect( + (await createNote(jsonRequest({ body: "Nope" }), params("grev-002"))).status, + ).toBe(403); + }); + }); + + describe("outcome", () => { + it("lets an officer read a local outcome and forbids another union", async () => { + authMock.mockResolvedValue(session()); + const empty = await getOutcome( + new Request("http://localhost"), + params("grev-001"), + ); + expect(empty.status).toBe(200); + expect(await empty.json()).toEqual({ outcome: null }); + + const foreign = await seedForeignGrievance(); + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const crossUnion = await getOutcome( + new Request("http://localhost"), + params(foreign.grievance.id), + ); + expect(crossUnion.status).toBe(403); + }); + + it("rejects extra tenant keys then records the session officer, not the body", async () => { + authMock.mockResolvedValue(session()); + const forged = await recordOutcome( + jsonRequest({ + outcomeType: "settled", + decidedAt: "2026-09-01T12:00:00.000Z", + unionId: "union-other", + recordedById: "attacker", + }), + params("grev-001"), + ); + expect(forged.status).toBe(400); + + const created = await recordOutcome( + jsonRequest({ + outcomeType: "settled", + settlementTerms: "Without prejudice", + decidedAt: "2026-09-01T12:00:00.000Z", + }), + params("grev-001"), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + outcome: { + grievanceId: string; + outcomeType: string; + recordedById: string; + }; + }; + expect(body.outcome.grievanceId).toBe("grev-001"); + expect(body.outcome.outcomeType).toBe("settled"); + expect(body.outcome.recordedById).toBe("user-president-243"); + }); + + it("forbids a steward from recording an outcome on an unassigned case", async () => { + authMock.mockResolvedValue(stewardSession()); + const res = await recordOutcome( + jsonRequest({ + outcomeType: "withdrawn", + decidedAt: "2026-09-01T12:00:00.000Z", + }), + params("grev-002"), + ); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/src/lib/marketplace/api-routes.test.ts b/src/lib/marketplace/api-routes.test.ts new file mode 100644 index 00000000..1e15d3ed --- /dev/null +++ b/src/lib/marketplace/api-routes.test.ts @@ -0,0 +1,374 @@ +import { afterEach, 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 listTemplates, + POST as createTemplate, +} from "@/app/api/marketplace/route"; +import { + DELETE as deleteTemplate, + GET as getTemplate, +} from "@/app/api/marketplace/[id]/route"; +import { + marketplaceStore, + resetMarketplaceMemoryForTests, +} from "./memory-adapter"; + +function session(input?: { + id?: string; + name?: string; + unionId?: string | null; + localId?: string | null; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-president-243", + name: input?.name ?? "Local 243 President", + unionId: + input?.unionId === null ? undefined : (input?.unionId ?? "union-opseu"), + localId: + input?.localId === null ? undefined : (input?.localId ?? "local-243"), + roles: input?.roles ?? (["local_president"] as UserRole[]), + }, + }; +} + +function jsonRequest(body: unknown): Request { + return { + json: async () => body, + } as Request; +} + +function listRequest(query = ""): Request { + return new Request(`http://localhost/api/marketplace${query}`); +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const validCreate = { + kind: "email", + title: "Step 2 follow-up", + description: "After the meeting", + body: "Please confirm the Step 2 date.", +}; + +describe("marketplace API routes", () => { + beforeEach(() => { + resetMarketplaceMemoryForTests(); + authMock.mockReset(); + }); + + afterEach(() => { + resetMarketplaceMemoryForTests(); + }); + + describe("GET /api/marketplace", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listTemplates(listRequest())).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listTemplates(listRequest()); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + }); + + it("returns 400 when the session has no union", async () => { + authMock.mockResolvedValue(session({ unionId: null })); + const res = await listTemplates(listRequest()); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Union required" }); + }); + + it("never lists another union and still shares other locals in the same union", async () => { + await marketplaceStore.create( + { + kind: "other", + title: "Other union leak", + description: "must not list", + body: "secret", + }, + { + unionId: "union-other", + localId: "local-243", + sharedById: "user-x", + sharedByName: "Other", + }, + ); + const otherLocal = await marketplaceStore.create( + { + kind: "checklist", + title: "Sister local intake", + description: "same union", + body: "Share within the union.", + }, + { + unionId: "union-opseu", + localId: "local-560", + sharedById: "user-y", + sharedByName: "560 President", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await listTemplates(listRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + templates: Array<{ id: string; unionId: string; localId: string; title: string }>; + }; + expect(body.templates.every((t) => t.unionId === "union-opseu")).toBe(true); + expect(body.templates.map((t) => t.title)).not.toContain("Other union leak"); + expect(body.templates.some((t) => t.id === otherLocal.id)).toBe(true); + }); + + it("filters by kind and q", async () => { + authMock.mockResolvedValue(session()); + const emails = await listTemplates(listRequest("?kind=email")); + expect(emails.status).toBe(200); + const emailBody = (await emails.json()) as { + templates: Array<{ kind: string }>; + }; + expect(emailBody.templates.length).toBeGreaterThan(0); + expect(emailBody.templates.every((t) => t.kind === "email")).toBe(true); + + const search = await listTemplates(listRequest("?q=just%20cause")); + expect(search.status).toBe(200); + const searchBody = (await search.json()) as { + templates: Array<{ title: string }>; + }; + expect(searchBody.templates).toEqual([]); + + const caption = await listTemplates(listRequest("?q=bargaining%20update")); + const captionBody = (await caption.json()) as { + templates: Array<{ id: string }>; + }; + expect(captionBody.templates.map((t) => t.id)).toContain("tmpl-003"); + }); + }); + + describe("POST /api/marketplace", () => { + it("rejects missing fields then stamps session union/local/author even when the body forges tenant keys", async () => { + authMock.mockResolvedValue(session()); + const missing = await createTemplate(jsonRequest({ title: "Nope" })); + expect(missing.status).toBe(400); + expect(await missing.json()).toEqual({ + error: "kind, title, and body are required", + }); + + const forged = await createTemplate( + jsonRequest({ + ...validCreate, + unionId: "union-other", + localId: "local-evil", + sharedById: "attacker", + sharedByName: "Attacker", + }), + ); + expect(forged.status).toBe(201); + const body = (await forged.json()) as { + template: { + unionId: string; + localId: string; + sharedById: string; + sharedByName: string; + title: string; + }; + }; + expect(body.template.unionId).toBe("union-opseu"); + expect(body.template.localId).toBe("local-243"); + expect(body.template.sharedById).toBe("user-president-243"); + expect(body.template.sharedByName).toBe("Local 243 President"); + expect(body.template.title).toBe("Step 2 follow-up"); + }); + + it("lets a steward publish and forbids local_exec and members", async () => { + authMock.mockResolvedValue( + session({ + id: "user-steward-243", + name: "Local 243 Steward", + roles: ["local_steward"], + }), + ); + const created = await createTemplate(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + template: { sharedById: string }; + }; + expect(body.template.sharedById).toBe("user-steward-243"); + + authMock.mockResolvedValue(session({ roles: ["local_exec"] })); + expect((await createTemplate(jsonRequest(validCreate))).status).toBe(403); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + expect((await createTemplate(jsonRequest(validCreate))).status).toBe(403); + }); + + it("returns 400 when union or local is missing", async () => { + authMock.mockResolvedValue(session({ localId: null })); + const res = await createTemplate(jsonRequest(validCreate)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Union and local required" }); + }); + }); + + describe("GET /api/marketplace/[id]", () => { + it("returns 404 for a missing id and 403 for another union, including platform_admin", async () => { + const foreign = await marketplaceStore.create( + { + kind: "other", + title: "Foreign", + description: "", + body: "no", + }, + { + unionId: "union-other", + localId: "local-1", + sharedById: "user-x", + sharedByName: "Other", + }, + ); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const missing = await getTemplate( + new Request("http://localhost"), + params("tmpl-does-not-exist"), + ); + expect(missing.status).toBe(404); + + const crossUnion = await getTemplate( + new Request("http://localhost"), + params(foreign.id), + ); + expect(crossUnion.status).toBe(403); + expect(await crossUnion.json()).toEqual({ error: "Forbidden" }); + }); + + it("lets an officer download a sister-local template in the same union", async () => { + const sister = await marketplaceStore.create( + { + kind: "email", + title: "560 opener", + description: "", + body: "Hello", + }, + { + unionId: "union-opseu", + localId: "local-560", + sharedById: "user-y", + sharedByName: "560", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await getTemplate( + new Request("http://localhost"), + params(sister.id), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + template: { id: string; localId: string }; + }; + expect(body.template.id).toBe(sister.id); + expect(body.template.localId).toBe("local-560"); + }); + }); + + describe("DELETE /api/marketplace/[id]", () => { + it("returns 401 without a session and 403 for members and local_exec", async () => { + authMock.mockResolvedValue(null); + expect( + (await deleteTemplate(new Request("http://localhost"), params("tmpl-001"))) + .status, + ).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + expect( + (await deleteTemplate(new Request("http://localhost"), params("tmpl-001"))) + .status, + ).toBe(403); + + authMock.mockResolvedValue(session({ roles: ["local_exec"] })); + expect( + (await deleteTemplate(new Request("http://localhost"), params("tmpl-001"))) + .status, + ).toBe(403); + }); + + it("lets a steward delete their own template but not another officer's", async () => { + authMock.mockResolvedValue( + session({ + id: "user-steward-243", + name: "Local 243 Steward", + roles: ["local_steward"], + }), + ); + const others = await deleteTemplate( + new Request("http://localhost"), + params("tmpl-001"), + ); + expect(others.status).toBe(403); + + const own = await deleteTemplate( + new Request("http://localhost"), + params("tmpl-003"), + ); + expect(own.status).toBe(200); + expect(await marketplaceStore.getById("tmpl-003")).toBeNull(); + }); + + it("lets a president delete another officer's template", async () => { + authMock.mockResolvedValue(session()); + const res = await deleteTemplate( + new Request("http://localhost"), + params("tmpl-003"), + ); + expect(res.status).toBe(200); + expect(await marketplaceStore.getById("tmpl-003")).toBeNull(); + }); + + it("returns 404 for a missing id and 403 for another union with no write", async () => { + const foreign = await marketplaceStore.create( + { + kind: "other", + title: "Foreign", + description: "", + body: "no", + }, + { + unionId: "union-other", + localId: "local-1", + sharedById: "user-x", + sharedByName: "Other", + }, + ); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + expect( + ( + await deleteTemplate( + new Request("http://localhost"), + params("tmpl-missing"), + ) + ).status, + ).toBe(404); + + const crossUnion = await deleteTemplate( + new Request("http://localhost"), + params(foreign.id), + ); + expect(crossUnion.status).toBe(403); + expect(await marketplaceStore.getById(foreign.id)).not.toBeNull(); + }); + }); +}); diff --git a/src/lib/marketplace/memory-adapter.ts b/src/lib/marketplace/memory-adapter.ts index 6e31c52e..add3a5f8 100644 --- a/src/lib/marketplace/memory-adapter.ts +++ b/src/lib/marketplace/memory-adapter.ts @@ -4,44 +4,48 @@ import type { SharedTemplate, } from "@/types/qol"; -const templates: SharedTemplate[] = [ - { - id: "tmpl-001", - unionId: "union-opseu", - localId: "local-243", - kind: "email", - title: "Step 1 meeting request (EN)", - description: "Reusable email opener for scheduling Step 1", - body: "Subject: Request for Step 1 grievance meeting\n\nDear [Manager],\n\nI am writing to request a Step 1 meeting regarding grievance [ID] filed on [DATE]. Please propose two available times within the next five working days.\n\nIn solidarity,\n[Steward name]", - sharedById: "user-president-243", - sharedByName: "Local 243 President", - createdAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), - }, - { - id: "tmpl-002", - unionId: "union-opseu", - localId: "local-243", - kind: "checklist", - title: "New steward intake checklist", - description: "Handoff checklist for incoming stewards", - body: "1. Review open grievances assigned to you\n2. Confirm MFA access to the hub\n3. Download hybrid encrypted backup\n4. Meet with outgoing officer\n5. Introduce yourself to members on your list", - sharedById: "user-president-243", - sharedByName: "Local 243 President", - createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), - }, - { - id: "tmpl-003", - unionId: "union-opseu", - localId: "local-243", - kind: "caption", - title: "Bargaining update social caption", - description: "Within-union shared social caption", - body: "Bargaining update: Your bargaining team met today. We are fighting for fair wages, job security, and respect at work. Stay tuned - and talk to your steward if you have questions. #Solidarity", - sharedById: "user-steward-243", - sharedByName: "Local 243 Steward", - createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), - }, -]; +function seedTemplates(): SharedTemplate[] { + return [ + { + id: "tmpl-001", + unionId: "union-opseu", + localId: "local-243", + kind: "email", + title: "Step 1 meeting request (EN)", + description: "Reusable email opener for scheduling Step 1", + body: "Subject: Request for Step 1 grievance meeting\n\nDear [Manager],\n\nI am writing to request a Step 1 meeting regarding grievance [ID] filed on [DATE]. Please propose two available times within the next five working days.\n\nIn solidarity,\n[Steward name]", + sharedById: "user-president-243", + sharedByName: "Local 243 President", + createdAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: "tmpl-002", + unionId: "union-opseu", + localId: "local-243", + kind: "checklist", + title: "New steward intake checklist", + description: "Handoff checklist for incoming stewards", + body: "1. Review open grievances assigned to you\n2. Confirm MFA access to the hub\n3. Download hybrid encrypted backup\n4. Meet with outgoing officer\n5. Introduce yourself to members on your list", + sharedById: "user-president-243", + sharedByName: "Local 243 President", + createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: "tmpl-003", + unionId: "union-opseu", + localId: "local-243", + kind: "caption", + title: "Bargaining update social caption", + description: "Within-union shared social caption", + body: "Bargaining update: Your bargaining team met today. We are fighting for fair wages, job security, and respect at work. Stay tuned - and talk to your steward if you have questions. #Solidarity", + sharedById: "user-steward-243", + sharedByName: "Local 243 Steward", + createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + }, + ]; +} + +const templates: SharedTemplate[] = seedTemplates(); function id(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -107,3 +111,8 @@ export class MemoryMarketplaceAdapter implements MarketplaceAdapter { export const marketplaceStore: MarketplaceAdapter = new MemoryMarketplaceAdapter(); + +/** @internal test helper — restores demo seed so mutating tests stay isolated. */ +export function resetMarketplaceMemoryForTests(): void { + templates.splice(0, templates.length, ...seedTemplates()); +}