13 changed files with 616 additions and 2 deletions
@ -0,0 +1,83 @@ |
|||
import { NextRequest } from "next/server"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import { POST } from "../route"; |
|||
|
|||
const originalFetch = global.fetch; |
|||
const originalBaseUrl = process.env.POPIART_API_BASE_URL; |
|||
|
|||
function createRequest(body: unknown): NextRequest { |
|||
return new NextRequest("http://localhost/api/popi/media/review", { |
|||
method: "POST", |
|||
headers: new Headers({ |
|||
Authorization: "Bearer user-token", |
|||
"Content-Type": "application/json", |
|||
}), |
|||
body: JSON.stringify(body), |
|||
}); |
|||
} |
|||
|
|||
describe("/api/popi/media/review route", () => { |
|||
beforeEach(() => { |
|||
process.env.POPIART_API_BASE_URL = "https://popi.example"; |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
global.fetch = originalFetch; |
|||
process.env.POPIART_API_BASE_URL = originalBaseUrl; |
|||
vi.restoreAllMocks(); |
|||
}); |
|||
|
|||
it("requires a media URL", async () => { |
|||
const fetchMock = vi.fn(); |
|||
global.fetch = fetchMock; |
|||
|
|||
const response = await POST(createRequest({ url: "" })); |
|||
|
|||
await expect(response.json()).resolves.toEqual({ success: false, error: "Media URL is required." }); |
|||
expect(response.status).toBe(400); |
|||
expect(fetchMock).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("proxies media review requests to Popiserver", async () => { |
|||
const payload = { |
|||
status: "0000", |
|||
message: "ok", |
|||
data: { |
|||
id: 12853, |
|||
reviewStatus: 1, |
|||
}, |
|||
}; |
|||
const fetchMock = vi.fn().mockResolvedValue( |
|||
new Response(JSON.stringify(payload), { status: 200 }) |
|||
); |
|||
global.fetch = fetchMock; |
|||
|
|||
const response = await POST(createRequest({ url: "https://example.com/image.png" })); |
|||
|
|||
await expect(response.json()).resolves.toEqual(payload); |
|||
expect(response.status).toBe(200); |
|||
expect(fetchMock).toHaveBeenCalledWith("https://popi.example/api_client/media/review", { |
|||
method: "POST", |
|||
headers: { |
|||
Accept: "application/json", |
|||
"Content-Type": "application/json", |
|||
Authorization: "Bearer user-token", |
|||
token: "user-token", |
|||
}, |
|||
body: JSON.stringify({ url: "https://example.com/image.png" }), |
|||
cache: "no-store", |
|||
}); |
|||
}); |
|||
|
|||
it("returns an error when Popiserver returns a non-success status", async () => { |
|||
const fetchMock = vi.fn().mockResolvedValue( |
|||
new Response(JSON.stringify({ status: "0500", message: "review failed" }), { status: 200 }) |
|||
); |
|||
global.fetch = fetchMock; |
|||
|
|||
const response = await POST(createRequest({ url: "https://example.com/image.png" })); |
|||
|
|||
await expect(response.json()).resolves.toEqual({ success: false, error: "review failed" }); |
|||
expect(response.status).toBe(502); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,66 @@ |
|||
import { fetchPopiserverGateway as fetch } from "@/app/api/_popiserverFetch"; |
|||
import { NextRequest, NextResponse } from "next/server"; |
|||
import { requireLogin } from "@/app/api/_auth"; |
|||
import { ApiError } from "@/app/api/_errors"; |
|||
|
|||
const POPI_MEDIA_REVIEW_PATH = "/api_client/media/review"; |
|||
|
|||
type PopiserverResponse<T> = { |
|||
status?: string; |
|||
message?: string; |
|||
data?: T; |
|||
}; |
|||
|
|||
type PopiMediaReviewData = { |
|||
reviewStatus?: number; |
|||
[key: string]: unknown; |
|||
}; |
|||
|
|||
function getPopiBaseUrl(request: NextRequest): string { |
|||
const baseUrl = |
|||
process.env.POPIART_API_BASE_URL || |
|||
process.env.NEXT_PUBLIC_APP_URL || |
|||
request.nextUrl.origin; |
|||
return baseUrl.replace(/\/+$/, ""); |
|||
} |
|||
|
|||
export async function POST(request: NextRequest) { |
|||
try { |
|||
const login = requireLogin(request); |
|||
const body = (await request.json().catch(() => null)) as { url?: unknown } | null; |
|||
const mediaUrl = typeof body?.url === "string" ? body.url.trim() : ""; |
|||
if (!mediaUrl) { |
|||
throw ApiError.badRequest("Media URL is required."); |
|||
} |
|||
|
|||
const upstream = await fetch(getPopiBaseUrl(request) + POPI_MEDIA_REVIEW_PATH, { |
|||
method: "POST", |
|||
headers: { |
|||
Accept: "application/json", |
|||
"Content-Type": "application/json", |
|||
Authorization: `Bearer ${login.token}`, |
|||
token: login.token, |
|||
}, |
|||
body: JSON.stringify({ url: mediaUrl }), |
|||
cache: "no-store", |
|||
}); |
|||
|
|||
const payload = (await upstream.json().catch(() => null)) as PopiserverResponse<PopiMediaReviewData> | null; |
|||
if (!upstream.ok) { |
|||
throw ApiError.provider(payload?.message || "Popiserver media review failed.", upstream.status); |
|||
} |
|||
if (!payload || payload.status !== "0000") { |
|||
throw ApiError.provider(payload?.message || "Popiserver media review returned an error.", 502); |
|||
} |
|||
|
|||
return NextResponse.json(payload); |
|||
} catch (error) { |
|||
if (error instanceof ApiError) { |
|||
return NextResponse.json({ success: false, error: error.message }, { status: error.status }); |
|||
} |
|||
return NextResponse.json( |
|||
{ success: false, error: error instanceof Error ? error.message : "Media review failed." }, |
|||
{ status: 500 } |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,161 @@ |
|||
import { act, render, waitFor } from "@testing-library/react"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import type { WorkflowNode } from "@/types"; |
|||
import { useWorkflowStore } from "@/store/workflowStore"; |
|||
import { useCanvasContextMenuActions } from "@/hooks/useCanvasContextMenuActions"; |
|||
import { authFetch } from "@/utils/authFetch"; |
|||
|
|||
const messageSuccess = vi.fn(); |
|||
const messageWarning = vi.fn(); |
|||
const messageError = vi.fn(); |
|||
let capturedOnAction: ((action: string) => void) | null = null; |
|||
|
|||
vi.mock("antd", () => ({ |
|||
App: { |
|||
useApp: () => ({ |
|||
message: { |
|||
success: messageSuccess, |
|||
warning: messageWarning, |
|||
error: messageError, |
|||
}, |
|||
}), |
|||
}, |
|||
})); |
|||
|
|||
vi.mock("@/utils/authFetch", () => ({ |
|||
authFetch: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/components/CanvasContextMenu", async () => { |
|||
const actual = await vi.importActual<typeof import("@/components/CanvasContextMenu")>("@/components/CanvasContextMenu"); |
|||
return { |
|||
...actual, |
|||
CanvasContextMenu: ({ onAction }: { onAction: (action: string) => void }) => { |
|||
capturedOnAction = onAction; |
|||
return null; |
|||
}, |
|||
}; |
|||
}); |
|||
|
|||
function makeImageNode(): WorkflowNode { |
|||
return { |
|||
id: "image-1", |
|||
type: "imageInput", |
|||
position: { x: 0, y: 0 }, |
|||
data: { |
|||
image: "https://example.com/image.png", |
|||
filename: "image.png", |
|||
dimensions: null, |
|||
}, |
|||
} as WorkflowNode; |
|||
} |
|||
|
|||
function renderActions(node = makeImageNode()) { |
|||
useWorkflowStore.setState({ |
|||
nodes: [node], |
|||
edges: [], |
|||
hasUnsavedChanges: false, |
|||
}); |
|||
|
|||
function TestComponent() { |
|||
const actions = useCanvasContextMenuActions({ |
|||
contextMenu: { |
|||
mode: "node", |
|||
nodeId: node.id, |
|||
position: { x: 10, y: 10 }, |
|||
flowPosition: { x: 10, y: 10 }, |
|||
targetMediaUrl: "https://example.com/image.png", |
|||
targetImageUrl: "https://example.com/image.png", |
|||
canReviewMedia: true, |
|||
}, |
|||
nodes: [node], |
|||
edges: [], |
|||
canUndo: false, |
|||
canRedo: false, |
|||
getCanvasFlowCenter: () => ({ centerX: 0, centerY: 0 }), |
|||
createNodesFromUploadedFiles: vi.fn(), |
|||
onCloseContextMenu: vi.fn(), |
|||
}); |
|||
return <>{actions.menu}</>; |
|||
} |
|||
|
|||
return render(<TestComponent />); |
|||
} |
|||
|
|||
describe("useCanvasContextMenuActions media review", () => { |
|||
beforeEach(() => { |
|||
capturedOnAction = null; |
|||
vi.useRealTimers(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
messageSuccess.mockClear(); |
|||
messageWarning.mockClear(); |
|||
messageError.mockClear(); |
|||
vi.mocked(authFetch).mockReset(); |
|||
}); |
|||
|
|||
it("stores reviewing status and saves approved review result", async () => { |
|||
vi.mocked(authFetch).mockResolvedValue( |
|||
new Response(JSON.stringify({ |
|||
status: "0000", |
|||
message: "ok", |
|||
data: { reviewStatus: 1 }, |
|||
}), { status: 200 }) |
|||
); |
|||
|
|||
renderActions(); |
|||
act(() => { |
|||
capturedOnAction?.("reviewMedia"); |
|||
}); |
|||
|
|||
expect(useWorkflowStore.getState().nodes[0].data.mediaReview).toMatchObject({ |
|||
status: "reviewing", |
|||
url: "https://example.com/image.png", |
|||
}); |
|||
|
|||
await waitFor(() => expect(useWorkflowStore.getState().nodes[0].data.mediaReview).toMatchObject({ |
|||
status: "approved", |
|||
reviewStatus: 1, |
|||
message: "ok", |
|||
url: "https://example.com/image.png", |
|||
})); |
|||
expect(messageSuccess).toHaveBeenCalledWith("校验成功"); |
|||
}); |
|||
|
|||
it("saves rejected review result and warns with response message", async () => { |
|||
vi.mocked(authFetch).mockResolvedValue( |
|||
new Response(JSON.stringify({ |
|||
status: "0000", |
|||
message: "not ok", |
|||
data: { reviewStatus: -1 }, |
|||
}), { status: 200 }) |
|||
); |
|||
|
|||
renderActions(); |
|||
act(() => { |
|||
capturedOnAction?.("reviewMedia"); |
|||
}); |
|||
|
|||
await waitFor(() => expect(useWorkflowStore.getState().nodes[0].data.mediaReview).toMatchObject({ |
|||
status: "rejected", |
|||
reviewStatus: -1, |
|||
message: "not ok", |
|||
})); |
|||
expect(messageWarning).toHaveBeenCalledWith("校验失败"); |
|||
}); |
|||
|
|||
it("clears reviewing status and shows an error when review request fails", async () => { |
|||
vi.mocked(authFetch).mockResolvedValue( |
|||
new Response(JSON.stringify({ success: false, error: "request failed" }), { status: 500 }) |
|||
); |
|||
|
|||
renderActions(); |
|||
act(() => { |
|||
capturedOnAction?.("reviewMedia"); |
|||
}); |
|||
|
|||
await waitFor(() => expect(useWorkflowStore.getState().nodes[0].data.mediaReview).toBeUndefined()); |
|||
expect(messageError).toHaveBeenCalledWith("request failed"); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,38 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
import type { WorkflowNode } from "@/types"; |
|||
import { getReviewableMediaUrl, resolveActiveMediaReview } from "@/utils/mediaReview"; |
|||
|
|||
function makeNode(data: WorkflowNode["data"], type: WorkflowNode["type"] = "imageInput"): WorkflowNode { |
|||
return { |
|||
id: "node-1", |
|||
type, |
|||
position: { x: 0, y: 0 }, |
|||
data, |
|||
} as WorkflowNode; |
|||
} |
|||
|
|||
describe("mediaReview", () => { |
|||
it("returns image and video URLs as reviewable media", () => { |
|||
expect(getReviewableMediaUrl(makeNode({ image: "https://example.com/a.png" }), [])).toBe("https://example.com/a.png"); |
|||
expect(getReviewableMediaUrl(makeNode({ video: "https://example.com/a.mp4" }, "videoInput"), [])).toBe("https://example.com/a.mp4"); |
|||
}); |
|||
|
|||
it("does not return audio URLs as reviewable media", () => { |
|||
expect(getReviewableMediaUrl(makeNode({ audioFile: "https://example.com/a.mp3" }, "audioInput"), [])).toBeNull(); |
|||
}); |
|||
|
|||
it("ignores stale review status when the node media URL changed", () => { |
|||
const node = makeNode({ |
|||
image: "https://example.com/new.png", |
|||
mediaReview: { |
|||
status: "approved", |
|||
reviewStatus: 1, |
|||
message: "ok", |
|||
url: "https://example.com/old.png", |
|||
updatedAt: "2026-07-23T00:00:00.000Z", |
|||
}, |
|||
}); |
|||
|
|||
expect(resolveActiveMediaReview(node, [])).toBeNull(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,53 @@ |
|||
import type { Node } from "@xyflow/react"; |
|||
import type { BaseNodeData, WorkflowEdge, WorkflowNode } from "@/types"; |
|||
import { getNodePrimaryImageUrl, getNodePrimaryMediaUrl } from "@/utils/canvasNodeMedia"; |
|||
|
|||
export type MediaReviewStatus = "reviewing" | "approved" | "rejected"; |
|||
|
|||
export type MediaReviewData = NonNullable<BaseNodeData["mediaReview"]>; |
|||
|
|||
export interface MediaReviewPayload { |
|||
url: string; |
|||
} |
|||
|
|||
export interface MediaReviewResponse { |
|||
status?: string; |
|||
message?: string; |
|||
data?: { |
|||
reviewStatus?: number; |
|||
[key: string]: unknown; |
|||
}; |
|||
} |
|||
|
|||
export function getReviewableMediaUrl(node: Node | WorkflowNode, edges: WorkflowEdge[]): string | null { |
|||
const imageUrl = getNodePrimaryImageUrl(node, edges); |
|||
if (imageUrl) return imageUrl; |
|||
|
|||
const mediaUrl = getNodePrimaryMediaUrl(node, edges); |
|||
if (!mediaUrl) return null; |
|||
|
|||
const type = node.type; |
|||
if (type === "audioInput" || type === "smartAudio" || type === "generateAudio" || type === "glbViewer" || type === "generate3d") { |
|||
return null; |
|||
} |
|||
|
|||
return mediaUrl; |
|||
} |
|||
|
|||
export function resolveActiveMediaReview( |
|||
node: Node | WorkflowNode | null | undefined, |
|||
edges: WorkflowEdge[] |
|||
): MediaReviewData | null { |
|||
if (!node) return null; |
|||
const review = (node.data as BaseNodeData | undefined)?.mediaReview; |
|||
if (!review) return null; |
|||
const mediaUrl = getReviewableMediaUrl(node, edges); |
|||
if (!mediaUrl || review.url !== mediaUrl) return null; |
|||
return review; |
|||
} |
|||
|
|||
export function mediaReviewStatusFromReviewStatus(reviewStatus: number | undefined): MediaReviewStatus | null { |
|||
if (reviewStatus === 1) return "approved"; |
|||
if (reviewStatus === -1) return "rejected"; |
|||
return null; |
|||
} |
|||
Loading…
Reference in new issue