// @vitest-environment jsdom import assert from "node:assert/strict"; import { test } from "vitest"; import { act, render, screen, waitFor } 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); }); // --- Alignment follow-up, item 1: the management screen gets search and paging --- // // `listAssets` has always accepted `q`/`cursor` and returned `nextCursor`, and // this screen's own heading promises search ("업로드한 Asset을 검색하고..."), // but the component asked for `{ limit: 50 }` and dropped `nextCursor` on the // floor. Past the 51st asset older assets were simply unmanageable, with // nothing on screen admitting anything had been left out. type ListCall = Readonly<{ q?: string; cursor?: string; limit?: number }>; type ListPage = Readonly<{ items: unknown[]; nextCursor: string | null }>; function assetNamed(assetKey: string, id: string) { return { ...(ASSET as object), id, assetKey } as never; } function libraryGateway(respond: (query: ListCall) => ListPage) { const calls: ListCall[] = []; const gateway = { async listAssets(query: ListCall) { calls.push(query); return respond(query) as never; }, async getAsset() { return { asset: ASSET, usages: [], hasPublicationHistory: false } as never; }, async uploadAsset() { throw new Error("not used"); }, async updateAssetMetadata() { return ASSET; }, async deleteAsset() {}, } as never; return { gateway, calls }; } test("searches assets with the typed query and announces the new result count", async () => { const user = userEvent.setup(); const older = assetNamed("older-diagram", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1"); const newer = assetNamed("newer-diagram", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2"); const { gateway, calls } = libraryGateway((query) => query.q === "older" ? { items: [older], nextCursor: null } : { items: [newer, older], nextCursor: null }, ); render(); await screen.findByRole("button", { name: "newer-diagram" }); await user.type(screen.getByLabelText("Asset 검색"), "older"); await user.click(screen.getByRole("button", { name: "검색" })); assert.ok(await screen.findByRole("button", { name: "older-diagram" })); assert.equal(screen.queryByRole("button", { name: "newer-diagram" }), null); assert.equal(calls.at(-1)?.q, "older"); // The result count lives in a `role="status"` region so a screen reader is // told the list changed -- a silently re-rendered list is the failure mode // a search box invites. const counts = screen .getAllByRole("status") .map((node) => node.textContent ?? ""); assert.ok(counts.some((text) => text.includes("1개")), counts.join(" | ")); }); test("typing alone never reaches the gateway -- only the submitted query does", async () => { const user = userEvent.setup(); const { gateway, calls } = libraryGateway(() => ({ items: [ASSET], nextCursor: null })); render(); await screen.findByRole("button", { name: "boundary" }); assert.equal(calls.length, 1); await user.type(screen.getByLabelText("Asset 검색"), "boundary"); assert.equal(calls.length, 1); }); test("loads the next page with the server cursor and appends it to the list", async () => { const user = userEvent.setup(); const first = assetNamed("page-one", "ffffffff-ffff-4fff-8fff-fffffffffff1"); const second = assetNamed("page-two", "ffffffff-ffff-4fff-8fff-fffffffffff2"); const { gateway, calls } = libraryGateway((query) => query.cursor === "cursor-1" ? { items: [second], nextCursor: null } : { items: [first], nextCursor: "cursor-1" }, ); render(); await screen.findByRole("button", { name: "page-one" }); await user.click(screen.getByRole("button", { name: "더 보기" })); assert.ok(await screen.findByRole("button", { name: "page-two" })); // Appended, not replaced: the first page must stay reachable. assert.ok(screen.getByRole("button", { name: "page-one" })); assert.equal(calls.at(-1)?.cursor, "cursor-1"); // Exhausted paging removes the control rather than leaving a button that // does nothing. assert.equal(screen.queryByRole("button", { name: "더 보기" }), null); }); test("더 보기 moves focus to the first newly loaded asset instead of stranding it on the removed control", async () => { const user = userEvent.setup(); const first = assetNamed("focus-page-one", "10101010-1010-4010-8010-101010101011"); const second = assetNamed("focus-page-two", "10101010-1010-4010-8010-101010101012"); const { gateway } = libraryGateway((query) => query.cursor === "cursor-1" ? { items: [second], nextCursor: null } : { items: [first], nextCursor: "cursor-1" }, ); render(); await screen.findByRole("button", { name: "focus-page-one" }); await user.click(screen.getByRole("button", { name: "더 보기" })); const appended = await screen.findByRole("button", { name: "focus-page-two" }); // Compared as a boolean, not as two nodes: `assert.equal(node, node)` builds // its failure message by inspecting both jsdom elements, and that traversal // is heavy enough to take the worker down instead of reporting the failure. assert.equal( document.activeElement === appended, true, `focus was on ${document.activeElement?.textContent ?? "nothing"}`, ); }); test("offers no 더 보기 control when the server reports no next cursor", async () => { render(); await screen.findByRole("button", { name: "boundary" }); assert.equal(screen.queryByRole("button", { name: "더 보기" }), null); }); test("a new search restarts paging from the first page instead of carrying the old cursor", async () => { const user = userEvent.setup(); const first = assetNamed("reset-page-one", "20202020-2020-4020-8020-202020202021"); const second = assetNamed("reset-page-two", "20202020-2020-4020-8020-202020202022"); const found = assetNamed("reset-found", "20202020-2020-4020-8020-202020202023"); const { gateway, calls } = libraryGateway((query) => { if (query.q === "reset-found") return { items: [found], nextCursor: null }; return query.cursor === "cursor-1" ? { items: [second], nextCursor: null } : { items: [first], nextCursor: "cursor-1" }; }); render(); await screen.findByRole("button", { name: "reset-page-one" }); await user.click(screen.getByRole("button", { name: "더 보기" })); await screen.findByRole("button", { name: "reset-page-two" }); await user.type(screen.getByLabelText("Asset 검색"), "reset-found"); await user.click(screen.getByRole("button", { name: "검색" })); assert.ok(await screen.findByRole("button", { name: "reset-found" })); assert.equal(screen.queryByRole("button", { name: "reset-page-one" }), null); assert.equal(screen.queryByRole("button", { name: "reset-page-two" }), null); assert.equal(calls.at(-1)?.cursor, undefined); }); test("a slow earlier search never overwrites the newer search's results", async () => { const user = userEvent.setup(); const initial = assetNamed("race-initial", "30303030-3030-4030-8030-303030303031"); const slow = assetNamed("race-slow", "30303030-3030-4030-8030-303030303032"); const fast = assetNamed("race-fast", "30303030-3030-4030-8030-303030303033"); const pending: Array<{ query: ListCall; settle: (page: ListPage) => void }> = []; const gateway = { async listAssets(query: ListCall) { return new Promise((resolve) => { pending.push({ query, settle: resolve }); }) as never; }, async getAsset() { return { asset: ASSET, usages: [], hasPublicationHistory: false } as never; }, async uploadAsset() { throw new Error("not used"); }, async updateAssetMetadata() { return ASSET; }, async deleteAsset() {}, } as never; render(); await waitFor(() => assert.equal(pending.length, 1)); await act(async () => { pending[0]!.settle({ items: [initial], nextCursor: null }); }); await screen.findByRole("button", { name: "race-initial" }); const input = screen.getByLabelText("Asset 검색"); await user.clear(input); await user.type(input, "slow"); await user.click(screen.getByRole("button", { name: "검색" })); await waitFor(() => assert.equal(pending.length, 2)); await user.clear(input); await user.type(input, "fast"); await user.click(screen.getByRole("button", { name: "검색" })); await waitFor(() => assert.equal(pending.length, 3)); await act(async () => { pending[2]!.settle({ items: [fast], nextCursor: null }); }); await screen.findByRole("button", { name: "race-fast" }); // The abandoned "slow" request lands last. Nothing about it may reach the // screen: the user is looking at "fast" results. await act(async () => { pending[1]!.settle({ items: [slow], nextCursor: null }); }); assert.equal(screen.queryByRole("button", { name: "race-slow" }), null); assert.ok(screen.getByRole("button", { name: "race-fast" })); }); test("a search that matches nothing says so instead of claiming no asset was ever uploaded", async () => { const user = userEvent.setup(); const { gateway } = libraryGateway((query) => query.q ? { items: [], nextCursor: null } : { items: [ASSET], nextCursor: null }, ); render(); await screen.findByRole("button", { name: "boundary" }); await user.type(screen.getByLabelText("Asset 검색"), "없는키"); await user.click(screen.getByRole("button", { name: "검색" })); assert.ok(await screen.findByText("조건에 맞는 Asset이 없습니다")); assert.equal(screen.queryByText("등록된 Asset이 없습니다"), null); });