|
|
@@ -0,0 +1,121 @@
|
|
|
+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<string, number>();
|
|
|
+ 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,
|
|
|
+ // 无法稳妥挑选"必有引用"的样本而不踩到孤儿。本轮仅做代码审查,不做运行态删除。
|
|
|
+});
|