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

test(s8): wire watch mapping cleanup fixture

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

+ 70 - 3
Web/tests/e2e/s8/g01-watch-mapping.spec.ts

@@ -1,21 +1,33 @@
 import { test, expect } from '../fixtures/auth';
+import { STORAGE_STATE_PATH } from '../fixtures/auth';
+import { request as playwrightRequest } from '@playwright/test';
 import type { Page } from '@playwright/test';
+import fs from 'node:fs';
 
 /**
- * G-01 非破坏性补强(S2-EV 安全夹具):
+ * G-01 非破坏性补强(S2-EV 安全夹具 + S2-EX cleanup 接入):
  *  M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
  *  M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
  *  M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
  *
- * 安全契约(无后端 hard-delete API 之前由 fixture 强制保证):
+ * 安全契约(C1-C5 由 fixture 强制;C6 由 S2-EW cleanup endpoint 提供兜底):
  *  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。
+ *  C6 test.afterAll 调用 DELETE /api/aidop/s8/watch-debug/test-cleanup 兜底软删任何
+ *     RelatedObjectCode 以 TEST_G09_ 开头的异常;cleanup 失败立即 hard fail,不吞错。
  *
- * RUN_ID 仅用于日志诊断,不写入任何 DB 字段(当前后端无清理路径,禁止主动写入)。
+ * 当前 RUN STATUS:BLOCKED-by-test-seed-gap。
+ *  原因:现有 watch rule (id=1) 的 RelatedObjectCode 由 AdoS8DataSource 配置的 SQL
+ *  查询结果决定('EQ-01',业务设备数据),后端没有 API 注入 TEST_G09_* 设备行;
+ *  无法构造一条只命中 TEST_G09_* 对象的 watch rule。在出现安全 seed 路径前,
+ *  即便接入 C6 cleanup,spec 仍会因 baseline 检查(C2)skip 或 fail,**不应当 live 跑**。
+ *  C6 兜底依然有价值:未来任何 TEST_G09_ 残留(人工种入或后续 seed 路径上线)会被 afterAll 清理。
+ *
+ * RUN_ID 仅用于日志诊断,不写入任何 DB 字段(无 seed 路径前禁止主动写入)。
  */
 
 const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
@@ -52,6 +64,31 @@ async function postRunOnce(page: Page) {
   return (await r.json()) as { count: number; results: any[] };
 }
 
+/**
+ * 从 storage-state.json 读取 superAdmin access-token,用于不依赖 Page 的 hooks(afterAll)。
+ * storage-state.json 由 global-setup 在测试运行前生成;不存在则视为 setup 未跑,hook 主动 throw。
+ */
+function tokenFromStorageState(): string {
+  if (!fs.existsSync(STORAGE_STATE_PATH)) {
+    throw new Error(`storage-state.json not found at ${STORAGE_STATE_PATH}; global-setup must run first.`);
+  }
+  const raw = fs.readFileSync(STORAGE_STATE_PATH, 'utf8');
+  const state = JSON.parse(raw) as { origins?: Array<{ localStorage?: Array<{ name: string; value: string }> }> };
+  for (const origin of state.origins ?? []) {
+    for (const item of origin.localStorage ?? []) {
+      if (/access-token$/i.test(item.name) && !/x-access-token$/i.test(item.name)) {
+        try {
+          const parsed = JSON.parse(item.value);
+          return typeof parsed === 'string' ? parsed : (parsed?.value ?? item.value);
+        } catch {
+          return item.value;
+        }
+      }
+    }
+  }
+  throw new Error('access-token not found in storage-state.json');
+}
+
 async function getException(page: Page, id: string | number) {
   const t = await token(page);
   const base = new URL(page.url()).origin;
@@ -182,3 +219,33 @@ test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage
     `[${RUN_ID}] AUTO_WATCH pending 计数从 ${baseline.count} 漂移到 ${after.count};可能存在未清理的写入。`,
   ).toBe(baseline.count);
 });
+
+/**
+ * C6 安全契约:测试套件结束后调用 S2-EW cleanup endpoint 兜底软删 TEST_G09_ 残留。
+ * 走独立的 request context(不依赖 authedPage 的 per-test 生命周期),从 storage-state.json
+ * 取 token;接口 404 / 401 / 500 一律 hard fail,不吞错。tenantId/factoryId=1/1 与本仓库
+ * dev e2e 约定一致;如未来上下文允许动态获取,应替换。
+ */
+test.afterAll(async () => {
+  const baseURL = process.env.AIDOP_E2E_BASE_URL ?? 'http://localhost:8888';
+  const apiOrigin = baseURL.replace(/\/+$/, '');
+  const token = tokenFromStorageState();
+  const ctx = await playwrightRequest.newContext({
+    baseURL: apiOrigin,
+    extraHTTPHeaders: { Authorization: `Bearer ${token}` },
+  });
+  try {
+    const r = await ctx.delete('/api/aidop/s8/watch-debug/test-cleanup?tenantId=1&factoryId=1');
+    expect(
+      r.status(),
+      `[${RUN_ID}] cleanup endpoint 返回非 200(status=${r.status()});可能 debug endpoint 未启用或后端未升级。`,
+    ).toBe(200);
+    const body = (await r.json()) as { deletedCount?: number };
+    expect(
+      typeof body.deletedCount === 'number' && body.deletedCount >= 0,
+      `[${RUN_ID}] cleanup 响应 deletedCount 字段缺失或非数字:${JSON.stringify(body)}`,
+    ).toBeTruthy();
+  } finally {
+    await ctx.dispose();
+  }
+});