// @vitest-environment jsdom
import assert from "node:assert/strict";
import { expect, 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: "보관" }));
expect(screen.queryByRole("button", { name: "삭제" })).not.toBeInTheDocument();
});
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);
expect(screen.queryByRole("button", { name: "boundary" })).not.toBeInTheDocument();
// `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.
//
// `toHaveFocus`, never `assert.equal(document.activeElement, heading)`:
// node:assert builds an AssertionError by running `util.inspect` over both
// operands at `depth: 1000`, and a React-rendered element carries
// `__reactFiber$*` own properties whose graph re-expands per traversal
// path. Inspecting one costs gigabytes, so the regression this test guards
// used to kill the worker instead of reporting itself. jest-dom prints DOM
// nodes through pretty-format's DOM plugin, which never touches the fiber.
const heading = screen.getByRole("heading", { name: "Asset", level: 1 });
expect(heading).toHaveFocus();
});
// --- 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" }));
expect(screen.queryByRole("button", { name: "newer-diagram" })).not.toBeInTheDocument();
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.
expect(screen.queryByRole("button", { name: "더 보기" })).not.toBeInTheDocument();
});
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" });
expect(appended).toHaveFocus();
});
test("offers no 더 보기 control when the server reports no next cursor", async () => {
render();
await screen.findByRole("button", { name: "boundary" });
expect(screen.queryByRole("button", { name: "더 보기" })).not.toBeInTheDocument();
});
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" }));
expect(screen.queryByRole("button", { name: "reset-page-one" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "reset-page-two" })).not.toBeInTheDocument();
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 });
});
expect(screen.queryByRole("button", { name: "race-slow" })).not.toBeInTheDocument();
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이 없습니다"));
expect(screen.queryByText("등록된 Asset이 없습니다")).not.toBeInTheDocument();
});