oh-my-muse/muse-studio/e2e/export-download.spec.ts
lili a5619a3566
Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
feat(content): 补齐导入导出真链路
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:40:10 -07:00

213 lines
8.1 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.

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);
}
});
});