merge: require cover for first project submission
This commit is contained in:
@@ -63,7 +63,9 @@ async function advancePoll() {
|
||||
});
|
||||
}
|
||||
|
||||
async function submitProjectMetadata() {
|
||||
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]);
|
||||
|
||||
async function submitProjectMetadata({ includeCover = true }: { includeCover?: boolean } = {}) {
|
||||
fetchCurrentWorksProjectStatusMock.mockRejectedValueOnce(Object.assign(
|
||||
new Error('project missing'),
|
||||
{ statusCode: 404 },
|
||||
@@ -75,6 +77,17 @@ async function submitProjectMetadata() {
|
||||
fireEvent.change(screen.getByLabelText(/发布者姓名/), { target: { value: '小明' } });
|
||||
fireEvent.change(screen.getByLabelText(/发布者年龄/), { target: { value: '12' } });
|
||||
fireEvent.change(screen.getByLabelText(/项目简介/), { target: { value: '这是一个太空清洁小游戏。' } });
|
||||
if (includeCover) {
|
||||
fireEvent.change(screen.getByLabelText(/项目封面/), {
|
||||
target: { files: [new File([pngBytes], 'space-cover.png', { type: 'image/png' })] },
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByText('space-cover.png')).toBeVisible();
|
||||
expect(screen.getByAltText('项目封面预览')).toBeVisible();
|
||||
expect(screen.getByText('重新选择')).toBeVisible();
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交审核' }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -95,7 +108,7 @@ describe('ProjectPublishAction', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('creates a new project without a cover and locks after Builder succeeds', async () => {
|
||||
it('creates a new project with a cover DTO and locks after Builder succeeds', async () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
@@ -104,7 +117,7 @@ describe('ProjectPublishAction', () => {
|
||||
await flushSubmission();
|
||||
|
||||
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
|
||||
'无封面作品与本次构建结果已提交,正在等待平台校验。',
|
||||
'作品封面与本次构建结果已提交,正在等待平台校验。',
|
||||
);
|
||||
|
||||
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
|
||||
@@ -121,9 +134,12 @@ describe('ProjectPublishAction', () => {
|
||||
age_band: null,
|
||||
difficulty: null,
|
||||
},
|
||||
cover: {
|
||||
fileName: 'space-cover.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: 'iVBORw0KGgoB',
|
||||
},
|
||||
});
|
||||
expect(screen.queryByLabelText(/项目封面/)).not.toBeInTheDocument();
|
||||
expect(publishWorksProjectSourceMock.mock.calls[0]?.[0]).not.toHaveProperty('cover');
|
||||
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce();
|
||||
|
||||
await advancePoll();
|
||||
@@ -133,7 +149,36 @@ describe('ProjectPublishAction', () => {
|
||||
);
|
||||
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled();
|
||||
expect(screen.getByTestId('project-publish-status')).toHaveTextContent('无封面作品已提交');
|
||||
expect(screen.getByTestId('project-publish-status')).toHaveTextContent('作品与封面已提交');
|
||||
});
|
||||
|
||||
it('blocks first submission until a cover is selected', async () => {
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
await submitProjectMetadata({ includeCover: false });
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('首次提交必须上传');
|
||||
expect(publishWorksProjectSourceMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
it('rejects unsupported and oversized cover files before confirmation', async () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockRejectedValue(Object.assign(new Error('missing'), { statusCode: 404 }));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const input = screen.getByLabelText(/项目封面/);
|
||||
|
||||
fireEvent.change(input, { target: { files: [new File(['text'], 'cover.gif', { type: 'image/gif' })] } });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('仅支持 PNG、JPEG 或 WebP');
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { files: [new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'huge.png', { type: 'image/png' })] },
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('不能超过 10 MiB');
|
||||
expect(publishWorksProjectSourceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a friendly Builder failure without raw details', async () => {
|
||||
|
||||
@@ -79,6 +79,14 @@ function createRendererRequest(method: string, body?: unknown): IncomingMessage
|
||||
});
|
||||
}
|
||||
|
||||
function validProjectCover(fileName = 'cover.png') {
|
||||
return {
|
||||
fileName,
|
||||
mimeType: 'image/png',
|
||||
dataBase64: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
async function writePublishableProject(projectPath: string): Promise<void> {
|
||||
await mkdir(join(projectPath, 'src'), { recursive: true });
|
||||
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
||||
@@ -1320,7 +1328,7 @@ describe('works square host api routes', () => {
|
||||
prepareProjectReleaseMock.mockResolvedValueOnce(release);
|
||||
|
||||
const handled = await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover: validProjectCover() }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1360,17 +1368,16 @@ describe('works square host api routes', () => {
|
||||
expect(response.json().package).not.toHaveProperty('archivePath');
|
||||
expect(getValidWorksSquareAccessTokenMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls[1]).toEqual([
|
||||
'https://square.nianxx.cn/api/projects',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer main-owned-access-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectMetadata),
|
||||
},
|
||||
]);
|
||||
const [createUrl, createInit] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
expect(createUrl).toBe('https://square.nianxx.cn/api/projects/with-cover');
|
||||
expect(createInit.method).toBe('POST');
|
||||
expect(createInit.headers).toEqual({ Authorization: 'Bearer main-owned-access-token' });
|
||||
const createForm = createInit.body as FormData;
|
||||
expect(JSON.parse(String(createForm.get('metadata')))).toEqual(projectMetadata);
|
||||
const submittedCover = createForm.get('cover');
|
||||
expect(submittedCover).toBeInstanceOf(File);
|
||||
expect((submittedCover as File).name).toBe('cover.png');
|
||||
expect((submittedCover as File).type).toBe('image/png');
|
||||
const [, uploadInit] = fetchMock.mock.calls[2] as [string, RequestInit];
|
||||
expect(uploadInit.headers).toMatchObject({
|
||||
Authorization: 'Bearer main-owned-access-token',
|
||||
@@ -1404,7 +1411,7 @@ describe('works square host api routes', () => {
|
||||
expect(JSON.stringify(response.json())).not.toContain('private-source.zip');
|
||||
});
|
||||
|
||||
it('fails before upload or create when a 404 preflight cannot atomically attach the submitted cover', async () => {
|
||||
it('requires a cover after a 404 ownership preflight and stops before atomic create or version upload', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-cover-publish-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
@@ -1420,18 +1427,13 @@ describe('works square host api routes', () => {
|
||||
age_band: '6-12岁',
|
||||
difficulty: '入门',
|
||||
};
|
||||
const cover = {
|
||||
fileName: 'cover.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: Buffer.from('cover bytes').toString('base64'),
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('{}', { status: 404 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1443,13 +1445,13 @@ describe('works square host api routes', () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'WORKS_SQUARE_UNAVAILABLE',
|
||||
error: '发布服务暂时无法安全保存封面,请稍后重试。',
|
||||
status: 400,
|
||||
code: 'PROJECT_COVER_REQUIRED',
|
||||
error: '首次提交必须选择有效的 PNG、JPEG 或 WebP 项目封面。',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/with-cover'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1465,11 +1467,7 @@ describe('works square host api routes', () => {
|
||||
creator_age: 12,
|
||||
status: 'published',
|
||||
};
|
||||
const cover = {
|
||||
fileName: 'replacement.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: Buffer.from('unused replacement cover').toString('base64'),
|
||||
};
|
||||
const cover = validProjectCover('replacement.png');
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
project: {
|
||||
@@ -1533,11 +1531,7 @@ describe('works square host api routes', () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-unsafe-ownership-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
const cover = {
|
||||
fileName: 'replacement.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: Buffer.from('must not upload').toString('base64'),
|
||||
};
|
||||
const cover = validProjectCover('replacement.png');
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
@@ -1621,7 +1615,7 @@ describe('works square host api routes', () => {
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when a 404 create conflict reveals published metadata that the form did not show', async () => {
|
||||
it('maps an atomic create race to metadata conflict without version upload', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-race-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
@@ -1630,26 +1624,15 @@ describe('works square host api routes', () => {
|
||||
title: 'Must not replace published title',
|
||||
summary: 'Must not replace published summary',
|
||||
};
|
||||
const ownedStatus = (status: 'published') => ({
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
title: 'Existing title',
|
||||
summary: 'Existing summary',
|
||||
status,
|
||||
},
|
||||
latest_version: null,
|
||||
versions: [],
|
||||
});
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 409 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(ownedStatus('published')), { status: 200 }));
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 409 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata, cover: validProjectCover() }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1667,14 +1650,13 @@ describe('works square host api routes', () => {
|
||||
});
|
||||
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
|
||||
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
|
||||
'https://square.nianxx.cn/api/projects',
|
||||
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
|
||||
'https://square.nianxx.cn/api/projects/with-cover',
|
||||
]);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed without PATCH or version upload when a create conflict reconfirms draft ownership', async () => {
|
||||
it('fails closed without PATCH or version upload when atomic create returns a conflict', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-draft-conflict-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
@@ -1683,26 +1665,15 @@ describe('works square host api routes', () => {
|
||||
title: 'Updated title',
|
||||
summary: 'Updated summary',
|
||||
};
|
||||
const draftStatus = {
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
title: 'Existing title',
|
||||
summary: 'Existing summary',
|
||||
status: 'draft',
|
||||
},
|
||||
latest_version: null,
|
||||
versions: [],
|
||||
};
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 409 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(draftStatus), { status: 200 }));
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 409 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata, cover: validProjectCover() }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1718,7 +1689,7 @@ describe('works square host api routes', () => {
|
||||
code: 'PROJECT_METADATA_CONFLICT',
|
||||
error: '作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
|
||||
@@ -1800,6 +1771,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
@@ -1866,6 +1838,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
@@ -1914,6 +1887,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
@@ -1958,7 +1932,7 @@ describe('works square host api routes', () => {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const response = createResponse();
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover: validProjectCover() }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
ctx,
|
||||
@@ -2005,6 +1979,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
@@ -2046,6 +2021,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
|
||||
Reference in New Issue
Block a user