// @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( '素材上传地址为空,请稍后重试', ); });