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).
388 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|