import { type TestConvex, convexTest } from "convex-test"; import { anyApi } from "convex/server"; import { describe, expect, test } from "vitest"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import schema from "./schema"; // `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it // has no type definition under the Convex tsconfig (which scopes `types` to // node), so declare the minimal shape the test relies on. 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: TestConvex, identity: { readonly tokenIdentifier: string } ) => { const org = await t .withIdentity(identity) .mutation(api.organizations.ensurePersonalOrganization, {}); return org._id as Id<"organizations">; }; const problemStatement = { title: "Deploy fails on staging", summary: "The staging deploy command exits with a DNS resolution error.", desiredOutcome: "Staging deploys successfully without DNS errors.", constraints: ["Must not change the production pipeline"], }; const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" }; // Begin and admit one user message, returning its messageId. const seedMessage = async ( t: TestConvex, identity: { readonly tokenIdentifier: string }, orgId: Id<"organizations">, clientRequestId: string, rawText: string ): Promise => { const begun = await t .withIdentity(identity) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId, organizationId: orgId, rawText, }); await t .withIdentity(identity) .mutation(api.conversationMessages.markAdmitted, { clientRequestId, organizationId: orgId, submissionId: `sub-${clientRequestId}`, }); return begun.messageId; }; const createProject = async ( t: TestConvex, ownerId: string ): Promise> => { // Resolve or create the owner's personal organization const ownerIdentity = { tokenIdentifier: ownerId }; await t .withIdentity(ownerIdentity) .mutation(api.organizations.ensurePersonalOrganization); const outcome = await t .withIdentity(ownerIdentity) .mutation(internal.projects.persistPublicGitImport, { userId: ownerId, source: { host: "github.com", projectName: "test-repo", repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`, normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`, url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`, }, remote: { defaultBranch: "main", documents: [ { kind: "readme" as const, path: "README.md", content: "# test-repo\n\nTest.\n", }, ], warnings: [], }, }); return outcome.id as unknown as Id<"projects">; }; describe("signals", () => { test("unauthenticated create is rejected", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); await expect( t.mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: ["m1"], organizationId: orgId, problemStatement, processedBy, }) ).rejects.toThrow(/Authentication required/u); }); test("creates one Signal from a single admitted user message", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const rawText = " I cannot deploy to staging "; const messageId = await seedMessage(t, identityA, orgId, "req-1", rawText); const result = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [messageId], organizationId: orgId, problemStatement, processedBy, }); const composed = await t .withIdentity(identityA) .query(api.signals.get, { signalId: result.signalId }); expect(composed).not.toBeNull(); expect(composed?.signal.organizationId).toBe(orgId); expect(composed?.signal.projectId).toBeNull(); expect(composed?.signal.conversationId).toBe(orgId); expect(composed?.sources).toHaveLength(1); expect(composed?.sources[0]?.messageId).toBe(messageId); // Exact raw snapshot preserved verbatim (whitespace intact). expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText); expect(composed?.sources[0]?.submissionId).toBe("sub-req-1"); expect(composed?.sources[0]?.ordinal).toBe(0); }); test("composes several ordered user messages into one Signal", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const m1 = await seedMessage(t, identityA, orgId, "req-a", "first message"); const m2 = await seedMessage( t, identityA, orgId, "req-b", "second message" ); const m3 = await seedMessage(t, identityA, orgId, "req-c", "third message"); const result = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [m1, m2, m3], organizationId: orgId, problemStatement, processedBy, }); const composed = await t .withIdentity(identityA) .query(api.signals.get, { signalId: result.signalId }); expect(composed?.sources).toHaveLength(3); expect(composed?.sources[0]?.messageId).toBe(m1); expect(composed?.sources[0]?.ordinal).toBe(0); expect(composed?.sources[1]?.messageId).toBe(m2); expect(composed?.sources[1]?.ordinal).toBe(1); expect(composed?.sources[2]?.messageId).toBe(m3); expect(composed?.sources[2]?.ordinal).toBe(2); expect( composed?.sources.map( (s: { rawTextSnapshot: string }) => s.rawTextSnapshot ) ).toEqual(["first message", "second message", "third message"]); }); test("preserves exact raw snapshots and a separate structured problem statement", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const rawText = "\n\t leading and trailing \n"; const messageId = await seedMessage( t, identityA, orgId, "req-exact", rawText ); const result = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [messageId], organizationId: orgId, problemStatement, processedBy, }); const composed = await t .withIdentity(identityA) .query(api.signals.get, { signalId: result.signalId }); // Raw evidence is exact and untouched. expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText); // The structured problem statement is stored separately. expect(composed?.signal.problemStatement).toEqual(problemStatement); }); test("rejects cross-organization messages", async () => { const t = newTest(); const orgA = await ensureOrg(t, identityA); const orgB = await ensureOrg(t, identityB); // Message belongs to org A. const messageId = await seedMessage( t, identityA, orgA, "req-x", "secret A" ); // User B tries to build a signal under org B referencing org A's message. await expect( t.withIdentity(identityB).mutation(api.signals.createFromMessages, { conversationId: orgB, messageIds: [messageId], organizationId: orgB, problemStatement, processedBy, }) ).rejects.toThrow(/Source message not found/u); }); test("rejects cross-conversation mismatch", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const messageId = await seedMessage(t, identityA, orgId, "req-conv", "hi"); await expect( t.withIdentity(identityA).mutation(api.signals.createFromMessages, { conversationId: "different-conversation", messageIds: [messageId], organizationId: orgId, problemStatement, processedBy, }) ).rejects.toThrow(/Conversation does not belong to organization/u); }); test("rejects a failed (non-admitted) message", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); // Begin then fail a message. const begun = await t .withIdentity(identityA) .mutation(api.conversationMessages.beginUserMessage, { clientRequestId: "req-failed", organizationId: orgId, rawText: "doomed", }); await t .withIdentity(identityA) .mutation(api.conversationMessages.markFailed, { clientRequestId: "req-failed", organizationId: orgId, }); await expect( t.withIdentity(identityA).mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [begun.messageId], organizationId: orgId, problemStatement, processedBy, }) ).rejects.toThrow(/not admitted/u); }); test("rejects a missing message id", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); await expect( t.withIdentity(identityA).mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: ["nonexistent-id"], organizationId: orgId, problemStatement, processedBy, }) ).rejects.toThrow(/Source message not found/u); }); test("rejects duplicate message ids in the selection", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const messageId = await seedMessage(t, identityA, orgId, "req-dup", "dup"); await expect( t.withIdentity(identityA).mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [messageId, messageId], organizationId: orgId, problemStatement, processedBy, }) ).rejects.toThrow(/must be unique/u); }); test("rejects an empty message selection", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); await expect( t.withIdentity(identityA).mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [], organizationId: orgId, problemStatement, processedBy, }) ).rejects.toThrow(/At least one source message is required/u); }); test("idempotent retry returns the same signal without duplicate sources", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const m1 = await seedMessage(t, identityA, orgId, "req-idem-1", "a"); const m2 = await seedMessage(t, identityA, orgId, "req-idem-2", "b"); const first = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [m1, m2], organizationId: orgId, problemStatement, processedBy, }); // Retry with the same ordered selection. const second = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [m1, m2], organizationId: orgId, problemStatement, processedBy, }); expect(second.signalId).toBe(first.signalId); // Exactly one signal and two sources exist. const list = await t .withIdentity(identityA) .query(api.signals.list, { organizationId: orgId }); expect(list).toHaveLength(1); expect(list[0]?.sources).toHaveLength(2); }); test("different ordered selections produce different signals", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const m1 = await seedMessage(t, identityA, orgId, "req-ord-1", "x"); const m2 = await seedMessage(t, identityA, orgId, "req-ord-2", "y"); const first = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [m1, m2], organizationId: orgId, problemStatement, processedBy, }); const second = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [m2, m1], organizationId: orgId, problemStatement, processedBy, }); expect(second.signalId).not.toBe(first.signalId); const list = await t .withIdentity(identityA) .query(api.signals.list, { organizationId: orgId }); expect(list).toHaveLength(2); }); test("reversed selection preserves agent ordinals and original timestamps", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const m1 = await seedMessage(t, identityA, orgId, "req-rev-1", "first"); const m2 = await seedMessage(t, identityA, orgId, "req-rev-2", "second"); // Chronological selection: m1 then m2. const chrono = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [m1, m2], organizationId: orgId, problemStatement, processedBy, }); // Reversed selection: m2 then m1 (non-chronological agent order). const reversed = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [m2, m1], organizationId: orgId, problemStatement, processedBy, }); expect(reversed.signalId).not.toBe(chrono.signalId); const chronoSignal = await t .withIdentity(identityA) .query(api.signals.get, { signalId: chrono.signalId }); const reversedSignal = await t .withIdentity(identityA) .query(api.signals.get, { signalId: reversed.signalId }); // Chronological: ordinals follow selection. expect(chronoSignal?.sources[0]?.messageId).toBe(m1); expect(chronoSignal?.sources[0]?.ordinal).toBe(0); expect(chronoSignal?.sources[1]?.messageId).toBe(m2); expect(chronoSignal?.sources[1]?.ordinal).toBe(1); // Reversed: ordinals follow the agent's reversed selection, and each // source retains its original creation timestamp (unchanged by reordering). expect(reversedSignal?.sources[0]?.messageId).toBe(m2); expect(reversedSignal?.sources[0]?.ordinal).toBe(0); expect(reversedSignal?.sources[1]?.messageId).toBe(m1); expect(reversedSignal?.sources[1]?.ordinal).toBe(1); // Exact raw text is preserved in both orderings. expect( chronoSignal?.sources.map( (s: { rawTextSnapshot: string }) => s.rawTextSnapshot ) ).toEqual(["first", "second"]); expect( reversedSignal?.sources.map( (s: { rawTextSnapshot: string }) => s.rawTextSnapshot ) ).toEqual(["second", "first"]); }); test("creates a project-scoped signal when the project owner is an org member", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); // Project owned by user A, who is a member of orgId. const projectId = await createProject(t, ID_A); const messageId = await seedMessage( t, identityA, orgId, "req-proj", "build" ); const result = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [messageId], organizationId: orgId, problemStatement, projectId, processedBy, }); const composed = await t .withIdentity(identityA) .query(api.signals.get, { signalId: result.signalId }); expect(composed?.signal.projectId).toBe(projectId); }); test("rejects a project whose owner is not an org member (cross-tenant)", async () => { const t = newTest(); const orgA = await ensureOrg(t, identityA); const _orgB = await ensureOrg(t, identityB); // Project owned by user B. const projectId = await createProject(t, ID_B); const messageId = await seedMessage( t, identityA, orgA, "req-cross-proj", "z" ); // User A is a member of orgA but the project owner (B) is not. await expect( t.withIdentity(identityA).mutation(api.signals.createFromMessages, { conversationId: orgA, messageIds: [messageId], organizationId: orgA, problemStatement, projectId, processedBy, }) ).rejects.toThrow(/does not belong to the target organization/u); }); test("authorized list returns only the organization's signals with composed sources", async () => { const t = newTest(); const orgA = await ensureOrg(t, identityA); const orgB = await ensureOrg(t, identityB); const mA1 = await seedMessage(t, identityA, orgA, "req-list-1", "a1"); const mA2 = await seedMessage(t, identityA, orgA, "req-list-2", "a2"); await seedMessage(t, identityB, orgB, "req-list-b", "b1"); await t.withIdentity(identityA).mutation(api.signals.createFromMessages, { conversationId: orgA, messageIds: [mA1], organizationId: orgA, problemStatement, processedBy, }); await t.withIdentity(identityA).mutation(api.signals.createFromMessages, { conversationId: orgA, messageIds: [mA1, mA2], organizationId: orgA, problemStatement, processedBy, }); const forA = await t .withIdentity(identityA) .query(api.signals.list, { organizationId: orgA }); const forB = await t .withIdentity(identityB) .query(api.signals.list, { organizationId: orgB }); expect(forA).toHaveLength(2); expect(forA[0]?.sources.length).toBeGreaterThan(0); expect(forB).toHaveLength(0); // Cross-org list is denied. await expect( t .withIdentity(identityB) .query(api.signals.list, { organizationId: orgA }) ).rejects.toThrow(/Organization membership required/u); }); test("authorized get denies cross-organization signal access", async () => { const t = newTest(); const orgA = await ensureOrg(t, identityA); const _orgB = await ensureOrg(t, identityB); const messageId = await seedMessage( t, identityA, orgA, "req-get", "secret" ); const result = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgA, messageIds: [messageId], organizationId: orgA, problemStatement, processedBy, }); // User B cannot read org A's signal. await expect( t .withIdentity(identityB) .query(api.signals.get, { signalId: result.signalId }) ).rejects.toThrow(/Organization membership required/u); // get returns null for an unknown but well-formed id when authorized. const other = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgA, messageIds: [await seedMessage(t, identityA, orgA, "req-ghost", "g")], organizationId: orgA, problemStatement, processedBy, }); expect(other.signalId).not.toBe(result.signalId); }); });