Skip to content

Commit 14c6598

Browse files
committed
test(admin): isolate UI E2E contracts from backend
1 parent 11c37c5 commit 14c6598

7 files changed

Lines changed: 259 additions & 0 deletions

File tree

docker-compose.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,24 @@ services:
123123
- php-swoole
124124
command: ["sh", "-lc", "npm ci && npm run test:e2e:static"]
125125

126+
ui-e2e-ui:
127+
image: mcr.microsoft.com/playwright:v1.61.1-noble
128+
profiles:
129+
- ui-e2e
130+
working_dir: /workspace/ui
131+
volumes:
132+
- .:/workspace:ro
133+
command:
134+
- sh
135+
- -lc
136+
- |
137+
workspace=$$(mktemp -d)
138+
trap 'rm -rf "$$workspace"' EXIT
139+
tar --exclude=node_modules -C /workspace/ui -cf - . | tar -C "$$workspace" -xf -
140+
cd "$$workspace"
141+
npm ci --silent
142+
npm run test:e2e:admin:ui
143+
126144
redis:
127145
restart: always
128146
image: valkey/valkey:latest

ui/angular.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@
123123
},
124124
"development": {
125125
"buildTarget": "psfs-admin:build:development"
126+
},
127+
"e2e": {
128+
"buildTarget": "psfs-admin:build:development",
129+
"proxyConfig": "projects/admin/proxy.e2e.json"
126130
}
127131
},
128132
"defaultConfiguration": "development"

ui/e2e/mock-api.mjs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { createServer } from 'node:http';
2+
3+
const csrfToken = 'ui-e2e-csrf';
4+
5+
const menu = [{
6+
module: 'PSFS',
7+
items: [
8+
{ label: 'General configuration', icon: 'cog', path: '/config' },
9+
{ label: 'User management', icon: 'users', path: '/setup' },
10+
{ label: 'Generate module', icon: 'layer', path: '/module' },
11+
{ label: 'System routes', icon: 'routes', path: '/routes' },
12+
{ label: 'API documentation', icon: 'book', path: '/api/docs' }
13+
]
14+
}];
15+
16+
const configurationForm = {
17+
name: 'config',
18+
title: 'General configuration',
19+
fields: {
20+
'db.host': { name: 'db.host', label: 'Database host', value: 'localhost', required: true },
21+
'db.password': { name: 'db.password', label: 'Database password', type: 'password', value: '', preserveIfEmpty: true },
22+
debug: { name: 'debug', label: 'Debug mode', type: 'checkbox', value: false }
23+
}
24+
};
25+
26+
const usersForm = {
27+
name: 'users',
28+
title: 'New user',
29+
fields: {
30+
username: { name: 'username', label: 'Username', required: true },
31+
password: { name: 'password', label: 'Password', type: 'password', required: true },
32+
role: { name: 'role', label: 'Role', type: 'select', value: 'admin', options: { admin: 'Administrator' } }
33+
}
34+
};
35+
36+
const modulesForm = {
37+
name: 'modules',
38+
title: 'Module generator',
39+
fields: {
40+
module: { name: 'module', label: 'Module', required: true },
41+
controllerType: { name: 'controllerType', label: 'Controller type', type: 'select', value: 'api', options: { api: 'API controller', web: 'Web controller' } }
42+
}
43+
};
44+
45+
function envelope(data, message = null) {
46+
return { ok: true, message, data, errors: {} };
47+
}
48+
49+
function error(message, errors = {}) {
50+
return { ok: false, message, data: null, errors };
51+
}
52+
53+
function response(reply, status, body) {
54+
reply.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
55+
reply.end(JSON.stringify(body));
56+
}
57+
58+
async function bodyOf(request) {
59+
let content = '';
60+
for await (const chunk of request) content += chunk;
61+
return content ? JSON.parse(content) : {};
62+
}
63+
64+
export function createMockApiServer() {
65+
const state = { users: [{ username: 'admin', role: 'Administrator', class: 'admin' }] };
66+
const server = createServer(async (request, reply) => {
67+
const url = new URL(request.url ?? '/', 'http://ui-e2e.local');
68+
const path = url.pathname;
69+
const method = request.method ?? 'GET';
70+
const locale = request.headers['x-api-lang'] === 'es_ES' ? 'es_ES' : 'en_US';
71+
const mutating = ['POST', 'PUT', 'DELETE'].includes(method);
72+
73+
if (!path.startsWith('/admin/api/v2/')) {
74+
response(reply, 404, error('Unknown UI mock route.'));
75+
return;
76+
}
77+
if (mutating && request.headers['x-psfs-csrf'] !== csrfToken) {
78+
response(reply, 403, error('Invalid CSRF token.'));
79+
return;
80+
}
81+
82+
if (method === 'GET' && path === '/admin/api/v2/bootstrap') {
83+
response(reply, 200, { identity: { username: 'admin', role: 'Administrator' }, locale, locales: ['en_US', 'es_ES'], menu, csrfToken });
84+
return;
85+
}
86+
if (method === 'PUT' && /^\/admin\/api\/v2\/locale\/[a-z]{2}_[A-Z]{2}$/.test(path)) {
87+
response(reply, 200, envelope({ locale: path.split('/').at(-1) }));
88+
return;
89+
}
90+
if (method === 'GET' && path === '/admin/api/v2/routes') {
91+
response(reply, 200, envelope({ routes: [{ slug: 'admin-v2', route: '/admin-v2/routes' }, { slug: 'api-v2', route: '/admin/api/v2/bootstrap' }] }));
92+
return;
93+
}
94+
if (method === 'POST' && path === '/admin/api/v2/routes/regenerate') {
95+
response(reply, 200, envelope({ regenerated: true }, locale === 'es_ES' ? 'Rutas regeneradas.' : 'Routes generated successfully'));
96+
return;
97+
}
98+
if (method === 'GET' && path === '/admin/api/v2/config') {
99+
response(reply, 200, envelope({ form: configurationForm, suggestions: ['custom.runtime.flag'] }));
100+
return;
101+
}
102+
if (method === 'PUT' && path === '/admin/api/v2/config') {
103+
response(reply, 200, envelope({ changed: ['db.host'] }, 'Configuración actualizada.'));
104+
return;
105+
}
106+
if (method === 'GET' && path === '/admin/api/v2/docs') {
107+
response(reply, 200, envelope({ domains: ['client'], documentPaths: { client: '/CLIENT/api/doc' } }));
108+
return;
109+
}
110+
if (method === 'GET' && path === '/admin/api/v2/modules/schema') {
111+
response(reply, 200, envelope({ form: modulesForm }));
112+
return;
113+
}
114+
if (method === 'POST' && path === '/admin/api/v2/modules') {
115+
const payload = await bodyOf(request);
116+
const module = payload.values?.module ?? '';
117+
if (!module) {
118+
response(reply, 422, error('Invalid module.', { module: ['Required'] }));
119+
} else {
120+
response(reply, 200, envelope({ module }, `Module ${module} generated.`));
121+
}
122+
return;
123+
}
124+
if (method === 'GET' && path === '/admin/api/v2/users') {
125+
response(reply, 200, envelope({ users: state.users, form: usersForm, profiles: { admin: 'Administrator' } }));
126+
return;
127+
}
128+
if (method === 'POST' && path === '/admin/api/v2/users') {
129+
const payload = await bodyOf(request);
130+
const username = payload.values?.username?.trim();
131+
if (!username || !payload.values?.password) {
132+
response(reply, 422, error('Invalid user.', { username: !username ? ['Required'] : [], password: !payload.values?.password ? ['Required'] : [] }));
133+
} else {
134+
state.users.push({ username, role: 'Administrator', class: 'admin' });
135+
response(reply, 200, envelope({}, 'Usuario creado correctamente.'));
136+
}
137+
return;
138+
}
139+
if (method === 'DELETE' && path === '/admin/api/v2/users') {
140+
const payload = await bodyOf(request);
141+
state.users = state.users.filter((user) => user.username !== payload.user);
142+
response(reply, 200, envelope({}, 'Usuario eliminado correctamente.'));
143+
return;
144+
}
145+
146+
response(reply, 404, error('Unknown Admin v2 contract.'));
147+
});
148+
149+
return {
150+
async listen(port = 4310) {
151+
await new Promise((resolve, reject) => server.once('error', reject).listen(port, '127.0.0.1', resolve));
152+
return server.address();
153+
},
154+
close: () => new Promise((resolve, reject) => server.close((failure) => failure ? reject(failure) : resolve()))
155+
};
156+
}
157+
158+
if (process.argv[1] && new URL(`file://${process.argv[1]}`).href === import.meta.url) {
159+
const api = createMockApiServer();
160+
const address = await api.listen(Number(process.env.PSFS_UI_MOCK_PORT ?? 4310));
161+
process.stdout.write(`PSFS UI mock API listening on ${address.port}\n`);
162+
process.on('SIGTERM', () => api.close().then(() => process.exit(0)));
163+
process.on('SIGINT', () => api.close().then(() => process.exit(0)));
164+
}

ui/e2e/mock-api.spec.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import assert from 'node:assert/strict';
2+
import { after, before, test } from 'node:test';
3+
import { createMockApiServer } from './mock-api.mjs';
4+
5+
let api;
6+
let baseUrl;
7+
8+
before(async () => {
9+
api = createMockApiServer();
10+
const address = await api.listen(0);
11+
baseUrl = `http://127.0.0.1:${address.port}`;
12+
});
13+
14+
after(() => api.close());
15+
16+
test('expone contratos Admin v2 sin depender de PSFS', async () => {
17+
const response = await fetch(`${baseUrl}/admin/api/v2/bootstrap`);
18+
const body = await response.json();
19+
20+
assert.equal(response.status, 200);
21+
assert.equal(body.identity.username, 'admin');
22+
assert.equal(typeof body.csrfToken, 'string');
23+
assert.ok(Array.isArray(body.menu));
24+
});
25+
26+
test('mantiene las mutaciones en memoria y exige CSRF', async () => {
27+
const rejected = await fetch(`${baseUrl}/admin/api/v2/routes/regenerate`, { method: 'POST' });
28+
assert.equal(rejected.status, 403);
29+
30+
const accepted = await fetch(`${baseUrl}/admin/api/v2/routes/regenerate`, {
31+
method: 'POST',
32+
headers: { 'x-psfs-csrf': 'ui-e2e-csrf' }
33+
});
34+
const body = await accepted.json();
35+
36+
assert.equal(accepted.status, 200);
37+
assert.equal(body.data.regenerated, true);
38+
});

ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"watch": "npm run watch:ui",
77
"watch:ui": "ng serve --host 0.0.0.0 --port 4200 --serve-path /ui/ --hmr",
88
"watch:admin": "ng serve psfs-admin --host 0.0.0.0 --port 4200 --serve-path /admin-v2/ --hmr",
9+
"watch:admin:e2e": "ng serve psfs-admin --configuration e2e --host 127.0.0.1 --port 4200 --serve-path /admin-v2/",
910
"build": "npm run build:ui",
1011
"build:ui": "ng build",
1112
"build:admin": "ng build psfs-admin",
@@ -14,6 +15,7 @@
1415
"test:admin:coverage": "ng test psfs-admin --watch=false --coverage",
1516
"test:e2e": "playwright test e2e/ui-development.spec.mjs",
1617
"test:e2e:admin": "playwright test e2e/admin-v2.spec.mjs e2e/admin-v2-config.spec.mjs e2e/admin-v2-docs.spec.mjs e2e/admin-v2-locale.spec.mjs e2e/admin-v2-modules.spec.mjs e2e/admin-v2-static.spec.mjs e2e/admin-v2-users.spec.mjs",
18+
"test:e2e:admin:ui": "node --test e2e/mock-api.spec.mjs && playwright test --config playwright.ui.config.mjs e2e/admin-v2.spec.mjs e2e/admin-v2-config.spec.mjs e2e/admin-v2-docs.spec.mjs e2e/admin-v2-locale.spec.mjs e2e/admin-v2-modules.spec.mjs e2e/admin-v2-users.spec.mjs",
1719
"test:e2e:admin:hmr": "playwright test e2e/admin-v2-hmr.spec.mjs",
1820
"test:e2e:static": "sh scripts/run-static-e2e.sh"
1921
},

ui/playwright.ui.config.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './e2e',
5+
outputDir: './test-results/ui',
6+
fullyParallel: false,
7+
workers: 1,
8+
retries: 0,
9+
timeout: 30_000,
10+
reporter: 'line',
11+
use: { baseURL: 'http://127.0.0.1:4200' },
12+
webServer: [
13+
{
14+
command: 'node e2e/mock-api.mjs',
15+
url: 'http://127.0.0.1:4310/admin/api/v2/bootstrap',
16+
reuseExistingServer: false,
17+
timeout: 30_000
18+
},
19+
{
20+
command: 'npm run watch:admin:e2e',
21+
url: 'http://127.0.0.1:4200/admin-v2/',
22+
reuseExistingServer: false,
23+
timeout: 60_000
24+
}
25+
]
26+
});

ui/projects/admin/proxy.e2e.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"/admin/api/v2": {
3+
"target": "http://127.0.0.1:4310",
4+
"secure": false,
5+
"changeOrigin": true
6+
}
7+
}

0 commit comments

Comments
 (0)