fix(editor): 修新建章节缺契约字段致真后端 400(反假绿挖出)+ 标注暴露的后端 order_no 软删 bug
前端 useChapterCreate 此前只传 {title},而后端 ChapterCreateReqVO 强制 commandId(幂等)+
expectedWorkRevision(作品乐观锁),缺失被 @Valid 拦成 400——dev mock 不校验、长期掩盖,真后端建章必败。
修:useChapterCreate 补 commandId + expectedWorkRevision;ChapterPanel 经 props 接 work.revision、
WorkspacePage 传入;建章后失效 workDetail 缓存避免后续写命令复用旧 revision。
补 chapter-create-delete.spec.ts(配对自清理 e2e),暂 test.fixme:前端修复后请求合法到达后端,
又暴露独立后端 bug——createChapter orderNo=selectCountByWorkId(active)+1,章节软删后行仍在、
uk_muse_content_chapter_work_order 不含 deleted(同文件 command uk 是 partial index 却此处遗漏)
→ 删章节后再建 order_no 冲突 500。属 schema/软删语义,需人类定 DDL 修法,fixme 待修。
tsc/eslint 0,studio vitest 79/79,全量 e2e 41/0(+1 fixme)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
901f8e8aec
commit
ca33c8e006
79
muse-studio/e2e/chapter-create-delete.spec.ts
Normal file
79
muse-studio/e2e/chapter-create-delete.spec.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 章节新建 + 删除写路 e2e(真实后端,MSW off)——配对自清理,无 fixture 污染。
|
||||
*
|
||||
* 前置:vite VITE_API_MOCK=false、真实 muse-server 48080。
|
||||
* 目的(反假绿):新建章节是作品级写命令,后端 ChapterCreateReqVO 强制 commandId(幂等)+
|
||||
* expectedWorkRevision(作品乐观锁),前端 useChapterCreate 此前只传 {title}→真后端必 400(dev mock 掩盖)。
|
||||
* 本测真打 POST /works/1/chapters 验 200/code:0(证前端已补齐契约字段),再真打 DELETE 删掉刚建章节
|
||||
* (验删除写路 + 自清理,避免唯一标题累积污染章节大纲)。
|
||||
* 设计:唯一标题每次产新值(断言才真证写生效);建→删配对,跑完 work1 章节大纲回到初态。
|
||||
* 注:删除走 window.confirm,用 page.once('dialog') 自动接受;新建章节 revision=1,匹配前端 delete 的 expectedRevision=1。
|
||||
*/
|
||||
const TOKEN = 'test1';
|
||||
|
||||
async function seedToken(page: Page): Promise<void> {
|
||||
await page.addInitScript((t) => {
|
||||
window.localStorage.setItem('accessToken', t);
|
||||
window.localStorage.setItem('tenantId', '1');
|
||||
}, TOKEN);
|
||||
}
|
||||
|
||||
// FIXME(后端待修):前端契约已补齐(commandId + expectedWorkRevision),create 在干净作品上可成功(curl 实证 code:0);
|
||||
// 但后端 createChapter 用 orderNo = selectCountByWorkId(active)+1,而章节软删后行仍在表、
|
||||
// uk_muse_content_chapter_work_order 不含 deleted(对比同文件 uk_muse_content_chapter_command 是 partial index)
|
||||
// → 删章节后再建 order_no 与软删行冲突 → 500。系统性 schema/软删语义问题(chapter create/reorder 受影响,
|
||||
// block 等表同形潜在),需人类定 DDL 修法(partial index / 物理删 / order_no 含软删 max)。后端修复后改回 test()。
|
||||
test.fixme('用户新建并删除章节(真实后端写命令:commandId + 作品乐观锁)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
|
||||
await page.goto('/workspace/1');
|
||||
await expect(page.getByRole('heading', { name: /正在创作/ })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 唯一标题:每次产新值,断言才能真证写生效(非 pre-existing 假绿)。
|
||||
const uniqueTitle = `章节·e2e-${Date.now()}`;
|
||||
|
||||
// 点「+」新建 → 行内输入框出现 → 填唯一标题。
|
||||
await page.getByTitle('新建章节').click();
|
||||
const input = page.getByPlaceholder('章节标题');
|
||||
await input.fill(uniqueTitle);
|
||||
|
||||
// 回车提交 → 等真实 POST /works/1/chapters(带 commandId + expectedWorkRevision)→ 200/code:0。
|
||||
const [createResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
/\/works\/1\/chapters$/.test(new URL(r.url()).pathname) &&
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: 15_000 }
|
||||
),
|
||||
input.press('Enter'),
|
||||
]);
|
||||
expect(createResp.status()).toBe(200);
|
||||
const createBody = await createResp.json();
|
||||
expect(createBody.code).toBe(0);
|
||||
expect(createBody.data?.id).toBeTruthy();
|
||||
|
||||
// 新章节出现在大纲(证真后端落库 + 列表刷新)。
|
||||
await expect(page.getByText(uniqueTitle)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 自清理:删除刚建章节。hover 出垃圾桶按钮,confirm 自动接受。
|
||||
page.once('dialog', (d) => d.accept());
|
||||
const chapterItem = page.locator('.group').filter({ hasText: uniqueTitle });
|
||||
await chapterItem.hover();
|
||||
|
||||
const [deleteResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
/\/works\/1\/chapters\/[^/]+$/.test(new URL(r.url()).pathname) &&
|
||||
r.request().method() === 'DELETE',
|
||||
{ timeout: 15_000 }
|
||||
),
|
||||
chapterItem.getByTitle('删除章节').click(),
|
||||
]);
|
||||
expect(deleteResp.status()).toBe(200);
|
||||
expect((await deleteResp.json()).code).toBe(0);
|
||||
|
||||
// 章节从大纲消失(证删除写路真后端生效 + 大纲回到初态,无污染残留)。
|
||||
await expect(page.getByText(uniqueTitle)).toHaveCount(0, { timeout: 10_000 });
|
||||
});
|
||||
@ -81,6 +81,8 @@ interface ChapterPanelProps {
|
||||
activeChapterId: string | null;
|
||||
/** 切换激活章节时的回调函数 */
|
||||
onSelectChapter: (chapterId: string | null) => void;
|
||||
/** 当前作品 revision:新建章节是作品级写命令,后端要求 expectedWorkRevision 乐观锁,缺失则 400 */
|
||||
expectedWorkRevision?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -91,6 +93,7 @@ export default function ChapterPanel({
|
||||
workId,
|
||||
activeChapterId,
|
||||
onSelectChapter,
|
||||
expectedWorkRevision,
|
||||
}: ChapterPanelProps) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
@ -132,8 +135,12 @@ export default function ChapterPanel({
|
||||
return;
|
||||
}
|
||||
|
||||
if (expectedWorkRevision == null) {
|
||||
// 作品详情尚未加载,拿不到作品乐观锁版本,不发建章请求(避免后端 400/乐观锁冲突)
|
||||
return;
|
||||
}
|
||||
createChapter(
|
||||
{ title: trimmedTitle },
|
||||
{ title: trimmedTitle, expectedWorkRevision },
|
||||
{
|
||||
onSuccess: (newCh) => {
|
||||
setNewTitle('');
|
||||
|
||||
@ -135,10 +135,19 @@ export function useChapterDelete(workId: string) {
|
||||
export function useChapterCreate(workId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: { title: string }) =>
|
||||
api.post<ChapterVO>(`/works/${workId}/chapters`, dto),
|
||||
// 新建章节是改作品结构的写命令:后端 ChapterCreateReqVO 强制 commandId(幂等)+ expectedWorkRevision
|
||||
// (作品乐观锁),缺任一会被 @Valid 拦成 400(dev mock 不校验、曾掩盖此契约)。commandId 前端生成,
|
||||
// expectedWorkRevision 由调用方传入作品当前 revision。
|
||||
mutationFn: (dto: { title: string; expectedWorkRevision: number }) =>
|
||||
api.post<ChapterVO>(`/works/${workId}/chapters`, {
|
||||
title: dto.title,
|
||||
commandId: crypto.randomUUID(),
|
||||
expectedWorkRevision: dto.expectedWorkRevision,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chapterList', workId] });
|
||||
// 建章后作品 revision 已 +1,失效作品详情缓存,避免后续写命令复用旧 revision 触发乐观锁冲突。
|
||||
queryClient.invalidateQueries({ queryKey: ['workDetail', workId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -199,6 +199,7 @@ export default function WorkspacePage() {
|
||||
workId={workId}
|
||||
activeChapterId={activeChapterId}
|
||||
onSelectChapter={setActiveChapter}
|
||||
expectedWorkRevision={work?.revision}
|
||||
/>
|
||||
|
||||
{/* 右侧编辑器动态装配区 */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user