test: integrate Ticket public regression coverage
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
"test:ticket:real-api": "cd ../.. && deno task test:ticket:real-api",
|
||||
"test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored",
|
||||
"build": "deno run -A npm:vite@8.3.2 build",
|
||||
"preview": "deno run -A npm:vite@8.3.2 preview",
|
||||
|
||||
@@ -21,10 +21,10 @@ function assigned(status: "waiting" | "done" = "waiting"): TicketDetail {
|
||||
action_eligibility: { ...fixtureDetail("T-1").action_eligibility, can_assign_orchestrator: true },
|
||||
};
|
||||
}
|
||||
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response> = () => { throw new Error("Unexpected request"); }) {
|
||||
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response> = () => { throw new Error("Unexpected request"); }, emptyAssignments = true) {
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = String(input);
|
||||
if (path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null });
|
||||
if (emptyAssignments && path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null });
|
||||
return await handle(path, init);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
@@ -104,3 +104,140 @@ test("uncertain assignment reconciliation only refreshes the projection and assi
|
||||
expect(view.queryByRole("alert")).toBeNull();
|
||||
expectNoDiagnostics(fetcher, view);
|
||||
});
|
||||
|
||||
const trackingRoot = "/workspaces/team/tickets/internal-T-1/assignment-tracking";
|
||||
function historicalDone(action: "replaced" | "unassigned" = "replaced"): TicketAssignmentTrackingProjection {
|
||||
const row = assigned("done").assignment_tracking[0];
|
||||
return { ...row, current: false, release: {
|
||||
action, actor: "Mika", at: "2026-10-13T10:00:00Z", event_id: "release-1",
|
||||
replacement_assignment_id: action === "replaced" ? "assignment-2" : null,
|
||||
last_tracking: row.tracking,
|
||||
} };
|
||||
}
|
||||
|
||||
test("paged retained done history preserves its result and replacement while Answer uses only current waiting observation", async () => {
|
||||
const old = historicalDone();
|
||||
const current = { ...assigned().assignment_tracking[0], assignment_id: "assignment-2", last_event_id: "handling-2",
|
||||
assigned_at: "2026-10-13T10:00:00Z", tracking: { status: "waiting" as const, waiting_reason: "Choose the new policy" } };
|
||||
const ticket = { ...assigned(), assignment_tracking: [current], assignments: [{ ...assigned().assignments[0], assignment_id: current.assignment_id }] };
|
||||
const historyEntry = (event_id: string, tracking: TicketAssignmentTrackingProjection["tracking"]) => ({
|
||||
event_id, tracking, at: "2026-10-13T09:00:00Z", operation_key: event_id, source_context: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
});
|
||||
const fetcher = installFetch((path, init) => {
|
||||
if (path === assignmentsPath) return Response.json({ items: [old], next_assignment_id: old.assignment_id });
|
||||
if (path === `${assignmentsPath}&after_assignment_id=assignment-1`) return Response.json({ items: [current], next_assignment_id: null });
|
||||
if (path === `${trackingRoot}?assignment_id=assignment-1`) return Response.json([old]);
|
||||
if (path === `${trackingRoot}/history?assignment_id=assignment-1&limit=20`) return Response.json({
|
||||
items: [historyEntry("old-waiting", { status: "waiting", waiting_reason: "Old question" })], next_event_id: "old-waiting",
|
||||
});
|
||||
if (path === `${trackingRoot}/history?assignment_id=assignment-1&limit=20&after_event_id=old-waiting`) return Response.json({
|
||||
items: [historyEntry("handling-1", old.tracking)], next_event_id: null,
|
||||
});
|
||||
if (path === `${trackingRoot}?assignment_id=assignment-2`) return Response.json([current]);
|
||||
if (path === `${trackingRoot}/history?assignment_id=assignment-2&limit=20`) return Response.json({
|
||||
items: [historyEntry("handling-2", current.tracking)], next_event_id: null,
|
||||
});
|
||||
expect(path).toBe(answerPath);
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ assignment_id: "assignment-2", expected_last_event_id: "handling-2", operation_key: expect.any(String), body: "Use the new policy" });
|
||||
return Response.json({ assignment_id: "assignment-2", input_event_id: "new-input", event_index: 1 });
|
||||
}, false);
|
||||
const view = mount(ticket);
|
||||
await fireEvent.click(view.getByText("Past assignment and release history"));
|
||||
await waitFor(() => expect(view.getByRole("button", { name: "More assignments" })).toBeTruthy());
|
||||
await fireEvent.click(view.getByRole("button", { name: "More assignments" }));
|
||||
await waitFor(() => expect(view.queryByRole("button", { name: "More assignments" })).toBeNull());
|
||||
expect(view.getByRole("button", { name: /Current · waiting/ })).toBeTruthy();
|
||||
expect(view.getByText(/Comparison delivered/)).toBeTruthy();
|
||||
await fireEvent.click(view.getByRole("button", { name: /Past · done/ }));
|
||||
await waitFor(() => expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy());
|
||||
expect(view.getByText(/replaced by Mika/)).toBeTruthy();
|
||||
await waitFor(() => expect(view.getByRole("button", { name: "More handling history" })).toBeTruthy());
|
||||
await fireEvent.click(view.getByRole("button", { name: "More handling history" }));
|
||||
await waitFor(() => expect(view.getByText("Result: Comparison delivered")).toBeTruthy());
|
||||
expect(view.getByText("Waiting reason: Old question")).toBeTruthy();
|
||||
expect(view.queryByRole("button", { name: "More handling history" })).toBeNull();
|
||||
expect(view.getByText("Self-reported handling: waiting")).toBeTruthy();
|
||||
await fireEvent.input(view.getByRole("textbox", { name: "Answer / continue this handling" }), { target: { value: "Use the new policy" } });
|
||||
await fireEvent.click(view.getByRole("button", { name: "Send answer to this assignment" }));
|
||||
await waitFor(() => expect(view.getByText(/^Answer saved to assignment assignment-2/)).toBeTruthy());
|
||||
expect(view.refresh).toHaveBeenCalledTimes(1);
|
||||
await fireEvent.click(view.getByRole("button", { name: "Read replacement assignment" }));
|
||||
await waitFor(() => {
|
||||
expect(view.getByText(/· current · last handling waiting/)).toBeTruthy();
|
||||
expect(view.queryByText("Previous result: Comparison delivered")).toBeNull();
|
||||
expect(view.queryByText("Result: Comparison delivered")).toBeNull();
|
||||
});
|
||||
expectNoDiagnostics(fetcher, view);
|
||||
});
|
||||
|
||||
test("released done is retained as past result without current Answer or implicit request", async () => {
|
||||
const old = historicalDone("unassigned");
|
||||
const ticket = { ...assigned(), assignment_tracking: [], assignments: [] };
|
||||
const fetcher = installFetch((path) => {
|
||||
if (path === assignmentsPath) return Response.json({ items: [old], next_assignment_id: null });
|
||||
if (path === `${trackingRoot}?assignment_id=assignment-1`) return Response.json([old]);
|
||||
expect(path).toBe(`${trackingRoot}/history?assignment_id=assignment-1&limit=20`);
|
||||
return Response.json({ items: [], next_event_id: null });
|
||||
}, false);
|
||||
const view = mount(ticket);
|
||||
await fireEvent.click(view.getByText("Past assignment and release history"));
|
||||
await waitFor(() => expect(view.getByRole("button", { name: /Past · done/ })).toBeTruthy());
|
||||
await fireEvent.click(view.getByRole("button", { name: /Past · done/ }));
|
||||
await waitFor(() => expect(view.getByText(/unassigned by Mika/)).toBeTruthy());
|
||||
expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy();
|
||||
expect(view.getByText("No current Orchestrator assignment.")).toBeTruthy();
|
||||
expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull();
|
||||
expect(view.queryByRole("button", { name: "Read replacement assignment" })).toBeNull();
|
||||
expect(view.getByRole("button", { name: "Request Orchestrator" })).toBeTruthy();
|
||||
expect(fetcher.mock.calls.every(([, init]) => !init?.method || init.method === "GET")).toBe(true);
|
||||
expectNoDiagnostics(fetcher, view);
|
||||
});
|
||||
|
||||
test("case close and reopen keep done result non-actionable and require an explicit new assignment", async () => {
|
||||
const fetcher = installFetch();
|
||||
const ticket = assigned("done");
|
||||
const view = mount(ticket);
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
|
||||
for (const state of ["closed", "open"] as const) {
|
||||
await view.rerender({ workspaceId: "team", ticket: { ...ticket, state }, orchestrator: null, refresh: view.refresh });
|
||||
expect(view.getByText(`This handling was returned. Ticket ${state} is unchanged.`)).toBeTruthy();
|
||||
expect(view.getByText("Comparison delivered")).toBeTruthy();
|
||||
expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull();
|
||||
expect(view.getByRole("button", { name: "Request again (new assignment)" })).toBeTruthy();
|
||||
}
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(view.refresh).not.toHaveBeenCalled();
|
||||
expectNoDiagnostics(fetcher, view);
|
||||
});
|
||||
|
||||
test("releasing responsibility while reading an old result uses current assignment rather than retained identity", async () => {
|
||||
const old = historicalDone();
|
||||
const ticket = assigned();
|
||||
ticket.assignment_tracking[0] = { ...ticket.assignment_tracking[0], assignment_id: "assignment-2", last_event_id: "handling-2" };
|
||||
ticket.assignments[0] = { ...ticket.assignments[0], assignment_id: "assignment-2" };
|
||||
ticket.action_eligibility.can_unassign_orchestrator = true;
|
||||
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||
const fetcher = installFetch((path, init) => {
|
||||
if (path === assignmentsPath) return Response.json({ items: [old], next_assignment_id: null });
|
||||
if (path === `${trackingRoot}?assignment_id=assignment-1`) return Response.json([old]);
|
||||
if (path === `${trackingRoot}/history?assignment_id=assignment-1&limit=20`) return Response.json({ items: [], next_event_id: null });
|
||||
const url = new URL(path, "http://fixture.invalid");
|
||||
expect(url.pathname).toBe(assignmentPath);
|
||||
expect(init?.method).toBe("DELETE");
|
||||
expect(url.searchParams.get("assignment_id")).toBe("assignment-2");
|
||||
expect(url.searchParams.get("operation_id")).toBeTruthy();
|
||||
return Response.json({ workspace_id: "team", ticket_id: "internal-T-1", assignment: null });
|
||||
}, false);
|
||||
const view = mount(ticket);
|
||||
await fireEvent.click(view.getByText("Past assignment and release history"));
|
||||
await waitFor(() => expect(view.getByRole("button", { name: /Past · done/ })).toBeTruthy());
|
||||
await fireEvent.click(view.getByRole("button", { name: /Past · done/ }));
|
||||
await waitFor(() => expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy());
|
||||
await fireEvent.click(view.getByRole("button", { name: "Release assignment" }));
|
||||
await waitFor(() => expect(view.getByText("Assignment operation saved: released.")).toBeTruthy());
|
||||
expect(view.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy();
|
||||
expect(fetcher.mock.calls.filter(([, init]) => init?.method === "DELETE")).toHaveLength(1);
|
||||
expectNoDiagnostics(fetcher, view);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Ticket実応答のWeb適合
|
||||
|
||||
`server_ticket_web_roundtrip_tests.rs` が、一時SQLiteと本番routerのHTTP bodyをUTF-8文字列のまま `roundtrip.ts` へ渡す。Webの本番parser・Answer operation・handling summaryを使い、旧done結果、caseのclose/reopen履歴、明示再依頼後の新waiting、retained release、ページ継続、現在のAnswer receiptを確認する。期待する状態と結果はfixtureの操作意図で与え、動的ID/eventは保存正本で別に観測する。応答JSONの再構成や値の補完はしない。
|
||||
|
||||
## 実行選択
|
||||
|
||||
rootまたは `web/workspace` で次を実行する。
|
||||
|
||||
```sh
|
||||
deno task test:ticket:real-api
|
||||
```
|
||||
|
||||
root taskはServerの通常lib targetから `ticket_web_roundtrip_tests` を `--ignored` 付きで明示選択する。DenoがPATHに必要なため通常Cargo実行ではignoredとなる。Deno以外の外部tool、npm取得、network権限、実モデル、稼働Serverは不要。自動jobからもこのtaskを選択する。リポジトリにはCI workflow定義がなく、CIでの実行完了や自動選択は保証しない。
|
||||
|
||||
一時DBはRAII、子Denoは `kill_on_drop` とstdin書込みを含むwatchdogで隔離・回収する。認証情報やDBパスは子へ渡さず、子のstdout/stderrを失敗診断へ残す。通信transport自体はRust router内であり、Denoは実HTTP bodyの消費者である。browserのfetch・DOMまでつなぐ製品E2Eとは称さない。
|
||||
|
||||
局所描画・操作は `TicketHandling.browser.spec.ts` の通常component suiteが担当する。こちらは手書きfetch fixtureで履歴選択中も現在のCASへAnswerすること等を確認するため、実Backend適合とは区別する。実session配送・復旧は既存Server delivery suiteに委ね、このbridgeではRuntime実行境界をrecordingに置き換える。
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"nodeModulesDir": "none",
|
||||
"lock": false,
|
||||
"fmt": { "proseWrap": "never" },
|
||||
"imports": {
|
||||
"#lib/": "../../src/lib/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import type { TicketAssignmentTrackingProjection } from "#lib/generated/ticket-api.ts";
|
||||
import { parseTicketDetail } from "#lib/workspace/api/ticket-browser.ts";
|
||||
import {
|
||||
answerOperation,
|
||||
parseAnswerReceipt,
|
||||
parseAssignmentsPage,
|
||||
parseTracking,
|
||||
parseTrackingHistory,
|
||||
} from "#lib/workspace/api/ticket-operations.ts";
|
||||
import {
|
||||
currentOrchestratorAssignment,
|
||||
handlingSummary,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
|
||||
// Rust側は隔離DBの本番router応答bodyを再投影せず文字列で渡す。
|
||||
// expectedは同じDBのstore再readから取り、Web用期待JSONを複製しない。
|
||||
export interface TicketLifecycleFixture {
|
||||
expected: {
|
||||
workspace_id: string;
|
||||
ticket_id: string;
|
||||
old_assignment_id: string;
|
||||
new_assignment_id: string;
|
||||
old_result: string;
|
||||
done_event_id: string;
|
||||
new_event_id: string;
|
||||
};
|
||||
done_detail: string;
|
||||
closed_detail: string;
|
||||
reopened_detail: string;
|
||||
reissued_detail: string;
|
||||
assignment_pages: string[];
|
||||
retained_old: string;
|
||||
old_history_pages: string[];
|
||||
answer_receipt: string;
|
||||
answered_detail: string;
|
||||
}
|
||||
|
||||
function assert(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => [key, canonical(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function equal(actual: unknown, expected: unknown, message: string) {
|
||||
assert(
|
||||
JSON.stringify(canonical(actual)) === JSON.stringify(canonical(expected)),
|
||||
`${message}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`,
|
||||
);
|
||||
}
|
||||
function current(detail: ReturnType<typeof parseTicketDetail>) {
|
||||
const row = currentOrchestratorAssignment(detail);
|
||||
assert(row, "detail must retain a current Orchestrator assignment");
|
||||
return row;
|
||||
}
|
||||
function rejectsAnswer(row: TicketAssignmentTrackingProjection) {
|
||||
let rejected = false;
|
||||
try {
|
||||
answerOperation(
|
||||
row.workspace_id,
|
||||
row.ticket_id,
|
||||
row,
|
||||
"not a request",
|
||||
"no-send",
|
||||
);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
assert(rejected, "done/past observation must never generate an Answer");
|
||||
}
|
||||
|
||||
export function assertTicketLifecycle(fixture: TicketLifecycleFixture) {
|
||||
const expected = fixture.expected;
|
||||
const done = parseTicketDetail(JSON.parse(fixture.done_detail));
|
||||
const closed = parseTicketDetail(JSON.parse(fixture.closed_detail));
|
||||
const reopened = parseTicketDetail(JSON.parse(fixture.reopened_detail));
|
||||
const reissued = parseTicketDetail(JSON.parse(fixture.reissued_detail));
|
||||
const answered = parseTicketDetail(JSON.parse(fixture.answered_detail));
|
||||
assert(
|
||||
expected.old_result.length > 0,
|
||||
"fixture must contain an actual old result",
|
||||
);
|
||||
equal(done.id, expected.ticket_id, "detail must identify the real Ticket");
|
||||
equal(done.state, "open", "done does not close the case");
|
||||
equal(closed.state, "closed", "explicit close is a case decision");
|
||||
equal(reopened.state, "open", "explicit reopen is a case decision");
|
||||
equal(
|
||||
closed.closure_history.length,
|
||||
1,
|
||||
"close must remain in public history",
|
||||
);
|
||||
equal(
|
||||
reopened.closure_history.length,
|
||||
2,
|
||||
"reopen must retain the prior closure",
|
||||
);
|
||||
for (const detail of [done, closed, reopened]) {
|
||||
const row = current(detail);
|
||||
equal(
|
||||
row.workspace_id,
|
||||
expected.workspace_id,
|
||||
"workspace scope survives parser",
|
||||
);
|
||||
equal(
|
||||
row.assignment_id,
|
||||
expected.old_assignment_id,
|
||||
"case action is not reissue",
|
||||
);
|
||||
equal(
|
||||
row.last_event_id,
|
||||
expected.done_event_id,
|
||||
"case action is not a handling update",
|
||||
);
|
||||
equal(row.tracking?.status, "done", "case action must not revive handling");
|
||||
equal(
|
||||
row.tracking?.result,
|
||||
expected.old_result,
|
||||
"public detail retains the result",
|
||||
);
|
||||
equal(
|
||||
handlingSummary(row),
|
||||
expected.old_result,
|
||||
"production summary uses done result",
|
||||
);
|
||||
rejectsAnswer(row);
|
||||
}
|
||||
|
||||
const retained = parseTracking(JSON.parse(fixture.retained_old));
|
||||
equal(
|
||||
retained.length,
|
||||
1,
|
||||
"retained read must identify exactly the old assignment",
|
||||
);
|
||||
const old = retained[0];
|
||||
equal(
|
||||
old.assignment_id,
|
||||
expected.old_assignment_id,
|
||||
"retained identity survives parser",
|
||||
);
|
||||
equal(
|
||||
old.current,
|
||||
false,
|
||||
"old done is no longer current after explicit reissue",
|
||||
);
|
||||
equal(
|
||||
old.last_event_id,
|
||||
expected.done_event_id,
|
||||
"replacement does not rewrite old handling",
|
||||
);
|
||||
equal(old.tracking?.status, "done", "replacement preserves done");
|
||||
equal(
|
||||
handlingSummary(old),
|
||||
expected.old_result,
|
||||
"retained summary keeps the old result",
|
||||
);
|
||||
equal(
|
||||
old.release?.action,
|
||||
"replaced",
|
||||
"replacement is distinct from handling done",
|
||||
);
|
||||
equal(
|
||||
old.release?.replacement_assignment_id,
|
||||
expected.new_assignment_id,
|
||||
"release links the new identity",
|
||||
);
|
||||
rejectsAnswer(old);
|
||||
|
||||
assert(
|
||||
fixture.assignment_pages.length >= 2,
|
||||
"exercise the real assignment page cursor",
|
||||
);
|
||||
const pages = fixture.assignment_pages.map((body) =>
|
||||
parseAssignmentsPage(JSON.parse(body))
|
||||
);
|
||||
for (let index = 0; index < pages.length; index++) {
|
||||
const page = pages[index];
|
||||
if (index < pages.length - 1) {
|
||||
assert(
|
||||
page.next_assignment_id,
|
||||
"nonterminal assignment page must expose its cursor",
|
||||
);
|
||||
equal(
|
||||
page.next_assignment_id,
|
||||
page.items.at(-1)?.assignment_id,
|
||||
"cursor binds the last assignment",
|
||||
);
|
||||
} else {
|
||||
assert(
|
||||
page.next_assignment_id == null,
|
||||
"last assignment page must terminate",
|
||||
);
|
||||
}
|
||||
}
|
||||
const rows = pages.flatMap((page) => page.items);
|
||||
equal(rows.length, 2, "traversal retains both old and new delegations");
|
||||
equal(
|
||||
new Set(rows.map((row) => row.assignment_id)).size,
|
||||
2,
|
||||
"assignment pages do not duplicate rows",
|
||||
);
|
||||
equal(
|
||||
rows.find((row) => row.assignment_id === old.assignment_id),
|
||||
old,
|
||||
"page and retained projection agree",
|
||||
);
|
||||
const next = current(reissued);
|
||||
equal(
|
||||
next.assignment_id,
|
||||
expected.new_assignment_id,
|
||||
"explicit request creates a new current identity",
|
||||
);
|
||||
assert(
|
||||
next.assignment_id !== old.assignment_id,
|
||||
"reissue must not reuse done identity",
|
||||
);
|
||||
equal(
|
||||
next.last_event_id,
|
||||
expected.new_event_id,
|
||||
"new current observation survives parser",
|
||||
);
|
||||
equal(
|
||||
next.tracking?.status,
|
||||
"waiting",
|
||||
"new handling starts waiting, not old done",
|
||||
);
|
||||
assert(
|
||||
next.tracking?.result == null && next.release == null,
|
||||
"new row must not inherit result/release",
|
||||
);
|
||||
equal(
|
||||
handlingSummary(next),
|
||||
next.tracking?.waiting_reason ?? "Waiting reason not reported",
|
||||
"new summary never borrows historical result",
|
||||
);
|
||||
equal(
|
||||
rows.find((row) => row.assignment_id === next.assignment_id),
|
||||
next,
|
||||
"page and current detail agree",
|
||||
);
|
||||
equal(reissued.state, "open", "reissue keeps the case open");
|
||||
equal(
|
||||
reissued.closure_history,
|
||||
reopened.closure_history,
|
||||
"reissue does not change case history",
|
||||
);
|
||||
|
||||
assert(
|
||||
fixture.old_history_pages.length >= 2,
|
||||
"exercise the real handling page cursor",
|
||||
);
|
||||
const histories = fixture.old_history_pages.map((body) =>
|
||||
parseTrackingHistory(JSON.parse(body))
|
||||
);
|
||||
for (let index = 0; index < histories.length; index++) {
|
||||
const page = histories[index];
|
||||
if (index < histories.length - 1) {
|
||||
assert(
|
||||
page.next_event_id,
|
||||
"nonterminal history page must expose its cursor",
|
||||
);
|
||||
equal(
|
||||
page.next_event_id,
|
||||
page.items.at(-1)?.event_id,
|
||||
"history cursor binds the last event",
|
||||
);
|
||||
} else {
|
||||
assert(page.next_event_id == null, "last history page must terminate");
|
||||
}
|
||||
}
|
||||
const events = histories.flatMap((page) => page.items);
|
||||
equal(events.length, 2, "old waiting and done history survive replacement");
|
||||
equal(
|
||||
new Set(events.map((entry) => entry.event_id)).size,
|
||||
2,
|
||||
"history pages do not duplicate events",
|
||||
);
|
||||
equal(
|
||||
events[0].tracking.status,
|
||||
"waiting",
|
||||
"initial handling is not overwritten",
|
||||
);
|
||||
equal(
|
||||
events[1].event_id,
|
||||
expected.done_event_id,
|
||||
"history retains last observed event",
|
||||
);
|
||||
equal(
|
||||
events[1].tracking.result,
|
||||
expected.old_result,
|
||||
"history parser retains old result",
|
||||
);
|
||||
|
||||
const operation = answerOperation(
|
||||
expected.workspace_id,
|
||||
expected.ticket_id,
|
||||
next,
|
||||
"Continue the new request",
|
||||
"web-current-answer",
|
||||
);
|
||||
equal(operation.method, "POST", "production Answer method");
|
||||
equal(
|
||||
operation.path,
|
||||
`/api/w/${encodeURIComponent(expected.workspace_id)}/tickets/${
|
||||
encodeURIComponent(expected.ticket_id)
|
||||
}/assignment-answer`,
|
||||
"production Answer target",
|
||||
);
|
||||
equal(JSON.parse(operation.body!), {
|
||||
assignment_id: expected.new_assignment_id,
|
||||
expected_last_event_id: expected.new_event_id,
|
||||
operation_key: "web-current-answer",
|
||||
body: "Continue the new request",
|
||||
}, "production Answer uses only the actionable current CAS");
|
||||
const receipt = parseAnswerReceipt(JSON.parse(fixture.answer_receipt));
|
||||
equal(
|
||||
receipt.assignment_id,
|
||||
next.assignment_id,
|
||||
"real saved Answer belongs to new delegation",
|
||||
);
|
||||
assert(
|
||||
receipt.input_event_id.length > 0,
|
||||
"real Answer receipt exposes saved input",
|
||||
);
|
||||
equal(
|
||||
current(answered),
|
||||
next,
|
||||
"Answer must not infer inprogress or replace waiting",
|
||||
);
|
||||
equal(answered.state, reissued.state, "Answer does not change case state");
|
||||
equal(
|
||||
answered.closure_history,
|
||||
reissued.closure_history,
|
||||
"Answer preserves closure history",
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
// パイプだけを入力境界とし、認証情報・実DBパス・HTTP接続は要求しない。
|
||||
const fixture: TicketLifecycleFixture = JSON.parse(
|
||||
await new Response(Deno.stdin.readable).text(),
|
||||
);
|
||||
assertTicketLifecycle(fixture);
|
||||
console.log(
|
||||
"real Ticket bytes → production parser/operation/summary lifecycle passed",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user