a2-delete-refcheck.spec.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import { test, expect } from '../fixtures/auth';
  2. import type { Page } from '@playwright/test';
  3. /**
  4. * A2 实测:S0 四类主数据 DELETE 是否在存在下游引用时拒绝。
  5. * 不修改任何数据:每条用例先用 list 找到一条"应当被引用"的记录,仅尝试 DELETE,
  6. * 期望 4xx + 业务错误码;若意外返回 2xx,则真的存在 BUG。
  7. */
  8. async function token(page: Page) {
  9. return page.evaluate(() => {
  10. for (let i = 0; i < localStorage.length; i++) {
  11. const k = localStorage.key(i)!;
  12. if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
  13. const raw = localStorage.getItem(k)!;
  14. try {
  15. const v = JSON.parse(raw);
  16. return typeof v === 'string' ? v : v?.value ?? raw;
  17. } catch {
  18. return raw;
  19. }
  20. }
  21. }
  22. return null;
  23. });
  24. }
  25. async function get(page: Page, url: string) {
  26. const t = await token(page);
  27. const r = await page.request.get(`${new URL(page.url()).origin}${url}`, {
  28. headers: { Authorization: `Bearer ${t!}` },
  29. });
  30. return { status: r.status(), json: r.ok() ? ((await r.json()) as any) : null };
  31. }
  32. async function del(page: Page, url: string) {
  33. const t = await token(page);
  34. const r = await page.request.delete(`${new URL(page.url()).origin}${url}`, {
  35. headers: { Authorization: `Bearer ${t!}` },
  36. });
  37. let body: any = null;
  38. try { body = await r.json(); } catch { body = await r.text().catch(() => null); }
  39. return { status: r.status(), body };
  40. }
  41. function rowsOf(j: any): any[] {
  42. return j?.list ?? j?.data?.list ?? j?.data?.items ?? j?.data ?? j?.items ?? [];
  43. }
  44. /** 取主表与子表全量,按 keyField 求交集,返回主表中 key 出现在子表的第一条。 */
  45. async function pickReferenced(
  46. page: Page,
  47. parentUrl: string,
  48. childUrl: string,
  49. parentField: string,
  50. childField: string,
  51. ): Promise<{ id: number; key: string; childCount: number } | null> {
  52. const parents = rowsOf((await get(page, parentUrl)).json);
  53. const children = rowsOf((await get(page, childUrl)).json);
  54. const childCounts = new Map<string, number>();
  55. for (const c of children) {
  56. const k = c[childField];
  57. if (k) childCounts.set(String(k), (childCounts.get(String(k)) ?? 0) + 1);
  58. }
  59. for (const p of parents) {
  60. const key = String(p[parentField] ?? '');
  61. if (key && childCounts.has(key)) {
  62. return { id: Number(p.id), key, childCount: childCounts.get(key)! };
  63. }
  64. }
  65. return null;
  66. }
  67. test.describe('A2 DELETE 引用检查实测', () => {
  68. test.beforeEach(async ({ authedPage }) => {
  69. await authedPage.goto('/#/dashboard/home', { waitUntil: 'domcontentloaded' });
  70. });
  71. test('Department 删除带 Employee 的部门 → 应 4xx', async ({ authedPage }) => {
  72. const target = await pickReferenced(
  73. authedPage,
  74. '/api/s0/warehouse/departments?page=1&pageSize=200&tenantId=1&factoryId=1',
  75. '/api/s0/warehouse/employees?page=1&pageSize=500&tenantId=1&factoryId=1',
  76. 'department',
  77. 'department',
  78. );
  79. test.skip(!target, '无可用"被员工引用的部门"样本');
  80. const resp = await del(
  81. authedPage,
  82. `/api/s0/warehouse/departments/${target!.id}?tenantId=1&factoryId=1`,
  83. );
  84. test.info().annotations.push({
  85. type: 'department',
  86. description: `target=${JSON.stringify(target)} resp=${resp.status} body=${JSON.stringify(resp.body).slice(0, 200)}`,
  87. });
  88. expect(resp.status, '应返回 4xx 拒绝').toBeGreaterThanOrEqual(400);
  89. });
  90. test('Location 删除带 Shelf 的库位 → 应 4xx', async ({ authedPage }) => {
  91. // 库位下游覆盖 LocationShelfMaster + ItemMaster.Location;前者无 S0 列表 API,
  92. // 改用 materials.location 求交集(命中其一即触发拒绝)
  93. const target = await pickReferenced(
  94. authedPage,
  95. '/api/s0/warehouse/locations?page=1&pageSize=200&tenantId=1&factoryId=1',
  96. '/api/s0/sales/materials?page=1&pageSize=2000&tenantId=1&factoryId=1',
  97. 'location',
  98. 'location',
  99. );
  100. test.skip(!target, '无可用"被物料/货架引用的库位"样本');
  101. const resp = await del(
  102. authedPage,
  103. `/api/s0/warehouse/locations/${target!.id}?tenantId=1&factoryId=1`,
  104. );
  105. test.info().annotations.push({
  106. type: 'location',
  107. description: `target=${JSON.stringify(target)} resp=${resp.status} body=${JSON.stringify(resp.body).slice(0, 200)}`,
  108. });
  109. expect(resp.status, '应返回 4xx 拒绝').toBeGreaterThanOrEqual(400);
  110. });
  111. // Material / Supplier 的运行态 DELETE 验证:未在 S0 路由下挂 SRM/Routing 子表 API,
  112. // 无法稳妥挑选"必有引用"的样本而不踩到孤儿。本轮仅做代码审查,不做运行态删除。
  113. });