Просмотр исходного кода

test(s8): add safe fixture for watch mapping e2e

YY968XX 5 месяцев назад
Родитель
Сommit
f60d3b63
1 измененных файлов с 76 добавлено и 4 удалено
  1. 76 4
      Web/tests/e2e/s8/g01-watch-mapping.spec.ts

+ 76 - 4
Web/tests/e2e/s8/g01-watch-mapping.spec.ts

@@ -2,15 +2,26 @@ import { test, expect } from '../fixtures/auth';
 import type { Page } from '@playwright/test';
 
 /**
- * G-01 非破坏性补强:
+ * G-01 非破坏性补强(S2-EV 安全夹具):
  *  M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
  *  M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
  *  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 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) {
   return page.evaluate(() => {
@@ -53,13 +64,63 @@ async function getException(page: Page, id: string | number) {
   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 }) => {
   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 轮,收集结果
   const runs: Array<Awaited<ReturnType<typeof postRunOnce>>> = [];
   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[] }) =>
     run.results
       .map(
@@ -83,8 +144,8 @@ test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage
   expect(sample.results.length, '至少应有一条命中以验证映射').toBeGreaterThan(0);
   const mappingNotes: string[] = [];
   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();
     const ex = await getException(authedPage, hit.matchedExceptionId);
     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(' || ') });
+
+  // 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);
 });