lili ce637ac2b6 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>
2026-06-25 12:06:17 -07:00

185 lines
9.6 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import { Client } from 'pg';
/**
* Playwright globalSetup —— admin 治理 e2e 转真后端的活体前置(可入库,替代一次性种子脚本)。
*
* WHY:admin e2e 关 mock 直连真实 muse-server(vite 代理 /admin-api→48080)+ 真 PostgreSQL(muse_slice_live)。
* admin 用户(system_users id=1)与 studio 共用 mock-token(Bearer test1 = mockSecret"test"+userId1),
* 但该用户在 muse_slice_live 里默认**无角色**(system_user_role 空)→ 命中 @PreAuthorize 403。
* 本前置幂等 seed `system_user_role(id=1,user_id=1,role_id=1)`(role 1=super_admin),令 admin 成超管
* (PermissionServiceImpl.hasAnyPermissions 超管直接放行,跳过具体权限点),再 DEL redis 权限缓存使其立即生效。
*
* 连接凭据从环境变量读取(`MUSE_POSTGRES_*` / `MUSE_REDIS_*`,见 ~/.config/muse-repo/infra.env;**绝不入库**):
* 跑前先 `set -a; . ~/.config/muse-repo/infra.env; set +a`。缺 PG 凭据时直接报错(本套件仅活体跑,无 DB 不可能真绿)。
* 如确知库已就绪、想跳过:置 `E2E_SKIP_SEED=1`。
*
* 说明: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') {
console.log('[admin e2e globalSetup] E2E_SKIP_SEED=1 → 跳过权限前置');
return;
}
const host = process.env.MUSE_POSTGRES_HOST;
const password = process.env.MUSE_POSTGRES_PASSWORD;
if (!host || !password) {
throw new Error(
'[admin e2e globalSetup] 缺少 MUSE_POSTGRES_* 环境变量。活体 e2e 需直连真实 PG seed 权限前提,' +
'请先 `set -a; . ~/.config/muse-repo/infra.env; set +a` 再跑(或置 E2E_SKIP_SEED=1 跳过)。',
);
}
const client = new Client({
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,
ssl: false,
connectionTimeoutMillis: 10_000,
});
await client.connect();
try {
// 幂等 seed:admin(system_users id=1)绑定 super_admin(system_role id=1)。
// ON CONFLICT DO NOTHING:重跑不重复;若已存在(基础种子已建)则 rowCount=0,无副作用。
const seed = await client.query(
`INSERT INTO system_user_role (id, user_id, role_id, tenant_id)
VALUES (1, 1, 1, 1) ON CONFLICT (id) DO NOTHING`,
);
// 复核绑定确实在(防御:若 id 冲突但内容被改过,这里能暴露)。
const check = await client.query(
'SELECT user_id, role_id FROM system_user_role WHERE id=1 AND deleted=false',
);
if (check.rowCount === 0) {
console.warn(
'[admin e2e globalSetup] ⚠️ system_user_role(id=1) 缺失或被软删,admin 可能仍 403',
);
}
console.log(
`[admin e2e globalSetup] seed system_user_role(super_admin) rowCount=${seed.rowCount}(0=已存在)`,
);
// 复位 market 写命令子域 fixture(详见函数注释)。
await resetMarketFixture(client);
} finally {
await client.end();
}
// DEL redis 权限缓存(yudao 单冒号 key user_role_ids:1),令 seed 立即生效。
// 本机/活体环境用 redis-cli(redis 在 127.0.0.1:6379,凭据来自 MUSE_REDIS_PASSWORD);
// 失败不致命(缓存未命中时下次查询会回源 DB),仅告警,避免阻断套件。
const redisHost = process.env.MUSE_REDIS_HOST ?? '127.0.0.1';
const redisPort = process.env.MUSE_REDIS_PORT ?? '6379';
const redisPassword = process.env.MUSE_REDIS_PASSWORD;
try {
const args = ['-h', redisHost, '-p', redisPort];
if (redisPassword) {
args.push('-a', redisPassword, '--no-auth-warning');
}
args.push('DEL', 'user_role_ids:1');
const out = execFileSync('redis-cli', args, { encoding: 'utf8' }).trim();
console.log(`[admin e2e globalSetup] DEL redis user_role_ids:1 => ${out}`);
} catch (error) {
console.warn(
`[admin e2e globalSetup] ⚠️ DEL redis 权限缓存失败(非致命,下次查询回源 DB):${
(error as Error).message
}`,
);
}
}
/**
* 复位 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;