Files
zopu-code/packages/backend/convex/linkedSignals.test.ts
-Puter d645d3e08b test(signals): linked-signals query coverage and deterministic ordering
Add Convex backend tests for listLinkedSignalsForProject covering:
- Per-issue grouping with correct source counts
- Project membership/authz (denies non-member access)
- Cross-project signal suppression (project1 cannot see project2 signals)
- Multi-source signal source-count accuracy
- Empty project returns empty record
- Issues without linked signals are omitted from result

Make linked signals deterministically ordered newest-first within
each issue group via toSorted(createdAt descending).
2026-07-24 21:36:54 +05:30

388 lines
12 KiB
TypeScript

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<string, () => Promise<unknown>>;
}
}
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<typeof schema>,
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<typeof schema>,
identity: { readonly tokenIdentifier: string },
orgId: Id<"organizations">,
clientRequestId: string,
rawText: string
): Promise<string> => {
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<typeof schema>,
ownerId: string,
suffix: string
): Promise<Id<"projects">> => {
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<typeof schema>,
identity: { readonly tokenIdentifier: string },
orgId: Id<"organizations">,
projectId: Id<"projects">,
msgSuffix: string,
ps: typeof problemStatement
): Promise<Id<"signals">> => {
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<typeof schema>,
signalId: Id<"signals">,
issueId: Id<"projectIssues">,
organizationId: Id<"organizations">,
projectId: Id<"projects">
): Promise<void> => {
await t.mutation((ctx) =>
ctx.db.insert("signalIssueAttachments", {
createdAt: Date.now(),
issueId,
organizationId,
projectId,
signalId,
})
);
};
const createIssue = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string },
projectId: Id<"projects">,
title: string
): Promise<Id<"projectIssues">> =>
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();
});
});