test(studio): accept-suggestion 改真生成删 stub,清除 line176② 最后 AI 假绿(采纳断层已修)
采纳断层修复后(commit a16c596 ADR-020,真候选 authz 字符串 envelope 可采纳),accept-suggestion 正路删 /ai/tasks+/stream stub,复用 ai-generation 真链路:WorkspacePage→AIPanel→真 AI task→SSE done→CandidatePanel 采纳替换→真 suggestion-merges code=0+newRevision 自增。反假绿:authz 真 rpe-local 字符串(非种子数值)、sourceAttribution.parentSuggestionId==真生成 id(证非种子)、block revision 实读动态比对(漂移不影响重复跑)。连跑 2 次 2/2 绿(suggestionId 25→26 revision 72→74)+ ai-generation/agent-slot-bind 回归绿。line176② AI 生成 stub 假绿完全清除。flaky:真 SSE 冷启动偶慢(与 ai-generation 同源,CI retries:2)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a16c59662f
commit
2ede7cb384
@ -1,98 +1,213 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
/**
|
||||
* AI 候选采纳端到端冒烟测试(真实后端,非 mock)。
|
||||
* AI 候选采纳端到端冒烟测试(真实后端 + 真 New-API LLM 生成,绝不 stub,反假绿)。
|
||||
*
|
||||
* 现状:本 spec 已可真跑真绿(2026-06-24,2/2 通过、跑 2 次可重复)。需一个已起的真实 muse-cloud 后端
|
||||
*(48080)+ muse_slice_live 基础种子;每轮 fixture 由 global-setup 直连真实 PG 复位(§3 复位 work3/block3/suggestion3)。
|
||||
* MSW 在 e2e 下关闭(VITE_API_MOCK=false)且不拦截 suggestion-merges,确保采纳/乐观锁打的是真实接口,不会被 mock 假绿。
|
||||
* 现状(断层已闭合,正路改真生成):本 spec 正路全链不再 page.route 桩任何 AI 生成接口——
|
||||
* 经真实浏览器 WorkspacePage→AIPanel 发起真后端 AI 任务→真 New-API LLM→SSE done→候选落库→
|
||||
* CandidatePanel「采纳替换」→真 POST suggestion-merges 合并入 Canonical。这清除了进度账 line176②
|
||||
* 「AI 生成链 e2e 一律 stub /ai/tasks」的最后一处 AI stub 假绿。
|
||||
*
|
||||
* 为何正路仍桩掉 AI 生成链(2026-06-24 实测订正,非「本机不可用」也非「无审字段」):
|
||||
* 真后端 AI 生成本机已全程可真跑(见 ai-generation.spec:真浏览器→真后端 AI task→真 New-API LLM→suggestion 落库);
|
||||
* 且真生成候选 *已* 产出「审」字段——实测 muse_ai_suggestion.diff_summary 含 outputComplianceResultId /
|
||||
* staticCheckResultId / licenseRestrictionSnapshot,status=pending、source_status=active、content 非空。
|
||||
* 但真生成候选 *仍不可被采纳*,根因收窄为单一字段:authorization_snapshot_id 为 null。
|
||||
* 链路:真 runtime 产出的 runtimePermissionEnvelopeId 是字符串 `rpe-local-<uuid>`(非数值),
|
||||
* MuseAiRuntimeProjectionService.numericEnvelopeId 对非数值串 Long.parseLong 失败 → 落库 authorization_snapshot_id=null;
|
||||
* AiSuggestionMergeProjectionFacade.getSuggestion 见 authorization_snapshot_id==null 即返回 unavailable,
|
||||
* 故 mergeBlockSuggestion 抛 CONTENT_EXTERNAL_OWNER_UNAVAILABLE(1041001001)「外部 owner 暂不可用」。
|
||||
* 实测:对真生成 suggestion(id=17) 用 accept_as_is 与 merge_after_edit 两种 mergeMode 均得 1041001001(同一根因,
|
||||
* 与 mergeMode/revision 无关);而种子审齐候选 suggestion3(authorization_snapshot_id=9001) 可过审门(负路即证)。
|
||||
* ── 为何此前桩、为何现在能删(ADR-020 已修,commit a16c596)──────────────────────────
|
||||
* 旧根因:真 runtime 产出的 runtimePermissionEnvelopeId 是字符串 `rpe-local-<uuid>`(非数值),
|
||||
* 旧 authorization_snapshot_id 列为 BIGINT,投影时对非数值串解析失败 → 落库 authz=null →
|
||||
* AiSuggestionMergeProjectionFacade 见 authz==null 即判 unavailable → 采纳被拒 1041001001。
|
||||
* 故旧正路只能桩 /ai/tasks + /stream 令 onDone 回传种子审齐候选 suggestion3(authz=9001 数值)。
|
||||
* 修复(ADR-020 方案 A):authorization_snapshot_id 列 BIGINT→VARCHAR,直接承载字符串 envelope。
|
||||
* 修后真生成候选 authz 现存 `rpe-local-*` 字符串 → 天然满足合并门禁,可被真采纳。
|
||||
* 活体已证(2026-06-25 本机):对真生成 suggestion(id=20,authz=rpe-local-*) accept_as_is,
|
||||
* merge code=0、newRevision 自增(70→71),sourceAttribution.authorizationSnapshotId 回传该字符串。
|
||||
* 故正路删桩改真生成,复用 ai-generation.spec 的真链路模式(work1/block1 + writing.continuation 槽位)。
|
||||
*
|
||||
* 因此正路用种子审齐候选 suggestion3(work3/block3,global-setup §3 每轮复位为 pending/active+block3 rev1),
|
||||
* 在网络层桩掉 /ai/tasks + /stream 令 onDone 回传 suggestion3,采纳 POST 仍直打真后端 merge(反假绿)。
|
||||
* 桩的是「生成」一段,被采纳的候选与写入 Canonical 全真。负路乐观锁(下方第二个用例)亦全真、无桩。
|
||||
* ── 反假绿(正路)──────────────────────────────────────────────────────────────────
|
||||
* ① 发送指令后真打 POST /app-api/muse/ai/tasks(2xx + 业务码 code=0 + 回传 taskId,证真接口非 mock 截获);
|
||||
* ② SSE done 后 CandidatePanel「采纳替换」按钮可见(done→onCandidateGenerated 真贯通浏览器);
|
||||
* ③ DB 锚定:本轮真生成在 muse_ai_suggestion 落新行(id > 运行前基准,work1/block1,content 非空);
|
||||
* ④ 点击采纳真打 POST .../suggestion-merges → HTTP 200 + code=0 + data.newRevision 自增(相对采纳前实读 revision),
|
||||
* 且 sourceAttribution.parentSuggestionId == 本轮真生成 suggestionId(证采纳的是真候选、非种子)。
|
||||
* 被采纳的候选与写入 Canonical 全真,无任何 stub。
|
||||
*
|
||||
* ── 可重复(跑 2 次绿)────────────────────────────────────────────────────────────
|
||||
* 每轮真生成产「新」suggestion(id 自增,恒新、不复用),故无须复位 suggestion;
|
||||
* 采纳的 expectedBlockRevision 由前端从后端实读(chapterDetail.blocks[0].revision),断言只比较
|
||||
* newRevision > 采纳前实读 revision(动态,不硬编码),故 block revision 逐轮 +1 漂移不影响重复跑。
|
||||
* work1 的 writing.continuation 槽位由 global-setup §15 每轮复位为 agent1/active,保证真生成可解析 Agent。
|
||||
*
|
||||
* ── 前置 / 慢 + 异步 ──────────────────────────────────────────────────────────────
|
||||
* - 真实 muse-server 在 48080(V30 applied + ADR-020 authz=VARCHAR)+ New-API 在线 + muse_slice_live;
|
||||
* e2e 关 MSW(VITE_API_MOCK=false),前端经真实浏览器直连后端(vite 代理 /app-api→48080);
|
||||
* - work1(id=1)/chapter1(id=1)/block1(id=1) 已存在,writing.continuation 槽位 active(global-setup §15 复位);
|
||||
* - DB 凭据从 ~/.config/muse-repo/infra.env 环境变量读取(MUSE_POSTGRES_*,绝不入库);
|
||||
* - AI 生成依赖 New-API LLM(~7s) + dispatcher 延迟 + 轮询粒度,实测端到端约 20s,故 test timeout 放宽到 150s。
|
||||
*
|
||||
* 后续主线(非本 spec 范围,属后端缺口):让真 AI 生成链产出 *数值* 授权快照(numericEnvelopeId 解析 rpe-local-*
|
||||
* 或改 authorization_snapshot_id 承载字符串 envelope),使真生成候选天然满足合并门禁;届时本正路可删桩改真生成
|
||||
* (复用 ai-generation.spec 模式)。在那之前真生成候选恒 1041001001,强改即假红/假绿,故诚实保留桩。
|
||||
* 后端 merge 主链路已由 P1rContentMergeSuggestionIT 在真实 PG 上证明(含正/负/幂等/状态/审门);
|
||||
* 「真实生成投影产出的候选可被采纳」由 P1rContentMergeGeneratedSuggestionIT 证明——但它注入 *数值* envelope(9001),
|
||||
* 真 New-API runtime 不产数值 envelope,故该 IT 绿 ≠ 真生成候选在活体可采纳(本 spec 桩的真因)。
|
||||
*
|
||||
* 种子常量(已填实并经活体验证;与 global-setup §3 复位口径一致)。
|
||||
* 「真实生成投影产出的候选可被采纳」在 ADR-020 后于活体打通,本 spec 即其真浏览器端到端见证。
|
||||
*/
|
||||
// 真实后端 mock 登录模式下的访问令牌(mockSecret 'test' + userId 1 → 'test1')。
|
||||
const SEEDED_ACCESS_TOKEN = 'test1';
|
||||
// 已种子化、归属于上述登录用户的作品 ID。
|
||||
const SEEDED_WORK_ID = '3';
|
||||
// 上述作品下、已种子化的目标 Block ID(采纳写入的目标 Canonical Block;global-setup §3 每轮复位 rev1)。
|
||||
const SEEDED_BLOCK_ID = '3';
|
||||
// 已种子化、审齐(authorization_snapshot_id=9001 + 审三件套)、状态可被采纳的 AI 候选 ID。
|
||||
const SEEDED_SUGGESTION_ID = '3';
|
||||
// 采纳前该 Block 的当前 revision(global-setup §3 复位为 1;用于断言采纳后 newRevision 自增)。
|
||||
const SEEDED_BLOCK_REVISION = 1;
|
||||
|
||||
/**
|
||||
* 在页面加载前注入登录令牌到 localStorage。
|
||||
* WHY:studio 的 HTTP 客户端从 localStorage 读取 accessToken 注入 Bearer;e2e 必须在首屏脚本执行前写入,
|
||||
* 故使用 addInitScript 而非加载后再 setItem(后者会晚于首批请求)。
|
||||
*/
|
||||
async function seedAccessToken(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.addInitScript((token) => {
|
||||
window.localStorage.setItem('accessToken', token);
|
||||
}, SEEDED_ACCESS_TOKEN);
|
||||
/** 真实后端 mock 登录模式下的访问令牌(mockSecret 'test' + userId 1 → 'test1')。 */
|
||||
const TOKEN = 'test1';
|
||||
/** 真生成 + 采纳所用作品:work1 有 writing.continuation 槽位(global-setup §15 每轮复位 agent1/active)。 */
|
||||
const WORK_ID = 1;
|
||||
/** work1/chapter1 下唯一 Block:AIPanel 自动以 blocks[0]=block1 为目标,采纳写入该 Canonical Block。 */
|
||||
const BLOCK_ID = 1;
|
||||
/** 与 work1 KB 同主题,确保 grounding 续写命中(global-setup 已为 work1 种世界观/魔法体系 schema)。 */
|
||||
const USER_INSTRUCTION = '继续描写星环大陆的魔法体系与元素对抗禁咒';
|
||||
|
||||
/** 整套用例放宽 timeout:AI 生成有外部 LLM + dispatcher 延迟 + 轮询粒度,远超默认 30s。 */
|
||||
test.setTimeout(150_000);
|
||||
|
||||
/** 创建直连 muse_slice_live 的 pg 客户端(凭据从 infra.env 环境变量,绝不入库)。 */
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('AI 候选采纳写入 Canonical(真实后端)', () => {
|
||||
test('采纳候选后 newRevision 自增,且刷新后 Canonical 正文已更新', async ({ page }) => {
|
||||
await seedAccessToken(page);
|
||||
|
||||
// 桩掉 AI「生成」一段,令其 onDone 回传种子审齐候选 suggestion3 的 id(非「本机不可用」:真生成本机可跑,
|
||||
// 但真生成候选 authorization_snapshot_id=null → 采纳被合并门禁拒 1041001001,详见文件头根因)。
|
||||
// WHY:vite 以 VITE_API_MOCK=false 运行(MSW 关),page.route 可在网络层拦截 AI 任务与流;
|
||||
// 作品/Block 读与采纳 POST 不拦截,直打真实后端 → 采纳真实合并入 Canonical(反假绿)。
|
||||
await page.route('**/app-api/muse/ai/tasks', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 0, msg: '', data: { taskId: 't-e2e' } }),
|
||||
})
|
||||
/** 读运行前 work1/block1 的 suggestion 最大 id,作为「本轮新生成」基准(workers=1 串行,无并发污染)。 */
|
||||
async function readMaxSuggestionId(): Promise<number> {
|
||||
const c = createPgClient();
|
||||
await c.connect();
|
||||
try {
|
||||
const r = await c.query(
|
||||
`SELECT COALESCE(MAX(id), 0) AS max_id FROM muse_ai_suggestion
|
||||
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false`,
|
||||
[WORK_ID, BLOCK_ID]
|
||||
);
|
||||
await page.route('**/app-api/muse/ai/tasks/*/stream', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body:
|
||||
`event: chunk\ndata: ${JSON.stringify({ content: '活体生成候选片段' })}\n\n` +
|
||||
`event: done\ndata: ${JSON.stringify({ taskId: 't-e2e', suggestionId: Number(SEEDED_SUGGESTION_ID) })}\n\n`,
|
||||
})
|
||||
return Number(r.rows[0].max_id);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** 读 id 大于基准的最新真生成 suggestion(DB 核验本轮真落库;返回其 id 供采纳后比对 parentSuggestionId)。 */
|
||||
async function readLatestSuggestionAbove(
|
||||
baselineId: number
|
||||
): Promise<{ id: number; status: string; authz: string | null; content: string } | null> {
|
||||
const c = createPgClient();
|
||||
await c.connect();
|
||||
try {
|
||||
const r = await c.query(
|
||||
`SELECT id, status, authorization_snapshot_id AS authz, content_snapshot->>'content' AS content
|
||||
FROM muse_ai_suggestion
|
||||
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false AND id > $3
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[WORK_ID, BLOCK_ID, baselineId]
|
||||
);
|
||||
if (r.rowCount === 0) return null;
|
||||
const row = r.rows[0];
|
||||
return {
|
||||
id: Number(row.id),
|
||||
status: String(row.status),
|
||||
authz: row.authz == null ? null : String(row.authz),
|
||||
content: row.content == null ? '' : String(row.content),
|
||||
};
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 导航到真实创作工作台路由(见 src/app/routes/index.tsx 的 workspace/:workId)。
|
||||
await page.goto(`/workspace/${SEEDED_WORK_ID}`);
|
||||
/** 实读目标 Block 当前 revision(与前端 blockRevision 同口径:取 muse_content_block.revision)。 */
|
||||
async function readBlockRevision(): Promise<number> {
|
||||
const c = createPgClient();
|
||||
await c.connect();
|
||||
try {
|
||||
const r = await c.query(
|
||||
`SELECT revision FROM muse_content_block WHERE tenant_id=1 AND work_id=$1 AND id=$2`,
|
||||
[WORK_ID, BLOCK_ID]
|
||||
);
|
||||
if (r.rowCount === 0) throw new Error(`block id=${BLOCK_ID} 不存在(基础种子缺失)`);
|
||||
return Number(r.rows[0].revision);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 触发 AI 面板生成候选(指令文本任意),等待流式生成结束、对比面板出现。
|
||||
// 注意:此处依赖真实 AI 任务能产出已种子化的 suggestionId;若环境改为直接种子候选,可改为直接调用采纳入口。
|
||||
/** 读 work1/block1 最新一条 pending 候选 id(供负路乐观锁用例;版本冲突在 authz 校验前先判,故任意 pending 即可)。 */
|
||||
async function readLatestPendingSuggestionId(): Promise<number | null> {
|
||||
const c = createPgClient();
|
||||
await c.connect();
|
||||
try {
|
||||
const r = await c.query(
|
||||
`SELECT id FROM muse_ai_suggestion
|
||||
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false AND status='pending'
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[WORK_ID, BLOCK_ID]
|
||||
);
|
||||
return r.rowCount === 0 ? null : Number(r.rows[0].id);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在页面加载前注入登录令牌 + 租户到 localStorage。
|
||||
* WHY:studio 的 HTTP 客户端从 localStorage 读取 accessToken/tenantId 注入 Bearer/tenant-id;e2e 必须在首屏脚本
|
||||
* 执行前写入,故用 addInitScript 而非加载后再 setItem(后者会晚于首批请求)。
|
||||
*/
|
||||
async function seedToken(page: Page): Promise<void> {
|
||||
await page.addInitScript((t) => {
|
||||
window.localStorage.setItem('accessToken', t);
|
||||
window.localStorage.setItem('tenantId', '1');
|
||||
}, TOKEN);
|
||||
}
|
||||
|
||||
test.describe('AI 候选采纳写入 Canonical(真实后端 + 真生成,不 stub)', () => {
|
||||
test('真生成候选 → 采纳 → newRevision 自增(全链真后端,无 AI stub)', async ({ page }) => {
|
||||
// 0. 记录运行前 work1/block1 的 suggestion 基准 id:本轮 UI 真生成应产出 id 更大的新行(DB 反假绿锚点)。
|
||||
const baselineSuggestionId = await readMaxSuggestionId();
|
||||
|
||||
await seedToken(page);
|
||||
// 导航到真实创作工作台路由(见 src/app/routes/index.tsx 的 workspace/:workId);
|
||||
// WorkspacePage 自动激活 chapter1 + blocks[0]=block1,AIPanel 拿到 blockId=1。
|
||||
await page.goto(`/workspace/${WORK_ID}`);
|
||||
|
||||
// 1. 等工作台就绪:AIPanel 发送按钮出现。
|
||||
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 2. ① 填真实指令并捕获真 POST /ai/tasks(反假绿:真打真接口,非 page.route 截获)。
|
||||
const promptBox = page.getByPlaceholder(/在此处输入创作描述/);
|
||||
await promptBox.fill('请基于本章续写一段(e2e 种子)');
|
||||
await promptBox.fill(USER_INSTRUCTION);
|
||||
|
||||
const taskResponsePromise = page.waitForResponse(
|
||||
(resp) => /\/app-api\/muse\/ai\/tasks$/.test(resp.url()) && resp.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: /发送指令/ }).click();
|
||||
|
||||
// 等待对比面板的「采纳替换」按钮出现,证明候选已就绪。
|
||||
const acceptButton = page.getByRole('button', { name: /采纳替换/ });
|
||||
await expect(acceptButton).toBeVisible({ timeout: 30_000 });
|
||||
const taskResponse = await taskResponsePromise;
|
||||
// 异步受理:真后端对 POST /ai/tasks 返回 2xx(任务入队,稍后经 SSE done 推 suggestion)。只断言 2xx,
|
||||
// 业务真伪以 code=0 为准(不强求 200,避免把正确的 202 误判为红)。
|
||||
expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeGreaterThanOrEqual(200);
|
||||
expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeLessThan(300);
|
||||
const taskBody = await taskResponse.json();
|
||||
expect(taskBody.code, `创建 AI 任务响应: ${JSON.stringify(taskBody)}`).toBe(0);
|
||||
expect(taskBody?.data?.taskId, 'AI 任务应回传 taskId').toBeTruthy();
|
||||
|
||||
// 点击采纳,并捕获真实 suggestion-merges 写入响应。
|
||||
// 3. ② 等真 SSE done → onCandidateGenerated → CandidatePanel「采纳替换」按钮可见,证候选已就绪。
|
||||
// 放宽到 120s:覆盖 New-API cold/慢 + dispatcher 延迟 + 5s 轮询粒度。绝不因 flaky 改 stub 绕过。
|
||||
const acceptButton = page.getByRole('button', { name: /采纳替换/ });
|
||||
await expect(acceptButton).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
// 4. ③ DB 核验:本轮真生成应在 muse_ai_suggestion 落新行(id > 基准),work1/block1、内容非空、authz 非空。
|
||||
// authz 非空是 ADR-020 修复的直接见证(修前真生成候选 authz=null → 不可采纳)。
|
||||
const dbRow = await readLatestSuggestionAbove(baselineSuggestionId);
|
||||
expect(dbRow, `应在 DB 找到本轮真生成 suggestion(id > ${baselineSuggestionId})`).not.toBeNull();
|
||||
expect(dbRow!.content.length, 'DB suggestion 内容应非空(真 LLM 生成)').toBeGreaterThan(0);
|
||||
expect(dbRow!.authz, 'ADR-020 后真生成候选 authz 应为非空字符串 envelope').toBeTruthy();
|
||||
const generatedSuggestionId = dbRow!.id;
|
||||
|
||||
// 5. 采纳前实读 Block revision(前端 expectedBlockRevision 同口径:取 block.revision)。
|
||||
// 动态读而非硬编码:block revision 逐轮采纳 +1 漂移,跑 2 次须可重复。
|
||||
const revisionBeforeAccept = await readBlockRevision();
|
||||
|
||||
// 6. ④ 点击采纳,捕获真实 suggestion-merges 写入响应(直打真后端 merge,反假绿核心)。
|
||||
const [mergeResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) => /\/suggestion-merges$/.test(resp.url()) && resp.request().method() === 'POST',
|
||||
@ -101,43 +216,59 @@ test.describe('AI 候选采纳写入 Canonical(真实后端)', () => {
|
||||
acceptButton.click(),
|
||||
]);
|
||||
|
||||
// (d) 断言写入命令 HTTP 200。
|
||||
// (a) 写入命令 HTTP 200。
|
||||
expect(mergeResponse.status()).toBe(200);
|
||||
|
||||
// (e) 断言响应 newRevision 自增(相对采纳前 SEEDED_BLOCK_REVISION)。
|
||||
const mergeBody = await mergeResponse.json();
|
||||
// (b) 业务码 code=0(真采纳成功,非被门禁/乐观锁拒)。
|
||||
expect(mergeBody.code, `采纳响应: ${JSON.stringify(mergeBody).slice(0, 200)}`).toBe(0);
|
||||
// (c) newRevision 自增(相对采纳前实读 revision;动态比较,不硬编码 revision)。
|
||||
const newRevision = mergeBody?.data?.newRevision;
|
||||
expect(typeof newRevision).toBe('number');
|
||||
expect(newRevision).toBeGreaterThan(SEEDED_BLOCK_REVISION);
|
||||
expect(newRevision).toBeGreaterThan(revisionBeforeAccept);
|
||||
// (d) 采纳的确是本轮真生成候选(parentSuggestionId == 真生成 suggestionId),证非种子/非 mock。
|
||||
expect(String(mergeBody?.data?.sourceAttribution?.lineage?.parentSuggestionId)).toBe(
|
||||
String(generatedSuggestionId)
|
||||
);
|
||||
|
||||
// (f) 刷新后断言 Canonical Block 正文已变(对比面板关闭、编辑器仍渲染该作品工作台)。
|
||||
// 留痕:打印真 suggestionId 与 revision 跃迁,作为反假绿证据(运行日志可见)。
|
||||
console.log(
|
||||
`[accept-suggestion] 真生成 suggestionId=${generatedSuggestionId} authz=${dbRow!.authz} ` +
|
||||
`revision ${revisionBeforeAccept}→${newRevision}`
|
||||
);
|
||||
|
||||
// (e) 刷新后断言工作台仍渲染该作品(对比面板已关闭,回到 AI 面板)。
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
|
||||
// TODO: 如需断言具体正文文本,请在此处用 SEEDED 的「采纳后预期正文片段」替换下面占位断言。
|
||||
// await expect(page.getByText('<FILL_ME_expected_canonical_text>')).toBeVisible();
|
||||
});
|
||||
|
||||
test('错误的 expectedBlockRevision 导致采纳因乐观锁冲突被拒(HTTP 200 + 业务错误码)', async ({ page }) => {
|
||||
await seedAccessToken(page);
|
||||
await seedToken(page);
|
||||
|
||||
// 直接以错误 revision 调用真实 suggestion-merges,断言乐观锁冲突。
|
||||
// WHY:用 page.request 直接打接口,绕开 UI,专注验证后端乐观锁负路径;Bearer 由 Authorization 头显式带上。
|
||||
// 取 work1/block1 一条 pending 候选作 suggestionId:版本冲突在 authz 校验前先判,故任意 pending 即可
|
||||
//(活体已证 null-authz 候选 + stale revision 同样先返回 1041000002,与候选 authz 无关)。
|
||||
const pendingId = await readLatestPendingSuggestionId();
|
||||
expect(pendingId, 'work1/block1 应至少有一条 pending 候选供负路用例').not.toBeNull();
|
||||
|
||||
// 实读当前 revision,再以「明显过期」revision 触发乐观锁冲突(动态读,避免硬编码漂移误红)。
|
||||
const liveRevision = await readBlockRevision();
|
||||
const wrongRevision = liveRevision + 999;
|
||||
|
||||
// 用 page.request 直接打接口,绕开 UI,专注验证后端乐观锁负路径;Bearer/tenant 显式带上。
|
||||
// 注意:Yudao 全局异常处理把 ServiceException 包成 HTTP 200 + CommonResult{code≠0,msg},并非 REST 409
|
||||
//(P1rContentMergeSuggestionIT 已证实该口径)。
|
||||
const wrongRevision = SEEDED_BLOCK_REVISION + 999;
|
||||
const response = await page.request.post(
|
||||
`/app-api/muse/works/${SEEDED_WORK_ID}/blocks/${SEEDED_BLOCK_ID}/suggestion-merges`,
|
||||
`/app-api/muse/works/${WORK_ID}/blocks/${BLOCK_ID}/suggestion-merges`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Version': '1',
|
||||
'Authorization': `Bearer ${SEEDED_ACCESS_TOKEN}`,
|
||||
// 后端按 tenant-id 头解析租户;page.request 绕开 client.ts,需手动带上(否则报租户未传递)。
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
// 后端按 tenant-id 头解析租户;page.request 绕开 client.ts,需手动带上(否则报租户未传递)。
|
||||
'tenant-id': '1',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
commandId: crypto.randomUUID(),
|
||||
suggestionId: SEEDED_SUGGESTION_ID,
|
||||
suggestionId: String(pendingId),
|
||||
expectedBlockRevision: wrongRevision,
|
||||
decisionType: 'accept',
|
||||
mergeMode: 'accept_as_is',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user