Files
PaperClipAI/ui/src/api/issues.test.ts
T
DottaandPaperclip c91fa107ba fix(ui): settle task geometry and seed saved card reads
Validate linked comment fragments and encode their path component. Seed saved work-product caches from core while duplicate reads remain pending, and reveal only after the initial pane width settles.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-29 08:26:27 -05:00

353 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
}));
vi.mock("./client", async (importOriginal) => ({
...(await importOriginal<typeof import("./client")>()),
api: mockApi,
}));
import type { IssueThreadInteraction } from "@paperclipai/shared";
import { acceptedResolutionMatches, answeredResolutionMatches, issuesApi } from "./issues";
import { ApiError } from "./client";
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
describe("issuesApi.list", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.post.mockReset();
mockApi.patch.mockReset();
mockApi.get.mockResolvedValue([]);
mockApi.post.mockResolvedValue({
id: "9af8228f-0be7-45ae-a104-6fbe0af6f1d3",
issueId: "5e5f9946-c706-4785-8988-d4d6f0f499ab",
body: "Saved fixture",
});
mockApi.patch.mockResolvedValue({});
});
it("encodes a linked comment id as one path component and forwards cancellation", async () => {
const signal = new AbortController().signal;
await issuesApi.getComment("issue-1", "../../health?raw=true#fragment", { signal });
expect(mockApi.get).toHaveBeenCalledWith(
"/issues/issue-1/comments/..%2F..%2Fhealth%3Fraw%3Dtrue%23fragment", { signal },
);
});
it.each([null, "stopped-run"])("dispatches a stopped queue using its current revision (%s)", async (target) => {
mockApi.get.mockResolvedValueOnce({ queueId: "queue-1", targetRunId: null, revision: "revision-2" });
await issuesApi.interruptLatestQueuedComments("issue-1", target);
expect(mockApi.post).toHaveBeenCalledWith("/issues/issue-1/queued-comments/interrupt", {
queueId: "queue-1", targetRunId: null, revision: "revision-2",
});
});
it.each([
{ queueId: null, targetRunId: null, revision: "empty" },
{ queueId: "queue-1", targetRunId: "new-run", revision: "changed" },
])("rejects a changed or empty queue before interruption", async queue => {
mockApi.get.mockResolvedValueOnce(queue);
await expect(issuesApi.interruptLatestQueuedComments("issue-1", "old-run")).rejects.toThrow("queued messages changed");
expect(mockApi.post).not.toHaveBeenCalled();
});
it("fetches all pages of tasks created from the source without filtering parentage", async () => {
const firstPage = Array.from({ length: 500 }, (_, index) => ({ id: `task-${index}` }));
mockApi.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce([{ id: "last-task" }]);
const result = await issuesApi.listAll("company-1", { createdFromIssueId: "source-1" });
expect(result).toHaveLength(501);
expect(mockApi.get).toHaveBeenNthCalledWith(1, "/companies/company-1/issues?createdFromIssueId=source-1&limit=500&sortField=id&sortDir=asc");
expect(mockApi.get).toHaveBeenNthCalledWith(2, "/companies/company-1/issues?createdFromIssueId=source-1&limit=500&sortField=id&sortDir=asc&afterId=task-499");
});
it("passes parentId through to the company issues endpoint", async () => {
await issuesApi.list("company-1", {
parentId: "issue-parent-1",
limit: 25,
});
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?parentId=issue-parent-1&limit=25",
);
});
it("sends explicit attachment receipt IDs with the atomic comment request", async () => {
const ids = [
"9af8228f-0be7-45ae-a104-6fbe0af6f1d3",
"5e5f9946-c706-4785-8988-d4d6f0f499ab",
];
await issuesApi.addComment("issue-1", "Inspect these", true, false, ids);
expect(mockApi.post).toHaveBeenCalledWith("/issues/issue-1/comments", {
body: "Inspect these",
reopen: true,
interrupt: false,
attachmentIds: ids,
});
await issuesApi.addComment(
"issue-1",
"[old](/api/attachments/old/content)",
);
expect(mockApi.post).toHaveBeenLastCalledWith("/issues/issue-1/comments", {
body: "[old](/api/attachments/old/content)",
});
});
it.each([
new TypeError("Failed to fetch"),
new SyntaxError("Unexpected end of JSON"),
new ApiError("Internal", 500, {}),
])(
"treats missing or invalid comment receipts as unknown %#",
async (error) => {
mockApi.post.mockRejectedValueOnce(error);
await expect(
issuesApi.addComment("issue-1", "saved maybe"),
).rejects.toBeInstanceOf(CommentSubmissionUnknownError);
mockApi.patch.mockRejectedValueOnce(error);
await expect(
issuesApi.update("issue-1", {
comment: "saved maybe",
assigneeUserId: "another",
}),
).rejects.toBeInstanceOf(CommentSubmissionUnknownError);
mockApi.patch.mockRejectedValueOnce(error);
await expect(
issuesApi.update("issue-1", { title: "ordinary update" }),
).rejects.toBe(error);
},
);
it.each([409, 422])(
"retains a known HTTP%d comment rejection",
async (status) => {
const error = new ApiError("Rejected", status, {});
mockApi.post.mockRejectedValueOnce(error);
await expect(issuesApi.addComment("issue-1", "not saved")).rejects.toBe(
error,
);
mockApi.patch.mockRejectedValueOnce(error);
await expect(
issuesApi.update("issue-1", { comment: "not saved" }),
).rejects.toBe(error);
},
);
it.each([null, {}, { id: "not-a-comment", body: "text" }])(
"does not confirm a parsed but missing comment receipt %#",
async (receipt) => {
mockApi.post.mockResolvedValueOnce(receipt);
await expect(
issuesApi.addComment("issue-1", "saved maybe"),
).rejects.toBeInstanceOf(CommentSubmissionUnknownError);
},
);
it("passes descendantOf through to the company issues endpoint", async () => {
await issuesApi.list("company-1", {
descendantOf: "issue-root-1",
includeBlockedBy: true,
limit: 25,
});
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?descendantOf=issue-root-1&includeBlockedBy=true&limit=25",
);
});
it("passes generic workspaceId filters through to the company issues endpoint", async () => {
await issuesApi.list("company-1", {
workspaceId: "workspace-1",
limit: 1000,
});
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?workspaceId=workspace-1&limit=1000",
);
});
it("passes pagination offsets through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { limit: 500, offset: 1500 });
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?limit=500&offset=1500",
);
});
it("passes issue list sort options through to the company issues endpoint", async () => {
await issuesApi.list("company-1", {
limit: 500,
sortField: "updated",
sortDir: "desc",
});
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?limit=500&sortField=updated&sortDir=desc",
);
});
it("requests the compact issue list view explicitly", async () => {
await issuesApi.listCompact("company-1", {
touchedByUserId: "me",
includeLiveDescendantSummary: true,
limit: 100,
sortField: "updated",
sortDir: "desc",
});
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?touchedByUserId=me&includeLiveDescendantSummary=true&limit=100&sortField=updated&sortDir=desc&view=compact",
);
});
it("passes plan document filters through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { hasPlanDocument: false, limit: 25 });
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?hasPlanDocument=false&limit=25",
);
});
it("passes live descendant summary opt-in through to the company issues endpoint", async () => {
await issuesApi.list("company-1", {
includeLiveDescendantSummary: true,
limit: 25,
});
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?includeLiveDescendantSummary=true&limit=25",
);
});
it("posts recovery action resolution to the source issue endpoint", async () => {
await issuesApi.resolveRecoveryAction("issue-1", {
actionId: "00000000-0000-0000-0000-0000000000aa",
outcome: "restored",
sourceIssueStatus: "done",
});
expect(mockApi.post).toHaveBeenCalledWith(
"/issues/issue-1/recovery-actions/resolve",
{
actionId: "00000000-0000-0000-0000-0000000000aa",
outcome: "restored",
sourceIssueStatus: "done",
},
);
});
it("posts stalled review decisions to the dedicated endpoint", async () => {
await issuesApi.decideStalledReview("issue-1", {
action: "request_changes",
note: "Please cover the race condition.",
});
expect(mockApi.post).toHaveBeenCalledWith(
"/issues/issue-1/stalled-review-decision",
{
action: "request_changes",
note: "Please cover the race condition.",
},
);
});
});
describe("interaction resolution replay", () => {
const alreadyResolved = () =>
new ApiError("Interaction has already been resolved", 409, {
error: "Interaction has already been resolved",
code: "interaction_already_resolved",
});
const outage = () => new ApiError("Paperclip is restarting", 503, { error: "tenant_app_unavailable" });
beforeEach(() => {
mockApi.get.mockReset();
mockApi.post.mockReset();
});
it("treats already-resolved after a transient failure as success when the resolution matches", async () => {
const accepted = { id: "interaction-1", status: "accepted" };
mockApi.post.mockRejectedValueOnce(outage()).mockRejectedValueOnce(alreadyResolved());
mockApi.get.mockResolvedValue([accepted]);
await expect(issuesApi.acceptInteraction("issue-1", "interaction-1")).rejects.toMatchObject({ status: 503 });
await expect(issuesApi.acceptInteraction("issue-1", "interaction-1")).resolves.toEqual(accepted);
expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/interactions");
});
it("retries the verification when reading the card back also hits the outage", async () => {
const accepted = { id: "interaction-6", status: "accepted" };
mockApi.post
.mockRejectedValueOnce(outage())
.mockRejectedValueOnce(alreadyResolved())
.mockRejectedValueOnce(alreadyResolved());
mockApi.get.mockRejectedValueOnce(outage()).mockResolvedValue([accepted]);
await expect(issuesApi.acceptInteraction("issue-1", "interaction-6")).rejects.toMatchObject({ status: 503 });
// The decision landed, but the read-back is still down: stay retryable.
await expect(issuesApi.acceptInteraction("issue-1", "interaction-6")).rejects.toMatchObject({ status: 503 });
await expect(issuesApi.acceptInteraction("issue-1", "interaction-6")).resolves.toEqual(accepted);
});
it("keeps the conflict when the card was resolved differently", async () => {
mockApi.post.mockRejectedValueOnce(outage()).mockRejectedValueOnce(alreadyResolved());
mockApi.get.mockResolvedValue([{ id: "interaction-2", status: "rejected" }]);
await expect(issuesApi.acceptInteraction("issue-1", "interaction-2")).rejects.toBeInstanceOf(ApiError);
await expect(issuesApi.acceptInteraction("issue-1", "interaction-2")).rejects.toMatchObject({ status: 409 });
});
it("keeps the conflict when someone else accepted different checkbox options", async () => {
const savedByOther = {
id: "interaction-4",
kind: "request_checkbox_confirmation",
status: "accepted",
result: { version: 1, outcome: "accepted", selectedOptionIds: ["b"] },
};
mockApi.post.mockRejectedValueOnce(outage()).mockRejectedValueOnce(alreadyResolved());
mockApi.get.mockResolvedValue([savedByOther]);
await expect(issuesApi.acceptInteraction("issue-1", "interaction-4", { selectedOptionIds: ["a"] })).rejects.toMatchObject({ status: 503 });
await expect(issuesApi.acceptInteraction("issue-1", "interaction-4", { selectedOptionIds: ["a"] })).rejects.toMatchObject({ status: 409 });
});
it("treats already-resolved as success when the saved checkbox options are ours", async () => {
const saved = {
id: "interaction-5",
kind: "request_checkbox_confirmation",
status: "accepted",
result: { version: 1, outcome: "accepted", selectedOptionIds: ["b", "a"] },
};
mockApi.post.mockRejectedValueOnce(outage()).mockRejectedValueOnce(alreadyResolved());
mockApi.get.mockResolvedValue([saved]);
await expect(issuesApi.acceptInteraction("issue-1", "interaction-5", { selectedOptionIds: ["a", "b"] })).rejects.toMatchObject({ status: 503 });
await expect(issuesApi.acceptInteraction("issue-1", "interaction-5", { selectedOptionIds: ["a", "b"] })).resolves.toEqual(saved);
});
it("matches answers and suggested-task selections exactly", () => {
const answers = [{ questionId: "q1", optionIds: ["x"], otherText: null }];
const answered = {
kind: "ask_user_questions",
status: "answered",
result: { version: 1, answers },
} as unknown as IssueThreadInteraction;
expect(answeredResolutionMatches(answered, answers)).toBe(true);
expect(answeredResolutionMatches(answered, [{ questionId: "q1", optionIds: ["y"] }])).toBe(false);
const suggested = {
kind: "suggest_tasks",
status: "accepted",
payload: { tasks: [{ clientKey: "t1" }, { clientKey: "t2" }] },
result: { version: 1, skippedClientKeys: ["t2"] },
} as unknown as IssueThreadInteraction;
expect(acceptedResolutionMatches(suggested, { selectedClientKeys: ["t1"] })).toBe(true);
expect(acceptedResolutionMatches(suggested, { selectedClientKeys: ["t1", "t2"] })).toBe(false);
expect(acceptedResolutionMatches(suggested, undefined)).toBe(false);
});
it("keeps the conflict when no transient failure came first", async () => {
mockApi.post.mockRejectedValueOnce(alreadyResolved());
mockApi.get.mockResolvedValue([{ id: "interaction-3", status: "answered" }]);
await expect(issuesApi.respondToInteraction("issue-1", "interaction-3", { answers: [] })).rejects.toMatchObject({ status: 409 });
expect(mockApi.get).not.toHaveBeenCalled();
});
});