@open-mercato/core 0.4.9-develop-e55592929f → 0.4.9-develop-ce96cffe00

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/helpers/integration/api.js +66 -0
  2. package/dist/helpers/integration/api.js.map +7 -0
  3. package/dist/helpers/integration/apiKeysFixtures.js +16 -0
  4. package/dist/helpers/integration/apiKeysFixtures.js.map +7 -0
  5. package/dist/helpers/integration/attachmentsFixtures.js +61 -0
  6. package/dist/helpers/integration/attachmentsFixtures.js.map +7 -0
  7. package/dist/helpers/integration/auth.js +190 -0
  8. package/dist/helpers/integration/auth.js.map +7 -0
  9. package/dist/helpers/integration/authFixtures.js +39 -0
  10. package/dist/helpers/integration/authFixtures.js.map +7 -0
  11. package/dist/helpers/integration/authUi.js +31 -0
  12. package/dist/helpers/integration/authUi.js.map +7 -0
  13. package/dist/helpers/integration/businessRulesFixtures.js +40 -0
  14. package/dist/helpers/integration/businessRulesFixtures.js.map +7 -0
  15. package/dist/helpers/integration/catalogFixtures.js +49 -0
  16. package/dist/helpers/integration/catalogFixtures.js.map +7 -0
  17. package/dist/helpers/integration/crmFixtures.js +91 -0
  18. package/dist/helpers/integration/crmFixtures.js.map +7 -0
  19. package/dist/helpers/integration/currenciesFixtures.js +39 -0
  20. package/dist/helpers/integration/currenciesFixtures.js.map +7 -0
  21. package/dist/helpers/integration/dictionariesFixtures.js +16 -0
  22. package/dist/helpers/integration/dictionariesFixtures.js.map +7 -0
  23. package/dist/helpers/integration/featureTogglesFixtures.js +23 -0
  24. package/dist/helpers/integration/featureTogglesFixtures.js.map +7 -0
  25. package/dist/helpers/integration/generalFixtures.js +56 -0
  26. package/dist/helpers/integration/generalFixtures.js.map +7 -0
  27. package/dist/helpers/integration/inboxFixtures.js +67 -0
  28. package/dist/helpers/integration/inboxFixtures.js.map +7 -0
  29. package/dist/helpers/integration/notificationsFixtures.js +48 -0
  30. package/dist/helpers/integration/notificationsFixtures.js.map +7 -0
  31. package/dist/helpers/integration/salesFixtures.js +63 -0
  32. package/dist/helpers/integration/salesFixtures.js.map +7 -0
  33. package/dist/helpers/integration/salesUi.js +827 -0
  34. package/dist/helpers/integration/salesUi.js.map +7 -0
  35. package/dist/helpers/integration/sseEventCollector.js +27 -0
  36. package/dist/helpers/integration/sseEventCollector.js.map +7 -0
  37. package/dist/helpers/integration/staffFixtures.js +47 -0
  38. package/dist/helpers/integration/staffFixtures.js.map +7 -0
  39. package/dist/testing/integration/api.js +2 -0
  40. package/dist/testing/integration/api.js.map +7 -0
  41. package/dist/testing/integration/auth.js +2 -0
  42. package/dist/testing/integration/auth.js.map +7 -0
  43. package/dist/testing/integration/authFixtures.js +2 -0
  44. package/dist/testing/integration/authFixtures.js.map +7 -0
  45. package/dist/testing/integration/authUi.js +2 -0
  46. package/dist/testing/integration/authUi.js.map +7 -0
  47. package/dist/testing/integration/crmFixtures.js +2 -0
  48. package/dist/testing/integration/crmFixtures.js.map +7 -0
  49. package/dist/testing/integration/dictionariesFixtures.js +2 -0
  50. package/dist/testing/integration/dictionariesFixtures.js.map +7 -0
  51. package/dist/testing/integration/generalFixtures.js +2 -0
  52. package/dist/testing/integration/generalFixtures.js.map +7 -0
  53. package/dist/testing/integration/index.js +48 -0
  54. package/dist/testing/integration/index.js.map +7 -0
  55. package/package.json +11 -3
  56. package/src/helpers/integration/api.ts +87 -0
  57. package/src/helpers/integration/apiKeysFixtures.ts +17 -0
  58. package/src/helpers/integration/attachmentsFixtures.ts +114 -0
  59. package/src/helpers/integration/auth.ts +208 -0
  60. package/src/helpers/integration/authFixtures.ts +52 -0
  61. package/src/helpers/integration/authUi.ts +33 -0
  62. package/src/helpers/integration/businessRulesFixtures.ts +53 -0
  63. package/src/helpers/integration/catalogFixtures.ts +73 -0
  64. package/src/helpers/integration/crmFixtures.ts +132 -0
  65. package/src/helpers/integration/currenciesFixtures.ts +49 -0
  66. package/src/helpers/integration/dictionariesFixtures.ts +17 -0
  67. package/src/helpers/integration/featureTogglesFixtures.ts +28 -0
  68. package/src/helpers/integration/generalFixtures.ts +71 -0
  69. package/src/helpers/integration/inboxFixtures.ts +94 -0
  70. package/src/helpers/integration/notificationsFixtures.ts +67 -0
  71. package/src/helpers/integration/salesFixtures.ts +89 -0
  72. package/src/helpers/integration/salesUi.ts +936 -0
  73. package/src/helpers/integration/sseEventCollector.ts +30 -0
  74. package/src/helpers/integration/staffFixtures.ts +61 -0
  75. package/src/testing/integration/api.ts +1 -0
  76. package/src/testing/integration/auth.ts +1 -0
  77. package/src/testing/integration/authFixtures.ts +1 -0
  78. package/src/testing/integration/authUi.ts +1 -0
  79. package/src/testing/integration/crmFixtures.ts +1 -0
  80. package/src/testing/integration/dictionariesFixtures.ts +1 -0
  81. package/src/testing/integration/generalFixtures.ts +1 -0
  82. package/src/testing/integration/index.ts +22 -0
  83. package/tsconfig.json +3 -0
@@ -0,0 +1,114 @@
1
+ import { expect, type APIRequestContext } from '@playwright/test';
2
+ import { apiRequest } from './api';
3
+ import { expectId, readJsonSafe } from './generalFixtures';
4
+
5
+ const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
6
+
7
+ type AttachmentAssignment = {
8
+ type: string;
9
+ id: string;
10
+ href?: string | null;
11
+ label?: string | null;
12
+ };
13
+
14
+ type MultipartFieldValue =
15
+ | string
16
+ | number
17
+ | boolean
18
+ | {
19
+ name: string;
20
+ mimeType: string;
21
+ buffer: Buffer;
22
+ };
23
+
24
+ function resolveApiUrl(path: string): string {
25
+ return `${BASE_URL}${path}`;
26
+ }
27
+
28
+ export async function uploadAttachmentFixture(
29
+ request: APIRequestContext,
30
+ token: string,
31
+ input: {
32
+ entityId: string;
33
+ recordId: string;
34
+ fileName: string;
35
+ mimeType: string;
36
+ buffer: Buffer;
37
+ partitionCode?: string;
38
+ tags?: string[];
39
+ assignments?: AttachmentAssignment[];
40
+ },
41
+ ): Promise<{
42
+ id: string;
43
+ partitionCode: string;
44
+ fileName: string;
45
+ tags: string[];
46
+ assignments: AttachmentAssignment[];
47
+ }> {
48
+ const multipart: Record<string, MultipartFieldValue> = {
49
+ entityId: input.entityId,
50
+ recordId: input.recordId,
51
+ file: {
52
+ name: input.fileName,
53
+ mimeType: input.mimeType,
54
+ buffer: input.buffer,
55
+ },
56
+ };
57
+ if (input.partitionCode) multipart.partitionCode = input.partitionCode;
58
+ if (input.tags) multipart.tags = JSON.stringify(input.tags);
59
+ if (input.assignments) multipart.assignments = JSON.stringify(input.assignments);
60
+
61
+ const response = await request.fetch(resolveApiUrl('/api/attachments'), {
62
+ method: 'POST',
63
+ headers: {
64
+ Authorization: `Bearer ${token}`,
65
+ },
66
+ multipart,
67
+ });
68
+ const body = await readJsonSafe<{
69
+ ok?: boolean;
70
+ item?: {
71
+ id?: string;
72
+ partitionCode?: string;
73
+ fileName?: string;
74
+ tags?: string[];
75
+ assignments?: AttachmentAssignment[];
76
+ };
77
+ }>(response);
78
+ expect(response.status(), 'POST /api/attachments should return 200').toBe(200);
79
+ return {
80
+ id: expectId(body?.item?.id, 'Attachment upload response should include item.id'),
81
+ partitionCode: String(body?.item?.partitionCode ?? ''),
82
+ fileName: String(body?.item?.fileName ?? ''),
83
+ tags: body?.item?.tags ?? [],
84
+ assignments: body?.item?.assignments ?? [],
85
+ };
86
+ }
87
+
88
+ export async function deleteAttachmentIfExists(
89
+ request: APIRequestContext,
90
+ token: string | null,
91
+ attachmentId: string | null,
92
+ ): Promise<void> {
93
+ if (!token || !attachmentId) return;
94
+ await apiRequest(
95
+ request,
96
+ 'DELETE',
97
+ `/api/attachments?id=${encodeURIComponent(attachmentId)}`,
98
+ { token },
99
+ ).catch(() => undefined);
100
+ }
101
+
102
+ export async function deleteAttachmentPartitionIfExists(
103
+ request: APIRequestContext,
104
+ token: string | null,
105
+ partitionId: string | null,
106
+ ): Promise<void> {
107
+ if (!token || !partitionId) return;
108
+ await apiRequest(
109
+ request,
110
+ 'DELETE',
111
+ `/api/attachments/partitions?id=${encodeURIComponent(partitionId)}`,
112
+ { token },
113
+ ).catch(() => undefined);
114
+ }
@@ -0,0 +1,208 @@
1
+ import { type Page } from '@playwright/test';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ function loadEnvFileContent(): string | null {
6
+ const candidatePaths = [
7
+ resolve(process.cwd(), 'apps/mercato/.env'),
8
+ resolve(process.cwd(), '.env'),
9
+ ];
10
+
11
+ for (const envPath of candidatePaths) {
12
+ try {
13
+ const content = readFileSync(envPath, 'utf-8');
14
+ if (content.trim().length > 0) {
15
+ return content;
16
+ }
17
+ } catch {
18
+ continue;
19
+ }
20
+ }
21
+
22
+ return null;
23
+ }
24
+
25
+ function loadEnvValue(key: string): string | undefined {
26
+ if (process.env[key]) return process.env[key];
27
+ const content = loadEnvFileContent();
28
+ if (!content) return undefined;
29
+ const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));
30
+ return match?.[1]?.trim();
31
+ }
32
+
33
+ export const DEFAULT_CREDENTIALS: Record<string, { email: string; password: string }> = {
34
+ superadmin: {
35
+ email: loadEnvValue('OM_INIT_SUPERADMIN_EMAIL') || 'superadmin@acme.com',
36
+ password: loadEnvValue('OM_INIT_SUPERADMIN_PASSWORD') || 'secret',
37
+ },
38
+ admin: { email: 'admin@acme.com', password: 'secret' },
39
+ employee: { email: 'employee@acme.com', password: 'secret' },
40
+ };
41
+
42
+ export type Role = 'superadmin' | 'admin' | 'employee';
43
+
44
+ function decodeJwtClaims(token: string): { tenantId?: string; orgId?: string | null } | null {
45
+ const parts = token.split('.');
46
+ if (parts.length < 2) return null;
47
+ try {
48
+ const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
49
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
50
+ const payload = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as {
51
+ tenantId?: string;
52
+ orgId?: string | null;
53
+ };
54
+ return payload;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ async function acknowledgeGlobalNotices(page: Page): Promise<void> {
61
+ const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
62
+ await page.context().addCookies([
63
+ {
64
+ name: 'om_demo_notice_ack',
65
+ value: 'ack',
66
+ url: baseUrl,
67
+ sameSite: 'Lax',
68
+ },
69
+ {
70
+ name: 'om_cookie_notice_ack',
71
+ value: 'ack',
72
+ url: baseUrl,
73
+ sameSite: 'Lax',
74
+ },
75
+ ]);
76
+ }
77
+
78
+ async function dismissGlobalNoticesIfPresent(page: Page): Promise<void> {
79
+ const cookieAcceptButton = page.getByRole('button', { name: /accept cookies/i }).first();
80
+ if (await cookieAcceptButton.isVisible().catch(() => false)) {
81
+ await cookieAcceptButton.click();
82
+ }
83
+
84
+ const demoNotice = page.getByText(/this instance is provided for demo purposes only/i).first();
85
+ if (await demoNotice.isVisible().catch(() => false)) {
86
+ const noticeContainer = demoNotice.locator('xpath=ancestor::div[contains(@class,"pointer-events-auto")]').first();
87
+ const dismissButton = noticeContainer.locator('button').first();
88
+ if (await dismissButton.isVisible().catch(() => false)) {
89
+ await dismissButton.click();
90
+ }
91
+ }
92
+ }
93
+
94
+ async function recoverClientSideErrorPageIfPresent(page: Page): Promise<void> {
95
+ const clientErrorHeading = page
96
+ .getByRole('heading', { name: /Application error: a client-side exception has occurred/i })
97
+ .first();
98
+ if (!(await clientErrorHeading.isVisible().catch(() => false))) return;
99
+ await page.reload({ waitUntil: 'domcontentloaded' });
100
+ await dismissGlobalNoticesIfPresent(page);
101
+ }
102
+
103
+ async function recoverGenericErrorPageIfPresent(page: Page): Promise<void> {
104
+ const errorHeading = page.getByRole('heading', { name: /^Something went wrong$/i }).first();
105
+ if (!(await errorHeading.isVisible().catch(() => false))) return;
106
+ const retryButton = page.getByRole('button', { name: /Try again/i }).first();
107
+ if (await retryButton.isVisible().catch(() => false)) {
108
+ await retryButton.click().catch(() => {});
109
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
110
+ await page.waitForTimeout(500).catch(() => {});
111
+ } else {
112
+ await page.reload({ waitUntil: 'domcontentloaded' });
113
+ }
114
+ await dismissGlobalNoticesIfPresent(page);
115
+ }
116
+
117
+ export async function login(page: Page, role: Role = 'admin'): Promise<void> {
118
+ const creds = DEFAULT_CREDENTIALS[role];
119
+ const loginReadySelector = 'form[data-auth-ready="1"]';
120
+ const hasBackendUrl = (): boolean => /\/backend(?:\/.*)?$/.test(page.url());
121
+ const waitForBackend = async (timeout: number): Promise<boolean> => {
122
+ try {
123
+ await page.waitForURL(/\/backend(?:\/.*)?$/, { timeout });
124
+ return true;
125
+ } catch {
126
+ return hasBackendUrl();
127
+ }
128
+ };
129
+
130
+ await acknowledgeGlobalNotices(page);
131
+ const apiLoginForm = new URLSearchParams();
132
+ apiLoginForm.set('email', creds.email);
133
+ apiLoginForm.set('password', creds.password);
134
+ const apiLoginResponse = await page.request.post('/api/auth/login', {
135
+ headers: {
136
+ 'content-type': 'application/x-www-form-urlencoded',
137
+ },
138
+ data: apiLoginForm.toString(),
139
+ }).catch(() => null);
140
+ if (apiLoginResponse?.ok()) {
141
+ const apiLoginBody = (await apiLoginResponse.json().catch(() => null)) as { token?: string } | null;
142
+ const claims = typeof apiLoginBody?.token === 'string' ? decodeJwtClaims(apiLoginBody.token) : null;
143
+ const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
144
+ const cookies = [];
145
+ if (claims?.tenantId) {
146
+ cookies.push({
147
+ name: 'om_selected_tenant',
148
+ value: claims.tenantId,
149
+ url: baseUrl,
150
+ sameSite: 'Lax' as const,
151
+ });
152
+ }
153
+ if (claims?.orgId) {
154
+ cookies.push({
155
+ name: 'om_selected_org',
156
+ value: claims.orgId,
157
+ url: baseUrl,
158
+ sameSite: 'Lax' as const,
159
+ });
160
+ }
161
+ if (cookies.length > 0) {
162
+ await page.context().addCookies(cookies);
163
+ }
164
+ await page.goto('/backend', { waitUntil: 'domcontentloaded' });
165
+ if (await waitForBackend(8_000)) return;
166
+ }
167
+
168
+ for (let attempt = 0; attempt < 4; attempt += 1) {
169
+ await page.goto('/login', { waitUntil: 'domcontentloaded' });
170
+ await dismissGlobalNoticesIfPresent(page);
171
+ await recoverClientSideErrorPageIfPresent(page);
172
+ await recoverGenericErrorPageIfPresent(page);
173
+ await page.waitForSelector(loginReadySelector, { state: 'visible', timeout: 3_000 }).catch(() => null);
174
+ if (await page.getByLabel('Email').isVisible().catch(() => false)) break;
175
+ if (attempt === 3) {
176
+ throw new Error(`Login form is unavailable for role: ${role}; current URL: ${page.url()}`);
177
+ }
178
+ }
179
+ await page.getByLabel('Email').fill(creds.email);
180
+
181
+ const passwordInput = page.getByLabel('Password').first();
182
+ if (await passwordInput.isVisible().catch(() => false)) {
183
+ await passwordInput.fill(creds.password);
184
+ await passwordInput.press('Enter');
185
+ } else {
186
+ const submitButton = page.getByRole('button', { name: /login|sign in|continue with sso/i }).first();
187
+ await submitButton.click();
188
+ }
189
+
190
+ if (await waitForBackend(7_000)) return;
191
+
192
+ const loginForm = page.locator('form').first();
193
+ if (await loginForm.isVisible().catch(() => false)) {
194
+ await loginForm.evaluate((element) => {
195
+ const form = element as HTMLFormElement
196
+ form.requestSubmit()
197
+ }).catch(() => {})
198
+ }
199
+ if (await waitForBackend(5_000)) return;
200
+
201
+ const loginButton = page.getByRole('button', { name: /login|sign in|continue with sso/i }).first();
202
+ if (await loginButton.isVisible().catch(() => false)) {
203
+ await loginButton.click({ force: true });
204
+ }
205
+ if (await waitForBackend(8_000)) return;
206
+
207
+ throw new Error(`Login did not reach backend for role: ${role}; current URL: ${page.url()}`);
208
+ }
@@ -0,0 +1,52 @@
1
+ import { expect, type APIRequestContext } from '@playwright/test';
2
+ import { apiRequest } from './api';
3
+ import { expectId, readJsonSafe } from './generalFixtures';
4
+
5
+ export async function createRoleFixture(
6
+ request: APIRequestContext,
7
+ token: string,
8
+ input: { name: string; tenantId?: string | null },
9
+ ): Promise<string> {
10
+ const response = await apiRequest(request, 'POST', '/api/auth/roles', {
11
+ token,
12
+ data: {
13
+ name: input.name,
14
+ tenantId: input.tenantId ?? null,
15
+ },
16
+ });
17
+ const body = await readJsonSafe<{ id?: string }>(response);
18
+ expect(response.status(), 'POST /api/auth/roles should return 201').toBe(201);
19
+ return expectId(body?.id, 'Role creation response should include id');
20
+ }
21
+
22
+ export async function deleteRoleIfExists(
23
+ request: APIRequestContext,
24
+ token: string | null,
25
+ roleId: string | null,
26
+ ): Promise<void> {
27
+ if (!token || !roleId) return;
28
+ await apiRequest(request, 'DELETE', `/api/auth/roles?id=${encodeURIComponent(roleId)}`, { token }).catch(() => undefined);
29
+ }
30
+
31
+ export async function createUserFixture(
32
+ request: APIRequestContext,
33
+ token: string,
34
+ input: { email: string; password: string; organizationId: string; roles: string[] },
35
+ ): Promise<string> {
36
+ const response = await apiRequest(request, 'POST', '/api/auth/users', {
37
+ token,
38
+ data: input,
39
+ });
40
+ const body = await readJsonSafe<{ id?: string }>(response);
41
+ expect(response.status(), 'POST /api/auth/users should return 201').toBe(201);
42
+ return expectId(body?.id, 'User creation response should include id');
43
+ }
44
+
45
+ export async function deleteUserIfExists(
46
+ request: APIRequestContext,
47
+ token: string | null,
48
+ userId: string | null,
49
+ ): Promise<void> {
50
+ if (!token || !userId) return;
51
+ await apiRequest(request, 'DELETE', `/api/auth/users?id=${encodeURIComponent(userId)}`, { token }).catch(() => undefined);
52
+ }
@@ -0,0 +1,33 @@
1
+ import { expect, type Page } from '@playwright/test';
2
+
3
+ export async function createUserViaUi(page: Page, input: { email: string; password: string; role?: string }) {
4
+ const role = input.role ?? 'employee';
5
+
6
+ await page.goto('/backend/users/create');
7
+ await expect(page.getByText('Create User')).toBeVisible();
8
+
9
+ await page.getByRole('textbox').nth(0).fill(input.email);
10
+ await page.getByRole('textbox').nth(1).fill(input.password);
11
+
12
+ const orgSelect = page.locator('main').locator('select').first();
13
+ await expect(orgSelect).toBeEnabled();
14
+ const orgValue = await orgSelect.evaluate((element) => {
15
+ const select = element as HTMLSelectElement;
16
+ for (const option of Array.from(select.options)) {
17
+ if (option.value && option.value.trim().length > 0) return option.value;
18
+ }
19
+ return '';
20
+ });
21
+ if (orgValue) {
22
+ await orgSelect.selectOption(orgValue);
23
+ }
24
+
25
+ const rolesInput = page.getByRole('textbox', { name: /add tag and press enter/i });
26
+ await rolesInput.fill(role);
27
+ await rolesInput.press('Enter');
28
+
29
+ await page.getByRole('button', { name: 'Create' }).first().click();
30
+ await expect(page).toHaveURL(/\/backend\/users(?:\?.*)?$/);
31
+ await page.getByRole('textbox', { name: 'Search' }).fill(input.email);
32
+ await expect(page.getByRole('row', { name: new RegExp(input.email, 'i') })).toBeVisible();
33
+ }
@@ -0,0 +1,53 @@
1
+ import { expect, type APIRequestContext } from '@playwright/test';
2
+ import { apiRequest } from './api';
3
+ import { expectId, readJsonSafe } from './generalFixtures';
4
+
5
+ export async function createRuleSetFixture(
6
+ request: APIRequestContext,
7
+ token: string,
8
+ data: Record<string, unknown>,
9
+ ): Promise<string> {
10
+ const response = await apiRequest(request, 'POST', '/api/business_rules/sets', { token, data });
11
+ const body = await readJsonSafe<{ id?: string }>(response);
12
+ expect(response.status(), 'POST /api/business_rules/sets should return 201').toBe(201);
13
+ return expectId(body?.id, 'Rule set creation response should include id');
14
+ }
15
+
16
+ export async function deleteRuleSetIfExists(
17
+ request: APIRequestContext,
18
+ token: string | null,
19
+ ruleSetId: string | null,
20
+ ): Promise<void> {
21
+ if (!token || !ruleSetId) return;
22
+ await apiRequest(
23
+ request,
24
+ 'DELETE',
25
+ `/api/business_rules/sets?id=${encodeURIComponent(ruleSetId)}`,
26
+ { token },
27
+ ).catch(() => undefined);
28
+ }
29
+
30
+ export async function createBusinessRuleFixture(
31
+ request: APIRequestContext,
32
+ token: string,
33
+ data: Record<string, unknown>,
34
+ ): Promise<string> {
35
+ const response = await apiRequest(request, 'POST', '/api/business_rules/rules', { token, data });
36
+ const body = await readJsonSafe<{ id?: string }>(response);
37
+ expect(response.status(), 'POST /api/business_rules/rules should return 201').toBe(201);
38
+ return expectId(body?.id, 'Business rule creation response should include id');
39
+ }
40
+
41
+ export async function deleteBusinessRuleIfExists(
42
+ request: APIRequestContext,
43
+ token: string | null,
44
+ ruleId: string | null,
45
+ ): Promise<void> {
46
+ if (!token || !ruleId) return;
47
+ await apiRequest(
48
+ request,
49
+ 'DELETE',
50
+ `/api/business_rules/rules?id=${encodeURIComponent(ruleId)}`,
51
+ { token },
52
+ ).catch(() => undefined);
53
+ }
@@ -0,0 +1,73 @@
1
+ import { expect, type APIRequestContext } from '@playwright/test';
2
+ import { apiRequest } from './api';
3
+
4
+ type ProductFixtureInput = {
5
+ title: string;
6
+ sku: string;
7
+ };
8
+
9
+ export async function createProductFixture(
10
+ request: APIRequestContext,
11
+ token: string,
12
+ input: ProductFixtureInput,
13
+ ): Promise<string> {
14
+ const response = await apiRequest(request, 'POST', '/api/catalog/products', {
15
+ token,
16
+ data: {
17
+ title: input.title,
18
+ sku: input.sku,
19
+ description:
20
+ 'Long enough description for SEO checks in QA automation flows. This text keeps the create validation satisfied.',
21
+ },
22
+ });
23
+ expect(response.ok(), `Failed to create product fixture: ${response.status()}`).toBeTruthy();
24
+ const body = (await response.json()) as { id?: string };
25
+ expect(typeof body.id === 'string' && body.id.length > 0).toBeTruthy();
26
+ return body.id as string;
27
+ }
28
+
29
+ type CategoryFixtureInput = {
30
+ name: string;
31
+ };
32
+
33
+ export async function createCategoryFixture(
34
+ request: APIRequestContext,
35
+ token: string,
36
+ input: CategoryFixtureInput,
37
+ ): Promise<string> {
38
+ const response = await apiRequest(request, 'POST', '/api/catalog/categories', {
39
+ token,
40
+ data: { name: input.name },
41
+ });
42
+ expect(response.ok(), `Failed to create category fixture: ${response.status()}`).toBeTruthy();
43
+ const body = (await response.json()) as { id?: string };
44
+ expect(typeof body.id === 'string' && body.id.length > 0).toBeTruthy();
45
+ return body.id as string;
46
+ }
47
+
48
+ export async function deleteCatalogCategoryIfExists(
49
+ request: APIRequestContext,
50
+ token: string | null,
51
+ categoryId: string | null,
52
+ ): Promise<void> {
53
+ if (!token || !categoryId) return;
54
+ try {
55
+ await apiRequest(request, 'DELETE', `/api/catalog/categories?id=${encodeURIComponent(categoryId)}`, { token });
56
+ } catch {
57
+ return;
58
+ }
59
+ }
60
+
61
+ export async function deleteCatalogProductIfExists(
62
+ request: APIRequestContext,
63
+ token: string | null,
64
+ productId: string | null,
65
+ ): Promise<void> {
66
+ if (!token || !productId) return;
67
+ try {
68
+ await apiRequest(request, 'DELETE', `/api/catalog/products?id=${encodeURIComponent(productId)}`, { token });
69
+ } catch {
70
+ return;
71
+ }
72
+ }
73
+
@@ -0,0 +1,132 @@
1
+ import { expect, type APIRequestContext } from '@playwright/test';
2
+ import { apiRequest } from './api';
3
+ import { readJsonSafe } from './generalFixtures';
4
+
5
+ type JsonRecord = Record<string, unknown>;
6
+
7
+ export { readJsonSafe } from './generalFixtures';
8
+
9
+ function isRecord(value: unknown): value is JsonRecord {
10
+ return typeof value === 'object' && value !== null;
11
+ }
12
+
13
+ function findStringByKeys(value: unknown, keys: readonly string[]): string | null {
14
+ if (!isRecord(value)) return null;
15
+
16
+ for (const key of keys) {
17
+ const candidate = value[key];
18
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
19
+ return candidate.trim();
20
+ }
21
+ }
22
+
23
+ for (const nested of Object.values(value)) {
24
+ if (Array.isArray(nested)) continue;
25
+ const found = findStringByKeys(nested, keys);
26
+ if (found) return found;
27
+ }
28
+
29
+ return null;
30
+ }
31
+
32
+ async function createEntity(
33
+ request: APIRequestContext,
34
+ token: string,
35
+ path: string,
36
+ data: Record<string, unknown>,
37
+ idKeys: readonly string[],
38
+ ): Promise<string> {
39
+ const response = await apiRequest(request, 'POST', path, { token, data });
40
+ const payload = await readJsonSafe(response);
41
+ expect(response.ok(), `Failed POST ${path}: ${response.status()}`).toBeTruthy();
42
+ const id = findStringByKeys(payload, idKeys);
43
+ expect(id, `No id in ${path} response`).toBeTruthy();
44
+ return id as string;
45
+ }
46
+
47
+ export async function createCompanyFixture(
48
+ request: APIRequestContext,
49
+ token: string,
50
+ displayName: string,
51
+ ): Promise<string> {
52
+ return createEntity(request, token, '/api/customers/companies', { displayName }, ['id', 'entityId', 'companyId']);
53
+ }
54
+
55
+ export async function createPersonFixture(
56
+ request: APIRequestContext,
57
+ token: string,
58
+ input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string },
59
+ ): Promise<string> {
60
+ const data: Record<string, unknown> = {
61
+ firstName: input.firstName,
62
+ lastName: input.lastName,
63
+ displayName: input.displayName,
64
+ };
65
+ if (input.companyEntityId) {
66
+ data.companyEntityId = input.companyEntityId;
67
+ }
68
+ return createEntity(request, token, '/api/customers/people', data, ['id', 'entityId', 'personId']);
69
+ }
70
+
71
+ export async function createDealFixture(
72
+ request: APIRequestContext,
73
+ token: string,
74
+ input: { title: string; companyIds?: string[]; personIds?: string[]; pipelineId?: string; pipelineStageId?: string; valueAmount?: number; valueCurrency?: string },
75
+ ): Promise<string> {
76
+ const data: Record<string, unknown> = { title: input.title };
77
+ if (input.companyIds?.length) data.companyIds = input.companyIds;
78
+ if (input.personIds?.length) data.personIds = input.personIds;
79
+ if (input.pipelineId) data.pipelineId = input.pipelineId;
80
+ if (input.pipelineStageId) data.pipelineStageId = input.pipelineStageId;
81
+ if (input.valueAmount !== undefined) data.valueAmount = input.valueAmount;
82
+ if (input.valueCurrency) data.valueCurrency = input.valueCurrency;
83
+ return createEntity(request, token, '/api/customers/deals', data, ['dealId', 'id', 'entityId']);
84
+ }
85
+
86
+ export async function createPipelineFixture(
87
+ request: APIRequestContext,
88
+ token: string,
89
+ input: { name: string; isDefault?: boolean },
90
+ ): Promise<string> {
91
+ const data: Record<string, unknown> = { name: input.name };
92
+ if (input.isDefault !== undefined) data.isDefault = input.isDefault;
93
+ return createEntity(request, token, '/api/customers/pipelines', data, ['id', 'pipelineId']);
94
+ }
95
+
96
+ export async function createPipelineStageFixture(
97
+ request: APIRequestContext,
98
+ token: string,
99
+ input: { pipelineId: string; label: string; order?: number },
100
+ ): Promise<string> {
101
+ const data: Record<string, unknown> = { pipelineId: input.pipelineId, label: input.label };
102
+ if (input.order !== undefined) data.order = input.order;
103
+ return createEntity(request, token, '/api/customers/pipeline-stages', data, ['id', 'stageId']);
104
+ }
105
+
106
+ export async function deleteEntityByBody(
107
+ request: APIRequestContext,
108
+ token: string | null,
109
+ path: string,
110
+ id: string | null,
111
+ ): Promise<void> {
112
+ if (!token || !id) return;
113
+ try {
114
+ await apiRequest(request, 'DELETE', path, { token, data: { id } });
115
+ } catch {
116
+ return;
117
+ }
118
+ }
119
+
120
+ export async function deleteEntityIfExists(
121
+ request: APIRequestContext,
122
+ token: string | null,
123
+ path: string,
124
+ id: string | null,
125
+ ): Promise<void> {
126
+ if (!token || !id) return;
127
+ try {
128
+ await apiRequest(request, 'DELETE', `${path}?id=${encodeURIComponent(id)}`, { token });
129
+ } catch {
130
+ return;
131
+ }
132
+ }