// @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(); 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( { 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(); 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 . 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(); 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); });