|
@@ -2,15 +2,26 @@ import { test, expect } from '../fixtures/auth';
|
|
|
import type { Page } from '@playwright/test';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * G-01 非破坏性补强:
|
|
|
|
|
|
|
+ * G-01 非破坏性补强(S2-EV 安全夹具):
|
|
|
* M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
|
|
* M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
|
|
|
* M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
|
|
* M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
|
|
|
* M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
|
|
* M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
|
|
|
- * 不闭单、不改数据、不调用任何动作类接口。
|
|
|
|
|
|
|
+ *
|
|
|
|
|
+ * 安全契约(无后端 hard-delete API 之前由 fixture 强制保证):
|
|
|
|
|
+ * C1 spec 不闭单、不改数据、不调动作类接口。
|
|
|
|
|
+ * C2 spec 进入 run-once 前先快照 AUTO_WATCH 未闭环异常计数;
|
|
|
|
|
+ * 若 baseline 为 0(即 dedup 目标缺失),spec 主动 test.skip(),避免任何写入。
|
|
|
|
|
+ * C3 任意一条 hit 出现 created=true 即立即 hard fail,并指明"无清理路径"。
|
|
|
|
|
+ * C4 spec 末尾再次快照计数;若 finalCount > baselineCount 立即 hard fail。
|
|
|
|
|
+ * C5 双键强制断言(5656a88d 起 DTO 已暴露 sourceRuleId/relatedObjectCode);不允许 fallback。
|
|
|
|
|
+ *
|
|
|
|
|
+ * RUN_ID 仅用于日志诊断,不写入任何 DB 字段(当前后端无清理路径,禁止主动写入)。
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
|
|
const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
|
|
|
const RUNS = 3;
|
|
const RUNS = 3;
|
|
|
|
|
+const AUTO_WATCH_SOURCE = 'AUTO_WATCH';
|
|
|
|
|
+const RUN_ID = `TEST_G09_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
|
|
|
|
async function token(page: Page) {
|
|
async function token(page: Page) {
|
|
|
return page.evaluate(() => {
|
|
return page.evaluate(() => {
|
|
@@ -53,13 +64,63 @@ async function getException(page: Page, id: string | number) {
|
|
|
return j?.data ?? j;
|
|
return j?.data ?? j;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 统计当前 AUTO_WATCH 来源、未闭环状态的异常数量(含 (sourceRuleId, relatedObjectCode) 集合)。
|
|
|
|
|
+ * 用于 fixture 的安全契约 C2/C4:进入 run-once 前后做基线对照。
|
|
|
|
|
+ */
|
|
|
|
|
+async function snapshotAutoWatchPending(page: Page): Promise<{ count: number; keys: string[] }> {
|
|
|
|
|
+ const t = await token(page);
|
|
|
|
|
+ const base = new URL(page.url()).origin;
|
|
|
|
|
+ const keys: string[] = [];
|
|
|
|
|
+ let count = 0;
|
|
|
|
|
+ // 分页保护:最多扫 5 页(500 条),dev 库 AUTO_WATCH 数量很小,足够。
|
|
|
|
|
+ for (let pageNo = 1; pageNo <= 5; pageNo++) {
|
|
|
|
|
+ const r = await page.request.get(
|
|
|
|
|
+ `${base}/api/aidop/s8/exceptions?tenantId=1&factoryId=1&statusBucket=pending&page=${pageNo}&pageSize=100`,
|
|
|
|
|
+ { headers: { Authorization: `Bearer ${t!}` } },
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!r.ok()) break;
|
|
|
|
|
+ const body = (await r.json()) as any;
|
|
|
|
|
+ const list: any[] = body?.list ?? body?.data?.list ?? [];
|
|
|
|
|
+ if (list.length === 0) break;
|
|
|
|
|
+ for (const item of list) {
|
|
|
|
|
+ if (String(item.sourceType ?? item.SourceType ?? '') !== AUTO_WATCH_SOURCE) continue;
|
|
|
|
|
+ count += 1;
|
|
|
|
|
+ keys.push(`${item.sourceRuleId ?? ''}|${item.relatedObjectCode ?? ''}|${item.id}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (list.length < 100) break;
|
|
|
|
|
+ }
|
|
|
|
|
+ return { count, keys };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage }) => {
|
|
test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage }) => {
|
|
|
await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
|
|
await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
|
|
|
|
|
|
|
|
|
|
+ // C2 安全契约:进入 run-once 前先取基线。无 pending dedup 目标即跳过,避免 run-once 写入。
|
|
|
|
|
+ const baseline = await snapshotAutoWatchPending(authedPage);
|
|
|
|
|
+ test.info().annotations.push({
|
|
|
|
|
+ type: 'fixture-baseline',
|
|
|
|
|
+ description: `runId=${RUN_ID} baselineCount=${baseline.count} keys=[${baseline.keys.join(',')}]`,
|
|
|
|
|
+ });
|
|
|
|
|
+ test.skip(
|
|
|
|
|
+ baseline.count === 0,
|
|
|
|
|
+ `[${RUN_ID}] AUTO_WATCH pending baseline=0;当前后端无 hard-delete 测试接口,run-once 会创建无清理路径的异常行,主动跳过。`,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
// M1:连跑 N 轮,收集结果
|
|
// M1:连跑 N 轮,收集结果
|
|
|
const runs: Array<Awaited<ReturnType<typeof postRunOnce>>> = [];
|
|
const runs: Array<Awaited<ReturnType<typeof postRunOnce>>> = [];
|
|
|
for (let i = 0; i < RUNS; i++) runs.push(await postRunOnce(authedPage));
|
|
for (let i = 0; i < RUNS; i++) runs.push(await postRunOnce(authedPage));
|
|
|
|
|
|
|
|
|
|
+ // C3 安全契约:任意一条 created=true 都视为污染(无清理路径)。立即 hard fail。
|
|
|
|
|
+ for (let i = 0; i < runs.length; i++) {
|
|
|
|
|
+ const created = runs[i].results.filter((r) => r.created === true);
|
|
|
|
|
+ expect(
|
|
|
|
|
+ created.length,
|
|
|
|
|
+ `[${RUN_ID}] 第 ${i + 1} 轮 run-once 创建了 ${created.length} 条新异常但 fixture 无清理机制;` +
|
|
|
|
|
+ `请在 backend 增加 TEST_ 前缀 hard-delete 接口后再重跑。created=${JSON.stringify(created)}`,
|
|
|
|
|
+ ).toBe(0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const fingerprint = (run: { results: any[] }) =>
|
|
const fingerprint = (run: { results: any[] }) =>
|
|
|
run.results
|
|
run.results
|
|
|
.map(
|
|
.map(
|
|
@@ -83,8 +144,8 @@ test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage
|
|
|
expect(sample.results.length, '至少应有一条命中以验证映射').toBeGreaterThan(0);
|
|
expect(sample.results.length, '至少应有一条命中以验证映射').toBeGreaterThan(0);
|
|
|
const mappingNotes: string[] = [];
|
|
const mappingNotes: string[] = [];
|
|
|
for (const hit of sample.results) {
|
|
for (const hit of sample.results) {
|
|
|
- expect(hit.skipped || hit.created, '命中既未创建也未跳过—状态机异常').toBeTruthy();
|
|
|
|
|
- if (!hit.skipped) continue;
|
|
|
|
|
|
|
+ // C3 已保证不会出现 created=true;此处要求每条都是 skipped。
|
|
|
|
|
+ expect(hit.skipped, '命中未走 skipped 分支—与安全契约 C3 不一致').toBeTruthy();
|
|
|
expect(hit.matchedExceptionId, 'skipped 必须携带 matchedExceptionId').toBeTruthy();
|
|
expect(hit.matchedExceptionId, 'skipped 必须携带 matchedExceptionId').toBeTruthy();
|
|
|
const ex = await getException(authedPage, hit.matchedExceptionId);
|
|
const ex = await getException(authedPage, hit.matchedExceptionId);
|
|
|
expect(ex, `matchedExceptionId=${hit.matchedExceptionId} 详情不可达`).toBeTruthy();
|
|
expect(ex, `matchedExceptionId=${hit.matchedExceptionId} 详情不可达`).toBeTruthy();
|
|
@@ -109,4 +170,15 @@ test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
test.info().annotations.push({ type: 'mapping', description: mappingNotes.join(' || ') });
|
|
test.info().annotations.push({ type: 'mapping', description: mappingNotes.join(' || ') });
|
|
|
|
|
+
|
|
|
|
|
+ // C4 安全契约:spec 末尾再次快照,确认计数没有变化。任何写入都必须立即被发现。
|
|
|
|
|
+ const after = await snapshotAutoWatchPending(authedPage);
|
|
|
|
|
+ test.info().annotations.push({
|
|
|
|
|
+ type: 'fixture-final',
|
|
|
|
|
+ description: `runId=${RUN_ID} finalCount=${after.count}(baseline=${baseline.count})`,
|
|
|
|
|
+ });
|
|
|
|
|
+ expect(
|
|
|
|
|
+ after.count,
|
|
|
|
|
+ `[${RUN_ID}] AUTO_WATCH pending 计数从 ${baseline.count} 漂移到 ${after.count};可能存在未清理的写入。`,
|
|
|
|
|
+ ).toBe(baseline.count);
|
|
|
});
|
|
});
|