Files
zopu-code/packages/backend/convex/workPlanning.test.ts

398 lines
11 KiB
TypeScript

import { convexTest } from "convex-test";
import { anyApi } 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 identity = { tokenIdentifier: "https://convex.test|planner-user" };
const seedProject = async (t: ReturnType<typeof convexTest>) =>
await t.withIdentity(identity).run(async (ctx) => {
const organizationId = await ctx.db.insert("organizations", {
createdAt: 1,
createdBy: identity.tokenIdentifier,
kind: "personal",
name: "Personal",
});
await ctx.db.insert("organizationMembers", {
createdAt: 1,
organizationId,
role: "owner",
userId: identity.tokenIdentifier,
});
const projectId = await ctx.db.insert("projects", {
createdAt: 2,
defaultBranch: "main",
name: "Test Project",
normalizedSourceUrl: "https://git.example.com/test/repo",
organizationId,
repositoryPath: "test/repo",
sourceHost: "gitea",
sourceUrl: "https://git.example.com/test/repo.git",
updatedAt: 2,
});
return { organizationId, projectId };
});
const seedReadyWork = async (
t: ReturnType<typeof convexTest>,
organizationId: string,
projectId: string,
workIdOverride?: string
) =>
await t.withIdentity(identity).run(async (ctx) => {
const workId =
workIdOverride ??
(await ctx.db.insert("works", {
createdAt: 3,
definitionVersion: 1,
designVersion: 1,
objective: "Build it",
organizationId,
projectId,
status: "ready",
title: "Ready Work",
updatedAt: 3,
}));
await ctx.db.insert("workDefinitions", {
createdAt: 3,
createdBy: "test",
payloadJson: JSON.stringify({
acceptanceCriteria: ["Works"],
assumptions: [],
constraints: [],
desiredOutcome: "Ship",
inScope: [],
outOfScope: [],
problem: "Missing",
questions: [],
requiredArtifacts: [],
risk: "low",
version: 1,
}),
risk: "low",
status: "current",
version: 1,
workId,
});
await ctx.db.insert("designPackets", {
createdAt: 3,
createdBy: "test",
definitionVersion: 1,
payloadJson: JSON.stringify({
architectureSummary: "Simple",
callFlowDelta: [],
concerns: [],
definitionVersion: 1,
evidenceRequirements: ["Tests pass"],
fileTreeDelta: [],
impactMap: {
files: [],
modules: [],
risks: [],
summary: "Low",
},
invariants: ["No breaking changes"],
keyInterfaces: [],
slices: [
{
codeBoundaries: ["src/index.ts"],
dependsOn: [],
evidenceRequirements: ["Build succeeds"],
id: "s1",
objective: "Implement",
observableBehavior: "Feature works",
reviewRequired: false,
title: "Slice 1",
verification: ["Run tests"],
},
],
tradeoffs: [],
version: 1,
}),
status: "current",
version: 1,
workId,
});
await ctx.db.insert("workSlices", {
createdAt: 3,
designVersion: 1,
objective: "Implement",
observableBehavior: "It works",
ordinal: 0,
payloadJson: "{}",
sliceId: "s1",
status: "ready",
title: "Slice 1",
workId,
});
return workId;
});
describe("Work planning without approvals", () => {
test("creates proposed Work and starts Definition planning on request", async () => {
const t = convexTest({ modules, schema });
const { organizationId, projectId } = await seedProject(t);
const workId = await t.run(
async (ctx) =>
await ctx.db.insert("works", {
createdAt: 4,
objective: "Implement the feature",
organizationId,
projectId,
status: "proposed",
title: "Feature Work",
updatedAt: 4,
})
);
await t
.withIdentity(identity)
.mutation(api.workPlanning.requestDefinition, {
workId,
});
const work = await t.run(async (ctx) => await ctx.db.get(workId));
expect(work?.status).toBe("defining");
});
test("Definition persistence auto-advances to Design phase", async () => {
const t = convexTest({ modules, schema });
const { organizationId, projectId } = await seedProject(t);
const workId = await t.run(
async (ctx) =>
await ctx.db.insert("works", {
createdAt: 1,
objective: "Build it",
organizationId,
projectId,
status: "defining",
title: "Auto-advance test",
updatedAt: 1,
})
);
const definition = {
acceptanceCriteria: ["Works end to end"],
assumptions: [],
constraints: [],
desiredOutcome: "Feature is live",
inScope: ["Backend"],
outOfScope: ["Mobile"],
problem: "Missing feature",
questions: [],
requiredArtifacts: ["PR"],
risk: "low",
};
await t
.withIdentity(identity)
.mutation(api.workPlanning.saveDefinitionProposal, {
payloadJson: JSON.stringify(definition),
workId,
});
const work = await t.run(async (ctx) => await ctx.db.get(workId));
expect(work?.status).toBe("designing");
expect(work?.definitionVersion).toBe(1);
});
test("Design persistence makes Work ready for execution", async () => {
const t = convexTest({ modules, schema });
const { organizationId, projectId } = await seedProject(t);
const workId = await t.run(
async (ctx) =>
await ctx.db.insert("works", {
createdAt: 1,
definitionVersion: 1,
objective: "Build it",
organizationId,
projectId,
status: "designing",
title: "Ready test",
updatedAt: 1,
})
);
await t.run(async (ctx) => {
await ctx.db.insert("workDefinitions", {
createdAt: 2,
createdBy: "test",
payloadJson: JSON.stringify({
acceptanceCriteria: [],
assumptions: [],
constraints: [],
desiredOutcome: "Ship",
inScope: [],
outOfScope: [],
problem: "Missing",
questions: [],
requiredArtifacts: [],
risk: "low",
version: 1,
}),
risk: "low",
status: "current",
version: 1,
workId,
});
});
const design = {
architectureSummary: "Simple",
callFlowDelta: [],
concerns: [],
evidenceRequirements: ["Tests pass"],
fileTreeDelta: [],
impactMap: {
files: [],
modules: [],
risks: [],
summary: "Low",
},
invariants: ["No breaking changes"],
keyInterfaces: [],
slices: [
{
codeBoundaries: ["src/index.ts"],
dependsOn: [],
evidenceRequirements: ["Build succeeds"],
id: "s1",
objective: "Implement",
observableBehavior: "Feature works",
reviewRequired: false,
title: "Slice 1",
verification: ["Run tests"],
},
],
tradeoffs: [],
};
await t
.withIdentity(identity)
.mutation(api.workPlanning.saveDesignProposal, {
payloadJson: JSON.stringify(design),
workId,
});
const work = await t.run(async (ctx) => await ctx.db.get(workId));
expect(work?.status).toBe("ready");
expect(work?.designVersion).toBe(1);
});
test("revising a Definition invalidates existing Design and restarts planning", async () => {
const t = convexTest({ modules, schema });
const { organizationId, projectId } = await seedProject(t);
const workId = await t.run(
async (ctx) =>
await ctx.db.insert("works", {
createdAt: 1,
definitionVersion: 1,
designVersion: 1,
objective: "Build it",
organizationId,
projectId,
status: "designing",
title: "Revision test",
updatedAt: 1,
})
);
await t.run(async (ctx) => {
await ctx.db.insert("designPackets", {
createdAt: 2,
createdBy: "test",
definitionVersion: 1,
payloadJson: "{}",
status: "current",
version: 1,
workId,
});
});
const revisedDefinition = {
acceptanceCriteria: ["Works"],
assumptions: [],
constraints: [],
desiredOutcome: "Ship",
inScope: ["All"],
outOfScope: [],
problem: "Missing",
questions: [],
requiredArtifacts: [],
risk: "medium",
};
await t
.withIdentity(identity)
.mutation(api.workPlanning.saveDefinitionProposal, {
payloadJson: JSON.stringify(revisedDefinition),
workId,
});
const work = await t.run(async (ctx) => await ctx.db.get(workId));
expect(work?.status).toBe("designing");
expect(work?.definitionVersion).toBe(2);
expect(work?.designVersion).toBeUndefined();
const designs = await t.run(
async (ctx) =>
await ctx.db
.query("designPackets")
.withIndex("by_work_and_version", (q) => q.eq("workId", workId))
.collect()
);
expect(designs.every((d) => d.status === "superseded")).toBe(true);
});
test("rejects execution when Work is not ready", async () => {
const t = convexTest({ modules, schema });
const { organizationId, projectId } = await seedProject(t);
const workId = await t.run(
async (ctx) =>
await ctx.db.insert("works", {
createdAt: 3,
objective: "Test",
organizationId,
projectId,
status: "proposed",
title: "Proposed Work",
updatedAt: 3,
})
);
await expect(
t.withIdentity(identity).mutation(api.workExecution.start, { workId })
).rejects.toThrow(/Ready/u);
});
test("rejects execution when Work lacks Definition or Design", async () => {
const t = convexTest({ modules, schema });
const { organizationId, projectId } = await seedProject(t);
const workId = await t.run(
async (ctx) =>
await ctx.db.insert("works", {
createdAt: 3,
objective: "Test",
organizationId,
projectId,
status: "ready",
title: "Ready but unplanned",
updatedAt: 3,
})
);
await expect(
t.withIdentity(identity).mutation(api.workExecution.start, { workId })
).rejects.toThrow(/Definition and Design/u);
});
test("execution mutations accept service token for agent calls", async () => {
const t = convexTest({ modules, schema });
const { organizationId, projectId } = await seedProject(t);
const workId = await seedReadyWork(t, organizationId, projectId);
// Service-auth path: token + organizationId, no user identity needed.
// This should not throw an auth error (it may fail later for missing
// git connection, but auth itself must pass).
await expect(
t.mutation(api.workExecution.start, {
organizationId,
token: "invalid-token",
workId,
})
).rejects.toThrow(/agent control token/u);
});
});