feat(admin): E2E content/system mock-сьюты, row testids, IFT smoke. Refs EventHub/EventHubFrontAdmin#33 #37 #38 #39

This commit is contained in:
2026-07-14 22:44:22 +03:00
parent a3468518b3
commit 836e4a7eea
23 changed files with 831 additions and 223 deletions
+12 -19
View File
@@ -1,5 +1,7 @@
import type { Page, Route } from '@playwright/test';
import { createModerationSeed, type ModerationState } from './moderationSeed';
import { createContentSeed } from './contentSeed';
import { tryHandleContentApi } from './handleContentApi';
export type MockAdmin = {
id: string;
@@ -43,6 +45,7 @@ type MockOptions = {
export async function installAdminApiMocks(page: Page, options: MockOptions = {}) {
let currentUser: MockAdmin = { ...MOCK_ADMIN, ...options.user };
const moderation: ModerationState | null = options.moderation === false ? null : createModerationSeed();
const content = createContentSeed();
await page.route('**/v1/admin/**', async (route) => {
const req = route.request();
@@ -111,6 +114,12 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
}
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
// Prefer content seed with sample node (monitoring)
if (
await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())
) {
return;
}
await json(route, 200, { nodes: [] });
return;
}
@@ -267,26 +276,10 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
return;
}
}
}
// soft stubs for linked entities in inbox
if (method === 'GET' && /\/v1\/admin\/users\/[^/]+$/.test(path)) {
await json(route, 200, {
id: 'user-1',
email: 'user1@eventhub.local',
nickname: 'User One',
role: 'user',
status: 'active',
});
return;
}
if (method === 'GET' && /\/v1\/admin\/events\/[^/]+$/.test(path)) {
await json(route, 200, {
id: 'evt-1',
title: 'E2E Event',
status: 'active',
});
return;
}
if (await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())) {
return;
}
if (method === 'GET') {
+148
View File
@@ -0,0 +1,148 @@
/** Content/system seed data for mock E2E (users, calendars, events, …). */
const now = '2026-07-14T12:00:00Z';
export function createContentSeed() {
const users = [
{
id: 'user-e2e-1',
email: 'alice@eventhub.local',
nickname: 'Alice',
role: 'user',
status: 'active',
language: 'ru',
phone: null,
avatar_url: null,
last_login: now,
created_at: now,
updated_at: now,
},
{
id: 'user-e2e-2',
email: 'bob@eventhub.local',
nickname: 'Bob',
role: 'user',
status: 'frozen',
language: 'en',
phone: null,
avatar_url: null,
last_login: now,
created_at: now,
updated_at: now,
},
];
const calendars = [
{
id: 'cal-e2e-1',
title: 'E2E Calendar',
description: 'for tests',
type: 'personal',
category: 'sport',
owner_id: 'user-e2e-1',
status: 'active',
reason: null,
tags: [],
color: '#3366ff',
image_url: '',
rating_avg: 4.5,
rating_count: 2,
short_name: 'e2ecal',
confirmation: 'none',
settings: null,
created_at: now,
updated_at: now,
},
];
const events = [
{
id: 'evt-e2e-1',
calendar_id: 'cal-e2e-1',
title: 'E2E Workout',
description: 'test event',
event_type: 'single',
start_time: '2026-01-01T10:00:00Z',
duration: 60,
recurrence: null,
master_id: null,
is_instance: false,
specialist_id: null,
location: null,
tags: [],
capacity: 10,
online_link: null,
status: 'active',
reason: null,
rating_avg: 5,
rating_count: 1,
created_at: now,
updated_at: now,
},
];
const subscriptions = [
{
id: 'sub-e2e-1',
user_id: 'user-e2e-1',
plan: 'pro',
status: 'active',
started_at: now,
expires_at: '2027-01-01T00:00:00Z',
auto_renew: true,
created_at: now,
updated_at: now,
},
];
const bannedWords = [
{ word: 'spamword', added_by: 'admin-e2e-1', created_at: now },
{ word: 'badword', added_by: 'admin-e2e-1', created_at: now },
];
const admins = [
{
id: 'admin-e2e-1',
email: 'e2e@eventhub.local',
nickname: 'E2E Admin',
role: 'superadmin',
language: 'ru',
phone: null,
avatar_url: null,
timezone: 'Europe/Moscow',
created_at: now,
updated_at: now,
last_login: now,
},
{
id: 'admin-e2e-2',
email: 'mod@eventhub.local',
nickname: 'Mod Admin',
role: 'moderator',
language: 'ru',
phone: null,
avatar_url: null,
timezone: 'UTC',
created_at: now,
updated_at: now,
last_login: now,
},
];
const audit = [
{
id: 'audit-e2e-1',
admin_id: 'admin-e2e-1',
action: 'user.update',
target_type: 'user',
target_id: 'user-e2e-1',
details: { status: 'frozen' },
ip: '127.0.0.1',
created_at: now,
},
];
return { users, calendars, events, subscriptions, bannedWords, admins, audit };
}
export type ContentState = ReturnType<typeof createContentSeed>;
+313
View File
@@ -0,0 +1,313 @@
import type { Route } from '@playwright/test';
import type { ContentState } from './contentSeed';
type JsonFn = (
route: Route,
status: number,
body: unknown,
headers?: Record<string, string>
) => Promise<void>;
function listSlice<T>(items: T[], url: URL): { rows: T[]; total: number } {
const limit = Number(url.searchParams.get('limit') || 50);
const offset = Number(url.searchParams.get('offset') || 0);
const q = (url.searchParams.get('q') || '').toLowerCase();
let rows = items;
if (q) {
rows = items.filter((item) => JSON.stringify(item).toLowerCase().includes(q));
}
const status = url.searchParams.get('status');
if (status && rows.length && typeof (rows[0] as { status?: string }).status === 'string') {
rows = rows.filter((item) => (item as { status?: string }).status === status);
}
const plan = url.searchParams.get('plan');
if (plan) {
rows = rows.filter((item) => (item as { plan?: string }).plan === plan);
}
const type = url.searchParams.get('type');
if (type) {
rows = rows.filter((item) => (item as { type?: string }).type === type);
}
return { rows: rows.slice(offset, offset + limit), total: rows.length };
}
/** Returns true if the request was handled. */
export async function tryHandleContentApi(
route: Route,
method: string,
path: string,
url: URL,
content: ContentState,
json: JsonFn,
postData: () => unknown
): Promise<boolean> {
// ---- users ----
if (method === 'GET' && path.endsWith('/v1/admin/users/stats')) {
await json(route, 200, {
total_users: content.users.length,
users_by_role: {},
users_by_status: {},
pending_users: 0,
});
return true;
}
if (method === 'GET' && path.endsWith('/v1/admin/users')) {
const page = listSlice(content.users, url);
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
return true;
}
{
const m = path.match(/\/v1\/admin\/users\/([^/]+)$/);
if (m) {
const id = decodeURIComponent(m[1]);
const idx = content.users.findIndex((u) => u.id === id);
if (idx < 0) {
await json(route, 404, { error: 'not_found' });
return true;
}
if (method === 'GET') {
await json(route, 200, content.users[idx]);
return true;
}
if (method === 'PUT') {
content.users[idx] = { ...content.users[idx], ...(postData() as object) };
await json(route, 200, content.users[idx]);
return true;
}
if (method === 'DELETE') {
content.users.splice(idx, 1);
await json(route, 200, { ok: true });
return true;
}
}
}
// ---- calendars ----
if (method === 'GET' && path.endsWith('/v1/admin/calendars/stats')) {
await json(route, 200, {
total_calendars: content.calendars.length,
calendars_by_type: {},
calendars_by_status: {},
top_calendars_by_reviews: [],
top_calendars_by_positive_reviews: [],
top_calendars_by_negative_reviews: [],
top_calendars_by_rating: [],
});
return true;
}
if (method === 'GET' && path.endsWith('/v1/admin/calendars')) {
const page = listSlice(content.calendars, url);
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
return true;
}
{
const m = path.match(/\/v1\/admin\/calendars\/([^/]+)$/);
if (m) {
const id = decodeURIComponent(m[1]);
const idx = content.calendars.findIndex((c) => c.id === id);
if (idx < 0) {
await json(route, 404, { error: 'not_found' });
return true;
}
if (method === 'GET') {
await json(route, 200, content.calendars[idx]);
return true;
}
if (method === 'PUT') {
content.calendars[idx] = { ...content.calendars[idx], ...(postData() as object) };
await json(route, 200, content.calendars[idx]);
return true;
}
if (method === 'DELETE') {
content.calendars.splice(idx, 1);
await json(route, 200, { ok: true });
return true;
}
}
}
// ---- events ----
if (method === 'GET' && path.endsWith('/v1/admin/events/stats')) {
await json(route, 200, {
total_events: content.events.length,
events_by_type: {},
events_by_status: {},
top_events_by_rating: [],
});
return true;
}
if (method === 'GET' && path.endsWith('/v1/admin/events')) {
const page = listSlice(content.events, url);
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
return true;
}
{
const m = path.match(/\/v1\/admin\/events\/([^/]+)$/);
if (m) {
const id = decodeURIComponent(m[1]);
const idx = content.events.findIndex((e) => e.id === id);
if (idx < 0) {
await json(route, 404, { error: 'not_found' });
return true;
}
if (method === 'GET') {
await json(route, 200, content.events[idx]);
return true;
}
if (method === 'PUT') {
content.events[idx] = { ...content.events[idx], ...(postData() as object) };
await json(route, 200, content.events[idx]);
return true;
}
if (method === 'DELETE') {
content.events.splice(idx, 1);
await json(route, 200, { ok: true });
return true;
}
}
}
// ---- subscriptions ----
if (method === 'GET' && path.endsWith('/v1/admin/subscriptions/stats')) {
await json(route, 200, {
total_subscriptions: content.subscriptions.length,
subscriptions_by_plan: {},
subscriptions_by_status: {},
trial_subscriptions: 0,
ending_paid_subscriptions: [],
});
return true;
}
if (method === 'GET' && path.endsWith('/v1/admin/subscriptions')) {
const page = listSlice(content.subscriptions, url);
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
return true;
}
{
const m = path.match(/\/v1\/admin\/subscriptions\/([^/]+)$/);
if (m) {
const id = decodeURIComponent(m[1]);
const idx = content.subscriptions.findIndex((s) => s.id === id);
if (idx < 0) {
await json(route, 404, { error: 'not_found' });
return true;
}
if (method === 'GET') {
await json(route, 200, content.subscriptions[idx]);
return true;
}
if (method === 'PUT') {
content.subscriptions[idx] = { ...content.subscriptions[idx], ...(postData() as object) };
await json(route, 200, content.subscriptions[idx]);
return true;
}
if (method === 'DELETE') {
content.subscriptions.splice(idx, 1);
await json(route, 200, { ok: true });
return true;
}
}
}
// ---- banned words ----
if (method === 'GET' && path.endsWith('/v1/admin/banned-words')) {
const page = listSlice(
content.bannedWords.map((w) => ({ ...w, id: w.word })),
url
);
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
return true;
}
if (method === 'POST' && path.endsWith('/v1/admin/banned-words')) {
const payload = postData() as { word?: string };
const word = (payload.word || '').trim();
if (word) content.bannedWords.push({ word, added_by: 'admin-e2e-1', created_at: '2026-07-14T12:00:00Z' });
await json(route, 200, { ok: true });
return true;
}
{
const m = path.match(/\/v1\/admin\/banned-words\/([^/]+)$/);
if (m && method === 'DELETE') {
const word = decodeURIComponent(m[1]);
content.bannedWords = content.bannedWords.filter((w) => w.word !== word);
await json(route, 200, { ok: true });
return true;
}
}
// ---- admins ----
if (method === 'GET' && path.endsWith('/v1/admin/admins')) {
const page = listSlice(content.admins, url);
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
return true;
}
if (method === 'POST' && path.endsWith('/v1/admin/admins')) {
const payload = postData() as Record<string, unknown>;
const created = {
id: `admin-new-${content.admins.length + 1}`,
email: String(payload.email || 'new@eventhub.local'),
nickname: String(payload.nickname || 'New Admin'),
role: String(payload.role || 'admin'),
language: 'ru',
phone: null,
avatar_url: null,
timezone: 'UTC',
created_at: '2026-07-14T12:00:00Z',
updated_at: '2026-07-14T12:00:00Z',
last_login: null,
};
content.admins.push(created);
await json(route, 200, created);
return true;
}
{
const m = path.match(/\/v1\/admin\/admins\/([^/]+)$/);
if (m) {
const id = decodeURIComponent(m[1]);
const idx = content.admins.findIndex((a) => a.id === id);
if (idx < 0) {
await json(route, 404, { error: 'not_found' });
return true;
}
if (method === 'GET') {
await json(route, 200, content.admins[idx]);
return true;
}
if (method === 'PUT') {
content.admins[idx] = { ...content.admins[idx], ...(postData() as object) };
await json(route, 200, content.admins[idx]);
return true;
}
if (method === 'DELETE') {
content.admins.splice(idx, 1);
await json(route, 200, { ok: true });
return true;
}
}
}
// ---- audit ----
if (method === 'GET' && path.endsWith('/v1/admin/audit')) {
const page = listSlice(content.audit, url);
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
return true;
}
// ---- monitoring enrichment ----
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
await json(route, 200, {
nodes: [
{
node: 'node-a',
cpu: 12,
memory_used_mb: 512,
memory_total_mb: 2048,
ts: '2026-07-14T12:00:00Z',
},
],
});
return true;
}
return false;
}