|
@@ -1,41 +1,74 @@
|
|
|
import { test, expect } from '../fixtures/auth';
|
|
import { test, expect } from '../fixtures/auth';
|
|
|
import { STORAGE_STATE_PATH } from '../fixtures/auth';
|
|
import { STORAGE_STATE_PATH } from '../fixtures/auth';
|
|
|
-import { request as playwrightRequest } from '@playwright/test';
|
|
|
|
|
|
|
+import { request as playwrightRequest, type APIRequestContext } from '@playwright/test';
|
|
|
import type { Page } from '@playwright/test';
|
|
import type { Page } from '@playwright/test';
|
|
|
import fs from 'node:fs';
|
|
import fs from 'node:fs';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * G-01 非破坏性补强(S2-EV 安全夹具 + S2-EX cleanup 接入):
|
|
|
|
|
- * M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
|
|
|
|
|
- * M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
|
|
|
|
|
|
|
+ * G-01 watch mapping e2e(S2-EY 路径 C 落地):
|
|
|
|
|
+ * M1 多次 run-once 的 TEST_G09_ hit 集合稳定(sourceRuleId / relatedObjectCode 一致)
|
|
|
|
|
+ * M2 命中对象映射闭环:每条 skipped TEST_G09_ hit 必然对应一条仍未闭环的现存异常
|
|
|
* M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
|
|
* M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
|
|
|
*
|
|
*
|
|
|
- * 安全契约(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。
|
|
|
|
|
|
|
+ * 安全契约(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。
|
|
* C5 双键强制断言(5656a88d 起 DTO 已暴露 sourceRuleId/relatedObjectCode);不允许 fallback。
|
|
|
- * C6 test.afterAll 调用 DELETE /api/aidop/s8/watch-debug/test-cleanup 兜底软删任何
|
|
|
|
|
- * RelatedObjectCode 以 TEST_G09_ 开头的异常;cleanup 失败立即 hard fail,不吞错。
|
|
|
|
|
- *
|
|
|
|
|
- * 当前 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 路径前禁止主动写入)。
|
|
|
|
|
|
|
+ * 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 PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
|
|
|
const RUNS = 3;
|
|
const RUNS = 3;
|
|
|
-const AUTO_WATCH_SOURCE = 'AUTO_WATCH';
|
|
|
|
|
-const RUN_ID = `TEST_G09_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
|
|
|
+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 token(page: Page) {
|
|
|
|
|
|
|
+async function pageToken(page: Page): Promise<string> {
|
|
|
return page.evaluate(() => {
|
|
return page.evaluate(() => {
|
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
|
const k = localStorage.key(i)!;
|
|
const k = localStorage.key(i)!;
|
|
@@ -49,203 +82,283 @@ async function token(page: Page) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- return null;
|
|
|
|
|
|
|
+ return null as unknown as string;
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function postRunOnce(page: Page) {
|
|
async function postRunOnce(page: Page) {
|
|
|
- const t = await token(page);
|
|
|
|
|
|
|
+ const t = await pageToken(page);
|
|
|
const base = new URL(page.url()).origin;
|
|
const base = new URL(page.url()).origin;
|
|
|
const r = await page.request.post(
|
|
const r = await page.request.post(
|
|
|
- `${base}/api/aidop/s8/watch-debug/run-once?tenantId=1&factoryId=1`,
|
|
|
|
|
- { headers: { Authorization: `Bearer ${t!}` } },
|
|
|
|
|
|
|
+ `${base}/api/aidop/s8/watch-debug/run-once?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`,
|
|
|
|
|
+ { headers: { Authorization: `Bearer ${t}` } },
|
|
|
);
|
|
);
|
|
|
expect(r.status()).toBe(200);
|
|
expect(r.status()).toBe(200);
|
|
|
return (await r.json()) as { count: number; results: any[] };
|
|
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) {
|
|
async function getException(page: Page, id: string | number) {
|
|
|
- const t = await token(page);
|
|
|
|
|
|
|
+ const t = await pageToken(page);
|
|
|
const base = new URL(page.url()).origin;
|
|
const base = new URL(page.url()).origin;
|
|
|
const r = await page.request.get(
|
|
const r = await page.request.get(
|
|
|
- `${base}/api/aidop/s8/exceptions/${id}?tenantId=1&factoryId=1`,
|
|
|
|
|
- { headers: { Authorization: `Bearer ${t!}` } },
|
|
|
|
|
|
|
+ `${base}/api/aidop/s8/exceptions/${id}?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`,
|
|
|
|
|
+ { headers: { Authorization: `Bearer ${t}` } },
|
|
|
);
|
|
);
|
|
|
if (!r.ok()) return null;
|
|
if (!r.ok()) return null;
|
|
|
const j = (await r.json()) as any;
|
|
const j = (await r.json()) as any;
|
|
|
return j?.data ?? j;
|
|
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 数量很小,足够。
|
|
|
|
|
|
|
+/** 列出当前 TEST_G09_ 前缀的未闭环异常;用于 C2 baseline 与 finalCount。 */
|
|
|
|
|
+async function listTestG09Pending(ctxOrPage: APIRequestContext | Page): Promise<any[]> {
|
|
|
|
|
+ const items: any[] = [];
|
|
|
for (let pageNo = 1; pageNo <= 5; pageNo++) {
|
|
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!}` } },
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ 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;
|
|
if (!r.ok()) break;
|
|
|
const body = (await r.json()) as any;
|
|
const body = (await r.json()) as any;
|
|
|
const list: any[] = body?.list ?? body?.data?.list ?? [];
|
|
const list: any[] = body?.list ?? body?.data?.list ?? [];
|
|
|
if (list.length === 0) break;
|
|
if (list.length === 0) break;
|
|
|
for (const item of list) {
|
|
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}`);
|
|
|
|
|
|
|
+ const code = String(item.relatedObjectCode ?? item.RelatedObjectCode ?? '');
|
|
|
|
|
+ if (code.startsWith(TEST_PREFIX)) items.push(item);
|
|
|
}
|
|
}
|
|
|
if (list.length < 100) break;
|
|
if (list.length < 100) break;
|
|
|
}
|
|
}
|
|
|
- return { count, keys };
|
|
|
|
|
|
|
+ return items;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage }) => {
|
|
|
|
|
- await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
|
|
|
|
|
|
|
+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;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- // 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(',')}]`,
|
|
|
|
|
|
|
+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,
|
|
|
|
|
+ },
|
|
|
});
|
|
});
|
|
|
- test.skip(
|
|
|
|
|
- baseline.count === 0,
|
|
|
|
|
- `[${RUN_ID}] AUTO_WATCH pending baseline=0;当前后端无 hard-delete 测试接口,run-once 会创建无清理路径的异常行,主动跳过。`,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ 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 轮,收集结果
|
|
// M1:连跑 N 轮,收集结果
|
|
|
const runs: Array<Awaited<ReturnType<typeof postRunOnce>>> = [];
|
|
const runs: Array<Awaited<ReturnType<typeof postRunOnce>>> = [];
|
|
|
for (let i = 0; i < RUNS; i++) runs.push(await postRunOnce(authedPage));
|
|
for (let i = 0; i < RUNS; i++) runs.push(await postRunOnce(authedPage));
|
|
|
|
|
|
|
|
- // C3 安全契约:任意一条 created=true 都视为污染(无清理路径)。立即 hard fail。
|
|
|
|
|
|
|
+ // C3: 任意一条 RelatedObjectCode 非 TEST_G09_ 的 created=true 必须 hard fail
|
|
|
for (let i = 0; i < runs.length; i++) {
|
|
for (let i = 0; i < runs.length; i++) {
|
|
|
- const created = runs[i].results.filter((r) => r.created === true);
|
|
|
|
|
|
|
+ const polluted = runs[i].results.filter(
|
|
|
|
|
+ (r) => r.created === true && !String(r.relatedObjectCode ?? '').startsWith(TEST_PREFIX),
|
|
|
|
|
+ );
|
|
|
expect(
|
|
expect(
|
|
|
- created.length,
|
|
|
|
|
- `[${RUN_ID}] 第 ${i + 1} 轮 run-once 创建了 ${created.length} 条新异常但 fixture 无清理机制;` +
|
|
|
|
|
- `请在 backend 增加 TEST_ 前缀 hard-delete 接口后再重跑。created=${JSON.stringify(created)}`,
|
|
|
|
|
|
|
+ polluted.length,
|
|
|
|
|
+ `[${RUN_ID}] 第 ${i + 1} 轮 run-once 在非 TEST_G09_ 对象上创建了 ${polluted.length} 条新异常(污染业务):${JSON.stringify(polluted)}`,
|
|
|
).toBe(0);
|
|
).toBe(0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const fingerprint = (run: { results: any[] }) =>
|
|
|
|
|
- run.results
|
|
|
|
|
- .map(
|
|
|
|
|
- (r) =>
|
|
|
|
|
- `${r.sourceRuleId}|${r.relatedObjectCode}|created=${r.created}|skipped=${r.skipped}|m=${r.matchedExceptionId ?? ''}`,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ // 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()
|
|
.sort()
|
|
|
.join(';');
|
|
.join(';');
|
|
|
-
|
|
|
|
|
- const fps = runs.map(fingerprint);
|
|
|
|
|
- test.info().annotations.push({
|
|
|
|
|
- type: 'stability',
|
|
|
|
|
- description: fps.map((f, i) => `R${i + 1}: ${f}`).join(' || '),
|
|
|
|
|
- });
|
|
|
|
|
- for (let i = 1; i < fps.length; i++) {
|
|
|
|
|
- expect(fps[i], `第 ${i + 1} 轮指纹与第 1 轮不一致`).toBe(fps[0]);
|
|
|
|
|
|
|
+ // 第 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:命中映射闭环
|
|
|
|
|
- const sample = runs[0];
|
|
|
|
|
- expect(sample.results.length, '至少应有一条命中以验证映射').toBeGreaterThan(0);
|
|
|
|
|
|
|
+ // 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[] = [];
|
|
const mappingNotes: string[] = [];
|
|
|
- for (const hit of sample.results) {
|
|
|
|
|
- // C3 已保证不会出现 created=true;此处要求每条都是 skipped。
|
|
|
|
|
- expect(hit.skipped, '命中未走 skipped 分支—与安全契约 C3 不一致').toBeTruthy();
|
|
|
|
|
|
|
+ for (const hit of stableTestHits) {
|
|
|
|
|
+ expect(hit.skipped, '末轮 TEST_G09_ hit 必须为 skipped').toBeTruthy();
|
|
|
expect(hit.matchedExceptionId, 'skipped 必须携带 matchedExceptionId').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);
|
|
const ex = await getException(authedPage, hit.matchedExceptionId);
|
|
|
expect(ex, `matchedExceptionId=${hit.matchedExceptionId} 详情不可达`).toBeTruthy();
|
|
expect(ex, `matchedExceptionId=${hit.matchedExceptionId} 详情不可达`).toBeTruthy();
|
|
|
expect(PENDING).toContain(String(ex.status));
|
|
expect(PENDING).toContain(String(ex.status));
|
|
|
- // 双键强制一致:5656a88d 起 ExceptionDetailDto 必须暴露 sourceRuleId / relatedObjectCode。
|
|
|
|
|
- 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 ?? ''),
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ // 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(
|
|
mappingNotes.push(
|
|
|
`hit{rule=${hit.sourceRuleId},obj=${hit.relatedObjectCode}} → ex#${ex.id}(${ex.status}, code=${ex.code ?? ex.exceptionCode})`,
|
|
`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(' || ') });
|
|
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);
|
|
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 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 约定一致;如未来上下文允许动态获取,应替换。
|
|
|
|
|
|
|
+ * C6 安全契约:先删除测试 watch rule(物理删;AdoS8WatchRule 无 IsDeleted),
|
|
|
|
|
+ * 再调 cleanup endpoint 软删 TEST_G09_ exception。
|
|
|
|
|
+ * finally 链保证 cleanup 总会执行;任一失败立即 hard fail,不吞错。
|
|
|
*/
|
|
*/
|
|
|
test.afterAll(async () => {
|
|
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}` },
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const ctx = await makeApiContext();
|
|
|
|
|
+ let firstError: unknown = null;
|
|
|
try {
|
|
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();
|
|
|
|
|
|
|
+ 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 {
|
|
} finally {
|
|
|
await ctx.dispose();
|
|
await ctx.dispose();
|
|
|
}
|
|
}
|
|
|
|
|
+ if (firstError !== null) throw firstError;
|
|
|
});
|
|
});
|