import { describe, expect, it } from "vitest"; import type { ManualLanguage } from "@tactical-media-broadcast-hub/core"; import { MANUAL_LANGUAGES, PARTICIPATION_MANUAL } from "@tactical-media-broadcast-hub/core"; import { onboardingSequence } from "./onboarding-content.js"; import { MatrixProvisioner, CLOSED_ROOM_ENCRYPTION, type MatrixAdminPort, type ProvisionerCall } from "./matrix-provisioner.js"; import { FOUNDER_POWER_LEVEL, MEMBER_POWER_LEVEL, type ProvisionCollectiveInput } from "./collectives.service.js"; import { loadProvisionerConfig, MatrixHttpClient, MatrixHttpError } from "./provisioner.config.js"; /** * Provisioner unit tests against a fake MatrixAdminPort. These assert the ROOM * SET SHAPE the design mandates (§"Rooms per collective"): open public+federated * and never encrypted; closed invite-only with m.room.encryption present at * creation with algorithm m.megolm.v1.aes-sha2; onboarding present, unencrypted, * carrying the manual ES-first; founder pinned to PL 100. */ interface RecordedRoom { roomId: string; body: Record; } /** Swap-fake for the Matrix port: no network, deterministic room ids. */ class FakeMatrix implements MatrixAdminPort { readonly userId = "@provisioner:chat.example.com"; rooms: RecordedRoom[] = []; invites: Array<{ roomId: string; userId: string }> = []; messages: Array<{ roomId: string; txnId: string; content: Record }> = []; stateEvents: Array<{ roomId: string; eventType: string; stateKey: string; content: Record }> = []; failCreateRoom = false; sequence = 0; private nextId(): string { this.sequence += 1; return `!room${this.sequence}:chat.example.com`; } async createRoom(body: Record): Promise<{ roomId: string }> { if (this.failCreateRoom) throw new Error("connection refused"); const roomId = this.nextId(); this.rooms.push({ roomId, body }); return { roomId }; } async sendStateEvent(roomId: string, eventType: string, stateKey: string, content: Record): Promise<{ eventId: string }> { this.stateEvents.push({ roomId, eventType, stateKey, content }); return { eventId: `$evt${this.stateEvents.length}` }; } async invite(roomId: string, userId: string): Promise { this.invites.push({ roomId, userId }); } async sendMessage(roomId: string, txnId: string, content: Record): Promise<{ eventId: string }> { this.messages.push({ roomId, txnId, content }); return { eventId: `$msg${this.messages.length}` }; } } function input(overrides: Partial = {}): ProvisionCollectiveInput { return { workspaceId: "11111111-1111-4111-8111-111111111111", groupId: "22222222-2222-4222-8222-222222222222", name: "Acampada 26S", language: "es", founderUserId: "33333333-3333-4333-8333-333333333333", founderMxid: "@founder:chat.example.com", ...overrides, }; } function initialState(room: RecordedRoom, type: string): Record | undefined { const list = Array.isArray(room.body.initial_state) ? (room.body.initial_state as Array>) : []; return list.find((event) => event.type === type)?.content as Record | undefined; } function provisioner(fake: FakeMatrix): MatrixProvisioner { return new MatrixProvisioner({ matrix: fake, txnPrefix: "test" }); } describe("MatrixProvisioner room set shape", () => { it("creates exactly three rooms: open, closed, onboarding", async () => { const fake = new FakeMatrix(); const set = await provisioner(fake).provisionCollective(input()); expect(fake.rooms).toHaveLength(3); expect(set.open.roomId).toBe("!room1:chat.example.com"); expect(set.closed.roomId).toBe("!room2:chat.example.com"); expect(set.onboarding.roomId).toBe("!room3:chat.example.com"); expect(new Set([set.open.roomId, set.closed.roomId, set.onboarding.roomId]).size).toBe(3); }); it("open room: public, listed, federated, history shared, NO encryption state", async () => { const fake = new FakeMatrix(); await provisioner(fake).provisionCollective(input()); const open = fake.rooms[0]!; expect(open.body.visibility).toBe("public"); expect(open.body.preset).toBe("public_chat"); expect((open.body.creation_content as Record)["m.federate"]).toBe(true); expect(initialState(open, "m.room.history_visibility")).toEqual({ history_visibility: "shared" }); // The single most important negative assertion in this suite. expect(initialState(open, "m.room.encryption")).toBeUndefined(); expect(JSON.stringify(open.body)).not.toContain("m.megolm"); }); it("closed room: private, invite-only preset, NOT federated, m.room.encryption set at creation with megolm", async () => { const fake = new FakeMatrix(); await provisioner(fake).provisionCollective(input()); const closed = fake.rooms[1]!; expect(closed.body.visibility).toBe("private"); expect(closed.body.preset).toBe("private_chat"); expect((closed.body.creation_content as Record)["m.federate"]).toBe(false); expect(initialState(closed, "m.room.encryption")).toEqual(CLOSED_ROOM_ENCRYPTION); expect((initialState(closed, "m.room.encryption") as { algorithm: string }).algorithm).toBe("m.megolm.v1.aes-sha2"); }); it("CLOSED_ROOM_ENCRYPTION is exactly the megolm algorithm constant", () => { expect(CLOSED_ROOM_ENCRYPTION).toEqual({ algorithm: "m.megolm.v1.aes-sha2" }); }); it("onboarding room: private, not federated, NOT encrypted, carries the manual", async () => { const fake = new FakeMatrix(); await provisioner(fake).provisionCollective(input()); const onboarding = fake.rooms[2]!; expect(onboarding.body.visibility).toBe("private"); expect((onboarding.body.creation_content as Record)["m.federate"]).toBe(false); expect(initialState(onboarding, "m.room.encryption")).toBeUndefined(); for (const message of fake.messages) expect(message.roomId).toBe(onboarding.roomId); }); it("posts the manual ES-first (Spanish header before English header)", async () => { const fake = new FakeMatrix(); await provisioner(fake).provisionCollective(input({ language: "es" })); const bodies = fake.messages.map((m) => String(m.content.body)); const esHeader = bodies.findIndex((b) => b.startsWith("Manual de participación")); const enHeader = bodies.findIndex((b) => b.startsWith("Participation manual")); expect(esHeader).toBeGreaterThanOrEqual(0); expect(enHeader).toBeGreaterThan(esHeader); // One header + one message per section, per language. expect(bodies).toHaveLength(2 * (PARTICIPATION_MANUAL.length + 1)); // Sections appear in manual order within each language block: ES section 1 // right after the ES header, EN section 1 right after the EN header. expect(bodies[1]).toContain(PARTICIPATION_MANUAL[0]!.heading.es); expect(bodies[2 + PARTICIPATION_MANUAL.length]).toContain(PARTICIPATION_MANUAL[0]!.heading.en); }); it("posts the collective's own language first when it is English", async () => { const fake = new FakeMatrix(); await provisioner(fake).provisionCollective(input({ language: "en" })); const bodies = fake.messages.map((m) => String(m.content.body)); expect(bodies[0]).toMatch(/^Participation manual/); expect(bodies[1 + PARTICIPATION_MANUAL.length]).toMatch(/^Manual de participación/); }); it("manual posts are m.text notices with deterministic txn ids", async () => { const fake = new FakeMatrix(); await provisioner(fake).provisionCollective(input()); for (const message of fake.messages) { expect(message.content.msgtype).toBe("m.text"); expect(message.txnId).toMatch(/^test-!room3:chat\.example\.com-\d+$/); } expect(new Set(fake.messages.map((m) => m.txnId)).size).toBe(fake.messages.length); }); it("power levels: founder and bot at 100, default member at 0, message allowed for members", async () => { const fake = new FakeMatrix(); const founder = "@founder:chat.example.com"; await provisioner(fake).provisionCollective(input({ founderMxid: founder })); for (const room of fake.rooms.slice(0, 2)) { const pl = room.body.power_level_content_override as Record; const users = pl.users as Record; expect(users[founder]).toBe(FOUNDER_POWER_LEVEL); expect(pl.users_default).toBe(MEMBER_POWER_LEVEL); expect((pl.events as Record)["m.room.message"]).toBe(MEMBER_POWER_LEVEL); expect(pl.state_default).toBe(FOUNDER_POWER_LEVEL); } }); it("invites the founder to all three rooms when an mxid is known", async () => { const fake = new FakeMatrix(); const set = await provisioner(fake).provisionCollective(input()); const invited = new Set(fake.invites.filter((i) => i.userId === "@founder:chat.example.com").map((i) => i.roomId)); expect(invited).toEqual(new Set([set.open.roomId, set.closed.roomId, set.onboarding.roomId])); }); it("skips invites and power-level grants when no founder mxid is known", async () => { const fake = new FakeMatrix(); await provisioner(fake).provisionCollective(input({ founderMxid: null })); expect(fake.invites).toHaveLength(0); for (const room of fake.rooms) { const users = (room.body.power_level_content_override as Record).users as Record; expect(Object.keys(users)).not.toContain("@provisioner:chat.example.com"); } }); it("maps any homeserver failure to ProvisionerUnavailableError and persists nothing", async () => { const fake = new FakeMatrix(); fake.failCreateRoom = true; const provisioner = new MatrixProvisioner({ matrix: fake }); await expect(provisioner.provisionCollective(input())).rejects.toMatchObject({ name: "ProvisionerUnavailableError" }); // No partial room set was produced, and no manual was posted. expect(provisioner.calls).toHaveLength(0); expect(fake.messages).toHaveLength(0); }); it("records every Matrix call in order (createRoom x3, manual posts, invites)", async () => { const fake = new FakeMatrix(); const provisioner = new MatrixProvisioner({ matrix: fake, txnPrefix: "rec" }); await provisioner.provisionCollective(input()); const kinds = (provisioner.calls as ProvisionerCall[]).map((call) => call.kind); expect(kinds.filter((kind) => kind === "createRoom")).toHaveLength(3); // The open/closed founder invites legitimately precede the manual posts; // what must hold is: every room exists before any post, and the onboarding // invite (the last call) happens after the manual is already in the room. expect(kinds.indexOf("sendMessage")).toBeGreaterThan(kinds.lastIndexOf("createRoom")); expect(kinds.lastIndexOf("invite")).toBeGreaterThan(kinds.lastIndexOf("sendMessage")); }); it("room names/topics follow the collective's language per room type", async () => { const fake = new FakeMatrix(); await provisioner(fake).provisionCollective(input({ language: "es", name: "Colectivo" })); expect(fake.rooms[0]!.body.name).toContain("abierta"); expect(fake.rooms[1]!.body.name).toContain("producción"); expect(fake.rooms[2]!.body.name).toContain("Acogida"); const fakeEn = new FakeMatrix(); await provisioner(fakeEn).provisionCollective(input({ language: "en", name: "Collective" })); expect(fakeEn.rooms[0]!.body.name).toContain("open"); expect(fakeEn.rooms[1]!.body.name).toContain("production"); expect(fakeEn.rooms[2]!.body.name).toContain("Onboarding"); }); }); describe("onboardingSequence", () => { it("is the two-language manual, primary first, for every supported language", () => { for (const language of MANUAL_LANGUAGES) { const sequence = onboardingSequence(language); expect(sequence).toHaveLength(2 * (PARTICIPATION_MANUAL.length + 1)); const first = language === "es" ? /^Manual de participación/ : /^Participation manual/; const second = language === "es" ? /^Participation manual/ : /^Manual de participación/; expect(sequence[0]).toMatch(first); expect(sequence[1 + PARTICIPATION_MANUAL.length]).toMatch(second); } }); it("covers every manual section in both languages", () => { const sequence = onboardingSequence("es"); for (const section of PARTICIPATION_MANUAL) { expect(sequence.some((text) => text.includes(section.heading.es))).toBe(true); expect(sequence.some((text) => text.includes(section.heading.en))).toBe(true); } }); }); describe("loadProvisionerConfig", () => { it("returns null when nothing is configured", () => { expect(loadProvisionerConfig({})).toBeNull(); expect( loadProvisionerConfig({ ARCHIVE_PROVISIONER_HOMESERVER_URL: "", ARCHIVE_PROVISIONER_USER: "", ARCHIVE_PROVISIONER_PASSWORD: "", }), ).toBeNull(); }); it("reads the ARCHIVE_PROVISIONER_* names and defaults the device id", () => { const config = loadProvisionerConfig({ ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://chat.example.com", ARCHIVE_PROVISIONER_USER: "@provisioner:chat.example.com", ARCHIVE_PROVISIONER_PASSWORD: "hunter2", }); expect(config).toEqual({ homeserverUrl: new URL("https://chat.example.com"), user: "@provisioner:chat.example.com", password: "hunter2", deviceId: "HUB_PROVISIONER_BOT", }); }); it("fails loudly on a partial configuration", () => { expect(() => loadProvisionerConfig({ ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://chat.example.com", ARCHIVE_PROVISIONER_USER: "@provisioner:chat.example.com", }), ).toThrow(/ARCHIVE_PROVISIONER_PASSWORD is required/); expect(() => loadProvisionerConfig({ ARCHIVE_PROVISIONER_HOMESERVER_URL: "ftp://nope", ARCHIVE_PROVISIONER_USER: "@p:chat.example.com", ARCHIVE_PROVISIONER_PASSWORD: "x", }), ).toThrow(/must be http\(s\)/); }); it("MatrixHttpClient maps login failures to ProvisionerUnavailableError", async () => { const client = new MatrixHttpClient({ homeserverUrl: new URL("https://chat.example.com"), user: "@p:chat.example.com", password: "x", deviceId: "D", }); const original = globalThis.fetch; globalThis.fetch = (async () => new Response(JSON.stringify({ errcode: "M_FORBIDDEN", error: "bad credentials" }), { status: 403 })) as typeof fetch; try { const thrown = await client.login().then( () => null, (err: unknown) => err, ); expect(thrown).toBeInstanceOf(MatrixHttpError); const error = thrown as InstanceType; expect(error.status).toBe(403); expect(error.errcode).toBe("M_FORBIDDEN"); expect(error.message).toContain("matrix HTTP 403"); } finally { globalThis.fetch = original; } }); });