Files
tech-log-frontend/tests/features/tech-log/asset-library.test.tsx
T
DongHyeonkaandClaude Opus 5 3ab04a236d fix: restore focus to a stable anchor, not a removed row, after asset delete
Fix round 1 for the Task 11 asset library review. I1 (Important): a
successful delete removed the row from state, so remove()'s reuse of
closeDetail() queued .focus() on a now-detached button -- a silent
no-op that stranded focus on <body>. Splits closeDetail() (cancel/close,
row still on screen, restores focus to the trigger) from a new
closeDetailAfterRemoval() (post-delete, focuses the page heading, the
one anchor guaranteed to survive any list change) so the two paths
stop sharing a helper that only one of them can safely use.

Also closes four Minors from the same review round:
- route-contract.test.ts's "27-route inventory" test title corrected
  to 28.
- canHardDelete's usageCount clause gets its own isolating assertion
  (every prior case used usageCount: 0, so that clause was never
  independently falsified).
- Dropped a duplicate role="status" announcement on a listAssets
  load failure; the existing role="alert" paragraph is now the sole
  announcement.
- Added success-path tests for archive() and remove() via a new
  recordingGateway() test helper that pins the exact gateway call
  shape (expectedVersion, managementStatus: ARCHIVED, idempotency
  keys), not just the resulting UI text; the delete-success test also
  pins the I1 focus fix so a regression back to the removed trigger
  fails loudly.

See task-11-report.md's "Fix round 1" section for the RED/GREEN
evidence (the pre-fix code reliably crashes the test worker rather
than failing the assertion cleanly -- explained there) and the
correction to this task's original claim about matching
publication-list.tsx's focus pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 07:53:17 +09:00

230 lines
8.8 KiB
TypeScript

// @vitest-environment jsdom
import assert from "node:assert/strict";
import { test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
AssetLibrary,
canHardDelete,
} from "../../../src/features/tech-log/presentation/studio/components/asset-library.tsx";
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
const ASSET = {
id: "11111111-1111-4111-8111-111111111111",
assetKey: "boundary",
kind: "DIAGRAM",
mediaType: "image/svg+xml",
originalFilename: "b.svg",
byteSize: 10,
width: 1080,
height: 420,
altText: "경계",
decorative: false,
managementStatus: "READY",
publicPath: "/media/boundary.svg",
usageCount: 0,
version: 1,
createdAt: "2026-08-14T01:00:00.000Z",
updatedAt: "2026-08-14T01:00:00.000Z",
} as never;
// `ASSET` is typed `never` (see above) so it can stand in for any generated
// contract shape without fighting the type checker across this file's other
// fixtures. That means its fields can't be dereferenced directly; these two
// mirror the literals in `ASSET` above for the fix-round-1 tests that need
// to assert on them.
const ASSET_ID = "11111111-1111-4111-8111-111111111111";
const ASSET_VERSION = 1;
function gatewayOf(detail: unknown, onDelete?: () => never) {
return {
async listAssets() {
return { items: [ASSET], nextCursor: null } as never;
},
async getAsset() {
return detail as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
return ASSET;
},
async deleteAsset() {
if (onDelete) onDelete();
},
} as never;
}
/**
* Fix round 1 (Minor 4). Unlike `gatewayOf`, this records exactly what
* `archive()`/`remove()` send the gateway, so a test can pin the command
* shape (`expectedVersion`, `managementStatus`) and the idempotency-key
* plumbing, not just the resulting UI text.
*/
function recordingGateway(detail: unknown) {
const archiveCalls: Array<{ assetId: string; command: unknown; options: unknown }> = [];
const deleteCalls: Array<{ assetId: string; options: unknown }> = [];
const archivedAsset = { ...(ASSET as object), managementStatus: "ARCHIVED" } as never;
const gateway = {
async listAssets() {
return { items: [ASSET], nextCursor: null } as never;
},
async getAsset() {
return detail as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata(assetId: string, command: unknown, options: unknown) {
archiveCalls.push({ assetId, command, options });
return archivedAsset;
},
async deleteAsset(assetId: string, options: unknown) {
deleteCalls.push({ assetId, options });
},
} as never;
return { gateway, archiveCalls, deleteCalls };
}
test("offers hard delete only for an unused asset with no publication history", () => {
assert.equal(
canHardDelete({ asset: ASSET, usages: [], hasPublicationHistory: false } as never),
true,
);
assert.equal(
canHardDelete({ asset: ASSET, usages: [], hasPublicationHistory: true } as never),
false,
);
assert.equal(
canHardDelete({
asset: ASSET,
usages: [{ documentId: "d", documentKind: "CASE", title: "문서", published: true }],
hasPublicationHistory: false,
} as never),
false,
);
// Fix round 1 (Minor 2). Every assertion above uses `usageCount: 0`, so
// the `asset.usageCount === 0` clause was never independently exercised --
// dropping or inverting it would still pass. `usages: []` and
// `hasPublicationHistory: false` isolate it: only the usage-count clause
// can make this `false`.
assert.equal(
canHardDelete({
asset: { ...(ASSET as object), usageCount: 1 },
usages: [],
hasPublicationHistory: false,
} as never),
false,
);
});
test("shows archive instead of delete for an asset in use", async () => {
const user = userEvent.setup();
render(<AssetLibrary gateway={gatewayOf({
asset: { ...(ASSET as object), usageCount: 1 },
usages: [{ documentId: "d", documentKind: "CASE", title: "사용 중 문서", published: true }],
hasPublicationHistory: true,
})} />);
await user.click(await screen.findByRole("button", { name: "boundary" }));
assert.ok(await screen.findByRole("button", { name: "보관" }));
assert.equal(screen.queryByRole("button", { name: "삭제" }), null);
});
test("surfaces ASSET_IN_USE when the server rejects a delete", async () => {
const user = userEvent.setup();
render(<AssetLibrary gateway={gatewayOf(
{ asset: ASSET, usages: [], hasPublicationHistory: false },
() => {
throw new StudioGatewayError({
type: "https://techlog.local/problems/asset-in-use",
title: "ASSET_IN_USE",
status: 409,
detail: "사용 중인 Asset은 삭제할 수 없습니다.",
code: "ASSET_IN_USE",
});
},
)} />);
await user.click(await screen.findByRole("button", { name: "boundary" }));
await user.click(await screen.findByRole("button", { name: "삭제" }));
assert.ok(await screen.findByText("사용 중인 Asset은 삭제할 수 없습니다."));
});
// Fix round 1 (Minor 4). The archive success path -- the exact command sent
// to the gateway, the merged list state, and the success notice -- was
// previously verified only by reading the source.
test("archives an asset by sending the expected command and merging the server's response into the list", async () => {
const user = userEvent.setup();
const { gateway, archiveCalls } = recordingGateway({
asset: { ...(ASSET as object), usageCount: 1 },
usages: [{ documentId: "d", documentKind: "CASE", title: "사용 중 문서", published: true }],
hasPublicationHistory: true,
});
render(<AssetLibrary gateway={gateway} />);
const row = (await screen.findByRole("button", { name: "boundary" })).closest("li");
assert.ok(row);
await user.click(await screen.findByRole("button", { name: "boundary" }));
await user.click(await screen.findByRole("button", { name: "보관" }));
assert.ok(await screen.findByText("보관했습니다."));
assert.equal(archiveCalls.length, 1);
assert.equal(archiveCalls[0].assetId, ASSET_ID);
assert.deepEqual(archiveCalls[0].command, {
expectedVersion: ASSET_VERSION,
managementStatus: "ARCHIVED",
});
const options = archiveCalls[0].options as { idempotencyKey?: unknown };
assert.equal(typeof options.idempotencyKey, "string");
assert.ok((options.idempotencyKey as string).length > 0);
// The row reflects the server's returned asset (now ARCHIVED), not a
// client-guessed status -- proving the list state actually merged the
// response instead of just leaving the old row in place. `findByText`
// above already settled on the post-update render, so this can assert
// directly instead of polling.
assert.ok(row.textContent?.includes("ARCHIVED"));
});
// Fix round 1 (I1 + Minor 4). Combines the delete success path's gateway
// call shape with the focus-restoration regression this round fixed: the
// row that opened the panel is removed from the DOM by a successful delete,
// so restoring focus to it (the pre-fix behaviour) is a silent no-op that
// strands focus on <body>. This test pins the fix -- focus must land on the
// page heading, the one anchor guaranteed to still exist -- so a future
// regression back to the removed trigger fails loudly here.
test("removes the asset and restores focus to the page heading, not the detached row button, after a successful delete", async () => {
const user = userEvent.setup();
const { gateway, deleteCalls } = recordingGateway({
asset: ASSET,
usages: [],
hasPublicationHistory: false,
});
render(<AssetLibrary gateway={gateway} />);
await user.click(await screen.findByRole("button", { name: "boundary" }));
await user.click(await screen.findByRole("button", { name: "삭제" }));
assert.ok(await screen.findByText("삭제했습니다."));
assert.equal(deleteCalls.length, 1);
assert.equal(deleteCalls[0].assetId, ASSET_ID);
const options = deleteCalls[0].options as { idempotencyKey?: unknown };
assert.equal(typeof options.idempotencyKey, "string");
assert.ok((options.idempotencyKey as string).length > 0);
assert.equal(screen.queryByRole("button", { name: "boundary" }), null);
// `findByText` above already settled on the post-delete render -- the
// `queueMicrotask`-scheduled focus call has necessarily run by then, since
// `findByText` only resolves after yielding through at least one macrotask
// (its polling uses `setTimeout`/`MutationObserver`), and microtasks always
// drain before the next macrotask runs. So this asserts directly rather
// than polling.
const heading = screen.getByRole("heading", { name: "Asset", level: 1 });
assert.equal(document.activeElement, heading);
});