From a3468518b3541fafe33033ec063da0b9074aa7a5 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Tue, 14 Jul 2026 22:35:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20E2E=20mock-=D1=81=D1=8C=D1=8E?= =?UTF-8?q?=D1=82=D1=8B=20inbox/reports/reviews=20+=20testids.=20Refs=20Ev?= =?UTF-8?q?entHub/EventHubFrontAdmin#33=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/mocks/adminApi.ts | 199 +++++++++++++++++++- e2e/mocks/moderationSeed.ts | 149 +++++++++++++++ e2e/tests/moderation.spec.ts | 84 +++++++++ src/components/explore/ExploreListShell.tsx | 4 +- src/pages/inbox/ReportInboxPage.tsx | 24 ++- src/pages/inbox/TicketInboxPage.tsx | 37 +++- src/pages/reports/ReportDetailPage.tsx | 21 ++- src/pages/reports/ReportListPage.tsx | 3 + src/pages/reviews/ReviewListPage.tsx | 4 + 9 files changed, 501 insertions(+), 24 deletions(-) create mode 100644 e2e/mocks/moderationSeed.ts create mode 100644 e2e/tests/moderation.spec.ts diff --git a/e2e/mocks/adminApi.ts b/e2e/mocks/adminApi.ts index 5052da2..714c4ed 100644 --- a/e2e/mocks/adminApi.ts +++ b/e2e/mocks/adminApi.ts @@ -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(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'; diff --git a/e2e/mocks/moderationSeed.ts b/e2e/mocks/moderationSeed.ts new file mode 100644 index 0000000..f0f46ef --- /dev/null +++ b/e2e/mocks/moderationSeed.ts @@ -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; diff --git a/e2e/tests/moderation.spec.ts b/e2e/tests/moderation.spec.ts new file mode 100644 index 0000000..613d59d --- /dev/null +++ b/e2e/tests/moderation.spec.ts @@ -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(); + }); +}); diff --git a/src/components/explore/ExploreListShell.tsx b/src/components/explore/ExploreListShell.tsx index 739dba1..fd5ba83 100644 --- a/src/components/explore/ExploreListShell.tsx +++ b/src/components/explore/ExploreListShell.tsx @@ -28,6 +28,7 @@ interface ExploreListShellProps { onViewModeChange: (mode: ExploreViewMode) => void; children: ReactNode; className?: string; + 'data-testid'?: string; } export function ExploreListShell({ @@ -41,10 +42,11 @@ export function ExploreListShell({ onViewModeChange, children, className, + 'data-testid': dataTestId, }: ExploreListShellProps) { const { t } = useTranslation(); return ( -
+

{title}

diff --git a/src/pages/inbox/ReportInboxPage.tsx b/src/pages/inbox/ReportInboxPage.tsx index 15ae938..2691ebb 100644 --- a/src/pages/inbox/ReportInboxPage.tsx +++ b/src/pages/inbox/ReportInboxPage.tsx @@ -150,7 +150,7 @@ const ReportInboxPage: React.FC = () => { }); return ( -
+

{t('inbox.reportsTitle')}

@@ -164,6 +164,7 @@ const ReportInboxPage: React.FC = () => { - diff --git a/src/pages/inbox/TicketInboxPage.tsx b/src/pages/inbox/TicketInboxPage.tsx index 0f4a391..a222d5b 100644 --- a/src/pages/inbox/TicketInboxPage.tsx +++ b/src/pages/inbox/TicketInboxPage.tsx @@ -127,20 +127,35 @@ const TicketInboxPage: React.FC = () => { }); return ( -
+

{t('inbox.ticketsTitle')}

{t('inbox.ticketsHints')}

- - -
@@ -167,6 +182,7 @@ const TicketInboxPage: React.FC = () => { )} - diff --git a/src/pages/reports/ReportDetailPage.tsx b/src/pages/reports/ReportDetailPage.tsx index afe3ca1..adc73ad 100644 --- a/src/pages/reports/ReportDetailPage.tsx +++ b/src/pages/reports/ReportDetailPage.tsx @@ -86,7 +86,7 @@ const ReportDetailPage: React.FC = () => { : t('common.dismissed'); return ( - <> +
{ {report.status === 'pending' && (
- -
@@ -184,7 +193,7 @@ const ReportDetailPage: React.FC = () => { !open && setConfirmStatus(null)}> - + {t('common.confirmMarkAs', { status: statusLabel })} @@ -192,13 +201,13 @@ const ReportDetailPage: React.FC = () => { - - +
); }; diff --git a/src/pages/reports/ReportListPage.tsx b/src/pages/reports/ReportListPage.tsx index 1b3c0d5..8bb2a95 100644 --- a/src/pages/reports/ReportListPage.tsx +++ b/src/pages/reports/ReportListPage.tsx @@ -250,6 +250,7 @@ const ReportListPage: React.FC = () => { return ( <> { {detailModal.report.status === 'pending' && (