Files
Genarrative/apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts
T
kdletters 00b96f244d 补齐 AGC 素材直传的错误文案与能力作用域验证
- AGC:直传对象存储抛错(能力作用域拒绝、网络不可达、请求被取消)时统一转成中文可操作文案,不再把英文插件错误原样暴露给作者。
- 测试:新增直传抛错的单测,确认失败时不再登记素材。
- 文档:记录用 tauri-plugin-http 同一套 urlpattern 逻辑验证 `https://*.aliyuncs.com/*` 命中 dev/生产 OSS 桶且拒绝无关主机,并说明桌面端真实执行仍需在装有 AGC 的机器上补跑。
2026-09-20 22:49:56 +08:00

200 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
/**
* AGC 素材直传(封面 / 截图)的三步链路边界。
*
* 这里只替换平台 API 调用与对象存储直传实现,验证「凭证 → 直传 → confirm」的字段口径、
* 地址白名单与错误文案,不触达真实 Tauri HTTP 插件或 OSS。
*/
import { beforeEach, expect, test, vi } from 'vitest';
const requestClientApiMock = vi.hoisted(() => vi.fn());
vi.mock('../src/services/clientApi', () => ({
requestClientApi: (...args: unknown[]) => requestClientApiMock(...args),
}));
import {
resolvePlatformAssetUploadUrl,
uploadPlatformMediaAsset,
} from '../src/services/assetDirectUpload';
const TICKET = {
upload: {
bucket: 'genarrative-assets',
host: 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/',
objectKey:
'generated-character-drafts/game-distribution/cover/42/cover.png',
legacyPublicPath: '/generated-character-drafts/game-distribution/cover/42',
formFields: {
key: 'generated-character-drafts/game-distribution/cover/42/cover.png',
policy: 'policy-value',
OSSAccessKeyId: 'ak-value',
signature: 'signature-value',
success_action_status: '204',
'x-oss-meta-asset_kind': 'game_distribution_cover',
},
},
};
const CONFIRMED = {
assetObject: {
assetObjectId: 'asset_cover_1',
objectKey: TICKET.upload.objectKey,
assetKind: 'game_distribution_cover',
},
};
function buildCoverFile() {
return new File(['cover-bytes'], 'cover.png', { type: 'image/png' });
}
beforeEach(() => {
requestClientApiMock.mockReset();
requestClientApiMock
.mockResolvedValueOnce(TICKET)
.mockResolvedValueOnce(CONFIRMED);
});
test('封面按凭证、直传、confirm 三步上传并返回素材标识', async () => {
const uploadFetch = vi.fn(async () => new Response(null, { status: 204 }));
const file = buildCoverFile();
const uploaded = await uploadPlatformMediaAsset({
file,
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', '42'],
entityId: 'game-distribution-cover',
metadata: { game_distribution_media: 'cover' },
fetchImpl: uploadFetch,
});
expect(uploaded).toEqual({
assetObjectId: 'asset_cover_1',
objectKey: TICKET.upload.objectKey,
});
const ticketCall = requestClientApiMock.mock.calls[0];
expect(ticketCall?.[0]).toBe('/api/assets/direct-upload-tickets');
const ticketBody = JSON.parse(String((ticketCall?.[1] as RequestInit).body));
expect(ticketBody).toEqual(
expect.objectContaining({
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'cover', '42'],
fileName: 'cover.png',
contentType: 'image/png',
access: 'private',
maxSizeBytes: file.size,
metadata: {
asset_kind: 'game_distribution_cover',
game_distribution_media: 'cover',
},
}),
);
const uploadCall = uploadFetch.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(uploadCall[0]).toBe(TICKET.upload.host);
expect(uploadCall[1].method).toBe('POST');
const formData = uploadCall[1].body as FormData;
expect(formData.get('policy')).toBe('policy-value');
expect(formData.get('signature')).toBe('signature-value');
expect(formData.get('success_action_status')).toBe('204');
const uploadedFile = formData.get('file');
expect(uploadedFile).toBeInstanceOf(File);
expect((uploadedFile as File).name).toBe('cover.png');
const confirmCall = requestClientApiMock.mock.calls[1];
expect(confirmCall?.[0]).toBe('/api/assets/objects/confirm');
expect(JSON.parse(String((confirmCall?.[1] as RequestInit).body))).toEqual({
bucket: 'genarrative-assets',
objectKey: TICKET.upload.objectKey,
contentType: 'image/png',
contentLength: file.size,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-cover',
});
});
test('直传地址不属于平台素材存储时失败关闭,且不发送文件', async () => {
requestClientApiMock.mockReset();
requestClientApiMock
.mockResolvedValueOnce({
upload: { ...TICKET.upload, host: 'https://evil.example.com/upload' },
})
.mockResolvedValueOnce(CONFIRMED);
const uploadFetch = vi.fn();
await expect(
uploadPlatformMediaAsset({
file: buildCoverFile(),
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', '42'],
entityId: 'game-distribution-cover',
fetchImpl: uploadFetch as never,
}),
).rejects.toThrow('素材上传地址不属于平台素材存储,已终止上传');
expect(uploadFetch).not.toHaveBeenCalled();
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
});
test('对象存储拒绝上传时给出可重试文案', async () => {
const uploadFetch = vi.fn(
async () => new Response('denied', { status: 403 }),
);
await expect(
uploadPlatformMediaAsset({
file: buildCoverFile(),
assetKind: 'game_distribution_screenshot',
pathSegments: ['game-distribution', 'screenshot', '42'],
entityId: 'game-distribution-screenshot',
fetchImpl: uploadFetch,
}),
).rejects.toThrow('上传素材到对象存储失败(HTTP 403),请重试');
// 直传失败时不得再登记素材,避免留下没有实体的素材记录。
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
});
test('直传网络/作用域错误转成可操作文案', async () => {
const uploadFetch = vi.fn(async () => {
throw new Error('url not allowed on the configured scope');
});
await expect(
uploadPlatformMediaAsset({
file: buildCoverFile(),
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', '42'],
entityId: 'game-distribution-cover',
fetchImpl: uploadFetch,
}),
).rejects.toThrow(
'上传素材失败:无法访问素材存储,请检查网络后重试(url not allowed on the configured scope)',
);
// 直传抛错时不得继续登记素材。
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
});
test('本地回环与阿里云 OSS 之外的主机一律拒绝', () => {
expect(resolvePlatformAssetUploadUrl('http://127.0.0.1:9000/bucket')).toBe(
'http://127.0.0.1:9000/bucket',
);
expect(
resolvePlatformAssetUploadUrl(
'https://genarrative-assets.oss-cn-beijing.aliyuncs.com/',
),
).toBe('https://genarrative-assets.oss-cn-beijing.aliyuncs.com/');
expect(() =>
resolvePlatformAssetUploadUrl('http://oss.example.com/'),
).toThrow('素材上传地址不属于平台素材存储,已终止上传');
expect(() => resolvePlatformAssetUploadUrl(' ')).toThrow(
'素材上传地址为空,请稍后重试',
);
});