import { test, expect } from '../fixtures/auth'; import { STORAGE_STATE_PATH } from '../fixtures/auth'; import { request as playwrightRequest, type APIRequestContext } from '@playwright/test'; import type { Page } from '@playwright/test'; import fs from 'node:fs'; /** * G-01 watch mapping e2e(S2-EY 路径 C 落地): * M1 多次 run-once 的 TEST_G09_ hit 集合稳定(sourceRuleId / relatedObjectCode 一致) * M2 命中对象映射闭环:每条 skipped TEST_G09_ hit 必然对应一条仍未闭环的现存异常 * M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致 * * 安全契约(C1-C6): * C1 spec 不闭单、不改业务数据、不调动作类接口;不新建/修改 alert_rule。 * C2 spec 自带 seed 路径:beforeAll 创建一条仅命中 TEST_G09_ 对象的临时 watch rule * (literal SELECT 常量),与现有 EQ-01 业务规则共享同一 alert rule。 * C3 任何 RelatedObjectCode 不以 TEST_G09_ 开头的 hit 出现 created=true 立即 hard fail * (表示业务规则被本测试污染)。TEST_G09_ hit 第 1 轮允许 created=true, * 第 2/3 轮必须 skipped=true 且 matchedExceptionId 稳定。 * C4 spec body 仅对 TEST_G09_ hit 做 M1/M2/M3 严格断言;EQ-01 等业务 hit 仅作漂移监控。 * C5 双键强制断言(5656a88d 起 DTO 已暴露 sourceRuleId/relatedObjectCode);不允许 fallback。 * C6 afterAll:DELETE 临时 watch rule(物理删,无 IsDeleted) → DELETE * /api/aidop/s8/watch-debug/test-cleanup 软删本轮 TEST_G09_ exception。 * 两步任一失败立即 hard fail;finally 顺序保证 cleanup 总会尝试执行。 */ const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION']; const RUNS = 3; const TENANT_ID = 1; const FACTORY_ID = 1; const SCENE_CODE = 'S2S6_PRODUCTION'; const DATA_SOURCE_ID = 1; // 复用现有 G01_TEST_DS(aidopdev 自指) const TEST_PREFIX = 'TEST_G09_'; const RUN_ID = `${TEST_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const TEST_RULE_CODE = `TEST_G09_RUN_${RUN_ID.slice(TEST_PREFIX.length)}`; const TEST_OBJECT_CODE = `TEST_G09_OBJ_${RUN_ID.slice(TEST_PREFIX.length)}`; let testRuleId: number | null = null; let createdSceneId: number | null = null; // 仅当 beforeAll 自己创建了 S2S6_PRODUCTION scene_config 时记录,afterAll 才删 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'); } function makeApiContext(): Promise { const baseURL = (process.env.AIDOP_E2E_BASE_URL ?? 'http://localhost:8888').replace(/\/+$/, ''); const token = tokenFromStorageState(); return playwrightRequest.newContext({ baseURL, extraHTTPHeaders: { Authorization: `Bearer ${token}` }, }); } async function pageToken(page: Page): Promise { return page.evaluate(() => { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i)!; if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) { const raw = localStorage.getItem(k)!; try { const v = JSON.parse(raw); return typeof v === 'string' ? v : v?.value ?? raw; } catch { return raw; } } } return null as unknown as string; }); } async function postRunOnce(page: Page) { const t = await pageToken(page); const base = new URL(page.url()).origin; const r = await page.request.post( `${base}/api/aidop/s8/watch-debug/run-once?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`, { headers: { Authorization: `Bearer ${t}` } }, ); expect(r.status()).toBe(200); return (await r.json()) as { count: number; results: any[] }; } async function getException(page: Page, id: string | number) { const t = await pageToken(page); const base = new URL(page.url()).origin; const r = await page.request.get( `${base}/api/aidop/s8/exceptions/${id}?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`, { headers: { Authorization: `Bearer ${t}` } }, ); if (!r.ok()) return null; const j = (await r.json()) as any; return j?.data ?? j; } /** 列出当前 TEST_G09_ 前缀的未闭环异常;用于 C2 baseline 与 finalCount。 */ async function listTestG09Pending(ctxOrPage: APIRequestContext | Page): Promise { const items: any[] = []; for (let pageNo = 1; pageNo <= 5; pageNo++) { const url = `/api/aidop/s8/exceptions?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}&statusBucket=pending&relatedObjectCode=&page=${pageNo}&pageSize=100`; const r = 'request' in ctxOrPage ? await ctxOrPage.request.get(url) : await ctxOrPage.get(url); 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) { const code = String(item.relatedObjectCode ?? item.RelatedObjectCode ?? ''); if (code.startsWith(TEST_PREFIX)) items.push(item); } if (list.length < 100) break; } return items; } async function callCleanupEndpoint(ctx: APIRequestContext): Promise { const r = await ctx.delete(`/api/aidop/s8/watch-debug/test-cleanup?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`); expect(r.status(), `cleanup status=${r.status()}`).toBe(200); const body = (await r.json()) as { deletedCount?: number }; expect(typeof body.deletedCount === 'number' && body.deletedCount >= 0, `bad deletedCount: ${JSON.stringify(body)}`).toBeTruthy(); return body.deletedCount as number; } /** * S8WatchRuleService.ValidateAsync 要求 scene_config 中存在匹配 (tenantId, factoryId, sceneCode) 行; * dev 库 ado_s8_scene_config 默认无 'S2S6_PRODUCTION' 行(现有规则 id=1 是 SeedData 直插绕过)。 * 此处 idempotent ensure:列表中找不到则创建;返回 createdId(若是本次创建)以便 afterAll 撤销。 */ /** * 后端 InsertAsync 不回填 IDENTITY,响应里的 id 总为 "0";改为 POST 后回查 list 拿真实 id。 * 业务键:sceneCode + (tenantId, factoryId) 唯一。 */ async function fetchSceneIdByCode(ctx: APIRequestContext, sceneCode: string): Promise { const list = await ctx.get(`/api/aidop/s8/config/scenes?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`); expect(list.status(), `list-scenes status=${list.status()}`).toBe(200); const body = (await list.json()) as any; const items: any[] = Array.isArray(body) ? body : (body?.data ?? body?.list ?? []); const found = items.find((s) => String(s.sceneCode ?? s.SceneCode) === sceneCode); if (!found) return null; const id = Number(found.id ?? found.Id); return Number.isFinite(id) && id > 0 ? id : null; } async function ensureSceneConfig(ctx: APIRequestContext): Promise { const existingId = await fetchSceneIdByCode(ctx, SCENE_CODE); if (existingId !== null) return null; const r = await ctx.post('/api/aidop/s8/config/scenes', { data: { tenantId: TENANT_ID, factoryId: FACTORY_ID, sceneCode: SCENE_CODE, sceneName: 'TEST G09 临时场景(spec 自建)', enabled: true, sortNo: 9999, }, }); expect(r.status(), `create-scene status=${r.status()} body=${await r.text()}`).toBe(200); const newId = await fetchSceneIdByCode(ctx, SCENE_CODE); expect(newId, `scene id not found after create`).toBeTruthy(); return newId as number; } async function deleteSceneConfig(ctx: APIRequestContext, sceneId: number): Promise { const r = await ctx.delete(`/api/aidop/s8/config/scenes/${sceneId}`); expect(r.status(), `delete-scene status=${r.status()}`).toBe(200); } async function fetchWatchRuleIdByCode(ctx: APIRequestContext, ruleCode: string): Promise { const r = await ctx.get(`/api/aidop/s8/config/watch-rules?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`); expect(r.status(), `list-rules status=${r.status()}`).toBe(200); const body = (await r.json()) as any; const items: any[] = Array.isArray(body) ? body : (body?.data ?? body?.list ?? []); const found = items.find((x) => String(x.ruleCode ?? x.RuleCode) === ruleCode); if (!found) return null; const id = Number(found.id ?? found.Id); return Number.isFinite(id) && id > 0 ? id : null; } async function createTestWatchRule(ctx: APIRequestContext): Promise { const expression = `SELECT '${TEST_OBJECT_CODE}' AS related_object_code, 999 AS current_value`; const r = await ctx.post('/api/aidop/s8/config/watch-rules', { data: { tenantId: TENANT_ID, factoryId: FACTORY_ID, ruleCode: TEST_RULE_CODE, sceneCode: SCENE_CODE, dataSourceId: DATA_SOURCE_ID, watchObjectType: 'DEVICE', expression, severity: 'HIGH', pollIntervalSeconds: 300, enabled: true, }, }); expect(r.status(), `create-rule status=${r.status()} body=${await r.text()}`).toBe(200); // InsertAsync 不回填 IDENTITY;列表回查拿真实 id const id = await fetchWatchRuleIdByCode(ctx, TEST_RULE_CODE); expect(id, `rule id not found after create (ruleCode=${TEST_RULE_CODE})`).toBeTruthy(); return id as number; } async function deleteTestWatchRule(ctx: APIRequestContext, ruleId: number): Promise { const r = await ctx.delete(`/api/aidop/s8/config/watch-rules/${ruleId}`); expect(r.status(), `delete-rule status=${r.status()}`).toBe(200); } test.beforeAll(async () => { const ctx = await makeApiContext(); try { // 1. 预清理:兜底任何历史 TEST_G09_ 残留(dev 库通常为 0) const pre = await callCleanupEndpoint(ctx); test.info().annotations.push({ type: 'pre-cleanup', description: `runId=${RUN_ID} pre.deletedCount=${pre}` }); // 2. ensure scene_config 行存在(不存在则创建临时;afterAll 决定是否回收) createdSceneId = await ensureSceneConfig(ctx); test.info().annotations.push({ type: 'seed-scene', description: `runId=${RUN_ID} createdSceneId=${createdSceneId ?? 'reused-existing'}`, }); // 3. 创建本轮 TEST_G09_ watch rule(literal SELECT,命中常量 TEST_OBJECT_CODE) testRuleId = await createTestWatchRule(ctx); test.info().annotations.push({ type: 'seed-rule', description: `runId=${RUN_ID} testRuleId=${testRuleId} ruleCode=${TEST_RULE_CODE} obj=${TEST_OBJECT_CODE}`, }); } finally { await ctx.dispose(); } }); test('S8-G01-MAPPING 多轮 run-once + TEST_G09_ 去重映射闭环', async ({ authedPage }) => { expect(testRuleId, 'beforeAll 未成功创建 TEST_G09_ watch rule').toBeTruthy(); await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' }); // C2: 进入 run-once 前先确认本轮 TEST_G09_ pending 为 0(pre-cleanup 已清空) const baselineTest = await listTestG09Pending(authedPage); expect(baselineTest.length, `[${RUN_ID}] pre-cleanup 后仍存在 TEST_G09_ pending:${JSON.stringify(baselineTest)}`).toBe(0); // M1:连跑 N 轮,收集结果 const runs: Array>> = []; for (let i = 0; i < RUNS; i++) runs.push(await postRunOnce(authedPage)); // C3: 任意一条 RelatedObjectCode 非 TEST_G09_ 的 created=true 必须 hard fail for (let i = 0; i < runs.length; i++) { const polluted = runs[i].results.filter( (r) => r.created === true && !String(r.relatedObjectCode ?? '').startsWith(TEST_PREFIX), ); expect( polluted.length, `[${RUN_ID}] 第 ${i + 1} 轮 run-once 在非 TEST_G09_ 对象上创建了 ${polluted.length} 条新异常(污染业务):${JSON.stringify(polluted)}`, ).toBe(0); } // C3: TEST_G09_ hit 第 1 轮允许 created=true,2/3 轮必须 skipped=true const testHitsByRun = runs.map((r) => r.results.filter((h) => String(h.relatedObjectCode ?? '').startsWith(TEST_PREFIX)), ); expect( testHitsByRun[0].length, `[${RUN_ID}] 第 1 轮未观测到 TEST_G09_ hit;可能 watch rule 未生效或 alert rule 阈值不匹配`, ).toBeGreaterThan(0); for (const h of testHitsByRun[0]) { expect(h.created === true || h.skipped === true, `[${RUN_ID}] 第 1 轮 TEST_G09_ hit 既未 created 也未 skipped: ${JSON.stringify(h)}`).toBeTruthy(); } for (let i = 1; i < runs.length; i++) { for (const h of testHitsByRun[i]) { expect(h.skipped, `[${RUN_ID}] 第 ${i + 1} 轮 TEST_G09_ hit 应 skipped=true(dedup 失效):${JSON.stringify(h)}`).toBeTruthy(); expect(h.matchedExceptionId, `[${RUN_ID}] 第 ${i + 1} 轮 skipped 必须携带 matchedExceptionId`).toBeTruthy(); } } // M1: 仅对 TEST_G09_ hit 做指纹稳定性断言(业务 EQ-01 hit 不参与)。 const fpOf = (hits: any[]) => hits .map((h) => `${h.sourceRuleId}|${h.relatedObjectCode}|skipped=${h.skipped}|m=${h.matchedExceptionId ?? ''}`) .sort() .join(';'); // 第 2/3 轮 TEST_G09_ 指纹必须一致(已稳定 dedup) if (runs.length >= 3) { expect(fpOf(testHitsByRun[2]), `[${RUN_ID}] 第 3 轮 TEST_G09_ 指纹与第 2 轮不一致`).toBe(fpOf(testHitsByRun[1])); } // M2 + M3 + C5: TEST_G09_ hit(取第 2 轮稳定态)每条都映射到 pending 异常 + 双键一致 const stableTestHits = testHitsByRun[runs.length - 1]; expect(stableTestHits.length, `[${RUN_ID}] 末轮无 TEST_G09_ hit`).toBeGreaterThan(0); const mappingNotes: string[] = []; for (const hit of stableTestHits) { expect(hit.skipped, '末轮 TEST_G09_ hit 必须为 skipped').toBeTruthy(); expect(hit.matchedExceptionId, 'skipped 必须携带 matchedExceptionId').toBeTruthy(); expect(String(hit.relatedObjectCode), 'TEST_G09_ hit relatedObjectCode 必须等于 TEST_OBJECT_CODE').toBe(TEST_OBJECT_CODE); expect(Number(hit.sourceRuleId), 'TEST_G09_ hit sourceRuleId 必须等于 testRuleId').toBe(testRuleId); const ex = await getException(authedPage, hit.matchedExceptionId); expect(ex, `matchedExceptionId=${hit.matchedExceptionId} 详情不可达`).toBeTruthy(); expect(PENDING).toContain(String(ex.status)); // C5 双键强制断言(不允许 fallback) expect(ex.sourceRuleId, `ex#${ex.id} DTO 未暴露 sourceRuleId(参见 5656a88d)`).not.toBeUndefined(); expect(ex.relatedObjectCode, `ex#${ex.id} DTO 未暴露 relatedObjectCode(参见 5656a88d)`).not.toBeUndefined(); expect(String(ex.sourceRuleId ?? ''), 'sourceRuleId 不一致').toBe(String(hit.sourceRuleId ?? '')); expect(String(ex.relatedObjectCode ?? ''), 'relatedObjectCode 不一致').toBe(String(hit.relatedObjectCode ?? '')); mappingNotes.push( `hit{rule=${hit.sourceRuleId},obj=${hit.relatedObjectCode}} → ex#${ex.id}(${ex.status}, code=${ex.code ?? ex.exceptionCode})`, ); } test.info().annotations.push({ type: 'mapping', description: mappingNotes.join(' || ') }); }); /** * C6 安全契约:先删除测试 watch rule(物理删;AdoS8WatchRule 无 IsDeleted), * 再调 cleanup endpoint 软删 TEST_G09_ exception。 * finally 链保证 cleanup 总会执行;任一失败立即 hard fail,不吞错。 */ test.afterAll(async () => { const ctx = await makeApiContext(); let firstError: unknown = null; try { if (testRuleId !== null) { try { await deleteTestWatchRule(ctx, testRuleId); } catch (e) { firstError = e; } } if (createdSceneId !== null) { try { await deleteSceneConfig(ctx, createdSceneId); } catch (e) { if (firstError === null) firstError = e; } } try { const deleted = await callCleanupEndpoint(ctx); const post = await listTestG09Pending(ctx); test.info().annotations.push({ type: 'cleanup', description: `runId=${RUN_ID} ruleId=${testRuleId} sceneId=${createdSceneId} deletedCount=${deleted} residue=${post.length}`, }); expect(post.length, `[${RUN_ID}] cleanup 后仍残留 TEST_G09_ pending:${JSON.stringify(post.map((x) => x.id))}`).toBe(0); } catch (e) { if (firstError === null) firstError = e; } } finally { await ctx.dispose(); } if (firstError !== null) throw firstError; });