paperclip/server/src/__tests__/issue-remove-cascade.test.ts

249 lines
7.8 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
costEvents,
createDb,
decisions,
feedbackVotes,
financeEvents,
goals,
heartbeatRuns,
issueComments,
issueInboxArchives,
issueReadStates,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { HttpError } from "../errors.js";
import { issueService } from "../services/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
// These tests protect DELETE /api/issues/:id. A foreign key to issues.id
// without a delete policy raised SQLSTATE 23503 and the route returned a bare
// 500. The dependent rows that have meaning only with their parent issue now
// cascade. The decisions table stays restricted on purpose, so the delete must
// return a typed 409 instead of a 500.
describeEmbeddedPostgres("issueService.remove referential integrity", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-remove-cascade-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
}, 20_000);
afterEach(async () => {
await db.delete(decisions);
await db.delete(issueThreadInteractions);
await db.delete(issueComments);
await db.delete(issueReadStates);
await db.delete(issueInboxArchives);
await db.delete(feedbackVotes);
// finance_events references cost_events, so delete finance rows first.
await db.delete(financeEvents);
await db.delete(costEvents);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(goals);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAgentRun() {
const companyId = randomUUID();
const goalId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(goals).values({
id: goalId,
companyId,
title: "Delete safety",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Origin",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "succeeded",
});
return { companyId, goalId, agentId, runId };
}
async function seedIssue(companyId: string, goalId: string) {
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Deletable issue",
status: "in_progress",
priority: "medium",
});
return issueId;
}
it("deletes an issue and cascades its dependent rows", async () => {
const { companyId, goalId, runId } = await seedCompanyAgentRun();
const issueId = await seedIssue(companyId, goalId);
await db.insert(feedbackVotes).values({
id: randomUUID(),
companyId,
issueId,
targetType: "comment",
targetId: "target-1",
authorUserId: "board-user",
vote: "up",
});
await db.insert(issueComments).values({
id: randomUUID(),
companyId,
issueId,
authorUserId: "board-user",
body: "First comment.",
});
await db.insert(issueThreadInteractions).values({
id: randomUUID(),
companyId,
issueId,
kind: "request_confirmation",
sourceRunId: runId,
payload: {
version: 1,
prompt: "Confirm the change.",
detailsMarkdown: "Details.",
target: { type: "custom", key: "target", revisionId: "v1" },
},
});
await db.insert(issueReadStates).values({
id: randomUUID(),
companyId,
issueId,
userId: "board-user",
});
await db.insert(issueInboxArchives).values({
id: randomUUID(),
companyId,
issueId,
userId: "board-user",
});
const removed = await svc.remove(issueId);
expect(removed).not.toBeNull();
expect(removed?.id).toBe(issueId);
expect(await db.select().from(issues).where(eq(issues.id, issueId))).toHaveLength(0);
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).toHaveLength(0);
expect(
await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId)),
).toHaveLength(0);
expect(await db.select().from(issueReadStates).where(eq(issueReadStates.issueId, issueId))).toHaveLength(0);
expect(await db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, issueId))).toHaveLength(0);
expect(await db.select().from(feedbackVotes).where(eq(feedbackVotes.issueId, issueId))).toHaveLength(0);
});
it("deletes an issue and keeps ledger rows with issueId set to null", async () => {
const { companyId, goalId, agentId } = await seedCompanyAgentRun();
const issueId = await seedIssue(companyId, goalId);
const costEventId = randomUUID();
await db.insert(costEvents).values({
id: costEventId,
companyId,
agentId,
issueId,
provider: "anthropic",
model: "claude-opus-4-8",
costCents: 100,
occurredAt: new Date(),
});
const financeEventId = randomUUID();
await db.insert(financeEvents).values({
id: financeEventId,
companyId,
agentId,
issueId,
costEventId,
eventKind: "usage",
biller: "anthropic",
amountCents: 100,
occurredAt: new Date(),
});
const removed = await svc.remove(issueId);
expect(removed?.id).toBe(issueId);
expect(await db.select().from(issues).where(eq(issues.id, issueId))).toHaveLength(0);
// The ledger rows must survive the delete with a detached issueId.
const costRows = await db.select().from(costEvents).where(eq(costEvents.id, costEventId));
expect(costRows).toHaveLength(1);
expect(costRows[0]?.issueId).toBeNull();
const financeRows = await db.select().from(financeEvents).where(eq(financeEvents.id, financeEventId));
expect(financeRows).toHaveLength(1);
expect(financeRows[0]?.issueId).toBeNull();
});
it("returns a 409 conflict when a restricted decisions row references the issue", async () => {
const { companyId, goalId, agentId, runId } = await seedCompanyAgentRun();
const issueId = await seedIssue(companyId, goalId);
await db.insert(decisions).values({
id: randomUUID(),
companyId,
originAgentId: agentId,
originIssueId: issueId,
originRunId: runId,
title: "Ledger decision",
body: "A decision that must survive issue deletion.",
options: [],
expiresAt: new Date(Date.now() + 60_000),
signedSpec: "signed",
targetSnapshots: {},
});
await expect(svc.remove(issueId)).rejects.toBeInstanceOf(HttpError);
await expect(svc.remove(issueId)).rejects.toMatchObject({ status: 409 });
// The delete failed, so the issue row must still exist.
expect(await db.select().from(issues).where(eq(issues.id, issueId))).toHaveLength(1);
});
});