295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
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;
|
|
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),
|
|
});
|
|
}
|
|
|
|
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);
|
|
return { rows: items.slice(offset, offset + limit), total: items.length };
|
|
}
|
|
|
|
type MockOptions = {
|
|
user?: MockAdmin;
|
|
moderation?: boolean;
|
|
};
|
|
|
|
/** Default admin API mocks for Control Center shell / login / moderation. */
|
|
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();
|
|
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: moderation?.reviews.length ?? 0,
|
|
calendars_total: 3,
|
|
tickets_total: moderation?.tickets.length ?? 0,
|
|
tickets_open: moderation?.tickets.filter((t) => t.status === 'open').length ?? 0,
|
|
reports_total: moderation?.reports.length ?? 0,
|
|
reports_pending: moderation?.reports.filter((r) => r.status === 'pending').length ?? 0,
|
|
reports_reviewed: moderation?.reports.filter((r) => r.status === 'reviewed').length ?? 0,
|
|
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')) {
|
|
// 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;
|
|
}
|
|
|
|
if (moderation) {
|
|
// ---- reports ----
|
|
if (method === 'GET' && path.endsWith('/v1/admin/reports/stats')) {
|
|
await json(route, 200, {
|
|
total_reports: moderation.reports.length,
|
|
reports_by_target_type: {},
|
|
reports_by_status: {
|
|
pending: moderation.reports.filter((r) => r.status === 'pending').length,
|
|
reviewed: moderation.reports.filter((r) => r.status === 'reviewed').length,
|
|
dismissed: moderation.reports.filter((r) => r.status === 'dismissed').length,
|
|
},
|
|
top_targets_by_reports: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === 'GET' && path.endsWith('/v1/admin/reports')) {
|
|
let rows = [...moderation.reports];
|
|
const status = url.searchParams.get('status');
|
|
if (status) rows = rows.filter((r) => r.status === status);
|
|
const pageRows = listSlice(rows, url);
|
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
|
return;
|
|
}
|
|
|
|
const reportMatch = path.match(/\/v1\/admin\/reports\/([^/]+)$/);
|
|
if (reportMatch) {
|
|
const id = decodeURIComponent(reportMatch[1]);
|
|
const idx = moderation.reports.findIndex((r) => r.id === id);
|
|
if (idx < 0) {
|
|
await json(route, 404, { error: 'not_found' });
|
|
return;
|
|
}
|
|
if (method === 'GET') {
|
|
await json(route, 200, moderation.reports[idx]);
|
|
return;
|
|
}
|
|
if (method === 'PUT') {
|
|
const payload = (req.postDataJSON() || {}) as { status?: MockReportStatus };
|
|
moderation.reports[idx] = {
|
|
...moderation.reports[idx],
|
|
...payload,
|
|
resolved_at: payload.status ? '2026-07-14T13:00:00Z' : moderation.reports[idx].resolved_at,
|
|
resolved_by: payload.status ? currentUser.id : moderation.reports[idx].resolved_by,
|
|
};
|
|
await json(route, 200, moderation.reports[idx]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ---- tickets ----
|
|
if (method === 'GET' && path.endsWith('/v1/admin/tickets/stats')) {
|
|
await json(route, 200, {
|
|
total_tickets: moderation.tickets.length,
|
|
open: moderation.tickets.filter((t) => t.status === 'open').length,
|
|
in_progress: moderation.tickets.filter((t) => t.status === 'in_progress').length,
|
|
resolved: moderation.tickets.filter((t) => t.status === 'resolved').length,
|
|
closed: moderation.tickets.filter((t) => t.status === 'closed').length,
|
|
total_errors: moderation.tickets.reduce((s, t) => s + t.count, 0),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === 'GET' && path.endsWith('/v1/admin/tickets')) {
|
|
const pageRows = listSlice(moderation.tickets, url);
|
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
|
return;
|
|
}
|
|
|
|
const ticketMatch = path.match(/\/v1\/admin\/tickets\/([^/]+)$/);
|
|
if (ticketMatch) {
|
|
const id = decodeURIComponent(ticketMatch[1]);
|
|
const idx = moderation.tickets.findIndex((t) => t.id === id);
|
|
if (idx < 0) {
|
|
await json(route, 404, { error: 'not_found' });
|
|
return;
|
|
}
|
|
if (method === 'GET') {
|
|
await json(route, 200, moderation.tickets[idx]);
|
|
return;
|
|
}
|
|
if (method === 'PUT') {
|
|
const payload = (req.postDataJSON() || {}) as Partial<(typeof moderation.tickets)[0]>;
|
|
moderation.tickets[idx] = { ...moderation.tickets[idx], ...payload };
|
|
await json(route, 200, moderation.tickets[idx]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ---- reviews ----
|
|
if (method === 'GET' && path.endsWith('/v1/admin/reviews/stats')) {
|
|
await json(route, 200, {
|
|
total_reviews: moderation.reviews.length,
|
|
reviews_by_target_type: {},
|
|
reviews_by_status: {
|
|
visible: moderation.reviews.filter((r) => r.status === 'visible').length,
|
|
hidden: moderation.reviews.filter((r) => r.status === 'hidden').length,
|
|
deleted: moderation.reviews.filter((r) => r.status === 'deleted').length,
|
|
},
|
|
top_targets_by_reviews: [],
|
|
top_targets_by_positive_reviews: [],
|
|
top_targets_by_negative_reviews: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === 'GET' && path.endsWith('/v1/admin/reviews')) {
|
|
const pageRows = listSlice(moderation.reviews, url);
|
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
|
return;
|
|
}
|
|
|
|
if (method === 'PATCH' && path.endsWith('/v1/admin/reviews')) {
|
|
const updates = (req.postDataJSON() || []) as Array<{ id: string; status: string; reason?: string }>;
|
|
for (const u of updates) {
|
|
const idx = moderation.reviews.findIndex((r) => r.id === u.id);
|
|
if (idx >= 0) {
|
|
moderation.reviews[idx] = {
|
|
...moderation.reviews[idx],
|
|
status: u.status as (typeof moderation.reviews)[0]['status'],
|
|
reason: u.reason ?? moderation.reviews[idx].reason,
|
|
updated_at: '2026-07-14T13:00:00Z',
|
|
};
|
|
}
|
|
}
|
|
await json(route, 200, updates.length);
|
|
return;
|
|
}
|
|
|
|
const reviewMatch = path.match(/\/v1\/admin\/reviews\/([^/]+)$/);
|
|
if (reviewMatch) {
|
|
const id = decodeURIComponent(reviewMatch[1]);
|
|
const idx = moderation.reviews.findIndex((r) => r.id === id);
|
|
if (idx < 0) {
|
|
await json(route, 404, { error: 'not_found' });
|
|
return;
|
|
}
|
|
if (method === 'GET') {
|
|
await json(route, 200, moderation.reviews[idx]);
|
|
return;
|
|
}
|
|
if (method === 'PUT') {
|
|
const payload = (req.postDataJSON() || {}) as Partial<(typeof moderation.reviews)[0]>;
|
|
moderation.reviews[idx] = {
|
|
...moderation.reviews[idx],
|
|
...payload,
|
|
updated_at: '2026-07-14T13:00:00Z',
|
|
};
|
|
await json(route, 200, moderation.reviews[idx]);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())) {
|
|
return;
|
|
}
|
|
|
|
if (method === 'GET') {
|
|
await json(route, 200, [], { 'X-Total-Count': '0' });
|
|
return;
|
|
}
|
|
|
|
await json(route, 200, { ok: true });
|
|
});
|
|
}
|
|
|
|
type MockReportStatus = 'pending' | 'reviewed' | 'dismissed';
|