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"; 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" }; 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, suffix: string ): Promise> => { 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-${suffix}`, repositoryPath: `owner-${suffix}/test-repo`, normalizedUrl: `https://github.com/owner-${suffix}/test-repo`, url: `https://github.com/owner-${suffix}/test-repo`, }, remote: { defaultBranch: "main", documents: [ { kind: "readme" as const, path: "README.md", content: "# test\n", }, ], warnings: [], }, }); return outcome.id as unknown as Id<"projects">; }; // --------------------------------------------------------------------------- // Helpers for creating signals and attachments via the DB directly (the // query under test is read-only; we seed through mutations/DB). // --------------------------------------------------------------------------- const createSignalInProject = async ( t: TestConvex, identity: { readonly tokenIdentifier: string }, orgId: Id<"organizations">, projectId: Id<"projects">, msgSuffix: string, ps: typeof problemStatement ): Promise> => { const messageId = await seedMessage( t, identity, orgId, `req-${msgSuffix}`, `evidence ${msgSuffix}` ); const result = await t .withIdentity(identity) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [messageId], organizationId: orgId, problemStatement: ps, projectId, processedBy, }); return result.signalId; }; /** Insert a signalIssueAttachment directly via the test DB. */ const linkSignalToIssue = async ( t: TestConvex, signalId: Id<"signals">, issueId: Id<"projectIssues">, organizationId: Id<"organizations">, projectId: Id<"projects"> ): Promise => { await t.mutation((ctx) => ctx.db.insert("signalIssueAttachments", { createdAt: Date.now(), issueId, organizationId, projectId, signalId, }) ); }; const createIssue = async ( t: TestConvex, identity: { readonly tokenIdentifier: string }, projectId: Id<"projects">, title: string ): Promise> => t.withIdentity(identity).mutation(api.projectIssues.create, { body: "A representative issue body long enough to pass validation", projectId, title, }); describe("listLinkedSignalsForProject", () => { test("groups linked signals by issue with correct source counts", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const projectId = await createProject(t, ID_A, "group"); const issue1 = await createIssue(t, identityA, projectId, "Issue one"); const issue2 = await createIssue(t, identityA, projectId, "Issue two"); // Signal 1: one source, linked to issue1 const sig1 = await createSignalInProject( t, identityA, orgId, projectId, "s1", problemStatement ); await linkSignalToIssue(t, sig1, issue1, orgId, projectId); // Signal 2: one source, linked to issue2 const sig2 = await createSignalInProject( t, identityA, orgId, projectId, "s2", { ...problemStatement, title: "Different signal", } ); await linkSignalToIssue(t, sig2, issue2, orgId, projectId); const result = await t .withIdentity(identityA) .query(api.signals.listLinkedSignalsForProject, { projectId }); expect(Object.keys(result)).toHaveLength(2); expect(result[String(issue1)]).toHaveLength(1); expect(result[String(issue2)]).toHaveLength(1); expect(result[String(issue1)]![0]!.title).toBe("Deploy fails on staging"); expect(result[String(issue2)]![0]!.title).toBe("Different signal"); expect(result[String(issue1)]![0]!.sourceCount).toBe(1); }); test("multiple signals linked to one issue are returned newest first", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const projectId = await createProject(t, ID_A, "order"); const issue = await createIssue(t, identityA, projectId, "Single issue"); const ps1 = { ...problemStatement, title: "First signal" }; const ps2 = { ...problemStatement, title: "Second signal" }; const sig1 = await createSignalInProject( t, identityA, orgId, projectId, "order1", ps1 ); // Small delay so createdAt differs. await t.mutation(async (ctx) => { // Patch createdAt to be earlier. const s = await ctx.db.get(sig1); if (s) { await ctx.db.patch(sig1, { createdAt: s.createdAt - 1000 }); } }); const sig2 = await createSignalInProject( t, identityA, orgId, projectId, "order2", ps2 ); await linkSignalToIssue(t, sig1, issue, orgId, projectId); await linkSignalToIssue(t, sig2, issue, orgId, projectId); const result = await t .withIdentity(identityA) .query(api.signals.listLinkedSignalsForProject, { projectId }); const linked = result[String(issue)]!; expect(linked).toHaveLength(2); // Newest first: sig2 was created later. expect(linked[0]!.title).toBe("Second signal"); expect(linked[1]!.title).toBe("First signal"); }); test("requires project membership — denies non-member access", async () => { const t = newTest(); const _orgA = await ensureOrg(t, identityA); const _orgB = await ensureOrg(t, identityB); const projectId = await createProject(t, ID_A, "auth"); // User B is NOT a member of org A / project. await expect( t .withIdentity(identityB) .query(api.signals.listLinkedSignalsForProject, { projectId }) ).rejects.toThrow(/Project not found|Organization membership required/u); }); test("does not return signals from a different project", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); // Two projects in the same org. const projectId1 = await createProject(t, ID_A, "p1"); const projectId2 = await createProject(t, ID_A, "p2"); const issue1 = await createIssue(t, identityA, projectId1, "Issue P1"); const issue2 = await createIssue(t, identityA, projectId2, "Issue P2"); // Signal linked to issue in project2. const sig = await createSignalInProject( t, identityA, orgId, projectId2, "xproj", problemStatement ); await linkSignalToIssue(t, sig, issue2, orgId, projectId2); // Querying project1 should NOT see project2's signal. const result1 = await t .withIdentity(identityA) .query(api.signals.listLinkedSignalsForProject, { projectId: projectId1, }); expect(result1[String(issue1)] ?? []).toHaveLength(0); // Querying project2 SHOULD see it. const result2 = await t .withIdentity(identityA) .query(api.signals.listLinkedSignalsForProject, { projectId: projectId2, }); expect(result2[String(issue2)] ?? []).toHaveLength(1); }); test("returns correct source count for multi-source signals", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const projectId = await createProject(t, ID_A, "sources"); const issue = await createIssue(t, identityA, projectId, "Multi-source"); // Create a signal with two sources. const m1 = await seedMessage(t, identityA, orgId, "ms-1", "evidence a"); const m2 = await seedMessage(t, identityA, orgId, "ms-2", "evidence b"); const result = await t .withIdentity(identityA) .mutation(api.signals.createFromMessages, { conversationId: orgId, messageIds: [m1, m2], organizationId: orgId, problemStatement, projectId, processedBy, }); await linkSignalToIssue(t, result.signalId, issue, orgId, projectId); const linked = await t .withIdentity(identityA) .query(api.signals.listLinkedSignalsForProject, { projectId }); const signals = linked[String(issue)]!; expect(signals).toHaveLength(1); expect(signals[0]!.sourceCount).toBe(2); }); test("returns empty record for a project with no attachments", async () => { const t = newTest(); const _orgId = await ensureOrg(t, identityA); const projectId = await createProject(t, ID_A, "empty"); await createIssue(t, identityA, projectId, "Orphan issue"); const result = await t .withIdentity(identityA) .query(api.signals.listLinkedSignalsForProject, { projectId }); expect(Object.keys(result)).toHaveLength(0); }); test("issue with no linked signals is omitted from the result", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); const projectId = await createProject(t, ID_A, "mixed"); const issueWith = await createIssue(t, identityA, projectId, "Has signal"); const issueWithout = await createIssue( t, identityA, projectId, "No signal" ); const sig = await createSignalInProject( t, identityA, orgId, projectId, "mixed1", problemStatement ); await linkSignalToIssue(t, sig, issueWith, orgId, projectId); const result = await t .withIdentity(identityA) .query(api.signals.listLinkedSignalsForProject, { projectId }); expect(Object.keys(result)).toHaveLength(1); expect(result[String(issueWith)]).toHaveLength(1); expect(result[String(issueWithout)]).toBeUndefined(); }); });