172 lines
4.9 KiB
TypeScript
172 lines
4.9 KiB
TypeScript
import { convexTest } from "convex-test";
|
|
import { anyApi, makeFunctionReference } from "convex/server";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
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 identityA = { tokenIdentifier: "https://convex.test|user-a" };
|
|
const identityB = { tokenIdentifier: "https://convex.test|user-b" };
|
|
const newTest = () => convexTest({ modules, schema });
|
|
const markProcessingRef = makeFunctionReference<
|
|
"mutation",
|
|
{ turnId: string; attempt: number; leaseOwner: string },
|
|
boolean
|
|
>("conversationMessages:markProcessing");
|
|
const failTurnRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
turnId: string;
|
|
attempt: number;
|
|
leaseOwner: string;
|
|
error: string;
|
|
retry: boolean;
|
|
},
|
|
boolean
|
|
>("conversationMessages:failTurn");
|
|
|
|
const ensureOrg = async (
|
|
t: ReturnType<typeof newTest>,
|
|
identity: { readonly tokenIdentifier: string }
|
|
) =>
|
|
await t
|
|
.withIdentity(identity)
|
|
.mutation(api.organizations.ensurePersonalOrganization, {});
|
|
|
|
describe("conversationMessages", () => {
|
|
test("queues one durable user turn and one assistant placeholder", async () => {
|
|
const t = newTest();
|
|
const organization = await ensureOrg(t, identityA);
|
|
|
|
await t.withIdentity(identityA).mutation(api.conversationMessages.send, {
|
|
clientRequestId: "request-1",
|
|
images: [],
|
|
organizationId: organization._id,
|
|
rawText: " Keep this exact text. ",
|
|
});
|
|
|
|
const rows = await t
|
|
.withIdentity(identityA)
|
|
.query(api.conversationMessages.listForCurrentOrganization, {
|
|
organizationId: organization._id,
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows[0]).toMatchObject({
|
|
rawText: " Keep this exact text. ",
|
|
role: "user",
|
|
status: "queued",
|
|
});
|
|
expect(rows[1]).toMatchObject({
|
|
rawText: "",
|
|
role: "assistant",
|
|
status: "queued",
|
|
});
|
|
});
|
|
|
|
test("is idempotent by client request id", async () => {
|
|
const t = newTest();
|
|
const organization = await ensureOrg(t, identityA);
|
|
const input = {
|
|
clientRequestId: "request-duplicate",
|
|
images: [],
|
|
organizationId: organization._id,
|
|
rawText: "First payload wins",
|
|
};
|
|
|
|
const first = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.send, input);
|
|
const second = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.send, {
|
|
...input,
|
|
rawText: "Ignored duplicate payload",
|
|
});
|
|
|
|
expect(second).toEqual(first);
|
|
const rows = await t
|
|
.withIdentity(identityA)
|
|
.query(api.conversationMessages.listForCurrentOrganization, {
|
|
organizationId: organization._id,
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows[0]?.rawText).toBe("First payload wins");
|
|
});
|
|
|
|
test("rejects unauthenticated and cross-organization access", async () => {
|
|
const t = newTest();
|
|
const organization = await ensureOrg(t, identityA);
|
|
await ensureOrg(t, identityB);
|
|
const input = {
|
|
clientRequestId: "request-private",
|
|
images: [],
|
|
organizationId: organization._id,
|
|
rawText: "Private",
|
|
};
|
|
|
|
await expect(
|
|
t.mutation(api.conversationMessages.send, input)
|
|
).rejects.toThrow(/Authentication required/u);
|
|
await expect(
|
|
t.withIdentity(identityB).mutation(api.conversationMessages.send, input)
|
|
).rejects.toThrow(/Organization membership required/u);
|
|
await expect(
|
|
t
|
|
.withIdentity(identityB)
|
|
.query(api.conversationMessages.listForCurrentOrganization, {
|
|
organizationId: organization._id,
|
|
})
|
|
).rejects.toThrow(/Organization membership required/u);
|
|
});
|
|
|
|
test("fences stale dispatch failures after admission", async () => {
|
|
const t = newTest();
|
|
const organization = await ensureOrg(t, identityA);
|
|
const sent = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.send, {
|
|
clientRequestId: "request-fenced",
|
|
images: [],
|
|
organizationId: organization._id,
|
|
rawText: "Keep only the admitted submission",
|
|
});
|
|
|
|
expect(
|
|
await t.mutation(markProcessingRef, {
|
|
attempt: 1,
|
|
leaseOwner: "worker-1",
|
|
turnId: sent.turnId,
|
|
})
|
|
).toBe(true);
|
|
await t.run(async (ctx) => {
|
|
await ctx.db.patch(sent.turnId, {
|
|
leaseExpiresAt: undefined,
|
|
leaseOwner: undefined,
|
|
status: "running",
|
|
submissionId: "submission-1",
|
|
});
|
|
});
|
|
|
|
expect(
|
|
await t.mutation(failTurnRef, {
|
|
attempt: 1,
|
|
error: "lost 202",
|
|
leaseOwner: "worker-1",
|
|
retry: true,
|
|
turnId: sent.turnId,
|
|
})
|
|
).toBe(false);
|
|
expect(await t.run((ctx) => ctx.db.get(sent.turnId))).toMatchObject({
|
|
status: "running",
|
|
submissionId: "submission-1",
|
|
});
|
|
});
|
|
});
|