diff --git a/.gitea/workflows/e2e-ift.yml b/.gitea/workflows/e2e-ift.yml new file mode 100644 index 0000000..e72f0ed --- /dev/null +++ b/.gitea/workflows/e2e-ift.yml @@ -0,0 +1,57 @@ +name: E2E IFT smoke (admin) + +# После успешного Deploy IFT — smoke против живого IFT (без API-моков). +# Secrets (repo EventHubFrontAdmin): E2E_ADMIN_EMAIL, E2E_ADMIN_PASSWORD; +# опционально E2E_IFT_BASE_URL (default https://admin-ui.ift.eventhub.local). +on: + workflow_run: + workflows: ["Deploy IFT (admin)"] + types: [completed] + workflow_dispatch: + +concurrency: + group: eventhub-frontadmin-e2e-ift + cancel-in-progress: false + +jobs: + ift-smoke: + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: IFT smoke + env: + E2E_TARGET: ift + E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }} + E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} + E2E_IFT_BASE_URL: ${{ secrets.E2E_IFT_BASE_URL }} + run: | + set -euo pipefail + if [ -z "${E2E_ADMIN_EMAIL:-}" ] || [ -z "${E2E_ADMIN_PASSWORD:-}" ]; then + echo "::error::Нужны secrets E2E_ADMIN_EMAIL и E2E_ADMIN_PASSWORD" + exit 1 + fi + npm run test:e2e:ift + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v3 + with: + name: playwright-ift-report + path: playwright-report/ + retention-days: 7 diff --git a/e2e/TESTIDS.md b/e2e/TESTIDS.md index ab74c62..acf45ec 100644 --- a/e2e/TESTIDS.md +++ b/e2e/TESTIDS.md @@ -23,3 +23,20 @@ Helper: `src/lib/testid.ts`. - kebab-case; dynamic id = API entity id (or banned word) - Prefer a11y for login fields - Do not decorate decorative wrappers + +## Запуск + +| Команда | Назначение | +|---------|------------| +| `npm run test:e2e` | mock-suite (CI / PR), API через Playwright route | +| `npm run test:e2e:ift` | IFT smoke (живой API), см. ниже | + +### IFT secrets (Gitea → Settings → Secrets) + +| Secret | Обязателен | Назначение | +|--------|------------|------------| +| `E2E_ADMIN_EMAIL` | да | логин smoke-админа на IFT | +| `E2E_ADMIN_PASSWORD` | да | пароль | +| `E2E_IFT_BASE_URL` | нет | default `https://admin-ui.ift.eventhub.local` | + +Workflow `e2e-ift.yml` стартует после успешного `Deploy IFT (admin)`. diff --git a/e2e/mocks/contentSeed.ts b/e2e/mocks/contentSeed.ts index ad0fee5..fbca9e9 100644 --- a/e2e/mocks/contentSeed.ts +++ b/e2e/mocks/contentSeed.ts @@ -85,8 +85,9 @@ export function createContentSeed() { { id: 'sub-e2e-1', user_id: 'user-e2e-1', - plan: 'pro', + plan: 'monthly', status: 'active', + trial_used: false, started_at: now, expires_at: '2027-01-01T00:00:00Z', auto_renew: true, @@ -96,8 +97,8 @@ export function createContentSeed() { ]; const bannedWords = [ - { word: 'spamword', added_by: 'admin-e2e-1', created_at: now }, - { word: 'badword', added_by: 'admin-e2e-1', created_at: now }, + { word: 'spamword', added_by: 'admin-e2e-1', added_at: now }, + { word: 'badword', added_by: 'admin-e2e-1', added_at: now }, ]; const admins = [ diff --git a/e2e/mocks/handleContentApi.ts b/e2e/mocks/handleContentApi.ts index 0a7c40d..feb6033 100644 --- a/e2e/mocks/handleContentApi.ts +++ b/e2e/mocks/handleContentApi.ts @@ -70,7 +70,9 @@ export async function tryHandleContentApi( return true; } if (method === 'PUT') { - content.users[idx] = { ...content.users[idx], ...(postData() as object) }; + const patch = (postData() || {}) as Record; + delete patch.id; + content.users[idx] = { ...content.users[idx], ...patch, id }; await json(route, 200, content.users[idx]); return true; } @@ -114,7 +116,9 @@ export async function tryHandleContentApi( return true; } if (method === 'PUT') { - content.calendars[idx] = { ...content.calendars[idx], ...(postData() as object) }; + const patch = (postData() || {}) as Record; + delete patch.id; + content.calendars[idx] = { ...content.calendars[idx], ...patch, id }; await json(route, 200, content.calendars[idx]); return true; } @@ -155,7 +159,9 @@ export async function tryHandleContentApi( return true; } if (method === 'PUT') { - content.events[idx] = { ...content.events[idx], ...(postData() as object) }; + const patch = (postData() || {}) as Record; + delete patch.id; + content.events[idx] = { ...content.events[idx], ...patch, id }; await json(route, 200, content.events[idx]); return true; } @@ -197,7 +203,9 @@ export async function tryHandleContentApi( return true; } if (method === 'PUT') { - content.subscriptions[idx] = { ...content.subscriptions[idx], ...(postData() as object) }; + const patch = (postData() || {}) as Record; + delete patch.id; + content.subscriptions[idx] = { ...content.subscriptions[idx], ...patch, id }; await json(route, 200, content.subscriptions[idx]); return true; } @@ -221,7 +229,7 @@ export async function tryHandleContentApi( 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' }); + if (word) content.bannedWords.push({ word, added_by: 'admin-e2e-1', added_at: '2026-07-14T12:00:00Z' }); await json(route, 200, { ok: true }); return true; } @@ -293,19 +301,30 @@ export async function tryHandleContentApi( return true; } - // ---- monitoring enrichment ---- + // ---- monitoring ---- 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', - }, - ], - }); + await json(route, 200, [ + { + active_sessions: 3, + io_in: 10, + io_out: 20, + memory_atom: 1, + memory_binary: 2, + memory_ets: 3, + memory_processes: 4, + memory_total: 2048, + mnesia_commits: 1, + mnesia_failures: 0, + node: 'node-a', + process_count: 100, + run_queue: 0, + table_sizes: { users: 10 }, + timestamp: '2026-07-14T12:00:00Z', + ws_connections: 5, + cpu_utilization: 12, + memory_available: 1024, + }, + ]); return true; } diff --git a/e2e/tests/content.spec.ts b/e2e/tests/content.spec.ts index 4cefeb7..4ba0f24 100644 --- a/e2e/tests/content.spec.ts +++ b/e2e/tests/content.spec.ts @@ -1,42 +1,101 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { seedAuthenticatedSession } from '../helpers/session'; +async function openRowAction(page: Page, id: string, label: RegExp) { + await page.getByTestId(`row-actions-${id}`).click(); + await page.getByRole('menuitem', { name: label }).click(); +} + +/** Radix Select in edit dialogs — ensure option chosen so Save enables. */ +async function pickDialogOption(page: Page, dialogTestId: string, comboboxIndex: number, option: string) { + const dialog = page.getByTestId(dialogTestId); + await dialog.getByRole('combobox').nth(comboboxIndex).click(); + await page.getByRole('option', { name: option, exact: true }).click(); +} + test.describe('content explore (mock)', () => { test.beforeEach(async ({ page }) => { await seedAuthenticatedSession(page); }); - test('users: список, поиск, detail', async ({ page }) => { + test('users: список, поиск, detail, edit, delete', async ({ page }) => { await page.goto('/users'); await expect(page.getByTestId('page-users')).toBeVisible(); + await page.getByTestId('view-rows').click(); await expect(page.getByTestId('row-user-e2e-1')).toBeVisible(); await page.getByTestId('filter-users-search-input').fill('Alice'); await page.getByTestId('filter-users-search').locator('button[type="submit"]').click(); await expect(page.getByTestId('row-user-e2e-1')).toBeVisible(); + + await openRowAction(page, 'user-e2e-1', /редакт|edit/i); + await expect(page.getByTestId('dialog-edit-user')).toBeVisible(); + await page.getByLabel(/ник|nickname/i).fill('Alice E2E'); + await pickDialogOption(page, 'dialog-edit-user', 0, 'user'); + await pickDialogOption(page, 'dialog-edit-user', 1, 'active'); + await expect(page.getByTestId('btn-save')).toBeEnabled(); + await page.getByTestId('btn-save').click(); + await expect(page.getByTestId('dialog-edit-user')).toHaveCount(0); + await page.goto('/users/user-e2e-1'); await expect(page.getByTestId('page-user-detail')).toBeVisible(); + + await page.goto('/users'); + await page.getByTestId('view-rows').click(); + await openRowAction(page, 'user-e2e-2', /удал|delete/i); + await expect(page.getByTestId('dialog-delete-user')).toBeVisible(); + await page.getByTestId('btn-delete-confirm').click(); + await expect(page.getByTestId('row-user-e2e-2')).toHaveCount(0); }); - test('calendars: список и detail', async ({ page }) => { + test('calendars: edit и detail', async ({ page }) => { await page.goto('/calendars'); - await expect(page.getByTestId('page-calendars')).toBeVisible(); + await page.getByTestId('view-rows').click(); await expect(page.getByTestId('row-cal-e2e-1')).toBeVisible(); + await openRowAction(page, 'cal-e2e-1', /редакт|edit/i); + await expect(page.getByTestId('dialog-edit-calendar')).toBeVisible(); + await page.getByTestId('dialog-edit-calendar').locator('input').first().fill('E2E Calendar'); + await pickDialogOption(page, 'dialog-edit-calendar', 0, 'personal'); + await pickDialogOption(page, 'dialog-edit-calendar', 1, 'active'); + await expect(page.getByTestId('btn-save')).toBeEnabled(); + await page.getByTestId('btn-save').click(); + await expect(page.getByTestId('dialog-edit-calendar')).toHaveCount(0); await page.goto('/calendars/cal-e2e-1'); await expect(page.getByTestId('page-calendar-detail')).toBeVisible(); }); - test('events: список и detail', async ({ page }) => { + test('events: edit и detail', async ({ page }) => { await page.goto('/events'); - await expect(page.getByTestId('page-events')).toBeVisible(); + await page.getByTestId('view-rows').click(); await expect(page.getByTestId('row-evt-e2e-1')).toBeVisible(); + await openRowAction(page, 'evt-e2e-1', /редакт|edit/i); + await expect(page.getByTestId('dialog-edit-event')).toBeVisible(); + await page.getByTestId('dialog-edit-event').locator('input').first().fill('E2E Workout'); + await pickDialogOption(page, 'dialog-edit-event', 0, 'single'); + await pickDialogOption(page, 'dialog-edit-event', 1, 'active'); + await expect(page.getByTestId('btn-save')).toBeEnabled(); + // Tall dialog: footer can sit outside viewport; submit via form API. + await page.getByTestId('dialog-edit-event').locator('form').evaluate((form) => { + (form as HTMLFormElement).requestSubmit(); + }); + await expect(page.getByTestId('dialog-edit-event')).toHaveCount(0); await page.goto('/events/evt-e2e-1'); await expect(page.getByTestId('page-event-detail')).toBeVisible(); }); - test('subscriptions: список', async ({ page }) => { + test('subscriptions: edit и delete', async ({ page }) => { await page.goto('/subscriptions'); - await expect(page.getByTestId('page-subscriptions')).toBeVisible(); + await page.getByTestId('view-rows').click(); await expect(page.getByTestId('row-sub-e2e-1')).toBeVisible(); + await openRowAction(page, 'sub-e2e-1', /редакт|edit/i); + await expect(page.getByTestId('dialog-edit-subscription')).toBeVisible(); + await pickDialogOption(page, 'dialog-edit-subscription', 0, 'monthly'); + await pickDialogOption(page, 'dialog-edit-subscription', 1, 'active'); + await page.getByTestId('btn-save').click(); + await expect(page.getByTestId('dialog-edit-subscription')).toHaveCount(0); + await openRowAction(page, 'sub-e2e-1', /удал|delete/i); + await expect(page.getByTestId('dialog-delete-subscription')).toBeVisible(); + await page.getByTestId('btn-delete-confirm').click(); + await expect(page.getByTestId('row-sub-e2e-1')).toHaveCount(0); }); test('banned-words: список и add', async ({ page }) => { diff --git a/e2e/tests/moderation.spec.ts b/e2e/tests/moderation.spec.ts index 613d59d..1492a64 100644 --- a/e2e/tests/moderation.spec.ts +++ b/e2e/tests/moderation.spec.ts @@ -75,10 +75,16 @@ test.describe('reviews explore (mock)', () => { await seedAuthenticatedSession(page); }); - test('страница reviews рендерится, bulk отключён без выбора', async ({ page }) => { + test('страница reviews и bulk hide', async ({ page }) => { await page.goto('/reviews'); await expect(page.getByTestId('page-reviews')).toBeVisible(); + await page.getByTestId('view-table').click(); await expect(page.getByText('Great event')).toBeVisible(); - await expect(page.getByTestId('btn-reviews-bulk-hidden')).toBeDisabled(); + await page.getByTestId('review-check-rev-vis-1').check(); + await page.getByTestId('btn-reviews-bulk-hidden').click(); + await expect(page.getByTestId('dialog-reviews-bulk-status')).toBeVisible(); + await page.getByTestId('input-bulk-reason').fill('e2e hide'); + await page.getByTestId('btn-save').click(); + await expect(page.getByTestId('dialog-reviews-bulk-status')).toHaveCount(0); }); }); diff --git a/e2e/tests/system.spec.ts b/e2e/tests/system.spec.ts index 1d392ce..24ee63d 100644 --- a/e2e/tests/system.spec.ts +++ b/e2e/tests/system.spec.ts @@ -24,11 +24,12 @@ test.describe('system / overview (mock)', () => { await expect(page.getByTestId('row-audit-e2e-1')).toBeVisible(); }); - test('monitoring: страница и range', async ({ page }) => { + test('monitoring: страница, range и node', async ({ page }) => { await page.goto('/monitoring'); await expect(page.getByTestId('page-monitoring')).toBeVisible(); await page.getByTestId('filter-monitoring-range-30').click(); - await expect(page.getByTestId('filter-monitoring-range-30')).toBeVisible(); + await page.getByTestId('filter-monitoring-node-node-a').click(); + await expect(page).toHaveURL(/node=node-a/); }); test('profile: view и edit', async ({ page }) => { @@ -47,13 +48,15 @@ test.describe('system / overview (mock)', () => { }); test.describe('i18n (mock)', () => { - test('переключение языка через меню', async ({ page }) => { + test('переключение языка через меню меняет UI', async ({ page }) => { await seedAuthenticatedSession(page); await page.goto('/dashboard'); await page.getByTestId('menu-user').click(); + await expect(page.getByTestId('menu-profile')).toHaveText(/Мой профиль/i); await page.getByTestId('lang-en').click(); - // profile language mutation returns user with language=en; menu stays open or reopens - await page.getByTestId('menu-user').click().catch(() => undefined); - await expect(page.getByTestId('lang-en')).toBeVisible(); + // Dropdown may close; reopen after store + i18n sync from PUT /me + await expect(page.getByText(/Профиль обновлён|Profile updated/i)).toBeVisible({ timeout: 15_000 }); + await page.getByTestId('menu-user').click(); + await expect(page.getByTestId('menu-profile')).toHaveText(/My profile/i); }); }); diff --git a/src/components/explore/DenseRowList.tsx b/src/components/explore/DenseRowList.tsx index 00a040e..687034b 100644 --- a/src/components/explore/DenseRowList.tsx +++ b/src/components/explore/DenseRowList.tsx @@ -92,7 +92,10 @@ export function DenseRowList({ {item.actions && item.actions.length > 0 && (
e.stopPropagation()}> - +
)} diff --git a/src/components/explore/ExploreViewToggle.tsx b/src/components/explore/ExploreViewToggle.tsx index 40bcf67..8a38f13 100644 --- a/src/components/explore/ExploreViewToggle.tsx +++ b/src/components/explore/ExploreViewToggle.tsx @@ -20,6 +20,7 @@ export function ExploreViewToggle({ value, onChange, className }: ExploreViewTog > - + {actions.map((action) => ( {action.separatorBefore && } diff --git a/src/pages/calendars/CalendarListPage.tsx b/src/pages/calendars/CalendarListPage.tsx index b37dec9..fa10b2f 100644 --- a/src/pages/calendars/CalendarListPage.tsx +++ b/src/pages/calendars/CalendarListPage.tsx @@ -118,8 +118,8 @@ const CalendarListPage: React.FC = () => { const original = originalCalendarRef.current; const cleanedValues: Record = {}; - const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]); - allKeys.forEach((key) => { + // Only form fields — do not null-out entity keys absent from the form (e.g. id, owner_id). + Object.keys(values).forEach((key) => { const newVal = (values as Record)[key]; if (newVal === '' || newVal === undefined || newVal === null) { const origVal = (original as unknown as Record)[key]; @@ -146,19 +146,18 @@ const CalendarListPage: React.FC = () => { }); useEffect(() => { - if (editingCalendar && editModal.open) { - originalCalendarRef.current = editingCalendar; - const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val); - editForm.reset({ - title: clean(editingCalendar.title) as string, - description: clean(editingCalendar.description) as string, - type: clean(editingCalendar.type) as string, - status: clean(editingCalendar.status) as string, - category: clean(editingCalendar.category) as string, - reason: clean(editingCalendar.reason) as string, - }); - } - }, [editingCalendar, editModal.open, editForm]); + if (!editModal.open || loadingCalendar || !editingCalendar) return; + originalCalendarRef.current = editingCalendar; + const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val); + editForm.reset({ + title: clean(editingCalendar.title) as string, + description: clean(editingCalendar.description) as string, + type: clean(editingCalendar.type) as string, + status: clean(editingCalendar.status) as string, + category: clean(editingCalendar.category) as string, + reason: clean(editingCalendar.reason) as string, + }); + }, [editingCalendar, editModal.open, loadingCalendar, editForm]); const handleDelete = (id: string) => setDeleteConfirm({ open: true, calendarId: id }); @@ -399,7 +398,7 @@ const CalendarListPage: React.FC = () => { !open && setEditModal({ open: false, calendarId: null })}> - + {t('calendars.editTitle')} @@ -424,7 +423,7 @@ const CalendarListPage: React.FC = () => { name="type" control={editForm.control} render={({ field }) => ( - @@ -443,7 +442,7 @@ const CalendarListPage: React.FC = () => { control={editForm.control} rules={{ required: true }} render={({ field }) => ( - @@ -470,7 +469,12 @@ const CalendarListPage: React.FC = () => { - @@ -478,7 +482,7 @@ const CalendarListPage: React.FC = () => { !open && setDeleteConfirm({ open: false, calendarId: null })}> - + {t('calendars.deleteTitle')} @@ -487,7 +491,12 @@ const CalendarListPage: React.FC = () => { - diff --git a/src/pages/events/EventListPage.tsx b/src/pages/events/EventListPage.tsx index ac7c4e3..f151d73 100644 --- a/src/pages/events/EventListPage.tsx +++ b/src/pages/events/EventListPage.tsx @@ -165,8 +165,8 @@ const EventListPage: React.FC = () => { capacity: values.capacity === '' || values.capacity === undefined ? undefined : Number(values.capacity), }; - const allKeys = new Set([...Object.keys(processedValues), ...Object.keys(original)]); - allKeys.forEach((key) => { + // Only form fields — do not null-out entity keys absent from the form (e.g. id). + Object.keys(processedValues).forEach((key) => { const newVal = processedValues[key]; if (newVal === '' || newVal === undefined || newVal === null) { const origVal = (original as unknown as Record)[key]; @@ -193,24 +193,23 @@ const EventListPage: React.FC = () => { }); useEffect(() => { - if (editingEvent && editModal.open) { - originalEventRef.current = editingEvent; - const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val); - editForm.reset({ - title: clean(editingEvent.title) as string, - description: clean(editingEvent.description) as string, - event_type: clean(editingEvent.event_type) as string, - status: clean(editingEvent.status) as string, - start_time: isoToDatetimeLocal(editingEvent.start_time), - duration: editingEvent.duration, - capacity: editingEvent.capacity ?? undefined, - specialist_id: clean(editingEvent.specialist_id) as string, - calendar_id: clean(editingEvent.calendar_id) as string, - online_link: clean(editingEvent.online_link) as string, - tags: (editingEvent.tags || []).join(', '), - }); - } - }, [editingEvent, editModal.open, editForm]); + if (!editModal.open || loadingEvent || !editingEvent) return; + originalEventRef.current = editingEvent; + const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val); + editForm.reset({ + title: clean(editingEvent.title) as string, + description: clean(editingEvent.description) as string, + event_type: clean(editingEvent.event_type) as string, + status: clean(editingEvent.status) as string, + start_time: isoToDatetimeLocal(editingEvent.start_time), + duration: editingEvent.duration, + capacity: editingEvent.capacity ?? undefined, + specialist_id: clean(editingEvent.specialist_id) as string, + calendar_id: clean(editingEvent.calendar_id) as string, + online_link: clean(editingEvent.online_link) as string, + tags: (editingEvent.tags || []).join(', '), + }); + }, [editingEvent, editModal.open, loadingEvent, editForm]); const handleDelete = (id: string) => setDeleteConfirm({ open: true, eventId: id }); @@ -473,7 +472,7 @@ const EventListPage: React.FC = () => { !open && setEditModal({ open: false, eventId: null })}> - + {t('events.editTitle')} @@ -499,7 +498,7 @@ const EventListPage: React.FC = () => { name="event_type" control={editForm.control} render={({ field }) => ( - @@ -518,7 +517,7 @@ const EventListPage: React.FC = () => { control={editForm.control} rules={{ required: true }} render={({ field }) => ( - @@ -567,7 +566,12 @@ const EventListPage: React.FC = () => { - @@ -575,7 +579,7 @@ const EventListPage: React.FC = () => { !open && setDeleteConfirm({ open: false, eventId: null })}> - + {t('events.deleteTitle')} @@ -584,7 +588,12 @@ const EventListPage: React.FC = () => { - diff --git a/src/pages/monitoring/MonitoringPage.tsx b/src/pages/monitoring/MonitoringPage.tsx index 58e1faf..c8879d7 100644 --- a/src/pages/monitoring/MonitoringPage.tsx +++ b/src/pages/monitoring/MonitoringPage.tsx @@ -365,6 +365,7 @@ const MonitoringPage: React.FC = () => {