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).
This commit is contained in:
387
packages/backend/convex/linkedSignals.test.ts
Normal file
387
packages/backend/convex/linkedSignals.test.ts
Normal file
@@ -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<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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user