feat: connect resource library workflows

This commit is contained in:
afei A
2026-03-28 13:42:22 +08:00
parent c604e6ace1
commit 162d3e12d2
42 changed files with 4709 additions and 305 deletions

View File

@@ -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,
},
],
}),
});
});

View 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",
});
});

View 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();
});

View File

@@ -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,
}),