4951b71d71
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m40s
Project CI / Repository checks (push) Successful in 5m37s
Project CI / Frontend tests (push) Successful in 6m21s
Project CI / Backend tests (push) Successful in 9m56s
Project CI / Native shell tests (push) Successful in 10m57s
Project CI / AI game creator shell web tests (push) Successful in 5m29s
项目快照对象键升级为 agc/project-snapshots/v2/{channel}/{user}/{project}/,渠道取部署配置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL(缺省沿用客户端下载渠道),非法渠道在写入处失败关闭
后台新增渠道列表接口,项目工程列表与下载接受 channel,游标绑定渠道并拒绝跨渠道复用
后台项目工程用户列改为素材查询口径:昵称 + 陶泥号 + 用户详情入口,由 api-server 解析作者信息
后台项目工程改为游标分页:每页 20/50/100、上一页/下一页与当前页提示,翻页失败保留当前页
部署环境示例补充快照渠道配置,并同步运维、技术方案、里程碑与决策记录文档
149 lines
4.6 KiB
TypeScript
149 lines
4.6 KiB
TypeScript
import { afterEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
downloadAdminProjectSnapshot,
|
|
getAdminProjectSnapshotChannels,
|
|
listAdminProjectSnapshots,
|
|
} from './adminApiClient';
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
test('项目列表携带分页与后台授权,解析标准响应', async () => {
|
|
const payload = { items: [], nextCursor: 'next' };
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
new Response(JSON.stringify({ ok: true, data: payload })),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const controller = new AbortController();
|
|
expect(
|
|
await listAdminProjectSnapshots(
|
|
'admin-token',
|
|
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
|
|
controller.signal,
|
|
),
|
|
).toEqual(payload);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
signal: controller.signal,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
|
|
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
new Response(JSON.stringify({ ok: true, data: payload })),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/admin/api/project-snapshots/channels',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response('PK\u0003\u0004', {
|
|
headers: {
|
|
'content-type': 'application/zip',
|
|
'content-disposition':
|
|
"attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip",
|
|
},
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const controller = new AbortController();
|
|
const archive = await downloadAdminProjectSnapshot(
|
|
'admin-token',
|
|
'release',
|
|
'user/a',
|
|
'project/b',
|
|
controller.signal,
|
|
);
|
|
expect(archive.filename).toBe('三消-r2.zip');
|
|
expect(archive.blob.type).toBe('application/zip');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer admin-token',
|
|
Accept: 'application/zip',
|
|
}),
|
|
signal: controller.signal,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test.each([
|
|
['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'],
|
|
['attachment; filename="../secret.zip"', '.._secret.zip'],
|
|
["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'],
|
|
[null, 'project.zip'],
|
|
])('ZIP 附件名兼容安全回退 %s', async (header, expected) => {
|
|
const headers: Record<string, string> = { 'content-type': 'application/zip' };
|
|
// Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。
|
|
if (header)
|
|
headers['content-disposition'] = header.replace('工程', 'project');
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
|
);
|
|
expect(
|
|
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
|
|
.filename,
|
|
).toBe(expected.replace('工程', 'project'));
|
|
});
|
|
|
|
test.each([401, 403, 409, 500])(
|
|
'下载 HTTP %s 保留后台错误,不返回 ZIP',
|
|
async (status) => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
ok: false,
|
|
error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' },
|
|
}),
|
|
{
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
},
|
|
),
|
|
),
|
|
);
|
|
await expect(
|
|
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
|
).rejects.toMatchObject({
|
|
status,
|
|
code: 'SNAPSHOT_FAILURE',
|
|
message: '工程尚未同步完成',
|
|
});
|
|
},
|
|
);
|
|
|
|
test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
new Response('{"ok":false}', {
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
),
|
|
);
|
|
await expect(
|
|
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
|
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
|
});
|