import { test, expect } from '../fixtures/auth'; import type { Page } from '@playwright/test'; /** * A2 实测:S0 四类主数据 DELETE 是否在存在下游引用时拒绝。 * 不修改任何数据:每条用例先用 list 找到一条"应当被引用"的记录,仅尝试 DELETE, * 期望 4xx + 业务错误码;若意外返回 2xx,则真的存在 BUG。 */ async function token(page: Page) { 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; }); } async function get(page: Page, url: string) { const t = await token(page); const r = await page.request.get(`${new URL(page.url()).origin}${url}`, { headers: { Authorization: `Bearer ${t!}` }, }); return { status: r.status(), json: r.ok() ? ((await r.json()) as any) : null }; } async function del(page: Page, url: string) { const t = await token(page); const r = await page.request.delete(`${new URL(page.url()).origin}${url}`, { headers: { Authorization: `Bearer ${t!}` }, }); let body: any = null; try { body = await r.json(); } catch { body = await r.text().catch(() => null); } return { status: r.status(), body }; } function rowsOf(j: any): any[] { return j?.list ?? j?.data?.list ?? j?.data?.items ?? j?.data ?? j?.items ?? []; } /** 取主表与子表全量,按 keyField 求交集,返回主表中 key 出现在子表的第一条。 */ async function pickReferenced( page: Page, parentUrl: string, childUrl: string, parentField: string, childField: string, ): Promise<{ id: number; key: string; childCount: number } | null> { const parents = rowsOf((await get(page, parentUrl)).json); const children = rowsOf((await get(page, childUrl)).json); const childCounts = new Map(); for (const c of children) { const k = c[childField]; if (k) childCounts.set(String(k), (childCounts.get(String(k)) ?? 0) + 1); } for (const p of parents) { const key = String(p[parentField] ?? ''); if (key && childCounts.has(key)) { return { id: Number(p.id), key, childCount: childCounts.get(key)! }; } } return null; } test.describe('A2 DELETE 引用检查实测', () => { test.beforeEach(async ({ authedPage }) => { await authedPage.goto('/#/dashboard/home', { waitUntil: 'domcontentloaded' }); }); test('Department 删除带 Employee 的部门 → 应 4xx', async ({ authedPage }) => { const target = await pickReferenced( authedPage, '/api/s0/warehouse/departments?page=1&pageSize=200&tenantId=1&factoryId=1', '/api/s0/warehouse/employees?page=1&pageSize=500&tenantId=1&factoryId=1', 'department', 'department', ); test.skip(!target, '无可用"被员工引用的部门"样本'); const resp = await del( authedPage, `/api/s0/warehouse/departments/${target!.id}?tenantId=1&factoryId=1`, ); test.info().annotations.push({ type: 'department', description: `target=${JSON.stringify(target)} resp=${resp.status} body=${JSON.stringify(resp.body).slice(0, 200)}`, }); expect(resp.status, '应返回 4xx 拒绝').toBeGreaterThanOrEqual(400); }); test('Location 删除带 Shelf 的库位 → 应 4xx', async ({ authedPage }) => { // 库位下游覆盖 LocationShelfMaster + ItemMaster.Location;前者无 S0 列表 API, // 改用 materials.location 求交集(命中其一即触发拒绝) const target = await pickReferenced( authedPage, '/api/s0/warehouse/locations?page=1&pageSize=200&tenantId=1&factoryId=1', '/api/s0/sales/materials?page=1&pageSize=2000&tenantId=1&factoryId=1', 'location', 'location', ); test.skip(!target, '无可用"被物料/货架引用的库位"样本'); const resp = await del( authedPage, `/api/s0/warehouse/locations/${target!.id}?tenantId=1&factoryId=1`, ); test.info().annotations.push({ type: 'location', description: `target=${JSON.stringify(target)} resp=${resp.status} body=${JSON.stringify(resp.body).slice(0, 200)}`, }); expect(resp.status, '应返回 4xx 拒绝').toBeGreaterThanOrEqual(400); }); // Material / Supplier 的运行态 DELETE 验证:未在 S0 路由下挂 SRM/Routing 子表 API, // 无法稳妥挑选"必有引用"的样本而不踩到孤儿。本轮仅做代码审查,不做运行态删除。 });