Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -203,15 +203,15 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
},
"BulkReportStatusInput": {
"properties": {
"reportIds": {
"requestIds": {
"items": {
"type": "string",
},
"type": "array",
},
},
"required": [
"reportIds",
"requestIds",
],
"type": "object",
},
Expand Down Expand Up @@ -2344,6 +2344,13 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"null",
],
},
"deletedAt": {
"format": "date-time",
"type": [
"string",
"null",
],
},
"error": {
"type": "string",
},
Expand Down
225 changes: 223 additions & 2 deletions apps/api/src/endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,10 @@ describe("API Endpoints", () => {
const res = await request(testServer()).delete("/api/v1/requests/r1");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("deleted", true);
expect(mocks.reportCollection.updateMany).toHaveBeenCalledWith(
{ requestId: "r1", deletedAt: { $exists: false } },
{ $set: { deletedAt: expect.any(Date), updatedAt: expect.any(Date) } },
);
});

it("returns 404 when request not found", async () => {
Expand All @@ -1062,12 +1066,57 @@ describe("API Endpoints", () => {
expect(res.status).toBe(404);
});

it("returns 410 when request already deleted", async () => {
it("returns 410 when request already deleted and finishes the report cascade", async () => {
const deletedAt = new Date("2026-10-01T00:00:00Z");
(mocks.collection.updateOne as any).mockResolvedValue({ matchedCount: 0, modifiedCount: 0 });
(mocks.collection.findOne as any).mockResolvedValue({ _id: "r1", deletedAt: new Date() });
(mocks.collection.findOne as any).mockResolvedValue({ _id: "r1", deletedAt });

const res = await request(testServer()).delete("/api/v1/requests/r1");
expect(res.status).toBe(410);
expect(mocks.reportCollection.updateMany).toHaveBeenCalledWith(
{ requestId: "r1", deletedAt: { $exists: false } },
{ $set: { deletedAt, updatedAt: deletedAt } },
);
});
});

describe("DELETE /api/v1/requests/bulk", () => {
it("cascades only to reports for requests that were actually deleted", async () => {
(mocks.collection.find as any).mockReturnValue({
toArray: vi.fn().mockResolvedValue([{ _id: "r1" }, { _id: "r3" }]),
});
(mocks.collection.updateMany as any).mockResolvedValue({
matchedCount: 2,
modifiedCount: 2,
});

const res = await request(testServer())
.delete("/api/v1/requests/bulk")
.send({ ids: ["r1", "missing", "r3"] });

expect(res.status).toBe(200);
expect(mocks.reportCollection.updateMany).toHaveBeenCalledWith(
{ requestId: { $in: ["r1", "r3"] }, deletedAt: { $exists: false } },
{ $set: { deletedAt: expect.any(Date), updatedAt: expect.any(Date) } },
);
expect(res.body.notFound).toEqual(["missing"]);
});

it("does not cascade reports when no active requests matched", async () => {
(mocks.collection.find as any).mockReturnValue({
toArray: vi.fn().mockResolvedValue([]),
});
(mocks.collection.updateMany as any).mockResolvedValue({
matchedCount: 0,
modifiedCount: 0,
});

const res = await request(testServer())
.delete("/api/v1/requests/bulk")
.send({ ids: ["missing"] });

expect(res.status).toBe(200);
expect(mocks.reportCollection.updateMany).not.toHaveBeenCalled();
});
});

Expand All @@ -1091,6 +1140,25 @@ describe("API Endpoints", () => {
const res = await request(testServer()).get(`/api/v1/reports?projectId=${TEST_PROJECT_ID}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(mocks.reportCollection.find).toHaveBeenCalledWith({
deletedAt: { $exists: false },
projectId: TEST_PROJECT_ID,
});
});

it("filters soft-deleted reports when listing by requestId", async () => {
(mocks.reportCollection.find as any).mockReturnValue({
sort: vi.fn().mockReturnValue({
toArray: vi.fn().mockResolvedValue([]),
}),
});

const res = await request(testServer()).get("/api/v1/reports?requestId=r1");
expect(res.status).toBe(200);
expect(mocks.reportCollection.find).toHaveBeenCalledWith({
deletedAt: { $exists: false },
requestId: "r1",
});
});
});

Expand All @@ -1109,6 +1177,10 @@ describe("API Endpoints", () => {
expect(res.status).toBe(201);
expect(res.body).toHaveProperty("requestId", "r1");
expect(res.body).toHaveProperty("status", "pending");
expect(mocks.collection.findOne).toHaveBeenCalledWith({
_id: "r1",
deletedAt: { $exists: false },
});
});

it("returns 400 when requestId missing", async () => {
Expand All @@ -1127,6 +1199,155 @@ describe("API Endpoints", () => {
.send({ requestId: "no-such-run" });

expect(res.status).toBe(404);
expect(mocks.collection.findOne).toHaveBeenCalledWith({
_id: "no-such-run",
deletedAt: { $exists: false },
});
});
});

describe("report soft-delete visibility", () => {
it("excludes deleted reports from bulk status", async () => {
(mocks.reportCollection.find as any).mockReturnValue({
sort: vi.fn().mockReturnValue({
toArray: vi.fn().mockResolvedValue([]),
}),
});

const res = await request(testServer())
.post("/api/v1/reports/bulk-status")
.send({ requestIds: ["r1", "r2"] });

expect(res.status).toBe(200);
expect(mocks.reportCollection.find).toHaveBeenCalledWith({
requestId: { $in: ["r1", "r2"] },
deletedAt: { $exists: false },
});
});

it("excludes deleted reports from bulk summary", async () => {
(mocks.reportCollection.aggregate as any).mockReturnValue({
toArray: vi.fn().mockResolvedValue([]),
});

const res = await request(testServer())
.post("/api/v1/reports/bulk-summary")
.send({ requestIds: ["r1", "r2"] });

expect(res.status).toBe(200);
const [pipeline] = (mocks.reportCollection.aggregate as any).mock.calls[0];
expect(pipeline[0]).toEqual({
$match: {
requestId: { $in: ["r1", "r2"] },
deletedAt: { $exists: false },
},
});
});

it("hides deleted reports from point reads", async () => {
(mocks.reportCollection.findOne as any).mockResolvedValue(null);

const res = await request(testServer()).get("/api/v1/reports/rp1");

expect(res.status).toBe(404);
expect(mocks.reportCollection.findOne).toHaveBeenCalledWith({
_id: "rp1",
deletedAt: { $exists: false },
});
});

it("hides deleted reports from log reads", async () => {
(mocks.reportCollection.findOne as any).mockResolvedValue(null);

const res = await request(testServer()).get("/api/v1/reports/rp1/logs");

expect(res.status).toBe(404);
expect(mocks.reportCollection.findOne).toHaveBeenCalledWith({
_id: "rp1",
deletedAt: { $exists: false },
});
});

it("hides deleted reports from report insight reads", async () => {
(mocks.reportCollection.findOne as any).mockResolvedValue(null);

const res = await request(testServer()).get("/api/v1/reports/rp1/insights");

expect(res.status).toBe(404);
expect(mocks.reportCollection.findOne).toHaveBeenCalledWith({
_id: "rp1",
deletedAt: { $exists: false },
});
});

it("hides deleted reports from insight report references", async () => {
(mocks.insightsCollection.findOne as any).mockResolvedValue({
_id: "i1",
content: "insight",
createdAt: new Date(),
});
(mocks.reportCollection.find as any).mockReturnValue({
sort: vi.fn().mockReturnValue({
toArray: vi.fn().mockResolvedValue([]),
}),
});

const res = await request(testServer()).get("/api/v1/insights/i1/reports");

expect(res.status).toBe(200);
expect(mocks.reportCollection.find).toHaveBeenCalledWith({
"insightReferences.insightId": "i1",
deletedAt: { $exists: false },
});
});

it("filters deleted runs from bulk report creation", async () => {
(mocks.collection.find as any).mockReturnValue({
toArray: vi.fn().mockResolvedValue([]),
});

const res = await request(testServer())
.post("/api/v1/reports/bulk-create")
.send({ requestIds: ["r1"] });

expect(res.status).toBe(201);
expect(mocks.collection.find).toHaveBeenCalledWith({
_id: { $in: ["r1"] },
deletedAt: { $exists: false },
});
});

it("filters deleted runs from report triggering", async () => {
(mocks.collection.findOne as any).mockResolvedValue(null);

const res = await request(testServer())
.post("/api/v1/reports/trigger")
.send({ requestId: "r1" });

expect(res.status).toBe(404);
expect(mocks.collection.findOne).toHaveBeenCalledWith({
_id: "r1",
deletedAt: { $exists: false },
});
});

it("filters deleted runs from bulk report triggering", async () => {
(mocks.collection.find as any).mockReturnValue({
toArray: vi.fn().mockResolvedValue([]),
});
(mocks.reportTemplateCollection.find as any).mockReturnValue({
toArray: vi.fn().mockResolvedValue([]),
});

const res = await request(testServer())
.post("/api/v1/reports/bulk-trigger")
.send({ requestIds: ["r1"] });

expect(res.status).toBe(201);
expect(mocks.collection.find).toHaveBeenCalledWith({
_id: { $in: ["r1"] },
deletedAt: { $exists: false },
});
});
});

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/routes/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ apiRoute(ctx.app, ctx.registry, {
}

const reports = await ctx.reportCollection
.find({ "insightReferences.insightId": id })
.find({ "insightReferences.insightId": id, deletedAt: { $exists: false } })
.sort({ createdAt: -1 })
.toArray();

Expand Down
Loading
Loading