import { convexTest } from "convex-test"; import { anyApi } from "convex/server"; import { describe, expect, test } from "vitest"; import { requireOrganizationMember } from "./authz"; import schema from "./schema"; declare global { interface ImportMeta { readonly glob: (pattern: string) => Record Promise>; } } const modules = import.meta.glob("./**/*.ts"); const api = anyApi; const ID_A = "https://convex.test|user-a"; const ID_B = "https://convex.test|user-b"; const identityA = { tokenIdentifier: ID_A }; const identityB = { tokenIdentifier: ID_B }; const newTest = () => convexTest({ schema, modules }); const ensureOrg = async ( t: ReturnType, identity: { readonly tokenIdentifier: string } ) => { const org = await t .withIdentity(identity) .mutation(api.organizations.ensurePersonalOrganization, {}); return org._id as string; }; describe("conversationMessages", () => { test("unauthenticated access is rejected", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); await expect( t.mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-1", organizationId: orgId, rawText: "hello", }) ).rejects.toThrow(/Authentication required/u); await expect( t.mutation(api.conversationMessages.markAdmitted, { clientRequestId: "req-1", organizationId: orgId, submissionId: "sub-1", }) ).rejects.toThrow(/Authentication required/u); await expect( t.query(api.conversationMessages.getByRequest, { clientRequestId: "req-1", organizationId: orgId, }) ).rejects.toThrow(/Authentication required/u); }); test("cross-organization access is denied for begin and observation", async () => { const t = newTest(); const orgA = await ensureOrg(t, identityA); const _orgB = await ensureOrg(t, identityB); // User A captures a message in its own organization. const created = await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-cross", organizationId: orgA, rawText: "private to org A", }); // User B cannot begin a message under organization A. await expect( t .withIdentity(identityB) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-cross", organizationId: orgA, rawText: "intruder", }) ).rejects.toThrow(/Organization membership required/u); // User B cannot observe organization A's message by request id. await expect( t.withIdentity(identityB).query(api.conversationMessages.getByRequest, { clientRequestId: "req-cross", organizationId: orgA, }) ).rejects.toThrow(/Organization membership required/u); // User B cannot list organization A's conversation. await expect( t .withIdentity(identityB) .query(api.conversationMessages.listForCurrentOrganization, { organizationId: orgA, }) ).rejects.toThrow(/Organization membership required/u); // The membership boundary independently denies cross-org access. await expect( t .withIdentity(identityB) .query((ctx) => requireOrganizationMember(ctx, orgA as never)) ).rejects.toThrow(/Organization membership required/u); expect(created.status).toBe("admitting"); }); test("duplicate request id is idempotent and preserves exact whitespace and casing", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const rawText = " Hello WORLD \n\t with spaces "; const first = await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-dup", organizationId: orgId, rawText, }); // A second begin with the same request id returns the same row — no // duplicate is created. const second = await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-dup", organizationId: orgId, rawText: "DIFFERENT TEXT MUST BE IGNORED", }); expect(second._id).toBe(first._id); expect(second.messageId).toBe(first.messageId); expect(second.rawText).toBe(rawText); expect(second.clientRequestId).toBe("req-dup"); expect(second.role).toBe("user"); expect(second.status).toBe("admitting"); // The conversation id equals the organization id (global agent scope). expect(first.conversationId).toBe(orgId); }); test("admitted transition records the submission id", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const begun = await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-admit", organizationId: orgId, rawText: "please help", }); expect(begun.status).toBe("admitting"); expect(begun.submissionId).toBeNull(); const admitted = await t .withIdentity(identityA) .mutation(api.conversationMessages.markAdmitted, { clientRequestId: "req-admit", organizationId: orgId, submissionId: "sub-receipt-123", }); expect(admitted.status).toBe("admitted"); expect(admitted.submissionId).toBe("sub-receipt-123"); // Marking admitted again is idempotent and keeps the same submission id. const again = await t .withIdentity(identityA) .mutation(api.conversationMessages.markAdmitted, { clientRequestId: "req-admit", organizationId: orgId, submissionId: "sub-receipt-123", }); expect(again.status).toBe("admitted"); expect(again.submissionId).toBe("sub-receipt-123"); }); test("failed transition records the failed status and preserves evidence", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-fail", organizationId: orgId, rawText: "doomed message", }); const failed = await t .withIdentity(identityA) .mutation(api.conversationMessages.markFailed, { clientRequestId: "req-fail", organizationId: orgId, }); expect(failed.status).toBe("failed"); expect(failed.submissionId).toBeNull(); expect(failed.rawText).toBe("doomed message"); // A failed message is still observable as evidence. const observed = await t .withIdentity(identityA) .query(api.conversationMessages.getByRequest, { clientRequestId: "req-fail", organizationId: orgId, }); expect(observed?.status).toBe("failed"); expect(observed?.rawText).toBe("doomed message"); }); test("admission wins over a concurrent failure and cannot be overwritten", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const begun = await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-race", organizationId: orgId, rawText: "race", }); // Admission completes first. await t .withIdentity(identityA) .mutation(api.conversationMessages.markAdmitted, { clientRequestId: "req-race", organizationId: orgId, submissionId: "sub-won", }); // A late failure is a no-op: an admitted message keeps its submission id. const afterFail = await t .withIdentity(identityA) .mutation(api.conversationMessages.markFailed, { clientRequestId: "req-race", organizationId: orgId, }); expect(afterFail.status).toBe("admitted"); expect(afterFail.submissionId).toBe("sub-won"); expect(begun.status).toBe("admitting"); }); test("list returns only the authenticated organization's messages", async () => { const t = newTest(); const orgA = await ensureOrg(t, identityA); const orgB = await ensureOrg(t, identityB); await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-a1", organizationId: orgA, rawText: "a-one", }); await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-a2", organizationId: orgA, rawText: "a-two", }); await t .withIdentity(identityB) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-b1", organizationId: orgB, rawText: "b-one", }); const forA = await t .withIdentity(identityA) .query(api.conversationMessages.listForCurrentOrganization, { organizationId: orgA, }); const forB = await t .withIdentity(identityB) .query(api.conversationMessages.listForCurrentOrganization, { organizationId: orgB, }); expect(forA).toHaveLength(2); expect( forA.map((m: { clientRequestId: string }) => m.clientRequestId) ).toEqual(expect.arrayContaining(["req-a1", "req-a2"])); expect(forB).toHaveLength(1); expect(forB[0]?.clientRequestId).toBe("req-b1"); }); });