@open-mercato/core 0.4.9-develop-e55592929f → 0.4.9-develop-97d4cca067
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.
- package/dist/helpers/integration/api.js +66 -0
- package/dist/helpers/integration/api.js.map +7 -0
- package/dist/helpers/integration/apiKeysFixtures.js +16 -0
- package/dist/helpers/integration/apiKeysFixtures.js.map +7 -0
- package/dist/helpers/integration/attachmentsFixtures.js +61 -0
- package/dist/helpers/integration/attachmentsFixtures.js.map +7 -0
- package/dist/helpers/integration/auth.js +190 -0
- package/dist/helpers/integration/auth.js.map +7 -0
- package/dist/helpers/integration/authFixtures.js +39 -0
- package/dist/helpers/integration/authFixtures.js.map +7 -0
- package/dist/helpers/integration/authUi.js +31 -0
- package/dist/helpers/integration/authUi.js.map +7 -0
- package/dist/helpers/integration/businessRulesFixtures.js +40 -0
- package/dist/helpers/integration/businessRulesFixtures.js.map +7 -0
- package/dist/helpers/integration/catalogFixtures.js +49 -0
- package/dist/helpers/integration/catalogFixtures.js.map +7 -0
- package/dist/helpers/integration/crmFixtures.js +91 -0
- package/dist/helpers/integration/crmFixtures.js.map +7 -0
- package/dist/helpers/integration/currenciesFixtures.js +39 -0
- package/dist/helpers/integration/currenciesFixtures.js.map +7 -0
- package/dist/helpers/integration/dictionariesFixtures.js +16 -0
- package/dist/helpers/integration/dictionariesFixtures.js.map +7 -0
- package/dist/helpers/integration/featureTogglesFixtures.js +23 -0
- package/dist/helpers/integration/featureTogglesFixtures.js.map +7 -0
- package/dist/helpers/integration/generalFixtures.js +56 -0
- package/dist/helpers/integration/generalFixtures.js.map +7 -0
- package/dist/helpers/integration/inboxFixtures.js +67 -0
- package/dist/helpers/integration/inboxFixtures.js.map +7 -0
- package/dist/helpers/integration/notificationsFixtures.js +48 -0
- package/dist/helpers/integration/notificationsFixtures.js.map +7 -0
- package/dist/helpers/integration/salesFixtures.js +63 -0
- package/dist/helpers/integration/salesFixtures.js.map +7 -0
- package/dist/helpers/integration/salesUi.js +827 -0
- package/dist/helpers/integration/salesUi.js.map +7 -0
- package/dist/helpers/integration/sseEventCollector.js +27 -0
- package/dist/helpers/integration/sseEventCollector.js.map +7 -0
- package/dist/helpers/integration/staffFixtures.js +47 -0
- package/dist/helpers/integration/staffFixtures.js.map +7 -0
- package/dist/modules/auth/lib/setup-app.js +17 -1
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/testing/integration/api.js +2 -0
- package/dist/testing/integration/api.js.map +7 -0
- package/dist/testing/integration/auth.js +2 -0
- package/dist/testing/integration/auth.js.map +7 -0
- package/dist/testing/integration/authFixtures.js +2 -0
- package/dist/testing/integration/authFixtures.js.map +7 -0
- package/dist/testing/integration/authUi.js +2 -0
- package/dist/testing/integration/authUi.js.map +7 -0
- package/dist/testing/integration/crmFixtures.js +2 -0
- package/dist/testing/integration/crmFixtures.js.map +7 -0
- package/dist/testing/integration/dictionariesFixtures.js +2 -0
- package/dist/testing/integration/dictionariesFixtures.js.map +7 -0
- package/dist/testing/integration/generalFixtures.js +2 -0
- package/dist/testing/integration/generalFixtures.js.map +7 -0
- package/dist/testing/integration/index.js +48 -0
- package/dist/testing/integration/index.js.map +7 -0
- package/package.json +11 -3
- package/src/helpers/integration/api.ts +87 -0
- package/src/helpers/integration/apiKeysFixtures.ts +17 -0
- package/src/helpers/integration/attachmentsFixtures.ts +114 -0
- package/src/helpers/integration/auth.ts +208 -0
- package/src/helpers/integration/authFixtures.ts +52 -0
- package/src/helpers/integration/authUi.ts +33 -0
- package/src/helpers/integration/businessRulesFixtures.ts +53 -0
- package/src/helpers/integration/catalogFixtures.ts +73 -0
- package/src/helpers/integration/crmFixtures.ts +132 -0
- package/src/helpers/integration/currenciesFixtures.ts +49 -0
- package/src/helpers/integration/dictionariesFixtures.ts +17 -0
- package/src/helpers/integration/featureTogglesFixtures.ts +28 -0
- package/src/helpers/integration/generalFixtures.ts +71 -0
- package/src/helpers/integration/inboxFixtures.ts +94 -0
- package/src/helpers/integration/notificationsFixtures.ts +67 -0
- package/src/helpers/integration/salesFixtures.ts +89 -0
- package/src/helpers/integration/salesUi.ts +936 -0
- package/src/helpers/integration/sseEventCollector.ts +30 -0
- package/src/helpers/integration/staffFixtures.ts +61 -0
- package/src/modules/auth/lib/setup-app.ts +22 -0
- package/src/testing/integration/api.ts +1 -0
- package/src/testing/integration/auth.ts +1 -0
- package/src/testing/integration/authFixtures.ts +1 -0
- package/src/testing/integration/authUi.ts +1 -0
- package/src/testing/integration/crmFixtures.ts +1 -0
- package/src/testing/integration/dictionariesFixtures.ts +1 -0
- package/src/testing/integration/generalFixtures.ts +1 -0
- package/src/testing/integration/index.ts +22 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/testing/integration/index.ts"],
|
|
4
|
+
"sourcesContent": ["export { getAuthToken, apiRequest, postForm } from '../../helpers/integration/api'\nexport { DEFAULT_CREDENTIALS, type Role } from '../../helpers/integration/auth'\nexport { createUserViaUi } from '../../helpers/integration/authUi'\nexport {\n createCompanyFixture,\n createPersonFixture,\n createDealFixture,\n createPipelineFixture,\n createPipelineStageFixture,\n deleteEntityByBody,\n deleteEntityIfExists,\n} from '../../helpers/integration/crmFixtures'\nexport {\n readJsonSafe,\n getTokenContext,\n getTokenScope,\n expectId,\n deleteEntityByPathIfExists,\n deleteGeneralEntityIfExists,\n} from '../../helpers/integration/generalFixtures'\nexport { createDictionaryFixture } from '../../helpers/integration/dictionariesFixtures'\nexport { createRoleFixture, deleteRoleIfExists, createUserFixture, deleteUserIfExists } from '../../helpers/integration/authFixtures'\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,cAAc,YAAY,gBAAgB;AACnD,SAAS,2BAAsC;AAC/C,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,+BAA+B;AACxC,SAAS,mBAAmB,oBAAoB,mBAAmB,0BAA0B;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.4.9-develop-
|
|
3
|
+
"version": "0.4.9-develop-97d4cca067",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
},
|
|
26
26
|
"exports": {
|
|
27
27
|
".": "./dist/index.js",
|
|
28
|
+
"./testing/integration": {
|
|
29
|
+
"types": "./src/testing/integration/index.ts",
|
|
30
|
+
"default": "./dist/testing/integration/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./helpers/integration/*": {
|
|
33
|
+
"types": "./src/helpers/integration/*.ts",
|
|
34
|
+
"default": "./dist/helpers/integration/*.js"
|
|
35
|
+
},
|
|
28
36
|
"./modules/customers": {
|
|
29
37
|
"types": "./src/modules/customers/index.ts",
|
|
30
38
|
"default": "./dist/modules/customers/index.js"
|
|
@@ -217,10 +225,10 @@
|
|
|
217
225
|
"semver": "^7.6.3"
|
|
218
226
|
},
|
|
219
227
|
"peerDependencies": {
|
|
220
|
-
"@open-mercato/shared": "0.4.9-develop-
|
|
228
|
+
"@open-mercato/shared": "0.4.9-develop-97d4cca067"
|
|
221
229
|
},
|
|
222
230
|
"devDependencies": {
|
|
223
|
-
"@open-mercato/shared": "0.4.9-develop-
|
|
231
|
+
"@open-mercato/shared": "0.4.9-develop-97d4cca067",
|
|
224
232
|
"@testing-library/dom": "^10.4.1",
|
|
225
233
|
"@testing-library/jest-dom": "^6.9.1",
|
|
226
234
|
"@testing-library/react": "^16.3.1",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type APIRequestContext } from '@playwright/test';
|
|
2
|
+
import { DEFAULT_CREDENTIALS, type Role } from './auth';
|
|
3
|
+
|
|
4
|
+
const BASE_URL = process.env.BASE_URL?.trim() || null;
|
|
5
|
+
|
|
6
|
+
function resolveUrl(path: string): string {
|
|
7
|
+
return BASE_URL ? `${BASE_URL}${path}` : path;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function getAuthToken(
|
|
11
|
+
request: APIRequestContext,
|
|
12
|
+
roleOrEmail: Role | string = 'admin',
|
|
13
|
+
password?: string,
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
const role = roleOrEmail in DEFAULT_CREDENTIALS ? (roleOrEmail as Role) : null;
|
|
16
|
+
const credentialAttempts: Array<{ email: string; password: string }> = [];
|
|
17
|
+
|
|
18
|
+
if (role) {
|
|
19
|
+
const configured = DEFAULT_CREDENTIALS[role];
|
|
20
|
+
credentialAttempts.push({ email: configured.email, password: password ?? configured.password });
|
|
21
|
+
if (!password) {
|
|
22
|
+
credentialAttempts.push({ email: `${role}@acme.com`, password: 'secret' });
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
credentialAttempts.push({ email: roleOrEmail, password: password ?? 'secret' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let lastStatus = 0;
|
|
29
|
+
|
|
30
|
+
for (const attempt of credentialAttempts) {
|
|
31
|
+
const form = new URLSearchParams();
|
|
32
|
+
form.set('email', attempt.email);
|
|
33
|
+
form.set('password', attempt.password);
|
|
34
|
+
|
|
35
|
+
const response = await request.post(resolveUrl('/api/auth/login'), {
|
|
36
|
+
headers: {
|
|
37
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
38
|
+
},
|
|
39
|
+
data: form.toString(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const raw = await response.text();
|
|
43
|
+
let body: Record<string, unknown> | null = null;
|
|
44
|
+
try {
|
|
45
|
+
body = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;
|
|
46
|
+
} catch {
|
|
47
|
+
body = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
lastStatus = response.status();
|
|
51
|
+
if (response.ok() && body && typeof body.token === 'string' && body.token) {
|
|
52
|
+
return body.token;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(`Failed to obtain auth token (status ${lastStatus})`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function apiRequest(
|
|
60
|
+
request: APIRequestContext,
|
|
61
|
+
method: string,
|
|
62
|
+
path: string,
|
|
63
|
+
options: { token: string; data?: unknown },
|
|
64
|
+
) {
|
|
65
|
+
const headers = {
|
|
66
|
+
Authorization: `Bearer ${options.token}`,
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
};
|
|
69
|
+
return request.fetch(resolveUrl(path), { method, headers, data: options.data });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function postForm(
|
|
73
|
+
request: APIRequestContext,
|
|
74
|
+
path: string,
|
|
75
|
+
data: Record<string, string>,
|
|
76
|
+
options?: { headers?: Record<string, string> },
|
|
77
|
+
) {
|
|
78
|
+
const form = new URLSearchParams();
|
|
79
|
+
for (const [key, value] of Object.entries(data)) form.set(key, value);
|
|
80
|
+
return request.post(resolveUrl(path), {
|
|
81
|
+
headers: {
|
|
82
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
83
|
+
...(options?.headers ?? {}),
|
|
84
|
+
},
|
|
85
|
+
data: form.toString(),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { expect, type APIRequestContext } from '@playwright/test';
|
|
2
|
+
import { apiRequest } from './api';
|
|
3
|
+
|
|
4
|
+
export async function createApiKeyFixture(
|
|
5
|
+
request: APIRequestContext,
|
|
6
|
+
token: string,
|
|
7
|
+
name: string,
|
|
8
|
+
): Promise<{ id: string; secret: string }> {
|
|
9
|
+
const response = await apiRequest(request, 'POST', '/api/api_keys/keys', {
|
|
10
|
+
token,
|
|
11
|
+
data: { name },
|
|
12
|
+
});
|
|
13
|
+
expect(response.ok(), `Failed to create API key fixture: ${response.status()}`).toBeTruthy();
|
|
14
|
+
const body = (await response.json()) as { id?: string; secret?: string };
|
|
15
|
+
expect(typeof body.id === 'string' && body.id.length > 0).toBeTruthy();
|
|
16
|
+
return { id: body.id as string, secret: body.secret ?? '' };
|
|
17
|
+
}
|
|
@@ -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
|
+
|