feat: connect resource library workflows
This commit is contained in:
@@ -1,8 +1,61 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { GET } from "../../../app/api/libraries/[libraryType]/route";
|
||||
import { GET, POST } from "../../../app/api/libraries/[libraryType]/route";
|
||||
import { RouteError } from "@/lib/http/response";
|
||||
|
||||
const { backendRequestMock } = vi.hoisted(() => ({
|
||||
backendRequestMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/http/backend-client", () => ({
|
||||
backendRequest: backendRequestMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
backendRequestMock.mockReset();
|
||||
});
|
||||
|
||||
test("proxies backend library resources into the existing frontend view-model shape", async () => {
|
||||
backendRequestMock.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
id: 12,
|
||||
resource_type: "model",
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
tags: ["女装", "棚拍"],
|
||||
status: "active",
|
||||
gender: "female",
|
||||
age_group: "adult",
|
||||
environment: null,
|
||||
category: null,
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
file_role: "thumbnail",
|
||||
storage_key: "library/models/ava/thumb.png",
|
||||
public_url: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
bucket: "images",
|
||||
mime_type: "image/png",
|
||||
size_bytes: 2345,
|
||||
sort_order: 0,
|
||||
width: 480,
|
||||
height: 600,
|
||||
created_at: "2026-03-28T10:00:00Z",
|
||||
},
|
||||
],
|
||||
cover_url: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
original_url: "https://images.marcusd.me/library/models/ava/original.png",
|
||||
created_at: "2026-03-28T10:00:00Z",
|
||||
updated_at: "2026-03-28T10:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
test("returns honest placeholder library data for unsupported backend modules", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/libraries/models"), {
|
||||
params: Promise.resolve({ libraryType: "models" }),
|
||||
});
|
||||
@@ -10,17 +63,23 @@ test("returns honest placeholder library data for unsupported backend modules",
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toMatchObject({
|
||||
mode: "placeholder",
|
||||
mode: "proxy",
|
||||
data: {
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "12",
|
||||
libraryType: "models",
|
||||
isMock: true,
|
||||
name: "Ava Studio",
|
||||
previewUri: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
isMock: false,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
message: "资源库当前使用占位数据,真实后端接口尚未提供。",
|
||||
message: "资源库当前显示真实后端数据。",
|
||||
});
|
||||
expect(backendRequestMock).toHaveBeenCalledWith(
|
||||
"/library/resources?resource_type=model&limit=100",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unsupported placeholder library types with a normalized error", async () => {
|
||||
@@ -48,3 +107,152 @@ test("rejects inherited object keys instead of treating them as valid library ty
|
||||
message: "不支持的资源库类型。",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes backend errors while proxying library resources", async () => {
|
||||
backendRequestMock.mockRejectedValue(
|
||||
new RouteError(502, "BACKEND_UNAVAILABLE", "后端暂时不可用,请稍后重试。"),
|
||||
);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/libraries/models"), {
|
||||
params: Promise.resolve({ libraryType: "models" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(payload).toEqual({
|
||||
error: "BACKEND_UNAVAILABLE",
|
||||
message: "后端暂时不可用,请稍后重试。",
|
||||
});
|
||||
});
|
||||
|
||||
test("proxies library resource creation and adapts the created item into the existing view-model shape", async () => {
|
||||
backendRequestMock.mockResolvedValue({
|
||||
status: 201,
|
||||
data: {
|
||||
id: 12,
|
||||
resource_type: "model",
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
tags: ["女装", "棚拍"],
|
||||
status: "active",
|
||||
gender: "female",
|
||||
age_group: "adult",
|
||||
pose_id: null,
|
||||
environment: null,
|
||||
category: null,
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
file_role: "thumbnail",
|
||||
storage_key: "library/models/ava/thumb.png",
|
||||
public_url: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
bucket: "images",
|
||||
mime_type: "image/png",
|
||||
size_bytes: 2345,
|
||||
sort_order: 0,
|
||||
width: 480,
|
||||
height: 600,
|
||||
created_at: "2026-03-28T10:00:00Z",
|
||||
},
|
||||
],
|
||||
cover_url: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
original_url: "https://images.marcusd.me/library/models/ava/original.png",
|
||||
created_at: "2026-03-28T10:00:00Z",
|
||||
updated_at: "2026-03-28T10:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/libraries/models", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
tags: ["女装", "棚拍"],
|
||||
gender: "female",
|
||||
ageGroup: "adult",
|
||||
files: [
|
||||
{
|
||||
fileRole: "original",
|
||||
storageKey: "library/models/2026/ava-original.png",
|
||||
publicUrl: "https://images.marcusd.me/library/models/ava/original.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 12345,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
fileRole: "thumbnail",
|
||||
storageKey: "library/models/2026/ava-thumb.png",
|
||||
publicUrl: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 2345,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ libraryType: "models" }),
|
||||
},
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
item: {
|
||||
id: "12",
|
||||
libraryType: "models",
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
previewUri: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
originalUri: "https://images.marcusd.me/library/models/ava/original.png",
|
||||
tags: ["女装", "棚拍"],
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
role: "thumbnail",
|
||||
url: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
},
|
||||
],
|
||||
isMock: false,
|
||||
backendId: 12,
|
||||
poseId: null,
|
||||
},
|
||||
},
|
||||
message: "资源创建成功。",
|
||||
});
|
||||
expect(backendRequestMock).toHaveBeenCalledWith("/library/resources", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
resource_type: "model",
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
tags: ["女装", "棚拍"],
|
||||
gender: "female",
|
||||
age_group: "adult",
|
||||
files: [
|
||||
{
|
||||
file_role: "original",
|
||||
storage_key: "library/models/2026/ava-original.png",
|
||||
public_url: "https://images.marcusd.me/library/models/ava/original.png",
|
||||
mime_type: "image/png",
|
||||
size_bytes: 12345,
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
file_role: "thumbnail",
|
||||
storage_key: "library/models/2026/ava-thumb.png",
|
||||
public_url: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
mime_type: "image/png",
|
||||
size_bytes: 2345,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
117
tests/app/api/library-resource-item.route.test.ts
Normal file
117
tests/app/api/library-resource-item.route.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { DELETE, PATCH } from "../../../app/api/libraries/[libraryType]/[resourceId]/route";
|
||||
|
||||
const { backendRequestMock } = vi.hoisted(() => ({
|
||||
backendRequestMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/http/backend-client", () => ({
|
||||
backendRequest: backendRequestMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
backendRequestMock.mockReset();
|
||||
});
|
||||
|
||||
test("proxies resource updates and adapts the updated item into the library view-model", async () => {
|
||||
backendRequestMock.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
id: 12,
|
||||
resource_type: "model",
|
||||
name: "Ava Studio Updated",
|
||||
description: "新的描述",
|
||||
tags: ["女装", "更新"],
|
||||
status: "active",
|
||||
gender: "female",
|
||||
age_group: "adult",
|
||||
pose_id: null,
|
||||
environment: null,
|
||||
category: null,
|
||||
files: [],
|
||||
cover_url: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
original_url: "https://images.marcusd.me/library/models/ava/original.png",
|
||||
created_at: "2026-03-28T10:00:00Z",
|
||||
updated_at: "2026-03-28T11:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await PATCH(
|
||||
new Request("http://localhost/api/libraries/models/12", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Ava Studio Updated",
|
||||
description: "新的描述",
|
||||
tags: ["女装", "更新"],
|
||||
}),
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ libraryType: "models", resourceId: "12" }),
|
||||
},
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
item: {
|
||||
id: "12",
|
||||
libraryType: "models",
|
||||
name: "Ava Studio Updated",
|
||||
description: "新的描述",
|
||||
previewUri: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
originalUri: "https://images.marcusd.me/library/models/ava/original.png",
|
||||
tags: ["女装", "更新"],
|
||||
files: [],
|
||||
isMock: false,
|
||||
backendId: 12,
|
||||
poseId: null,
|
||||
},
|
||||
},
|
||||
message: "资源更新成功。",
|
||||
});
|
||||
expect(backendRequestMock).toHaveBeenCalledWith("/library/resources/12", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
name: "Ava Studio Updated",
|
||||
description: "新的描述",
|
||||
tags: ["女装", "更新"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("soft deletes a library resource through the backend proxy", async () => {
|
||||
backendRequestMock.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
id: 12,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await DELETE(
|
||||
new Request("http://localhost/api/libraries/models/12", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ libraryType: "models", resourceId: "12" }),
|
||||
},
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
id: "12",
|
||||
},
|
||||
message: "资源已移入归档。",
|
||||
});
|
||||
expect(backendRequestMock).toHaveBeenCalledWith("/library/resources/12", {
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
94
tests/app/api/library-uploads-presign.route.test.ts
Normal file
94
tests/app/api/library-uploads-presign.route.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { POST } from "../../../app/api/libraries/uploads/presign/route";
|
||||
|
||||
const { backendRequestMock } = vi.hoisted(() => ({
|
||||
backendRequestMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/http/backend-client", () => ({
|
||||
backendRequest: backendRequestMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
backendRequestMock.mockReset();
|
||||
});
|
||||
|
||||
test("proxies upload presign requests and normalizes the response for the frontend", async () => {
|
||||
backendRequestMock.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
method: "PUT",
|
||||
upload_url: "https://s3.example.com/presigned-put",
|
||||
headers: {
|
||||
"content-type": "image/png",
|
||||
},
|
||||
storage_key: "library/models/2026/ava-original.png",
|
||||
public_url: "https://images.example.com/library/models/2026/ava-original.png",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/libraries/uploads/presign", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
resourceType: "model",
|
||||
fileRole: "original",
|
||||
fileName: "ava-original.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
method: "PUT",
|
||||
uploadUrl: "https://s3.example.com/presigned-put",
|
||||
headers: {
|
||||
"content-type": "image/png",
|
||||
},
|
||||
storageKey: "library/models/2026/ava-original.png",
|
||||
publicUrl: "https://images.example.com/library/models/2026/ava-original.png",
|
||||
},
|
||||
});
|
||||
expect(backendRequestMock).toHaveBeenCalledWith("/library/uploads/presign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
resource_type: "model",
|
||||
file_role: "original",
|
||||
file_name: "ava-original.png",
|
||||
content_type: "image/png",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid presign payloads before proxying", async () => {
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/libraries/uploads/presign", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
resourceType: "unknown",
|
||||
fileRole: "original",
|
||||
fileName: "",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toMatchObject({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "上传参数不合法。",
|
||||
});
|
||||
expect(backendRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -37,7 +37,6 @@ test("proxies order creation to the backend and returns normalized success data"
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
@@ -76,7 +75,6 @@ test("rejects invalid order creation payloads before proxying", async () => {
|
||||
customer_level: "low",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
@@ -143,7 +141,6 @@ test("normalizes upstream validation errors from the backend", async () => {
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
|
||||
71
tests/features/libraries/library-edit-modal.test.tsx
Normal file
71
tests/features/libraries/library-edit-modal.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { LibraryEditModal } from "@/features/libraries/components/library-edit-modal";
|
||||
|
||||
const { updateLibraryResourceMock } = vi.hoisted(() => ({
|
||||
updateLibraryResourceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/libraries/manage-resource", () => ({
|
||||
updateLibraryResource: updateLibraryResourceMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
updateLibraryResourceMock.mockReset();
|
||||
updateLibraryResourceMock.mockResolvedValue({
|
||||
id: "12",
|
||||
backendId: 12,
|
||||
libraryType: "models",
|
||||
name: "Ava / Studio",
|
||||
description: "中性棚拍模特占位数据,用于提交页联调。",
|
||||
previewUri: "mock://libraries/models/ava",
|
||||
tags: ["女装", "半身", "mock"],
|
||||
files: [
|
||||
{
|
||||
id: 101,
|
||||
role: "thumbnail",
|
||||
url: "mock://libraries/models/ava",
|
||||
},
|
||||
],
|
||||
isMock: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("uses backendId when saving resource metadata edits", async () => {
|
||||
render(
|
||||
<LibraryEditModal
|
||||
item={{
|
||||
id: "local-model-12",
|
||||
backendId: 12,
|
||||
libraryType: "models",
|
||||
name: "Ava / Studio",
|
||||
description: "中性棚拍模特占位数据,用于提交页联调。",
|
||||
previewUri: "mock://libraries/models/ava",
|
||||
tags: ["女装", "半身", "mock"],
|
||||
files: [
|
||||
{
|
||||
id: 101,
|
||||
role: "thumbnail",
|
||||
url: "mock://libraries/models/ava",
|
||||
},
|
||||
],
|
||||
isMock: false,
|
||||
}}
|
||||
libraryType="models"
|
||||
open
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存修改" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateLibraryResourceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: "12",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { LibraryPage } from "@/features/libraries/library-page";
|
||||
@@ -6,19 +6,112 @@ import type { LibraryItemVM } from "@/lib/types/view-models";
|
||||
|
||||
const MODEL_ITEMS: LibraryItemVM[] = [
|
||||
{
|
||||
id: "model-ava",
|
||||
id: "12",
|
||||
libraryType: "models",
|
||||
name: "Ava / Studio",
|
||||
description: "中性棚拍模特占位数据,用于提交页联调。",
|
||||
previewUri: "mock://libraries/models/ava",
|
||||
tags: ["女装", "半身", "mock"],
|
||||
isMock: true,
|
||||
isMock: false,
|
||||
backendId: 12,
|
||||
files: [
|
||||
{
|
||||
id: 101,
|
||||
role: "thumbnail",
|
||||
url: "mock://libraries/models/ava",
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
role: "gallery",
|
||||
url: "mock://libraries/models/ava-side",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
test("states that the resource library is still backed by mock data", () => {
|
||||
test("surfaces the current resource library data-source message", () => {
|
||||
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
|
||||
|
||||
expect(screen.getByText("当前资源库仍使用 mock 数据")).toBeInTheDocument();
|
||||
expect(screen.getByText("资源库当前显示真实后端数据。")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ava / Studio")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders in-page tabs plus an upload slot ahead of the masonry cards", () => {
|
||||
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Library sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "模特" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "场景" })).toHaveAttribute(
|
||||
"href",
|
||||
"/libraries/scenes",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "服装" })).toHaveAttribute(
|
||||
"href",
|
||||
"/libraries/garments",
|
||||
);
|
||||
expect(screen.getByText("上传模特资源")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "打开上传弹窗" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("library-masonry").className).toContain("columns-1");
|
||||
expect(screen.getByTestId("library-masonry").className).toContain("2xl:columns-4");
|
||||
expect(screen.getByText("Ava / Studio")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders direct management actions inside each resource card", () => {
|
||||
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
|
||||
|
||||
expect(screen.getByRole("img", { name: "Ava / Studio 预览图" })).toHaveAttribute(
|
||||
"src",
|
||||
"mock://libraries/models/ava",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "编辑" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "删除" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens a model-specific upload dialog from the first masonry card", () => {
|
||||
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "打开上传弹窗" }));
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "上传模特资源" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("资源名称")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("模特性别")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("年龄段")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("原图")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("附加图库")).toBeInTheDocument();
|
||||
expect(screen.getByText("缩略图将根据原图自动生成。")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens an edit dialog that lets operators inspect multiple images and choose a cover", () => {
|
||||
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "编辑" }));
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "编辑模特资源" })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Ava / Studio")).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "Ava / Studio 图片 1" })).toHaveAttribute(
|
||||
"src",
|
||||
"mock://libraries/models/ava",
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "Ava / Studio 图片 2" })).toHaveAttribute(
|
||||
"src",
|
||||
"mock://libraries/models/ava-side",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "设为封面 2" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens an alert dialog before archiving a resource", () => {
|
||||
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除" }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("alertdialog", { name: "确认删除模特资源" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "确认删除" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "取消" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
96
tests/features/libraries/manage-resource.test.ts
Normal file
96
tests/features/libraries/manage-resource.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
archiveLibraryResource,
|
||||
updateLibraryResource,
|
||||
} from "@/features/libraries/manage-resource";
|
||||
|
||||
test("updates resource metadata and cover through the library item route", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
item: {
|
||||
id: "12",
|
||||
libraryType: "models",
|
||||
name: "Ava Studio Updated",
|
||||
description: "新的描述",
|
||||
previewUri: "https://images.marcusd.me/library/models/ava/gallery-1.png",
|
||||
originalUri: "https://images.marcusd.me/library/models/ava/original.png",
|
||||
tags: ["女装", "更新"],
|
||||
files: [
|
||||
{
|
||||
id: 101,
|
||||
role: "thumbnail",
|
||||
url: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
role: "gallery",
|
||||
url: "https://images.marcusd.me/library/models/ava/gallery-1.png",
|
||||
},
|
||||
],
|
||||
isMock: false,
|
||||
backendId: 12,
|
||||
poseId: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const updated = await updateLibraryResource({
|
||||
fetchFn: fetchMock,
|
||||
libraryType: "models",
|
||||
resourceId: "12",
|
||||
values: {
|
||||
name: "Ava Studio Updated",
|
||||
description: "新的描述",
|
||||
tags: ["女装", "更新"],
|
||||
coverFileId: 102,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
id: "12",
|
||||
name: "Ava Studio Updated",
|
||||
previewUri: "https://images.marcusd.me/library/models/ava/gallery-1.png",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/libraries/models/12", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Ava Studio Updated",
|
||||
description: "新的描述",
|
||||
tags: ["女装", "更新"],
|
||||
coverFileId: 102,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("archives a resource through the library item route", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
id: "12",
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const archivedId = await archiveLibraryResource({
|
||||
fetchFn: fetchMock,
|
||||
libraryType: "models",
|
||||
resourceId: "12",
|
||||
});
|
||||
|
||||
expect(archivedId).toBe("12");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/libraries/models/12", {
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
173
tests/features/libraries/upload-resource.test.ts
Normal file
173
tests/features/libraries/upload-resource.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { uploadLibraryResource } from "@/features/libraries/upload-resource";
|
||||
|
||||
test("uploads original thumbnail and gallery files before creating the resource record", async () => {
|
||||
const originalFile = new File(["original"], "ava-original.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const thumbnailFile = new File(["thumbnail"], "ava-thumb.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const galleryFile = new File(["gallery"], "ava-gallery.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
const thumbnailGenerator = vi.fn().mockResolvedValue(thumbnailFile);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
method: "PUT",
|
||||
uploadUrl: "https://upload.test/original",
|
||||
headers: { "content-type": "image/png" },
|
||||
storageKey: "library/models/2026/ava-original.png",
|
||||
publicUrl: "https://images.test/library/models/2026/ava-original.png",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
method: "PUT",
|
||||
uploadUrl: "https://upload.test/thumbnail",
|
||||
headers: { "content-type": "image/png" },
|
||||
storageKey: "library/models/2026/ava-thumb.png",
|
||||
publicUrl: "https://images.test/library/models/2026/ava-thumb.png",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
method: "PUT",
|
||||
uploadUrl: "https://upload.test/gallery",
|
||||
headers: { "content-type": "image/png" },
|
||||
storageKey: "library/models/2026/ava-gallery.png",
|
||||
publicUrl: "https://images.test/library/models/2026/ava-gallery.png",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
item: {
|
||||
id: "12",
|
||||
libraryType: "models",
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
previewUri: "https://images.test/library/models/2026/ava-thumb.png",
|
||||
tags: ["女装", "棚拍"],
|
||||
isMock: false,
|
||||
backendId: 12,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 201 },
|
||||
),
|
||||
);
|
||||
|
||||
const created = await uploadLibraryResource({
|
||||
fetchFn: fetchMock,
|
||||
thumbnailGenerator,
|
||||
libraryType: "models",
|
||||
values: {
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
tags: ["女装", "棚拍"],
|
||||
gender: "female",
|
||||
ageGroup: "adult",
|
||||
environment: "",
|
||||
category: "",
|
||||
},
|
||||
files: {
|
||||
original: originalFile,
|
||||
gallery: [galleryFile],
|
||||
},
|
||||
});
|
||||
|
||||
expect(created).toEqual({
|
||||
id: "12",
|
||||
libraryType: "models",
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
previewUri: "https://images.test/library/models/2026/ava-thumb.png",
|
||||
tags: ["女装", "棚拍"],
|
||||
isMock: false,
|
||||
backendId: 12,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/libraries/uploads/presign",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://upload.test/original",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: originalFile,
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
7,
|
||||
"/api/libraries/models",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"/api/libraries/models",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
tags: ["女装", "棚拍"],
|
||||
gender: "female",
|
||||
ageGroup: "adult",
|
||||
environment: undefined,
|
||||
category: undefined,
|
||||
files: [
|
||||
{
|
||||
fileRole: "original",
|
||||
storageKey: "library/models/2026/ava-original.png",
|
||||
publicUrl: "https://images.test/library/models/2026/ava-original.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 8,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
fileRole: "thumbnail",
|
||||
storageKey: "library/models/2026/ava-thumb.png",
|
||||
publicUrl: "https://images.test/library/models/2026/ava-thumb.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 9,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
fileRole: "gallery",
|
||||
storageKey: "library/models/2026/ava-gallery.png",
|
||||
publicUrl: "https://images.test/library/models/2026/ava-gallery.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 7,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(thumbnailGenerator).toHaveBeenCalledWith(originalFile);
|
||||
});
|
||||
48
tests/features/orders/resource-picker-options.test.ts
Normal file
48
tests/features/orders/resource-picker-options.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { mapModelOptions, mapResourceOptions } from "@/features/orders/resource-picker-options";
|
||||
import type { LibraryItemVM } from "@/lib/types/view-models";
|
||||
|
||||
test("prefers backend-backed ids for models when the resource library provides them even without pose", () => {
|
||||
const items: LibraryItemVM[] = [
|
||||
{
|
||||
id: "12",
|
||||
libraryType: "models",
|
||||
name: "Ava Studio",
|
||||
description: "棚拍女模特",
|
||||
previewUri: "https://images.marcusd.me/library/models/ava/thumb.png",
|
||||
tags: ["女装"],
|
||||
isMock: false,
|
||||
backendId: 12,
|
||||
},
|
||||
];
|
||||
|
||||
expect(mapModelOptions(items)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "12",
|
||||
backendId: 12,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("prefers backend-backed ids for scene and garment resources when provided", () => {
|
||||
const items: LibraryItemVM[] = [
|
||||
{
|
||||
id: "21",
|
||||
libraryType: "scenes",
|
||||
name: "Loft Window",
|
||||
description: "暖调室内场景",
|
||||
previewUri: "https://images.marcusd.me/library/scenes/loft/thumb.png",
|
||||
tags: ["室内"],
|
||||
isMock: false,
|
||||
backendId: 21,
|
||||
},
|
||||
];
|
||||
|
||||
expect(mapResourceOptions(items)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "21",
|
||||
backendId: 21,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -79,6 +79,11 @@ function createFetchMock({
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseSelectOption(label: string, optionName: string) {
|
||||
fireEvent.click(screen.getByRole("combobox", { name: label }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: optionName }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
});
|
||||
@@ -92,26 +97,30 @@ test("forces low customers to use auto_basic and mid customers to use semi_pro",
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
const customerLevelSelect = await screen.findByLabelText("客户层级");
|
||||
const serviceModeSelect = screen.getByLabelText("服务模式");
|
||||
await screen.findByText("Ava / Studio");
|
||||
|
||||
fireEvent.change(customerLevelSelect, {
|
||||
target: { value: "low" },
|
||||
});
|
||||
await chooseSelectOption("客户层级", "低客单 low");
|
||||
|
||||
expect(serviceModeSelect).toHaveValue("auto_basic");
|
||||
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
|
||||
"自动基础处理 auto_basic",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "服务模式" }));
|
||||
expect(
|
||||
screen.getByRole("option", { name: "半人工专业处理 semi_pro" }),
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.change(customerLevelSelect, {
|
||||
target: { value: "mid" },
|
||||
).toHaveAttribute("data-disabled");
|
||||
fireEvent.keyDown(document.activeElement ?? document.body, {
|
||||
key: "Escape",
|
||||
});
|
||||
|
||||
expect(serviceModeSelect).toHaveValue("semi_pro");
|
||||
await chooseSelectOption("客户层级", "中客单 mid");
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
|
||||
"半人工专业处理 semi_pro",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "服务模式" }));
|
||||
expect(
|
||||
screen.getByRole("option", { name: "自动基础处理 auto_basic" }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute("data-disabled");
|
||||
});
|
||||
|
||||
test("preserves selected values when order submission fails", async () => {
|
||||
@@ -135,18 +144,10 @@ test("preserves selected values when order submission fails", async () => {
|
||||
|
||||
await screen.findByText("Ava / Studio");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("客户层级"), {
|
||||
target: { value: "low" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("模特资源"), {
|
||||
target: { value: "model-ava" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("场景资源"), {
|
||||
target: { value: "scene-loft" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("服装资源"), {
|
||||
target: { value: "garment-coat-01" },
|
||||
});
|
||||
await chooseSelectOption("客户层级", "低客单 low");
|
||||
await chooseSelectOption("模特资源", "Ava / Studio");
|
||||
await chooseSelectOption("场景资源", "Loft Window");
|
||||
await chooseSelectOption("服装资源", "Structured Coat 01");
|
||||
|
||||
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
|
||||
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
|
||||
@@ -158,11 +159,21 @@ test("preserves selected values when order submission fails", async () => {
|
||||
expect(
|
||||
await screen.findByText("后端暂时不可用,请稍后重试。"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("客户层级")).toHaveValue("low");
|
||||
expect(screen.getByLabelText("服务模式")).toHaveValue("auto_basic");
|
||||
expect(screen.getByLabelText("模特资源")).toHaveValue("model-ava");
|
||||
expect(screen.getByLabelText("场景资源")).toHaveValue("scene-loft");
|
||||
expect(screen.getByLabelText("服装资源")).toHaveValue("garment-coat-01");
|
||||
expect(screen.getByRole("combobox", { name: "客户层级" })).toHaveTextContent(
|
||||
"低客单 low",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
|
||||
"自动基础处理 auto_basic",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "模特资源" })).toHaveTextContent(
|
||||
"Ava / Studio",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "场景资源" })).toHaveTextContent(
|
||||
"Loft Window",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "服装资源" })).toHaveTextContent(
|
||||
"Structured Coat 01",
|
||||
);
|
||||
});
|
||||
|
||||
test("submits the selected resources, surfaces returned ids, and redirects to the order detail page", async () => {
|
||||
@@ -189,18 +200,10 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
|
||||
|
||||
await screen.findByText("Ava / Studio");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("客户层级"), {
|
||||
target: { value: "low" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("模特资源"), {
|
||||
target: { value: "model-ava" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("场景资源"), {
|
||||
target: { value: "scene-loft" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("服装资源"), {
|
||||
target: { value: "garment-coat-01" },
|
||||
});
|
||||
await chooseSelectOption("客户层级", "低客单 low");
|
||||
await chooseSelectOption("模特资源", "Ava / Studio");
|
||||
await chooseSelectOption("场景资源", "Loft Window");
|
||||
await chooseSelectOption("服装资源", "Structured Coat 01");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
|
||||
|
||||
@@ -213,7 +216,6 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
|
||||
customer_level: "low",
|
||||
service_mode: "auto_basic",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
|
||||
@@ -8,12 +8,14 @@ test("renders a dense toolbar row with compact controls", () => {
|
||||
render(
|
||||
<PageToolbar>
|
||||
<Input aria-label="search" />
|
||||
<Select aria-label="status">
|
||||
<option value="all">全部状态</option>
|
||||
</Select>
|
||||
<Select
|
||||
aria-label="status"
|
||||
options={[{ value: "all", label: "全部状态" }]}
|
||||
value="all"
|
||||
/>
|
||||
</PageToolbar>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("search").className).toContain("h-9");
|
||||
expect(screen.getByLabelText("status").className).toContain("h-9");
|
||||
expect(screen.getByRole("combobox", { name: "status" }).className).toContain("h-9");
|
||||
});
|
||||
|
||||
32
tests/ui/select.test.tsx
Normal file
32
tests/ui/select.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { Select } from "@/components/ui/select";
|
||||
|
||||
test("renders a shadcn-style popover select and emits the chosen value", () => {
|
||||
const onValueChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Select
|
||||
aria-label="status"
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "waiting_review", label: "待审核" },
|
||||
]}
|
||||
placeholder="请选择状态"
|
||||
value="all"
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("combobox", { name: "status" });
|
||||
expect(trigger.className).toContain("inline-flex");
|
||||
expect(trigger.className).toContain("justify-between");
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(screen.getByRole("option", { name: "待审核" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "待审核" }));
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith("waiting_review");
|
||||
});
|
||||
Reference in New Issue
Block a user