187d7735b9
将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移 修复快速编辑支付弹窗期间框选显示和交互冻结 补齐后台图片放大以及视频音频资源预览 收紧主站和 External 资源换签授权并记录管理员跨用户审计 固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口 加固运行时身份轮换、bootstrap secret 与生产构建发布门禁 同步生成绑定、定向测试、运维脚本和项目文档
508 lines
16 KiB
TypeScript
508 lines
16 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
getAdminAssetReadUrl,
|
|
listAdminEditorAssets,
|
|
} from '../api/adminApiClient';
|
|
import type { AdminEditorAssetPayload } from '../api/adminApiTypes';
|
|
import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage';
|
|
|
|
vi.mock('../api/adminApiClient', () => ({
|
|
getAdminAssetReadUrl: vi.fn(),
|
|
listAdminEditorAssets: vi.fn(),
|
|
}));
|
|
|
|
const generatedAsset: AdminEditorAssetPayload = {
|
|
assetId: 'asset-1',
|
|
ownerUserId: 'user-1',
|
|
authorDisplayName: '作者昵称',
|
|
authorPublicUserCode: 'SY-00000042',
|
|
folderId: 'folder-1',
|
|
label: '角色形象 1',
|
|
assetObjectId: 'asset-object-1',
|
|
imageSrc: '/generated-character-drafts/editor/spec.png',
|
|
objectKey: 'generated-character-drafts/editor/spec.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
prompt: '完整提示词内容',
|
|
actualPrompt: null,
|
|
model: 'gpt-image-2',
|
|
provider: 'character',
|
|
taskId: 'task-1',
|
|
assetKind: 'character',
|
|
generationInputs: { style: 'clay' },
|
|
sourceResourceId: 'resource-1',
|
|
thumbnailSrc: null,
|
|
generationCostMudPoints: 12,
|
|
createdAt: '2026-07-04T10:00:00Z',
|
|
updatedAt: '2026-07-04T10:00:00Z',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValue({
|
|
entries: [generatedAsset],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
|
read: {
|
|
objectKey: 'generated-character-drafts/editor/spec.png',
|
|
signedUrl:
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
});
|
|
|
|
test('后台素材查询展示作者昵称和陶泥号', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('作者昵称')).toBeTruthy();
|
|
expect(screen.getByText('SY-00000042')).toBeTruthy();
|
|
expect(screen.queryByText('user-1')).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询按用户、搜索和时间调用查询接口', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByRole('img', { name: '素材:角色形象 1' });
|
|
|
|
fireEvent.change(screen.getByLabelText('用户 ID'), {
|
|
target: { value: 'user-1' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('搜索'), {
|
|
target: { value: '陶泥角色' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('开始时间'), {
|
|
target: { value: '2026-07-01' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('结束时间'), {
|
|
target: { value: '2026-07-04' },
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', {
|
|
ownerUserId: 'user-1',
|
|
keyword: '陶泥角色',
|
|
createdAfter: '2026-07-01T00:00:00+08:00',
|
|
createdBefore: '2026-07-04T23:59:59.999+08:00',
|
|
limit: 80,
|
|
});
|
|
});
|
|
});
|
|
|
|
test('后台素材查询不展示分类筛选和分类列', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
|
expect(screen.queryByLabelText('分类')).toBeNull();
|
|
expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
|
|
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-character-drafts/editor/spec.png',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展示', async () => {
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
imageSrc:
|
|
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-character-drafts/editor/absolute.png?x-oss-process=image/resize,w_320',
|
|
objectKey: null,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
|
read: {
|
|
objectKey: 'generated-character-drafts/editor/absolute.png',
|
|
signedUrl: 'https://signed.example.com/absolute.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/absolute.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-character-drafts/editor/absolute.png',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询点击图片缩略图可打开放大预览', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: '素材:角色形象 1' }),
|
|
);
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
|
const image = within(dialog).getByRole('img', {
|
|
name: '图片预览:角色形象 1',
|
|
});
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-character-animation-1',
|
|
label: '角色动作首帧',
|
|
imageSrc: '/generated-animations/editor/source-1/task-1/frame00.png',
|
|
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
|
|
assetKind: 'character-animation',
|
|
thumbnailSrc:
|
|
'/generated-animations/editor/source-1/task-1/frame00.png',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
|
read: {
|
|
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
|
|
signedUrl: 'https://signed.example.com/character-animation-frame00.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: '素材:角色动作首帧' }),
|
|
);
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
|
const image = within(dialog).getByRole('img', {
|
|
name: '图片预览:角色动作首帧',
|
|
});
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/character-animation-frame00.png',
|
|
);
|
|
});
|
|
expect(within(dialog).queryByLabelText('视频预览:角色动作首帧')).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询音频素材使用统一封面缩略图', async () => {
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-audio-1',
|
|
label: '胜利音效',
|
|
imageSrc: '/generated-editor-audios/sfx.mp3',
|
|
objectKey: 'generated-editor-audios/sfx.mp3',
|
|
assetKind: 'sound-effect',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:胜利音效' });
|
|
expect(image.getAttribute('src')).toBe(
|
|
'/creation-home/audio-asset-cover.png',
|
|
);
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('后台素材查询点击音频缩略图后签名音频并展示播放器', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-audio-1',
|
|
label: '胜利音效',
|
|
imageSrc: '/generated-editor-audios/sfx.mp3',
|
|
objectKey: 'generated-editor-audios/sfx.mp3',
|
|
assetKind: 'sound-effect',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
|
read: {
|
|
objectKey: 'generated-editor-audios/sfx.mp3',
|
|
signedUrl: 'https://signed.example.com/generated-editor-audios/sfx.mp3',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: '素材:胜利音效' }),
|
|
);
|
|
|
|
const player = await screen.findByLabelText('音频预览:胜利音效');
|
|
await waitFor(() => {
|
|
expect(player.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-editor-audios/sfx.mp3',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-editor-audios/sfx.mp3',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询视频缩略图使用封面并在预览中播放视频', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-video-1',
|
|
label: '生成视频 1',
|
|
imageSrc: '/generated-editor-videos/task-1/preview.mp4',
|
|
objectKey:
|
|
'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
|
assetKind: 'editor_video',
|
|
thumbnailSrc: '/generated-editor-videos/task-1/cover.png',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => {
|
|
if ('legacyPublicPath' in request) {
|
|
return {
|
|
read: {
|
|
objectKey: 'generated-editor-videos/task-1/cover.png',
|
|
signedUrl: 'https://signed.example.com/video-cover.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
};
|
|
}
|
|
const objectKey = request.objectKey ?? '';
|
|
return {
|
|
read: {
|
|
objectKey,
|
|
signedUrl: objectKey.endsWith('.mp4')
|
|
? 'https://signed.example.com/video-preview.mp4'
|
|
: 'https://signed.example.com/video-cover-by-key.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
};
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:生成视频 1' });
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/video-cover.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-editor-videos/task-1/cover.png',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
|
expireSeconds: 300,
|
|
});
|
|
|
|
vi.mocked(getAdminAssetReadUrl).mockClear();
|
|
await user.click(screen.getByRole('button', { name: '素材:生成视频 1' }));
|
|
|
|
const video = await screen.findByLabelText('视频预览:生成视频 1');
|
|
await waitFor(() => {
|
|
expect(video.getAttribute('src')).toBe(
|
|
'https://signed.example.com/video-preview.mp4',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询将无 objectKey 的绝对 OSS 视频和封面分别换签', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-video-2',
|
|
label: '生成视频 2',
|
|
imageSrc:
|
|
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/preview.mp4?versionId=video',
|
|
objectKey: null,
|
|
assetKind: 'editor_video',
|
|
thumbnailSrc:
|
|
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/cover.png?versionId=poster',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => {
|
|
const legacyPublicPath = request.legacyPublicPath ?? '';
|
|
return {
|
|
read: {
|
|
objectKey: legacyPublicPath.replace(/^\//u, ''),
|
|
signedUrl: legacyPublicPath.endsWith('/cover.png')
|
|
? 'https://signed.example.com/video-2-cover.png'
|
|
: 'https://signed.example.com/video-2-preview.mp4',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
};
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const thumbnail = await screen.findByRole('img', {
|
|
name: '素材:生成视频 2',
|
|
});
|
|
await waitFor(() => {
|
|
expect(thumbnail.getAttribute('src')).toBe(
|
|
'https://signed.example.com/video-2-cover.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-editor-videos/task-2/cover.png',
|
|
expireSeconds: 300,
|
|
});
|
|
|
|
vi.mocked(getAdminAssetReadUrl).mockClear();
|
|
await user.click(screen.getByRole('button', { name: '素材:生成视频 2' }));
|
|
|
|
const video = await screen.findByLabelText('视频预览:生成视频 2');
|
|
await waitFor(() => {
|
|
expect(video.getAttribute('src')).toBe(
|
|
'https://signed.example.com/video-2-preview.mp4',
|
|
);
|
|
expect(video.getAttribute('poster')).toBe(
|
|
'https://signed.example.com/video-2-cover.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-editor-videos/task-2/preview.mp4',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-editor-videos/task-2/cover.png',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询格式化微秒时间文本', async () => {
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
createdAt: '1783231493.573727Z',
|
|
updatedAt: '1783231493.573727Z',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
|
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询可查看素材详情', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
|
|
|
|
const dialog = screen.getByRole('dialog', { name: '素材详情' });
|
|
expect(within(dialog).getByText('asset-1')).toBeTruthy();
|
|
expect(within(dialog).getByText('1024 x 1024')).toBeTruthy();
|
|
expect(within(dialog).getByText('12 泥点')).toBeTruthy();
|
|
expect(within(dialog).getByText(/"style": "clay"/u)).toBeTruthy();
|
|
});
|
|
|
|
test('后台素材查询可打开弹窗查看完整提示词', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '完整提示词内容' }),
|
|
);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: '完整提示词' });
|
|
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
|
|
|
|
fireEvent.click(
|
|
within(dialog).getByRole('button', { name: '关闭完整提示词' }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('dialog', { name: '完整提示词' })).toBeNull();
|
|
});
|
|
});
|