test(admin-e2e): market 子域转真后端真连(下架/申诉真打+真 PG 反查落库)

market 5 个 skip 用例全是 MSW payload 拦截的负向断言(验前端预览绑定守卫不发陈旧 payload、靠 museAdminPayloadsByPage),真后端 MSW-off 无 payload 收集器无法逐字搬、且纯前端逻辑已 vitest 覆盖,故替换为真后端真连用例。

真连(反假绿:不只看 UI toast,真 PG 反查 muse_market_* 真实行):①市场治理页渲染三工作台(按激活 tabpanel 收口,ant Tabs 不卸载隐藏 pane);②下架真打命令→真 PG 反查 listing_status=delisted+新增 1 条 applied delist 治理动作;③申诉 maintained 真打处理→真 PG 反查 status=maintained、resolver_id=1。global-setup 加 resetMarketFixture(资产复位 listed+申诉复位 supplementing+软删历轮累积终态申诉(42 条 closed 挤出首页锚点)+清理治理动作/预览)、每轮幂等自愈。

揪出 mock 臆想契约:assetId/appealId 是数字串(非 asset-e2e);publish status DB submitted→API pending(后端 toOpenApiStatus 唯一特例、mock 四态不成立);appeal.resolution 列存处理理由文本非结论枚举(结论在 status);delist expectedStatus 只接受 listed(传别的 400);preview actionType 实为 5 值(含 review_rejection)非 4 值。

isIgnoredRuntimeIssue 增忽略 Failed to load resource: net::ERR_*(资源懒加载抖动、非应用错误,命令正确性另有真 PG 反查兜底)——同解 account 首跑偶发红根因(原误判 console.error,实为 dev-server 负载下 chunk 懒加载超时)。独立连跑全量 account+market e2e x2 各 4 passed;psql 复核 asset1=delisted/appeal1=maintained resolver=1/gov_action=1,与报告一致。restore 票据路径 e2e 未覆盖(用例走 maintained、后端不消费 restore 预览),restore 仍只 mock-service 单测无真 PG IT、缺口待补(:397 注释标注)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-25 12:06:17 -07:00
parent c5d1762faa
commit ce637ac2b6
2 changed files with 274 additions and 189 deletions

View File

@ -17,6 +17,12 @@ import { Client } from 'pg';
*
* 说明:account users/usage-records/purchase-records , muse_slice_live
* (user1 account profile / purchase ), account fixture,
*
* market (//)是写命令子域:e2e ///,
* muse_slice_live 绿("上一轮把资产下架/申诉处理成终态导致本轮按钮 disabled"),
* seed market fixture( resetMarketFixture):/ e2e
* ,/ tenant_id=1(mock-token user1 ), e2e
* ( 1/4 1 2), account/publish
*/
async function globalSetup(): Promise<void> {
if (process.env.E2E_SKIP_SEED === '1') {
@ -63,6 +69,9 @@ async function globalSetup(): Promise<void> {
console.log(
`[admin e2e globalSetup] seed system_user_role(super_admin) rowCount=${seed.rowCount}(0=已存在)`,
);
// 复位 market 写命令子域 fixture(详见函数注释)。
await resetMarketFixture(client);
} finally {
await client.end();
}
@ -90,4 +99,86 @@ async function globalSetup(): Promise<void> {
}
}
/**
* market e2e fixture(tenant_id=1 )
*
* market e2e ( muse_slice_live ,,/):
* - id=1·: listed,e2e tab /( listing_status)
* - id=4·e2e: listed,( account )
* - id=2: not_listed, 1
* - id=1: supplementing(),e2e tab maintained
*
* 为什么要复位:下架/ listing_status delisted/recalled status ,
* / disabled( canDelistAsset/canResolveAppeal ),
* ,( ready)(),
* e2e muse_market_governance_action "本轮新增一行"DB
*
* best-effort 的硬前置:若锚点资产/(), fail-fast,
* market e2e
*/
async function resetMarketFixture(client: Client): Promise<void> {
// 锚点存在性校验:缺任一锚点说明切片库未就绪,直接报错(market e2e 不可能真绿)。
const anchors = await client.query(
`SELECT id, listing_status FROM muse_market_asset
WHERE tenant_id = 1 AND deleted = false AND id IN (1, 2, 4)`,
);
const anchorIds = new Set(anchors.rows.map((row) => Number(row.id)));
const missingAssets = [1, 2, 4].filter((id) => !anchorIds.has(id));
const appeal = await client.query(
`SELECT id, status FROM muse_market_appeal
WHERE tenant_id = 1 AND deleted = false AND id = 1`,
);
if (missingAssets.length > 0 || appeal.rowCount === 0) {
throw new Error(
`[admin e2e globalSetup] market 锚点缺失(缺资产 ${missingAssets.join(',') || '无'}` +
`申诉1 ${appeal.rowCount === 0 ? '缺失' : '存在'});` +
'请确认 muse_slice_live 已灌 market 基础种子,或置 E2E_SKIP_SEED=1 跳过。',
);
}
// 1) 资产 1/4 复位为已上架(撤销历轮下架/召回);仅状态偏离时才写,rowCount 反映实际修复条数。
const resetAssets = await client.query(
`UPDATE muse_market_asset
SET listing_status = 'listed', status = 'active'
WHERE tenant_id = 1 AND deleted = false AND id IN (1, 4)
AND (listing_status <> 'listed' OR status <> 'active')`,
);
// 2) 申诉 1 复位为 supplementing 开放态(撤销历轮 maintained/restored 处理结论)。
const resetAppeal = await client.query(
`UPDATE muse_market_appeal
SET status = 'supplementing', resolution = NULL, resolver_id = NULL, resolved_at = NULL
WHERE tenant_id = 1 AND deleted = false AND id = 1 AND status <> 'supplementing'`,
);
// 2b) 软删历轮累积的终态申诉(关联资产 2、非锚点 id≠1):前端申诉 tab 只拉 pageNo=1/pageSize=20 且按 id DESC,
// 历轮 e2e 处理出的 closed/maintained 申诉(都挂在资产 2 上)会把唯一开放态锚点申诉 1(最老 id)挤出首页,
// 导致申诉 tab 看不到可处理行。软删(deleted=true)让列表(查询过滤 deleted=false)只剩锚点申诉 1,
// 使行定位唯一、按钮可点;不动用户真实申诉(仅清 e2e 在资产 2 上造的终态噪声),也不碰锚点申诉 1。
const softDeleteClosedAppeals = await client.query(
`UPDATE muse_market_appeal
SET deleted = true
WHERE tenant_id = 1 AND deleted = false AND id <> 1 AND asset_id = 2
AND status IN ('closed', 'maintained', 'restored')`,
);
// 3) 清理历轮累积的治理动作与治理预览(只清 e2e 锚点资产 1/2/4 的行),让本轮 DB 断言可精确计数。
// 申诉处理(maintained)不写治理动作,故这里清理足以让"下架本轮新增一行 delist 治理动作"成立。
const clearActions = await client.query(
`DELETE FROM muse_market_governance_action
WHERE tenant_id = 1 AND asset_id IN (1, 2, 4)`,
);
const clearPreviews = await client.query(
`DELETE FROM muse_market_governance_preview
WHERE tenant_id = 1 AND asset_id IN (1, 2, 4)`,
);
console.log(
`[admin e2e globalSetup] reset market fixture: assets=${resetAssets.rowCount} ` +
`appeal=${resetAppeal.rowCount} softDeleteClosedAppeals=${softDeleteClosedAppeals.rowCount} ` +
`clearActions=${clearActions.rowCount} clearPreviews=${clearPreviews.rowCount}` +
`(均为修复/清理条数,0=已是初态)`,
);
}
export default globalSetup;

View File

@ -1,3 +1,4 @@
import { Client } from 'pg';
import { expect, test } from 'playwright/test';
import type { Page } from 'playwright/test';
@ -7,6 +8,73 @@ const commonResult = (data: unknown) => ({
msg: '',
});
// market 子域是写命令子域:e2e 真打下架/申诉处理命令后,必须直连真实 PG 核验 muse_market_* 真实行
// (反假绿:不能只看 UI toast,要看 DB listing_status/governance_action 真的变了)。连接凭据从环境变量读
// (MUSE_POSTGRES_*,见 ~/.config/muse-repo/infra.env;绝不入库),与 global-setup 同源。
async function withMarketDb<T>(fn: (client: Client) => Promise<T>): Promise<T> {
const client = 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,
});
await client.connect();
try {
return await fn(client);
} finally {
await client.end();
}
}
/** 读取 market 锚点资产当前上架状态(tenant_id=1),用于真打命令后的 DB 反查。 */
async function queryAssetListingStatus(assetId: number): Promise<string> {
return withMarketDb(async (client) => {
const result = await client.query(
`SELECT listing_status FROM muse_market_asset
WHERE tenant_id = 1 AND deleted = false AND id = $1`,
[assetId],
);
return result.rows[0]?.listing_status ?? '';
});
}
/** 统计某资产某类型 applied 治理动作行数(tenant_id=1),用于"真打下架本轮新增治理动作"DB 断言。 */
async function countGovernanceAction(
assetId: number,
actionType: string,
): Promise<number> {
return withMarketDb(async (client) => {
const result = await client.query(
`SELECT count(*)::int AS c FROM muse_market_governance_action
WHERE tenant_id = 1 AND asset_id = $1 AND action_type = $2 AND status = 'applied'`,
[assetId, actionType],
);
return result.rows[0]?.c ?? 0;
});
}
/** 读取申诉当前状态/结论/处理人(tenant_id=1),用于真打申诉处理命令后的 DB 反查。 */
async function queryAppealFacts(
appealId: number,
): Promise<{ resolution: null | string; resolverId: null | string; status: string }> {
return withMarketDb(async (client) => {
const result = await client.query(
`SELECT status, resolution, resolver_id FROM muse_market_appeal
WHERE tenant_id = 1 AND deleted = false AND id = $1`,
[appealId],
);
const row = result.rows[0] ?? {};
return {
resolution: row.resolution ?? null,
resolverId: row.resolver_id == null ? null : String(row.resolver_id),
status: row.status ?? '',
};
});
}
// 运行时确认的真实 Pinia 持久化 key:namespace=`${VITE_APP_NAMESPACE}-${VITE_APP_VERSION}-${env}`,
// 而 VITE_APP_NAMESPACE 未在任何 .env 定义 → 运行时取值为字符串 'undefined';VITE_APP_VERSION=package.json 版本 5.7.0;
// dev 环境 env='dev';persist 后缀 store id='core-access'(见 packages/stores setup.ts `${namespace}-${storeKey}`)。
@ -22,6 +90,13 @@ const museAdminPayloadsByPage = new WeakMap<Page, Record<string, unknown>[]>();
const isIgnoredRuntimeIssue = (message: string) =>
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/i.test(
message,
) ||
// dev-server 活体 e2e 噪声:并发负载下 Vite 偶发某个懒加载 chunk/静态资源请求超时,
// 浏览器报 `Failed to load resource: net::ERR_TIMED_OUT` 的 console.error。这是环境/网络抖动,
// 不是应用正确性信号(命令/契约断言另有真后端+真 PG 反查兜底),故与 ResizeObserver 噪声一并忽略,
// 避免严格 console.error 门禁把可复现的资源超时误判成红。真正的组件解析失败/未捕获 rejection 仍会命中。
/Failed to load resource: net::ERR_(TIMED_OUT|CONNECTION_|ABORTED|FAILED)/i.test(
message,
);
// 转真后端后不再有"未处理的 muse mock"概念(muse 端点直打 48080);保留同名空壳让既有调用点与
@ -252,210 +327,129 @@ test.skip('任务取消和来源传播重试提交 commandId 与 reason', async
expectNoUnhandledMuseAdminApis(page);
});
test.skip('市场下架不能复用其他资产的影响预览票据', async ({ page }) => {
// market 子域 —— 转真后端(MSW-off,真打 48080 + 真 PG 反查)。
// 锚点全部对齐 muse_slice_live 真数据(curl + psql 实证,见 global-setup resetMarketFixture 注释):
// 资产列表真返资产 1「活体市场资产·测试」(listed)、4「活体市场资产·安装e2e」(listed)、2「活体申诉演练资产」(not_listed);
// 申诉列表真返开放态申诉 1(supplementing,关联资产 2,review_rejection 类型);
// 发布审核队列真返 status=pending(后端把 DB 'submitted' 映射成 'pending')的真发布申请。
// 真分页 wrapper={pageNo,pageSize,total,list},前端 PageResult 只取 list/total。
test('市场治理页对真后端 market 子域渲染三大工作台', async ({ page }) => {
await page.goto('/muse/market');
// 页头与边界文案。
await expect(page.getByRole('heading', { name: '市场治理' })).toBeVisible();
await expect(page.getByText('不修改用户私有副本').first()).toBeVisible();
// 审核队列 tab(默认 active):真发布申请(后端 'submitted'→'pending')。
// 注意 ant Tabs 不卸载非激活 pane,锚点文案在多个 pane 都存在 → 必须按激活 tabpanel 收口可见性断言。
const reviewPanel = page.getByRole('tabpanel', { name: '审核队列' });
await expect(reviewPanel.getByText('市场审核是发布硬门槛')).toBeVisible();
// 资产治理 tab:真资产行(脱敏摘要,无私有正文)。
await page.getByRole('tab', { name: '资产治理' }).click();
const assetPanel = page.getByRole('tabpanel', { name: '资产治理' });
await expect(assetPanel.getByText('不改用户私有副本')).toBeVisible();
await expect(assetPanel.getByText('活体市场资产·测试')).toBeVisible();
await expect(assetPanel.getByText('活体市场资产·安装e2e')).toBeVisible();
// 申诉处理 tab:真开放态申诉(关联资产 2)。
await page.getByRole('tab', { name: '申诉处理' }).click();
const appealPanel = page.getByRole('tabpanel', { name: '申诉处理' });
await expect(appealPanel.getByText('恢复上架不能绕过合规审核')).toBeVisible();
await expect(appealPanel.getByText('活体申诉演练资产')).toBeVisible();
expectNoUnhandledMuseAdminApis(page);
});
// 市场资产下架真命令:在「资产治理」tab 对锚点资产 1 生成真影响预览 → 真打下架命令 →
// 真 PG 反查 listing_status='delisted' + 新增一条 applied delist 治理动作(反假绿:不只看 UI toast)。
// 注意:本用例真实改 muse_slice_live 共享行(资产 1 → delisted);下一轮 globalSetup.resetMarketFixture
// 会把它复位回 listed。同一轮内本用例必须排在"召回"等同样针对资产 1 的写用例之前。
test('市场资产下架真打命令并落库脱敏治理动作', async ({ page }) => {
const assetId = 1;
// 前置真值核验:资产 1 必须处于 listed(globalSetup 已复位),否则前端"下架"按钮逻辑前提不成立。
expect(await queryAssetListingStatus(assetId)).toBe('listed');
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
await page
.getByRole('row', { name: /^市场资产/ })
.getByRole('button')
.first()
.click();
await page.locator('.ant-modal-footer .ant-btn-primary').click();
// 锚点资产 1 行(按激活 tabpanel 收口,避开 ant 不卸载的隐藏 pane 同名行)。
// ant-design-vue 会在两个中文字按钮间插空格,故"下架"可达名是「下 架」→ 用 /下\s*架/ 容错。
const assetPanel = page.getByRole('tabpanel', { name: '资产治理' });
const assetRow = assetPanel.getByRole('row', { name: /活体市场资产·测试/ });
await assetRow.getByRole('button', { name: /下\s*架/ }).click();
const dialog = page.getByRole('dialog', { name: /市场治理:活体市场资产·测试/ });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
// 真生成影响预览:后端返回 gov-preview-* 票据,前端回写到只读输入框。
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(/^gov-preview-/);
// 填可审计原因并真打下架命令。
await dialog.locator('textarea').fill('e2e 真后端下架验证');
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
// UI 反馈:成功 toast(命令 code:0)。
await expect(page.getByText('市场资产下架命令已提交')).toBeVisible();
// 反假绿核心:真 PG 反查资产真的下架 + 真的落了一条 applied delist 治理动作。
await expect
.poll(() =>
museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' &&
item.path === '/market/assets/asset-e2e/governance-impact',
)?.payload,
)
.toEqual(
expect.objectContaining({
actionType: 'delist',
commandId: expect.stringMatching(/^market-impact-preview-/),
scope: 'stop_new_acquire',
}),
);
await page.locator('.ant-modal-footer .ant-btn-default').click();
.poll(() => queryAssetListingStatus(assetId), { timeout: 10_000 })
.toBe('delisted');
expect(await countGovernanceAction(assetId, 'delist')).toBe(1);
await page
.getByRole('row', { name: /另一市场资产/ })
.getByRole('button')
.nth(1)
.click();
await expect(page.getByPlaceholder('先执行影响预览后自动带入')).toHaveValue('');
await page
.locator('.ant-modal textarea')
.fill('尝试复用上一资产影响预览');
await page.locator('.ant-modal-footer .ant-btn-primary').click();
const delistPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' && item.path === '/market/assets/asset-two/delist',
)?.payload;
expect(delistPayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test.skip('市场下架切换范围后必须重新生成影响预览', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
await page
.getByRole('row', { name: /^市场资产/ })
.getByRole('button')
.nth(1)
.click();
// 市场申诉处理真命令:在「申诉处理」tab 对开放态申诉 1(supplementing)生成真影响预览 →
// 以默认结论 maintained 真打处理命令 → 真 PG 反查申诉 status='maintained'、resolver_id=当前 admin(1)。
//
// restore 断层留意(诚实记录):本用例走 maintained 结论,后端不消费 restore 影响预览票据
// (MarketAppealServiceImpl 仅 restored/partially_restored 才走 restoreAssetByPreview→consumeReadyPreview)。
// appeal restore 票据一次性消费路径只有 mock-service 单测、无真 PG IT,且本 e2e 未覆盖 restore 分支
// (FE openAppealAction 默认 resolution=maintained,本用例不切到 restored)。restore 真后端覆盖缺口待补。
test('市场申诉以 maintained 结论真打处理命令并落库', async ({ page }) => {
const appealId = 1;
// 前置真值核验:申诉 1 必须为开放态 supplementing(globalSetup 已复位),前端"处理"按钮才放行。
const before = await queryAppealFacts(appealId);
expect(before.status).toBe('supplementing');
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(
'preview-asset-e2e-delist-stop_new_acquire',
);
await dialog.locator('.ant-select-selector').click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('停止新绑定')
.click();
await expect(previewInput).toHaveValue('');
await dialog.locator('textarea').fill('切换范围后不能复用旧影响预览');
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
const delistPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' && item.path === '/market/assets/asset-e2e/delist',
)?.payload;
expect(delistPayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test.skip('市场召回切换动作后必须重新生成影响预览', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
await page
.getByRole('row', { name: /^市场资产/ })
.getByRole('button')
.nth(2)
.click();
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(
'preview-asset-e2e-recall-full_recall',
);
await dialog.locator('.ant-select-selector').first().click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('下架', { exact: true })
.click();
await expect(previewInput).toHaveValue('');
await dialog.getByPlaceholder('法律、合规或安全依据').fill('合规召回依据');
await dialog.locator('textarea').fill('切换动作后不能复用旧影响预览');
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
const recallPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' && item.path === '/market/assets/asset-e2e/recall',
)?.payload;
expect(recallPayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test.skip('市场召回切到下架并重新生成预览后不能提交召回', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
await page
.getByRole('row', { name: /^市场资产/ })
.getByRole('button')
.nth(2)
.click();
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
await dialog.locator('.ant-select-selector').first().click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('下架', { exact: true })
.click();
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(
'preview-asset-e2e-delist-full_recall',
);
await dialog.getByPlaceholder('法律、合规或安全依据').fill('合规召回依据');
await dialog.locator('textarea').fill('尝试用下架预览提交召回');
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
const recallPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' && item.path === '/market/assets/asset-e2e/recall',
)?.payload;
expect(recallPayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test.skip('市场申诉切换 appealId 后必须重新生成影响预览', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '申诉处理' }).click();
await page
.getByRole('row', { name: /^市场资产\s+delist\s+申诉人/ })
.getByRole('button', { name: /处\s*理/ })
.click();
const firstDialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
const firstPreviewInput =
firstDialog.getByPlaceholder('先执行影响预览后自动带入');
// 申诉 1 关联资产「活体申诉演练资产」,行内唯一动作按钮"处理"(ant 中文字按钮带空格→/处\s*理/)。
// 按激活 tabpanel 收口,避开 ant 不卸载的隐藏 pane(资产 tab 也有同名资产行)。
const appealPanel = page.getByRole('tabpanel', { name: '申诉处理' });
const appealRow = appealPanel.getByRole('row', { name: /活体申诉演练资产/ });
await appealRow.getByRole('button', { name: /处\s*理/ }).click();
await firstDialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(firstPreviewInput).toHaveValue(
'preview-asset-e2e-delist-stop_new_acquire',
);
await firstDialog.locator('.ant-modal-footer .ant-btn-default').click();
const dialog = page.getByRole('dialog', { name: /市场治理:活体申诉演练资产/ });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
await page
.getByRole('row', { name: /^另一市场资产\s+delist\s+另一申诉人/ })
.getByRole('button', { name: /处\s*理/ })
.click();
const secondDialog = page.getByRole('dialog', {
name: '市场治理:另一市场资产',
});
await expect(
secondDialog.getByPlaceholder('先执行影响预览后自动带入'),
).toHaveValue('');
await secondDialog.locator('textarea').fill('切换申诉后不能复用旧影响预览');
await secondDialog.locator('.ant-modal-footer .ant-btn-primary').click();
// 真生成影响预览(申诉类型 review_rejection → 前端 actionType=review_rejection,后端接受)。
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(/^gov-preview-/);
const resolvePayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' &&
item.path === '/market/appeals/appeal-two/resolve',
)?.payload;
// 结论默认 maintained(不切到 restored,避免触达未覆盖的 restore 票据消费路径)。
const auditReason = 'e2e 真后端维持原处理决定';
await dialog.locator('textarea').fill(auditReason);
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
await expect(page.getByText('市场申诉已处理')).toBeVisible();
// 反假绿核心:真 PG 反查申诉真的落为 maintained 终态、处理人写入当前 admin(user1)。
// 注意后端契约:muse_market_appeal.resolution 列存的是"处理理由"文本(与 P1rMarketAdminAppeal IT 一致),
// 不是结论枚举;maintained 结论体现在 status 列。故此处校验 status=maintained + resolution=本次理由。
await expect
.poll(async () => (await queryAppealFacts(appealId)).status, {
timeout: 10_000,
})
.toBe('maintained');
const after = await queryAppealFacts(appealId);
expect(after.resolution).toBe(auditReason);
expect(after.resolverId).toBe('1');
expect(resolvePayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});