feat(admin): E2E mock-сьюты inbox/reports/reviews + testids. Refs EventHub/EventHubFrontAdmin#33 #36

This commit is contained in:
2026-07-14 22:35:21 +03:00
parent 87c6b55e81
commit a3468518b3
9 changed files with 501 additions and 24 deletions
+192 -7
View File
@@ -1,4 +1,5 @@
import type { Page, Route } from '@playwright/test';
import { createModerationSeed, type ModerationState } from './moderationSeed';
export type MockAdmin = {
id: string;
@@ -27,13 +28,21 @@ async function json(route: Route, status: number, body: unknown, headers: Record
});
}
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. */
/** 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();
await page.route('**/v1/admin/**', async (route) => {
const req = route.request();
@@ -82,13 +91,13 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
await json(route, 200, {
users_total: 10,
events_total: 5,
reviews_total: 2,
reviews_total: moderation?.reviews.length ?? 0,
calendars_total: 3,
tickets_total: 1,
tickets_open: 1,
reports_total: 2,
reports_pending: 1,
reports_reviewed: 1,
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: [],
@@ -106,6 +115,180 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
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;
}
}
// 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 (method === 'GET') {
await json(route, 200, [], { 'X-Total-Count': '0' });
return;
@@ -114,3 +297,5 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
await json(route, 200, { ok: true });
});
}
type MockReportStatus = 'pending' | 'reviewed' | 'dismissed';
+149
View File
@@ -0,0 +1,149 @@
/** Stateful moderation fixtures for mock E2E. */
export type MockReport = {
id: string;
reporter_id: string;
target_type: 'calendar' | 'event' | 'review';
target_id: string;
reason: string;
status: 'pending' | 'reviewed' | 'dismissed';
created_at: string;
resolved_at: string | null;
resolved_by: string | null;
};
export type MockTicket = {
id: string;
reporter_id: string;
error_hash: string;
error_message: string;
stacktrace: string;
context: string;
count: number;
first_seen: string;
last_seen: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed';
assigned_to: string | null;
resolution_note: string | null;
};
export type MockReview = {
id: string;
user_id: string;
target_type: 'calendar' | 'event';
target_id: string;
rating: number;
comment: string;
status: 'visible' | 'hidden' | 'deleted';
reason: string | null;
likes: number;
dislikes: number;
created_at: string;
updated_at: string;
};
const now = '2026-07-14T12:00:00Z';
export function createModerationSeed() {
const reports: MockReport[] = [
{
id: 'rpt-pending-1',
reporter_id: 'user-1',
target_type: 'event',
target_id: 'evt-1',
reason: 'Spam content',
status: 'pending',
created_at: now,
resolved_at: null,
resolved_by: null,
},
{
id: 'rpt-pending-2',
reporter_id: 'user-2',
target_type: 'calendar',
target_id: 'cal-1',
reason: 'Offensive title',
status: 'pending',
created_at: now,
resolved_at: null,
resolved_by: null,
},
{
id: 'rpt-done-1',
reporter_id: 'user-1',
target_type: 'review',
target_id: 'rev-1',
reason: 'Duplicate',
status: 'reviewed',
created_at: now,
resolved_at: now,
resolved_by: 'admin-e2e-1',
},
];
const tickets: MockTicket[] = [
{
id: 'tkt-open-1',
reporter_id: 'user-1',
error_hash: 'hash-open-1',
error_message: 'NullPointer in join',
stacktrace: 'at joiner:42',
context: '{}',
count: 3,
first_seen: now,
last_seen: now,
status: 'open',
assigned_to: null,
resolution_note: null,
},
{
id: 'tkt-prog-1',
reporter_id: 'user-2',
error_hash: 'hash-prog-1',
error_message: 'Timeout on sync',
stacktrace: 'at sync:10',
context: '{}',
count: 1,
first_seen: now,
last_seen: now,
status: 'in_progress',
assigned_to: 'admin-e2e-1',
resolution_note: null,
},
];
const reviews: MockReview[] = [
{
id: 'rev-vis-1',
user_id: 'user-1',
target_type: 'event',
target_id: 'evt-1',
rating: 5,
comment: 'Great event',
status: 'visible',
reason: null,
likes: 2,
dislikes: 0,
created_at: now,
updated_at: now,
},
{
id: 'rev-vis-2',
user_id: 'user-2',
target_type: 'calendar',
target_id: 'cal-1',
rating: 1,
comment: 'Bad calendar',
status: 'visible',
reason: null,
likes: 0,
dislikes: 1,
created_at: now,
updated_at: now,
},
];
return { reports, tickets, reviews };
}
export type ModerationState = ReturnType<typeof createModerationSeed>;
+84
View File
@@ -0,0 +1,84 @@
import { test, expect } from '@playwright/test';
import { seedAuthenticatedSession } from '../helpers/session';
test.describe('inbox reports (mock)', () => {
test.beforeEach(async ({ page }) => {
await seedAuthenticatedSession(page);
});
test('очередь pending, select и reviewed', async ({ page }) => {
await page.goto('/inbox/reports');
await expect(page.getByTestId('page-inbox-reports')).toBeVisible();
await expect(page.getByTestId('report-row-rpt-pending-1')).toBeVisible();
await expect(page.getByTestId('report-row-rpt-done-1')).toHaveCount(0);
await page.getByTestId('report-select-rpt-pending-1').click();
await expect(page.getByTestId('inbox-report-detail')).toBeVisible();
await page.getByTestId('btn-report-reviewed').click();
await expect(page.getByTestId('report-row-rpt-pending-1')).toHaveCount(0);
});
test('filter all показывает reviewed', async ({ page }) => {
await page.goto('/inbox/reports');
await page.getByTestId('filter-all').click();
await expect(page.getByTestId('report-row-rpt-done-1')).toBeVisible();
});
test('bulk dismissed', async ({ page }) => {
await page.goto('/inbox/reports');
await page.getByTestId('report-check-rpt-pending-1').check();
await page.getByTestId('report-check-rpt-pending-2').check();
await page.getByTestId('btn-bulk-dismissed').click();
await expect(page.getByTestId('report-row-rpt-pending-1')).toHaveCount(0);
await expect(page.getByTestId('report-row-rpt-pending-2')).toHaveCount(0);
});
});
test.describe('inbox tickets (mock)', () => {
test.beforeEach(async ({ page }) => {
await seedAuthenticatedSession(page);
});
test('open → in_progress → resolved', async ({ page }) => {
await page.goto('/inbox/tickets');
await expect(page.getByTestId('page-inbox-tickets')).toBeVisible();
await page.getByTestId('ticket-row-tkt-open-1').click();
await page.getByTestId('btn-ticket-in-progress').click();
await expect(page.getByTestId('btn-ticket-resolved')).toBeVisible();
await page.getByTestId('btn-ticket-resolved').click();
// resolved leaves active queue
await expect(page.getByTestId('ticket-row-tkt-open-1')).toHaveCount(0);
await page.getByTestId('filter-all').click();
await expect(page.getByTestId('ticket-row-tkt-open-1')).toBeVisible();
});
});
test.describe('reports explore (mock)', () => {
test.beforeEach(async ({ page }) => {
await seedAuthenticatedSession(page);
});
test('список и detail reviewed с confirm', async ({ page }) => {
await page.goto('/reports');
await expect(page.getByTestId('page-reports')).toBeVisible();
await page.goto('/reports/rpt-pending-1');
await expect(page.getByTestId('page-report-detail')).toBeVisible();
await page.getByTestId('btn-report-reviewed').click();
await expect(page.getByTestId('dialog-confirm-report-status')).toBeVisible();
await page.getByTestId('btn-confirm-status').click();
await expect(page.getByTestId('btn-report-reviewed')).toHaveCount(0);
});
});
test.describe('reviews explore (mock)', () => {
test.beforeEach(async ({ page }) => {
await seedAuthenticatedSession(page);
});
test('страница reviews рендерится, bulk отключён без выбора', async ({ page }) => {
await page.goto('/reviews');
await expect(page.getByTestId('page-reviews')).toBeVisible();
await expect(page.getByText('Great event')).toBeVisible();
await expect(page.getByTestId('btn-reviews-bulk-hidden')).toBeDisabled();
});
});