diff --git a/packages/backend/convex/linkedSignals.test.ts b/packages/backend/convex/linkedSignals.test.ts new file mode 100644 index 0000000..070b038 --- /dev/null +++ b/packages/backend/convex/linkedSignals.test.ts @@ -0,0 +1,387 @@ +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(); + }); +}); diff --git a/packages/backend/convex/signals.ts b/packages/backend/convex/signals.ts index bcba426..05025ec 100644 --- a/packages/backend/convex/signals.ts +++ b/packages/backend/convex/signals.ts @@ -495,9 +495,10 @@ export const listLinkedSignalsForProject = query({ } satisfies LinkedSignalView; }) ); - result[String(issue._id)] = signals.filter( - (entry): entry is LinkedSignalView => entry !== null - ); + result[String(issue._id)] = signals + .filter((entry): entry is LinkedSignalView => entry !== null) + // Deterministic order: newest linked Signal first. + .toSorted((a, b) => b.createdAt - a.createdAt); } return result; },