feat(admin): Playwright E2E каркас — TESTIDS, login/shell/roles, CI mock. Refs EventHub/EventHubFrontAdmin#33 #34 #35
CI / test (push) Failing after 14m2s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s

This commit is contained in:
2026-07-14 22:18:34 +03:00
parent 2a32465050
commit 87c6b55e81
19 changed files with 493 additions and 1462 deletions
+26
View File
@@ -0,0 +1,26 @@
# E2E testid contract (EventHubFrontAdmin)
Селекторы: **`data-testid`** + a11y (`getByRole` / `getByLabel`) где имя стабильно.
## Naming
| Pattern | Example |
|---------|---------|
| `page-{name}` | `page-login`, `page-users` |
| `nav-{slug}` | `nav-users` (slug = path without `/`) |
| `{entity}-row-{id}` | `user-row-{uuid}` |
| `{entity}-detail` | `user-detail` |
| `{entity}-filter-{field}` | `event-filter-status` |
| `btn-{action}` | `btn-login`, `btn-save`, `btn-delete-confirm` |
| `dialog-{name}` | `dialog-edit-user` |
| `menu-user` | avatar dropdown |
| `palette-input` | command palette |
Helper in app: `src/lib/testid.ts``testid(['user-row', id])`.
## Rules
- kebab-case only
- Dynamic id segment = real API entity id
- Prefer roles/labels for login email/password and primary submit when i18n fixed to `ru` in mock suite
- Do not decorate every layout div — only interaction anchors
+10
View File
@@ -0,0 +1,10 @@
import type { Page } from '@playwright/test';
import { MOCK_ADMIN } from '../mocks/adminApi';
export async function loginAsE2EAdmin(page: Page) {
await page.goto('/login');
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
await page.getByLabel(/пароль|password/i).fill('e2e-pass');
await page.getByTestId('btn-login').click();
await page.waitForURL(/\/(dashboard|inbox)/);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../../src/utils/constants';
import { installAdminApiMocks, MOCK_ADMIN, type MockAdmin } from '../mocks/adminApi';
export async function seedAuthenticatedSession(page: Page, admin: Partial<MockAdmin> = {}) {
const user: MockAdmin = { ...MOCK_ADMIN, ...admin };
await installAdminApiMocks(page, { user });
// Origin + localStorage before app boot (addInitScript alone races under load).
await page.goto('/login');
await page.evaluate(
([accessKey, refreshKey]) => {
localStorage.setItem(accessKey, 'e2e-access-token');
localStorage.setItem(refreshKey, 'e2e-refresh-token');
},
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY] as [string, string]
);
return user;
}
+116
View File
@@ -0,0 +1,116 @@
import type { Page, Route } from '@playwright/test';
export type MockAdmin = {
id: string;
email: string;
nickname: string;
role: string;
language: string;
avatar_url: string | null;
};
export const MOCK_ADMIN: MockAdmin = {
id: 'admin-e2e-1',
email: 'e2e@eventhub.local',
nickname: 'E2E Admin',
role: 'superadmin',
language: 'ru',
avatar_url: null,
};
async function json(route: Route, status: number, body: unknown, headers: Record<string, string> = {}) {
await route.fulfill({
status,
contentType: 'application/json',
headers,
body: JSON.stringify(body),
});
}
type MockOptions = {
user?: MockAdmin;
};
/** Default admin API mocks for Control Center shell / login. */
export async function installAdminApiMocks(page: Page, options: MockOptions = {}) {
let currentUser: MockAdmin = { ...MOCK_ADMIN, ...options.user };
await page.route('**/v1/admin/**', async (route) => {
const req = route.request();
const method = req.method();
const url = new URL(req.url());
const path = url.pathname;
if (method === 'POST' && path.endsWith('/v1/admin/login')) {
const payload = req.postDataJSON() as { email?: string; password?: string };
if (payload?.email === MOCK_ADMIN.email && payload?.password === 'e2e-pass') {
await json(route, 200, {
token: 'e2e-access-token',
refresh_token: 'e2e-refresh-token',
user: currentUser,
});
return;
}
await json(route, 401, { error: 'invalid_credentials' });
return;
}
if (method === 'GET' && path.endsWith('/v1/admin/me')) {
await json(route, 200, currentUser);
return;
}
if (method === 'PUT' && path.endsWith('/v1/admin/me')) {
const payload = (req.postDataJSON() || {}) as Partial<MockAdmin>;
currentUser = { ...currentUser, ...payload };
await json(route, 200, currentUser);
return;
}
if (method === 'GET' && path.endsWith('/v1/admin/health')) {
await json(route, 200, {
status: 'ok',
service: 'eventhub',
version: '0.1',
git_sha: 'e2etestsha000',
built_at: '2026-07-14T00:00:00Z',
});
return;
}
if (method === 'GET' && path.endsWith('/v1/admin/stats')) {
await json(route, 200, {
users_total: 10,
events_total: 5,
reviews_total: 2,
calendars_total: 3,
tickets_total: 1,
tickets_open: 1,
reports_total: 2,
reports_pending: 1,
reports_reviewed: 1,
avg_report_resolution_h: 1.5,
avg_ticket_resolution_h: 2,
events_by_day: [],
registrations_by_day: [],
reviews_by_day: [],
reports_by_day: [],
tickets_by_day: [],
subscriptions_by_day: [],
});
return;
}
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
await json(route, 200, { nodes: [] });
return;
}
if (method === 'GET') {
await json(route, 200, [], { 'X-Total-Count': '0' });
return;
}
await json(route, 200, { ok: true });
});
}
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
import { installAdminApiMocks, MOCK_ADMIN } from '../mocks/adminApi';
test.describe('auth / login (mock)', () => {
test.beforeEach(async ({ page }) => {
await installAdminApiMocks(page);
});
test('успешный вход ведёт на dashboard', async ({ page }) => {
await page.goto('/login');
await expect(page.getByTestId('page-login')).toBeVisible();
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
await page.getByLabel(/пароль|password/i).fill('e2e-pass');
await page.getByRole('button', { name: /войти|log in|sign in/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByTestId('page-dashboard')).toBeVisible();
});
test('неверный пароль показывает ошибку', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
await page.getByLabel(/пароль|password/i).fill('wrong');
await page.getByTestId('btn-login').click();
await expect(page.getByText(/неверный email или пароль|invalid/i)).toBeVisible();
await expect(page).toHaveURL(/\/login/);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { test } from '@playwright/test';
/** IFT smoke project placeholder — filled in phase 6. */
test.describe('IFT smoke', () => {
test.skip(!process.env.E2E_ADMIN_EMAIL, 'Set E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD for IFT');
test('placeholder', async () => {
// Implemented in E2E phase 6
});
});
+68
View File
@@ -0,0 +1,68 @@
import { test, expect } from '@playwright/test';
import { seedAuthenticatedSession } from '../helpers/session';
import { loginAsE2EAdmin } from '../helpers/auth';
import { installAdminApiMocks } from '../mocks/adminApi';
test.describe('shell / layout (mock)', () => {
test('после логина видны sidebar, nav dashboard и user menu', async ({ page }) => {
await installAdminApiMocks(page);
await loginAsE2EAdmin(page);
await expect(page.getByTestId('layout-shell')).toBeVisible();
await expect(page.getByTestId('layout-sidebar')).toBeVisible();
await expect(page.getByTestId('nav-dashboard')).toBeVisible();
await expect(page.getByTestId('nav-users')).toBeVisible();
await expect(page.getByTestId('nav-admins')).toBeVisible();
await page.getByTestId('menu-user').click();
await expect(page.getByTestId('menu-user-content')).toBeVisible();
await expect(page.getByTestId('menu-profile')).toBeVisible();
await expect(page.getByTestId('btn-logout')).toBeVisible();
});
test('command palette открывается и навигирует', async ({ page }) => {
await seedAuthenticatedSession(page);
await page.goto('/dashboard');
await expect(page.getByTestId('layout-shell')).toBeVisible();
await page.getByTestId('btn-open-palette').click();
await expect(page.getByTestId('command-palette')).toBeVisible();
await page.getByTestId('palette-input').fill('Пользователи');
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/\/users/);
});
test('logout возвращает на login', async ({ page }) => {
await seedAuthenticatedSession(page);
await page.goto('/dashboard');
await page.getByTestId('menu-user').click();
await page.getByTestId('btn-logout').click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByTestId('page-login')).toBeVisible();
});
});
test.describe('shell / roles (mock)', () => {
test('moderator: нет nav users, admins запрещён', async ({ page }) => {
await seedAuthenticatedSession(page, { role: 'moderator', nickname: 'Mod' });
await page.goto('/dashboard');
await expect(page.getByTestId('nav-inbox-reports')).toBeVisible();
await expect(page.getByTestId('nav-users')).toHaveCount(0);
await expect(page.getByTestId('nav-admins')).toHaveCount(0);
await page.goto('/admins');
await expect(page.getByTestId('page-forbidden')).toBeVisible();
await page.getByTestId('btn-to-dashboard').click();
await expect(page).toHaveURL(/\/dashboard/);
});
test('support: tickets inbox есть, reports inbox нет', async ({ page }) => {
await seedAuthenticatedSession(page, { role: 'support', nickname: 'Support' });
await page.goto('/dashboard');
await expect(page.getByTestId('nav-inbox-tickets')).toBeVisible();
await expect(page.getByTestId('nav-inbox-reports')).toHaveCount(0);
await page.goto('/inbox/reports');
await expect(page.getByTestId('page-forbidden')).toBeVisible();
});
});