542 lines
16 KiB
TypeScript
542 lines
16 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";
|
|
|
|
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
|
|
// has no type definition under the Convex tsconfig (which scopes `types` to
|
|
// node), so declare the minimal shape the test relies on.
|
|
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 repository = {
|
|
defaultBranch: "main",
|
|
id: 12345,
|
|
name: "zopu",
|
|
owner: "puter",
|
|
url: "https://github.com/puter/zopu",
|
|
};
|
|
|
|
// Begin and admit one user message, returning its messageId.
|
|
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,
|
|
repoId = 12345
|
|
): Promise<Id<"projects">> => {
|
|
return await t.mutation(internal.projects.storeGitHubProject, {
|
|
ownerId,
|
|
repository: { ...repository, id: repoId },
|
|
});
|
|
};
|
|
|
|
describe("signals", () => {
|
|
test("unauthenticated create is rejected", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
|
|
await expect(
|
|
t.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: ["m1"],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/Authentication required/u);
|
|
});
|
|
|
|
test("creates one Signal from a single admitted user message", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const rawText = " I cannot deploy to staging ";
|
|
const messageId = await seedMessage(t, identityA, orgId, "req-1", rawText);
|
|
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId: result.signalId });
|
|
|
|
expect(composed).not.toBeNull();
|
|
expect(composed?.signal.organizationId).toBe(orgId);
|
|
expect(composed?.signal.projectId).toBeNull();
|
|
expect(composed?.signal.conversationId).toBe(orgId);
|
|
expect(composed?.sources).toHaveLength(1);
|
|
expect(composed?.sources[0]?.messageId).toBe(messageId);
|
|
// Exact raw snapshot preserved verbatim (whitespace intact).
|
|
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
|
expect(composed?.sources[0]?.submissionId).toBe("sub-req-1");
|
|
expect(composed?.sources[0]?.ordinal).toBe(0);
|
|
});
|
|
|
|
test("composes several ordered user messages into one Signal", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const m1 = await seedMessage(t, identityA, orgId, "req-a", "first message");
|
|
const m2 = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgId,
|
|
"req-b",
|
|
"second message"
|
|
);
|
|
const m3 = await seedMessage(t, identityA, orgId, "req-c", "third message");
|
|
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m1, m2, m3],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId: result.signalId });
|
|
|
|
expect(composed?.sources).toHaveLength(3);
|
|
expect(composed?.sources[0]?.messageId).toBe(m1);
|
|
expect(composed?.sources[0]?.ordinal).toBe(0);
|
|
expect(composed?.sources[1]?.messageId).toBe(m2);
|
|
expect(composed?.sources[1]?.ordinal).toBe(1);
|
|
expect(composed?.sources[2]?.messageId).toBe(m3);
|
|
expect(composed?.sources[2]?.ordinal).toBe(2);
|
|
expect(
|
|
composed?.sources.map(
|
|
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
|
|
)
|
|
).toEqual(["first message", "second message", "third message"]);
|
|
});
|
|
|
|
test("preserves exact raw snapshots and a separate structured problem statement", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const rawText = "\n\t leading and trailing \n";
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgId,
|
|
"req-exact",
|
|
rawText
|
|
);
|
|
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId: result.signalId });
|
|
|
|
// Raw evidence is exact and untouched.
|
|
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
|
// The structured problem statement is stored separately.
|
|
expect(composed?.signal.problemStatement).toEqual(problemStatement);
|
|
});
|
|
|
|
test("rejects cross-organization messages", async () => {
|
|
const t = newTest();
|
|
const orgA = await ensureOrg(t, identityA);
|
|
const orgB = await ensureOrg(t, identityB);
|
|
// Message belongs to org A.
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgA,
|
|
"req-x",
|
|
"secret A"
|
|
);
|
|
|
|
// User B tries to build a signal under org B referencing org A's message.
|
|
await expect(
|
|
t.withIdentity(identityB).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgB,
|
|
messageIds: [messageId],
|
|
organizationId: orgB,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/Source message not found/u);
|
|
});
|
|
|
|
test("rejects cross-conversation mismatch", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const messageId = await seedMessage(t, identityA, orgId, "req-conv", "hi");
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: "different-conversation",
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/Conversation does not belong to organization/u);
|
|
});
|
|
|
|
test("rejects a failed (non-admitted) message", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
|
|
// Begin then fail a message.
|
|
const begun = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.beginUserMessage, {
|
|
clientRequestId: "req-failed",
|
|
organizationId: orgId,
|
|
rawText: "doomed",
|
|
});
|
|
await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.markFailed, {
|
|
clientRequestId: "req-failed",
|
|
organizationId: orgId,
|
|
});
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [begun.messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/not admitted/u);
|
|
});
|
|
|
|
test("rejects a missing message id", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: ["nonexistent-id"],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/Source message not found/u);
|
|
});
|
|
|
|
test("rejects duplicate message ids in the selection", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const messageId = await seedMessage(t, identityA, orgId, "req-dup", "dup");
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [messageId, messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/must be unique/u);
|
|
});
|
|
|
|
test("rejects an empty message selection", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/At least one source message is required/u);
|
|
});
|
|
|
|
test("idempotent retry returns the same signal without duplicate sources", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const m1 = await seedMessage(t, identityA, orgId, "req-idem-1", "a");
|
|
const m2 = await seedMessage(t, identityA, orgId, "req-idem-2", "b");
|
|
|
|
const first = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m1, m2],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
// Retry with the same ordered selection.
|
|
const second = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m1, m2],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
expect(second.signalId).toBe(first.signalId);
|
|
|
|
// Exactly one signal and two sources exist.
|
|
const list = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.list, { organizationId: orgId });
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0]?.sources).toHaveLength(2);
|
|
});
|
|
|
|
test("different ordered selections produce different signals", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const m1 = await seedMessage(t, identityA, orgId, "req-ord-1", "x");
|
|
const m2 = await seedMessage(t, identityA, orgId, "req-ord-2", "y");
|
|
|
|
const first = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m1, m2],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
const second = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m2, m1],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
expect(second.signalId).not.toBe(first.signalId);
|
|
|
|
const list = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.list, { organizationId: orgId });
|
|
expect(list).toHaveLength(2);
|
|
});
|
|
|
|
test("creates a project-scoped signal when the project owner is an org member", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
// Project owned by user A, who is a member of orgId.
|
|
const projectId = await createProject(t, ID_A);
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgId,
|
|
"req-proj",
|
|
"build"
|
|
);
|
|
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
projectId,
|
|
processedBy,
|
|
});
|
|
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId: result.signalId });
|
|
|
|
expect(composed?.signal.projectId).toBe(projectId);
|
|
});
|
|
|
|
test("rejects a project whose owner is not an org member (cross-tenant)", async () => {
|
|
const t = newTest();
|
|
const orgA = await ensureOrg(t, identityA);
|
|
const _orgB = await ensureOrg(t, identityB);
|
|
// Project owned by user B.
|
|
const projectId = await createProject(t, ID_B);
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgA,
|
|
"req-cross-proj",
|
|
"z"
|
|
);
|
|
|
|
// User A is a member of orgA but the project owner (B) is not.
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [messageId],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
projectId,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/does not belong to the target organization/u);
|
|
});
|
|
|
|
test("authorized list returns only the organization's signals with composed sources", async () => {
|
|
const t = newTest();
|
|
const orgA = await ensureOrg(t, identityA);
|
|
const orgB = await ensureOrg(t, identityB);
|
|
|
|
const mA1 = await seedMessage(t, identityA, orgA, "req-list-1", "a1");
|
|
const mA2 = await seedMessage(t, identityA, orgA, "req-list-2", "a2");
|
|
await seedMessage(t, identityB, orgB, "req-list-b", "b1");
|
|
|
|
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [mA1],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [mA1, mA2],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
const forA = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.list, { organizationId: orgA });
|
|
const forB = await t
|
|
.withIdentity(identityB)
|
|
.query(api.signals.list, { organizationId: orgB });
|
|
|
|
expect(forA).toHaveLength(2);
|
|
expect(forA[0]?.sources.length).toBeGreaterThan(0);
|
|
expect(forB).toHaveLength(0);
|
|
|
|
// Cross-org list is denied.
|
|
await expect(
|
|
t
|
|
.withIdentity(identityB)
|
|
.query(api.signals.list, { organizationId: orgA })
|
|
).rejects.toThrow(/Organization membership required/u);
|
|
});
|
|
|
|
test("authorized get denies cross-organization signal access", async () => {
|
|
const t = newTest();
|
|
const orgA = await ensureOrg(t, identityA);
|
|
const _orgB = await ensureOrg(t, identityB);
|
|
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgA,
|
|
"req-get",
|
|
"secret"
|
|
);
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [messageId],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
// User B cannot read org A's signal.
|
|
await expect(
|
|
t
|
|
.withIdentity(identityB)
|
|
.query(api.signals.get, { signalId: result.signalId })
|
|
).rejects.toThrow(/Organization membership required/u);
|
|
|
|
// get returns null for an unknown but well-formed id when authorized.
|
|
const other = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [await seedMessage(t, identityA, orgA, "req-ghost", "g")],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
expect(other.signalId).not.toBe(result.signalId);
|
|
});
|
|
});
|