g01-watch-mapping.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import { test, expect } from '../fixtures/auth';
  2. import { STORAGE_STATE_PATH } from '../fixtures/auth';
  3. import { request as playwrightRequest, type APIRequestContext } from '@playwright/test';
  4. import type { Page } from '@playwright/test';
  5. import fs from 'node:fs';
  6. /**
  7. * G-01 watch mapping e2e(S2-EY 路径 C 落地):
  8. * M1 多次 run-once 的 TEST_G09_ hit 集合稳定(sourceRuleId / relatedObjectCode 一致)
  9. * M2 命中对象映射闭环:每条 skipped TEST_G09_ hit 必然对应一条仍未闭环的现存异常
  10. * M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
  11. *
  12. * 安全契约(C1-C6):
  13. * C1 spec 不闭单、不改业务数据、不调动作类接口;不新建/修改 alert_rule。
  14. * C2 spec 自带 seed 路径:beforeAll 创建一条仅命中 TEST_G09_ 对象的临时 watch rule
  15. * (literal SELECT 常量),与现有 EQ-01 业务规则共享同一 alert rule。
  16. * C3 任何 RelatedObjectCode 不以 TEST_G09_ 开头的 hit 出现 created=true 立即 hard fail
  17. * (表示业务规则被本测试污染)。TEST_G09_ hit 第 1 轮允许 created=true,
  18. * 第 2/3 轮必须 skipped=true 且 matchedExceptionId 稳定。
  19. * C4 spec body 仅对 TEST_G09_ hit 做 M1/M2/M3 严格断言;EQ-01 等业务 hit 仅作漂移监控。
  20. * C5 双键强制断言(5656a88d 起 DTO 已暴露 sourceRuleId/relatedObjectCode);不允许 fallback。
  21. * C6 afterAll:DELETE 临时 watch rule(物理删,无 IsDeleted) → DELETE
  22. * /api/aidop/s8/watch-debug/test-cleanup 软删本轮 TEST_G09_ exception。
  23. * 两步任一失败立即 hard fail;finally 顺序保证 cleanup 总会尝试执行。
  24. */
  25. const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
  26. const RUNS = 3;
  27. const TENANT_ID = 1;
  28. const FACTORY_ID = 1;
  29. const SCENE_CODE = 'S2S6_PRODUCTION';
  30. const DATA_SOURCE_ID = 1; // 复用现有 G01_TEST_DS(aidopdev 自指)
  31. const TEST_PREFIX = 'TEST_G09_';
  32. const RUN_ID = `${TEST_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  33. const TEST_RULE_CODE = `TEST_G09_RUN_${RUN_ID.slice(TEST_PREFIX.length)}`;
  34. const TEST_OBJECT_CODE = `TEST_G09_OBJ_${RUN_ID.slice(TEST_PREFIX.length)}`;
  35. let testRuleId: number | null = null;
  36. let createdSceneId: number | null = null; // 仅当 beforeAll 自己创建了 S2S6_PRODUCTION scene_config 时记录,afterAll 才删
  37. function tokenFromStorageState(): string {
  38. if (!fs.existsSync(STORAGE_STATE_PATH)) {
  39. throw new Error(`storage-state.json not found at ${STORAGE_STATE_PATH}; global-setup must run first.`);
  40. }
  41. const raw = fs.readFileSync(STORAGE_STATE_PATH, 'utf8');
  42. const state = JSON.parse(raw) as { origins?: Array<{ localStorage?: Array<{ name: string; value: string }> }> };
  43. for (const origin of state.origins ?? []) {
  44. for (const item of origin.localStorage ?? []) {
  45. if (/access-token$/i.test(item.name) && !/x-access-token$/i.test(item.name)) {
  46. try {
  47. const parsed = JSON.parse(item.value);
  48. return typeof parsed === 'string' ? parsed : (parsed?.value ?? item.value);
  49. } catch {
  50. return item.value;
  51. }
  52. }
  53. }
  54. }
  55. throw new Error('access-token not found in storage-state.json');
  56. }
  57. function makeApiContext(): Promise<APIRequestContext> {
  58. const baseURL = (process.env.AIDOP_E2E_BASE_URL ?? 'http://localhost:8888').replace(/\/+$/, '');
  59. const token = tokenFromStorageState();
  60. return playwrightRequest.newContext({
  61. baseURL,
  62. extraHTTPHeaders: { Authorization: `Bearer ${token}` },
  63. });
  64. }
  65. async function pageToken(page: Page): Promise<string> {
  66. return page.evaluate(() => {
  67. for (let i = 0; i < localStorage.length; i++) {
  68. const k = localStorage.key(i)!;
  69. if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
  70. const raw = localStorage.getItem(k)!;
  71. try {
  72. const v = JSON.parse(raw);
  73. return typeof v === 'string' ? v : v?.value ?? raw;
  74. } catch {
  75. return raw;
  76. }
  77. }
  78. }
  79. return null as unknown as string;
  80. });
  81. }
  82. async function postRunOnce(page: Page) {
  83. const t = await pageToken(page);
  84. const base = new URL(page.url()).origin;
  85. const r = await page.request.post(
  86. `${base}/api/aidop/s8/watch-debug/run-once?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`,
  87. { headers: { Authorization: `Bearer ${t}` } },
  88. );
  89. expect(r.status()).toBe(200);
  90. return (await r.json()) as { count: number; results: any[] };
  91. }
  92. async function getException(page: Page, id: string | number) {
  93. const t = await pageToken(page);
  94. const base = new URL(page.url()).origin;
  95. const r = await page.request.get(
  96. `${base}/api/aidop/s8/exceptions/${id}?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`,
  97. { headers: { Authorization: `Bearer ${t}` } },
  98. );
  99. if (!r.ok()) return null;
  100. const j = (await r.json()) as any;
  101. return j?.data ?? j;
  102. }
  103. /** 列出当前 TEST_G09_ 前缀的未闭环异常;用于 C2 baseline 与 finalCount。 */
  104. async function listTestG09Pending(ctxOrPage: APIRequestContext | Page): Promise<any[]> {
  105. const items: any[] = [];
  106. for (let pageNo = 1; pageNo <= 5; pageNo++) {
  107. const url = `/api/aidop/s8/exceptions?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}&statusBucket=pending&relatedObjectCode=&page=${pageNo}&pageSize=100`;
  108. const r = 'request' in ctxOrPage
  109. ? await ctxOrPage.request.get(url)
  110. : await ctxOrPage.get(url);
  111. if (!r.ok()) break;
  112. const body = (await r.json()) as any;
  113. const list: any[] = body?.list ?? body?.data?.list ?? [];
  114. if (list.length === 0) break;
  115. for (const item of list) {
  116. const code = String(item.relatedObjectCode ?? item.RelatedObjectCode ?? '');
  117. if (code.startsWith(TEST_PREFIX)) items.push(item);
  118. }
  119. if (list.length < 100) break;
  120. }
  121. return items;
  122. }
  123. async function callCleanupEndpoint(ctx: APIRequestContext): Promise<number> {
  124. const r = await ctx.delete(`/api/aidop/s8/watch-debug/test-cleanup?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`);
  125. expect(r.status(), `cleanup status=${r.status()}`).toBe(200);
  126. const body = (await r.json()) as { deletedCount?: number };
  127. expect(typeof body.deletedCount === 'number' && body.deletedCount >= 0, `bad deletedCount: ${JSON.stringify(body)}`).toBeTruthy();
  128. return body.deletedCount as number;
  129. }
  130. /**
  131. * S8WatchRuleService.ValidateAsync 要求 scene_config 中存在匹配 (tenantId, factoryId, sceneCode) 行;
  132. * dev 库 ado_s8_scene_config 默认无 'S2S6_PRODUCTION' 行(现有规则 id=1 是 SeedData 直插绕过)。
  133. * 此处 idempotent ensure:列表中找不到则创建;返回 createdId(若是本次创建)以便 afterAll 撤销。
  134. */
  135. /**
  136. * 后端 InsertAsync 不回填 IDENTITY,响应里的 id 总为 "0";改为 POST 后回查 list 拿真实 id。
  137. * 业务键:sceneCode + (tenantId, factoryId) 唯一。
  138. */
  139. async function fetchSceneIdByCode(ctx: APIRequestContext, sceneCode: string): Promise<number | null> {
  140. const list = await ctx.get(`/api/aidop/s8/config/scenes?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`);
  141. expect(list.status(), `list-scenes status=${list.status()}`).toBe(200);
  142. const body = (await list.json()) as any;
  143. const items: any[] = Array.isArray(body) ? body : (body?.data ?? body?.list ?? []);
  144. const found = items.find((s) => String(s.sceneCode ?? s.SceneCode) === sceneCode);
  145. if (!found) return null;
  146. const id = Number(found.id ?? found.Id);
  147. return Number.isFinite(id) && id > 0 ? id : null;
  148. }
  149. async function ensureSceneConfig(ctx: APIRequestContext): Promise<number | null> {
  150. const existingId = await fetchSceneIdByCode(ctx, SCENE_CODE);
  151. if (existingId !== null) return null;
  152. const r = await ctx.post('/api/aidop/s8/config/scenes', {
  153. data: {
  154. tenantId: TENANT_ID,
  155. factoryId: FACTORY_ID,
  156. sceneCode: SCENE_CODE,
  157. sceneName: 'TEST G09 临时场景(spec 自建)',
  158. enabled: true,
  159. sortNo: 9999,
  160. },
  161. });
  162. expect(r.status(), `create-scene status=${r.status()} body=${await r.text()}`).toBe(200);
  163. const newId = await fetchSceneIdByCode(ctx, SCENE_CODE);
  164. expect(newId, `scene id not found after create`).toBeTruthy();
  165. return newId as number;
  166. }
  167. async function deleteSceneConfig(ctx: APIRequestContext, sceneId: number): Promise<void> {
  168. const r = await ctx.delete(`/api/aidop/s8/config/scenes/${sceneId}`);
  169. expect(r.status(), `delete-scene status=${r.status()}`).toBe(200);
  170. }
  171. async function fetchWatchRuleIdByCode(ctx: APIRequestContext, ruleCode: string): Promise<number | null> {
  172. const r = await ctx.get(`/api/aidop/s8/config/watch-rules?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`);
  173. expect(r.status(), `list-rules status=${r.status()}`).toBe(200);
  174. const body = (await r.json()) as any;
  175. const items: any[] = Array.isArray(body) ? body : (body?.data ?? body?.list ?? []);
  176. const found = items.find((x) => String(x.ruleCode ?? x.RuleCode) === ruleCode);
  177. if (!found) return null;
  178. const id = Number(found.id ?? found.Id);
  179. return Number.isFinite(id) && id > 0 ? id : null;
  180. }
  181. async function createTestWatchRule(ctx: APIRequestContext): Promise<number> {
  182. const expression = `SELECT '${TEST_OBJECT_CODE}' AS related_object_code, 999 AS current_value`;
  183. const r = await ctx.post('/api/aidop/s8/config/watch-rules', {
  184. data: {
  185. tenantId: TENANT_ID,
  186. factoryId: FACTORY_ID,
  187. ruleCode: TEST_RULE_CODE,
  188. sceneCode: SCENE_CODE,
  189. dataSourceId: DATA_SOURCE_ID,
  190. watchObjectType: 'DEVICE',
  191. expression,
  192. severity: 'HIGH',
  193. pollIntervalSeconds: 300,
  194. enabled: true,
  195. },
  196. });
  197. expect(r.status(), `create-rule status=${r.status()} body=${await r.text()}`).toBe(200);
  198. // InsertAsync 不回填 IDENTITY;列表回查拿真实 id
  199. const id = await fetchWatchRuleIdByCode(ctx, TEST_RULE_CODE);
  200. expect(id, `rule id not found after create (ruleCode=${TEST_RULE_CODE})`).toBeTruthy();
  201. return id as number;
  202. }
  203. async function deleteTestWatchRule(ctx: APIRequestContext, ruleId: number): Promise<void> {
  204. const r = await ctx.delete(`/api/aidop/s8/config/watch-rules/${ruleId}`);
  205. expect(r.status(), `delete-rule status=${r.status()}`).toBe(200);
  206. }
  207. test.beforeAll(async () => {
  208. const ctx = await makeApiContext();
  209. try {
  210. // 1. 预清理:兜底任何历史 TEST_G09_ 残留(dev 库通常为 0)
  211. const pre = await callCleanupEndpoint(ctx);
  212. test.info().annotations.push({ type: 'pre-cleanup', description: `runId=${RUN_ID} pre.deletedCount=${pre}` });
  213. // 2. ensure scene_config 行存在(不存在则创建临时;afterAll 决定是否回收)
  214. createdSceneId = await ensureSceneConfig(ctx);
  215. test.info().annotations.push({
  216. type: 'seed-scene',
  217. description: `runId=${RUN_ID} createdSceneId=${createdSceneId ?? 'reused-existing'}`,
  218. });
  219. // 3. 创建本轮 TEST_G09_ watch rule(literal SELECT,命中常量 TEST_OBJECT_CODE)
  220. testRuleId = await createTestWatchRule(ctx);
  221. test.info().annotations.push({
  222. type: 'seed-rule',
  223. description: `runId=${RUN_ID} testRuleId=${testRuleId} ruleCode=${TEST_RULE_CODE} obj=${TEST_OBJECT_CODE}`,
  224. });
  225. } finally {
  226. await ctx.dispose();
  227. }
  228. });
  229. test('S8-G01-MAPPING 多轮 run-once + TEST_G09_ 去重映射闭环', async ({ authedPage }) => {
  230. expect(testRuleId, 'beforeAll 未成功创建 TEST_G09_ watch rule').toBeTruthy();
  231. await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
  232. // C2: 进入 run-once 前先确认本轮 TEST_G09_ pending 为 0(pre-cleanup 已清空)
  233. const baselineTest = await listTestG09Pending(authedPage);
  234. expect(baselineTest.length, `[${RUN_ID}] pre-cleanup 后仍存在 TEST_G09_ pending:${JSON.stringify(baselineTest)}`).toBe(0);
  235. // M1:连跑 N 轮,收集结果
  236. const runs: Array<Awaited<ReturnType<typeof postRunOnce>>> = [];
  237. for (let i = 0; i < RUNS; i++) runs.push(await postRunOnce(authedPage));
  238. // C3: 任意一条 RelatedObjectCode 非 TEST_G09_ 的 created=true 必须 hard fail
  239. for (let i = 0; i < runs.length; i++) {
  240. const polluted = runs[i].results.filter(
  241. (r) => r.created === true && !String(r.relatedObjectCode ?? '').startsWith(TEST_PREFIX),
  242. );
  243. expect(
  244. polluted.length,
  245. `[${RUN_ID}] 第 ${i + 1} 轮 run-once 在非 TEST_G09_ 对象上创建了 ${polluted.length} 条新异常(污染业务):${JSON.stringify(polluted)}`,
  246. ).toBe(0);
  247. }
  248. // C3: TEST_G09_ hit 第 1 轮允许 created=true,2/3 轮必须 skipped=true
  249. const testHitsByRun = runs.map((r) =>
  250. r.results.filter((h) => String(h.relatedObjectCode ?? '').startsWith(TEST_PREFIX)),
  251. );
  252. expect(
  253. testHitsByRun[0].length,
  254. `[${RUN_ID}] 第 1 轮未观测到 TEST_G09_ hit;可能 watch rule 未生效或 alert rule 阈值不匹配`,
  255. ).toBeGreaterThan(0);
  256. for (const h of testHitsByRun[0]) {
  257. expect(h.created === true || h.skipped === true, `[${RUN_ID}] 第 1 轮 TEST_G09_ hit 既未 created 也未 skipped: ${JSON.stringify(h)}`).toBeTruthy();
  258. }
  259. for (let i = 1; i < runs.length; i++) {
  260. for (const h of testHitsByRun[i]) {
  261. expect(h.skipped, `[${RUN_ID}] 第 ${i + 1} 轮 TEST_G09_ hit 应 skipped=true(dedup 失效):${JSON.stringify(h)}`).toBeTruthy();
  262. expect(h.matchedExceptionId, `[${RUN_ID}] 第 ${i + 1} 轮 skipped 必须携带 matchedExceptionId`).toBeTruthy();
  263. }
  264. }
  265. // M1: 仅对 TEST_G09_ hit 做指纹稳定性断言(业务 EQ-01 hit 不参与)。
  266. const fpOf = (hits: any[]) =>
  267. hits
  268. .map((h) => `${h.sourceRuleId}|${h.relatedObjectCode}|skipped=${h.skipped}|m=${h.matchedExceptionId ?? ''}`)
  269. .sort()
  270. .join(';');
  271. // 第 2/3 轮 TEST_G09_ 指纹必须一致(已稳定 dedup)
  272. if (runs.length >= 3) {
  273. expect(fpOf(testHitsByRun[2]), `[${RUN_ID}] 第 3 轮 TEST_G09_ 指纹与第 2 轮不一致`).toBe(fpOf(testHitsByRun[1]));
  274. }
  275. // M2 + M3 + C5: TEST_G09_ hit(取第 2 轮稳定态)每条都映射到 pending 异常 + 双键一致
  276. const stableTestHits = testHitsByRun[runs.length - 1];
  277. expect(stableTestHits.length, `[${RUN_ID}] 末轮无 TEST_G09_ hit`).toBeGreaterThan(0);
  278. const mappingNotes: string[] = [];
  279. for (const hit of stableTestHits) {
  280. expect(hit.skipped, '末轮 TEST_G09_ hit 必须为 skipped').toBeTruthy();
  281. expect(hit.matchedExceptionId, 'skipped 必须携带 matchedExceptionId').toBeTruthy();
  282. expect(String(hit.relatedObjectCode), 'TEST_G09_ hit relatedObjectCode 必须等于 TEST_OBJECT_CODE').toBe(TEST_OBJECT_CODE);
  283. expect(Number(hit.sourceRuleId), 'TEST_G09_ hit sourceRuleId 必须等于 testRuleId').toBe(testRuleId);
  284. const ex = await getException(authedPage, hit.matchedExceptionId);
  285. expect(ex, `matchedExceptionId=${hit.matchedExceptionId} 详情不可达`).toBeTruthy();
  286. expect(PENDING).toContain(String(ex.status));
  287. // C5 双键强制断言(不允许 fallback)
  288. expect(ex.sourceRuleId, `ex#${ex.id} DTO 未暴露 sourceRuleId(参见 5656a88d)`).not.toBeUndefined();
  289. expect(ex.relatedObjectCode, `ex#${ex.id} DTO 未暴露 relatedObjectCode(参见 5656a88d)`).not.toBeUndefined();
  290. expect(String(ex.sourceRuleId ?? ''), 'sourceRuleId 不一致').toBe(String(hit.sourceRuleId ?? ''));
  291. expect(String(ex.relatedObjectCode ?? ''), 'relatedObjectCode 不一致').toBe(String(hit.relatedObjectCode ?? ''));
  292. mappingNotes.push(
  293. `hit{rule=${hit.sourceRuleId},obj=${hit.relatedObjectCode}} → ex#${ex.id}(${ex.status}, code=${ex.code ?? ex.exceptionCode})`,
  294. );
  295. }
  296. test.info().annotations.push({ type: 'mapping', description: mappingNotes.join(' || ') });
  297. });
  298. /**
  299. * C6 安全契约:先删除测试 watch rule(物理删;AdoS8WatchRule 无 IsDeleted),
  300. * 再调 cleanup endpoint 软删 TEST_G09_ exception。
  301. * finally 链保证 cleanup 总会执行;任一失败立即 hard fail,不吞错。
  302. */
  303. test.afterAll(async () => {
  304. const ctx = await makeApiContext();
  305. let firstError: unknown = null;
  306. try {
  307. if (testRuleId !== null) {
  308. try {
  309. await deleteTestWatchRule(ctx, testRuleId);
  310. } catch (e) {
  311. firstError = e;
  312. }
  313. }
  314. if (createdSceneId !== null) {
  315. try {
  316. await deleteSceneConfig(ctx, createdSceneId);
  317. } catch (e) {
  318. if (firstError === null) firstError = e;
  319. }
  320. }
  321. try {
  322. const deleted = await callCleanupEndpoint(ctx);
  323. const post = await listTestG09Pending(ctx);
  324. test.info().annotations.push({
  325. type: 'cleanup',
  326. description: `runId=${RUN_ID} ruleId=${testRuleId} sceneId=${createdSceneId} deletedCount=${deleted} residue=${post.length}`,
  327. });
  328. expect(post.length, `[${RUN_ID}] cleanup 后仍残留 TEST_G09_ pending:${JSON.stringify(post.map((x) => x.id))}`).toBe(0);
  329. } catch (e) {
  330. if (firstError === null) firstError = e;
  331. }
  332. } finally {
  333. await ctx.dispose();
  334. }
  335. if (firstError !== null) throw firstError;
  336. });