feat: add the TechLog Studio asset library route and screen
Adds TECH_LOG_STUDIO_ASSETS (/studio/assets) as a route reachable but excluded from primary Studio navigation (navigationLabel/navigationOrder null), plus the AssetLibrary screen that lists assets, shows usage, and lets an operator archive or hard-delete one. canHardDelete() is a pure gate mirroring the server's ASSET_IN_USE rule so the screen never offers an action the server would refuse. Adding a 28th route also required updating the route-scoped CI accessibility-evidence gate (FE-GATE-009 in config/ci/gates.json, plus its authority-baseline counts and shape digest in scripts/contracts/ci-gates.ts) and the Vite route-to-chunk map that scripts/generate-build-manifest.ts depends on, or test:unit and the production build both fail. See task-11-report.md for the full breakdown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
073fda87eb
commit
b11aa94f1c
@@ -0,0 +1,105 @@
|
||||
// @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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
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은 삭제할 수 없습니다."));
|
||||
});
|
||||
Reference in New Issue
Block a user