import "reflect-metadata"; import type { PoolClient } from "pg"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { appendTenantAudit } from "../audit/audit.service.js"; import { parseContact, parsePatch } from "./outreach.controller.js"; import { OutreachService } from "./outreach.service.js"; /** * Outreach service + request parsing with the DB faked. RLS and the migration * shape belong to a live-DB suite. */ const dbMock = vi.hoisted(() => ({ withTenant: vi.fn() })); vi.mock("../db/client.js", () => ({ withTenant: dbMock.withTenant })); vi.mock("../audit/audit.service.js", () => ({ appendTenantAudit: vi.fn(async () => "audit-id") })); const WS = "0d9a1b40-6f0f-4b1e-8f0e-3f3c9e6b4a10"; const ID = "11111111-2222-4333-8444-555555555555"; const ACTOR = { userId: "8f7f6e50-7b0f-4a2e-9d3e-2f2d1a0c5b21", role: "Editor" }; const row = (overrides: Record = {}) => ({ id: ID, name: "Climáximo", platform: "bluesky", handle: "climaximopt.bsky.social", profile_url: "https://bsky.app/profile/climaximopt.bsky.social", category: "clima", language: "pt", message: "Olá!", notes: "", status: "todo", sent_at: null, replied_at: null, created_at: new Date("2026-10-15T10:00:00Z"), updated_at: new Date("2026-10-15T10:00:00Z"), ...overrides, }); type Query = (sql: string, values?: unknown[]) => Promise<{ rows: unknown[]; rowCount?: number }>; function tenant(query: Query): Array<{ sql: string; values: unknown[] }> { const calls: Array<{ sql: string; values: unknown[] }> = []; dbMock.withTenant.mockImplementation(async (_ws: string, fn: (client: PoolClient) => Promise) => fn({ query: async (sql: string, values: unknown[] = []) => { calls.push({ sql, values }); return query(sql, values); }, } as unknown as PoolClient), ); return calls; } beforeEach(() => { dbMock.withTenant.mockReset(); vi.mocked(appendTenantAudit).mockClear(); }); describe("parseContact", () => { const valid = { name: " DAL ", platform: "Instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" }; it("trims and lowercases the platform id", () => { expect(parseContact(valid, "c")).toEqual({ name: "DAL", platform: "instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" }); }); it("rejects non-https profile links and odd platform ids", () => { expect(() => parseContact({ ...valid, profileUrl: "http://example.org/x" }, "c")).toThrow(/https/); expect(() => parseContact({ ...valid, profileUrl: "javascript:alert(1)" }, "c")).toThrow(/https/); expect(() => parseContact({ ...valid, platform: "x y" }, "c")).toThrow(/platform/); expect(() => parseContact({ ...valid, language: "spanish" }, "c")).toThrow(/language/); expect(() => parseContact({ ...valid, name: " " }, "c")).toThrow(/name/); }); }); describe("parsePatch", () => { it("accepts the known statuses only and refuses an empty patch", () => { expect(parsePatch({ status: "sent" })).toEqual({ status: "sent" }); expect(() => parsePatch({ status: "spam" })).toThrow(/status/); expect(() => parsePatch({})).toThrow(/nothing/); }); }); describe("OutreachService", () => { const service = new OutreachService(); it("imports in one statement, skips known accounts and audits counts only", async () => { const calls = tenant(async () => ({ rows: [{ id: ID }], rowCount: 1 })); const result = await service.import(WS, [ { name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a", message: "private draft" }, { name: "B", platform: "bluesky", handle: "b", profileUrl: "https://bsky.app/profile/b" }, ], ACTOR); expect(result).toEqual({ added: 1, skipped: 1 }); expect(calls).toHaveLength(1); expect(calls[0]?.sql).toContain("ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING"); expect(JSON.parse(String(calls[0]?.values[2]))).toHaveLength(2); expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.imported", diff: { added: 1, skipped: 1 } })); expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("private draft"); }); it("does not audit an import that added nothing", async () => { tenant(async () => ({ rows: [], rowCount: 0 })); expect(await service.import(WS, [{ name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a" }], ACTOR)).toEqual({ added: 0, skipped: 1 }); expect(appendTenantAudit).not.toHaveBeenCalled(); }); it("marks a contact sent and audits the transition without the message", async () => { const calls = tenant(async (sql) => { if (sql.startsWith("SELECT status")) return { rows: [{ status: "todo" }] }; if (sql.startsWith("UPDATE outreach_contacts")) return { rows: [row({ status: "sent", message: "Olá, secret text", sent_at: new Date("2026-10-15T11:00:00Z") })] }; throw new Error(`unexpected query: ${sql}`); }); const contact = await service.update(WS, ID, { status: "sent", message: "Olá, secret text" }, ACTOR); expect(contact.status).toBe("sent"); expect(contact.sentAt).toBe("2026-10-15T11:00:00.000Z"); expect(calls[1]?.values).toEqual([ID, "Olá, secret text", null, "sent", true, ACTOR.userId]); expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.sent", diff: expect.objectContaining({ from: "todo", to: "sent" }) })); expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("secret text"); }); it("edits the draft without a status change and without an audit entry", async () => { const calls = tenant(async (sql) => { if (sql.startsWith("SELECT status")) return { rows: [{ status: "sent" }] }; return { rows: [row({ status: "sent", message: "v2" })] }; }); await service.update(WS, ID, { message: "v2" }, ACTOR); expect(calls[1]?.values).toEqual([ID, "v2", null, "sent", false, ACTOR.userId]); expect(appendTenantAudit).not.toHaveBeenCalled(); }); it("404s on an unknown contact", async () => { tenant(async () => ({ rows: [] })); await expect(service.update(WS, ID, { status: "sent" }, ACTOR)).rejects.toThrow(/not found/); await expect(service.remove(WS, ID, ACTOR)).rejects.toThrow(/not found/); expect(appendTenantAudit).not.toHaveBeenCalled(); }); });