Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
8.1 KiB
TypeScript
213 lines
8.1 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
||
import { readFileSync } from 'node:fs';
|
||
import { Client } from 'pg';
|
||
|
||
/**
|
||
* 作品导出下载端到端测试(真实浏览器 + 真后端 + 真 FileService + 真 PostgreSQL)。
|
||
*
|
||
* 旅程:工作台点击「导出」→ 创建 TXT 导出任务 → 后端经 FileApi 落包并返回下载凭证 →
|
||
* 浏览器点击「下载导出包」→ 真 GET /downloads/{credentialId} 获取文件流。
|
||
*
|
||
* 反假绿锚点:
|
||
* 1. 不使用 MSW,不拦截任何业务接口;
|
||
* 2. 断言 POST /export-tasks 与 GET /downloads 都为真实 2xx 响应;
|
||
* 3. 直连 PG 反查导出任务 completed、packageRef/credentialId 非空;
|
||
* 4. 文件内容必须包含真实作品标题和正文片段,证明不是空文件或 mock 字节。
|
||
*/
|
||
const TOKEN = 'test1';
|
||
const WORK_ID = 1;
|
||
|
||
test.setTimeout(90_000);
|
||
|
||
interface CommonResult<T> {
|
||
code: number;
|
||
msg?: string;
|
||
data: T;
|
||
}
|
||
|
||
interface ExportTaskData {
|
||
id: string | number;
|
||
status: string;
|
||
progress?: number;
|
||
downloadCredentialId?: string;
|
||
}
|
||
|
||
interface ExportEvidence {
|
||
status: string;
|
||
exportFormat: string;
|
||
packageRef: string | null;
|
||
credentialId: string | null;
|
||
progress: number;
|
||
}
|
||
|
||
interface ExportExpectation {
|
||
title: string;
|
||
contentFragment: string;
|
||
}
|
||
|
||
function createPgClient(): Client {
|
||
return new Client({
|
||
host: process.env.MUSE_POSTGRES_HOST,
|
||
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
|
||
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
|
||
user: process.env.MUSE_POSTGRES_USERNAME ?? 'root',
|
||
password: process.env.MUSE_POSTGRES_PASSWORD,
|
||
ssl: false,
|
||
connectionTimeoutMillis: 10_000,
|
||
});
|
||
}
|
||
|
||
async function seedToken(page: Page): Promise<void> {
|
||
await page.addInitScript((t) => {
|
||
window.localStorage.setItem('accessToken', t);
|
||
window.localStorage.setItem('tenantId', '1');
|
||
}, TOKEN);
|
||
}
|
||
|
||
async function readExportEvidence(taskId: number): Promise<ExportEvidence> {
|
||
const c = createPgClient();
|
||
await c.connect();
|
||
try {
|
||
const r = await c.query(
|
||
`SELECT status, export_format, package_ref, credential_id, progress
|
||
FROM muse_content_export_task
|
||
WHERE tenant_id=1 AND id=$1 AND work_id=$2 AND owner_user_id=1 AND deleted=false`,
|
||
[taskId, WORK_ID]
|
||
);
|
||
if (r.rowCount === 0) {
|
||
throw new Error(`export task id=${taskId} 不存在`);
|
||
}
|
||
const row = r.rows[0];
|
||
return {
|
||
status: String(row.status),
|
||
exportFormat: String(row.export_format),
|
||
packageRef: row.package_ref == null ? null : String(row.package_ref),
|
||
credentialId: row.credential_id == null ? null : String(row.credential_id),
|
||
progress: Number(row.progress),
|
||
};
|
||
} finally {
|
||
await c.end();
|
||
}
|
||
}
|
||
|
||
/** 读取当前作品真实标题与正文片段,避免 e2e 因共享库内容漂移而使用硬编码断言。 */
|
||
async function readExportExpectation(): Promise<ExportExpectation> {
|
||
const c = createPgClient();
|
||
await c.connect();
|
||
try {
|
||
const r = await c.query(
|
||
`SELECT w.title, b.content_text
|
||
FROM muse_content_work w
|
||
JOIN muse_content_chapter ch ON ch.tenant_id=w.tenant_id AND ch.work_id=w.id AND ch.deleted=false
|
||
JOIN muse_content_block b ON b.tenant_id=w.tenant_id AND b.chapter_id=ch.id AND b.deleted=false
|
||
WHERE w.tenant_id=1 AND w.id=$1 AND w.deleted=false AND b.content_text IS NOT NULL AND b.content_text <> ''
|
||
ORDER BY ch.order_no ASC, b.order_no ASC
|
||
LIMIT 1`,
|
||
[WORK_ID]
|
||
);
|
||
if (r.rowCount === 0) {
|
||
throw new Error(`work id=${WORK_ID} 缺少可导出的正文种子`);
|
||
}
|
||
const title = String(r.rows[0].title ?? '');
|
||
const contentText = String(r.rows[0].content_text ?? '');
|
||
return {
|
||
title,
|
||
contentFragment: contentText.slice(0, Math.min(8, contentText.length)),
|
||
};
|
||
} finally {
|
||
await c.end();
|
||
}
|
||
}
|
||
|
||
async function cleanupExportRows(taskId: number | null): Promise<void> {
|
||
if (taskId == null) return;
|
||
const c = createPgClient();
|
||
await c.connect();
|
||
try {
|
||
const r = await c.query(
|
||
`SELECT command_id, credential_id, package_ref
|
||
FROM muse_content_export_task
|
||
WHERE tenant_id=1 AND id=$1`,
|
||
[taskId]
|
||
);
|
||
const commandIds = r.rows
|
||
.flatMap((row) => [row.command_id, row.credential_id ? `download:${row.credential_id}` : null])
|
||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||
await c.query('DELETE FROM muse_content_export_task WHERE tenant_id=1 AND id=$1', [taskId]);
|
||
const packageRefs = r.rows
|
||
.map((row) => row.package_ref)
|
||
.filter((ref): ref is string => typeof ref === 'string' && ref.length > 0);
|
||
if (packageRefs.length > 0) {
|
||
await c.query('DELETE FROM infra_file WHERE path = ANY($1::varchar[])', [packageRefs]);
|
||
}
|
||
if (commandIds.length > 0) {
|
||
await c.query('DELETE FROM muse_content_command_log WHERE tenant_id=1 AND command_id = ANY($1::varchar[])', [
|
||
commandIds,
|
||
]);
|
||
}
|
||
} finally {
|
||
await c.end();
|
||
}
|
||
}
|
||
|
||
test.describe('作品导出下载(真实后端, MSW off)', () => {
|
||
test('创建 TXT 导出任务 → 下载导出包字节', async ({ page }) => {
|
||
let taskId: number | null = null;
|
||
try {
|
||
const expectation = await readExportExpectation();
|
||
await seedToken(page);
|
||
await page.goto(`/workspace/${WORK_ID}`);
|
||
|
||
await page.getByRole('button', { name: '导出', exact: true }).click();
|
||
await expect(page.getByRole('heading', { name: '导出作品' })).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByLabel('格式')).toHaveValue('txt');
|
||
|
||
const createResponsePromise = page.waitForResponse(
|
||
(resp) => /\/app-api\/muse\/works\/1\/export-tasks$/.test(resp.url()) && resp.request().method() === 'POST',
|
||
{ timeout: 30_000 }
|
||
);
|
||
await page.getByRole('button', { name: '创建导出任务' }).click();
|
||
const createResponse = await createResponsePromise;
|
||
expect(createResponse.status()).toBe(200);
|
||
const createBody = (await createResponse.json()) as CommonResult<ExportTaskData>;
|
||
expect(createBody.code, `创建导出任务响应: ${JSON.stringify(createBody).slice(0, 300)}`).toBe(0);
|
||
taskId = Number(createBody.data.id);
|
||
expect(taskId).toBeGreaterThan(0);
|
||
expect(createBody.data.status).toBe('completed');
|
||
expect(createBody.data.downloadCredentialId).toBeTruthy();
|
||
|
||
await expect(page.getByText(new RegExp(`导出任务 ${taskId}:completed`))).toBeVisible({ timeout: 15_000 });
|
||
const evidence = await readExportEvidence(taskId);
|
||
expect(evidence.status).toBe('completed');
|
||
expect(evidence.exportFormat).toBe('txt');
|
||
expect(evidence.progress).toBe(100);
|
||
expect(evidence.packageRef).toBeTruthy();
|
||
expect(evidence.credentialId).toBe(createBody.data.downloadCredentialId);
|
||
|
||
const downloadResponsePromise = page.waitForResponse(
|
||
(resp) => /\/app-api\/muse\/downloads\/[A-Za-z0-9-]+$/.test(resp.url()) && resp.request().method() === 'GET',
|
||
{ timeout: 30_000 }
|
||
);
|
||
const browserDownloadPromise = page.waitForEvent('download', { timeout: 30_000 });
|
||
await page.getByRole('button', { name: '下载导出包' }).click();
|
||
const downloadResponse = await downloadResponsePromise;
|
||
const browserDownload = await browserDownloadPromise;
|
||
const downloadRequestHeaders = downloadResponse.request().headers();
|
||
expect(downloadRequestHeaders.authorization, '下载文件流必须携带登录态,防止凭证下载绕过 owner 校验').toBe(
|
||
`Bearer ${TOKEN}`
|
||
);
|
||
expect(downloadResponse.status()).toBe(200);
|
||
expect(downloadResponse.headers()['content-type']).toContain('text/plain');
|
||
expect(browserDownload.suggestedFilename()).toMatch(/\.txt$/);
|
||
const downloadedPath = await browserDownload.path();
|
||
expect(downloadedPath).toBeTruthy();
|
||
const exportedText = readFileSync(downloadedPath!, 'utf8');
|
||
expect(exportedText.length).toBeGreaterThan(0);
|
||
expect(exportedText).toContain(expectation.title);
|
||
expect(exportedText).toContain(expectation.contentFragment);
|
||
} finally {
|
||
await cleanupExportRows(taskId);
|
||
}
|
||
});
|
||
});
|