| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364 |
- 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<APIRequestContext> {
- 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<string> {
- 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<any[]> {
- 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<number> {
- 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<number | null> {
- 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<number | null> {
- 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<void> {
- 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<number | null> {
- 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<number> {
- 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<void> {
- 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<Awaited<ReturnType<typeof postRunOnce>>> = [];
- 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;
- });
|