398 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|