diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 1a18e5e7..f3259729 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -155,7 +155,12 @@ export class E2eTestDataBuilder { } async createNamespace(base = 'e2e-team'): Promise { - const slug = `${base}-${this.suffix}`.slice(0, 64) + const rawSlug = `${base}-${this.suffix}` + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + const slug = rawSlug.slice(0, 64) const displayName = `E2E ${slug}` const created = await parseEnvelope( diff --git a/web/e2e/reviews-pagination.spec.ts b/web/e2e/reviews-pagination.spec.ts new file mode 100644 index 00000000..2d18b30c --- /dev/null +++ b/web/e2e/reviews-pagination.spec.ts @@ -0,0 +1,83 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' + +interface ApiEnvelope { + code: number + msg: string + data: T +} + +interface ReviewPageData { + total: number + size: number +} + +async function fetchReviewPageMeta(page: Page, status: ReviewStatus): Promise { + const response = await page.request.get(`/api/web/reviews?status=${status}&page=0&size=20&sortDirection=DESC`) + const body = await response.json() as ApiEnvelope + if (!response.ok() || body.code !== 0) { + throw new Error(`Failed to query reviews for ${status}: status=${response.status()} code=${body.code} msg=${body.msg}`) + } + return body.data +} + +test.describe('Review Management Pagination (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + }) + + test('matches pagination rendering with real review totals', async ({ page }) => { + const statuses: ReviewStatus[] = ['PENDING', 'APPROVED', 'REJECTED'] + const metaByStatus = new Map() + + for (const status of statuses) { + metaByStatus.set(status, await fetchReviewPageMeta(page, status)) + } + + await page.goto('/dashboard/reviews') + await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible() + + const tabMeta: Record = { + PENDING: { tabLabel: 'Pending', summaryPrefix: 'Total' }, + APPROVED: { tabLabel: 'Approved', summaryPrefix: 'Total' }, + REJECTED: { tabLabel: 'Rejected', summaryPrefix: 'Total' }, + } + + for (const status of statuses) { + await page.getByRole('button', { name: tabMeta[status].tabLabel }).click() + + const meta = metaByStatus.get(status) + if (!meta) { + throw new Error(`Missing metadata for ${status}`) + } + const totalPages = meta.size > 0 ? Math.ceil(meta.total / meta.size) : 0 + + if (meta.total === 0) { + await expect(page.getByText('No review tasks')).toBeVisible() + await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0) + continue + } + + const previousButton = page.getByRole('button', { name: 'Previous' }).first() + const nextButton = page.getByRole('button', { name: 'Next' }).first() + await expect(previousButton).toBeVisible() + await expect(nextButton).toBeVisible() + await expect(previousButton).toBeDisabled() + + if (totalPages > 1) { + await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 1`))).toBeVisible() + await expect(nextButton).toBeEnabled() + await nextButton.click() + await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 2`))).toBeVisible() + } else { + await expect(nextButton).toBeDisabled() + } + } + }) +}) diff --git a/web/src/pages/dashboard/namespace-reviews.test.ts b/web/src/pages/dashboard/namespace-reviews.test.ts index 79830e2f..8e031088 100644 --- a/web/src/pages/dashboard/namespace-reviews.test.ts +++ b/web/src/pages/dashboard/namespace-reviews.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { createElement } from 'react' vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ slug: 'test-ns' }), @@ -19,10 +21,6 @@ vi.mock('@/shared/lib/date-time', () => ({ formatLocalDateTime: (v: string) => v, })) -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - vi.mock('@/shared/ui/card', () => ({ Card: ({ children }: { children: unknown }) => children, })) @@ -42,12 +40,22 @@ vi.mock('@/shared/ui/tabs', () => ({ TabsTrigger: ({ children }: { children: unknown }) => children, })) -vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useNamespaceDetail: () => ({ data: null, isLoading: false }), +const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = [] +vi.mock('@/shared/components/pagination', () => ({ + Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => { + paginationProps.push(props) + return null + }, })) +const useNamespaceDetailMock = vi.fn() +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useNamespaceDetail: (...args: unknown[]) => useNamespaceDetailMock(...args), +})) + +const useReviewListMock = vi.fn() vi.mock('@/features/review/use-review-list', () => ({ - useReviewList: () => ({ data: null, isLoading: false }), + useReviewList: (...args: unknown[]) => useReviewListMock(...args), })) vi.mock('@/shared/components/dashboard-page-header', () => ({ @@ -61,7 +69,89 @@ vi.mock('@/features/namespace/namespace-header', () => ({ import { NamespaceReviewsPage } from './namespace-reviews' describe('NamespaceReviewsPage', () => { + function createReviewItem(id: number) { + return { + id, + namespace: 'demo-ns', + skillSlug: `skill-${id}`, + version: '1.0.0', + submittedBy: 'user-1', + submittedByName: 'User 1', + submittedAt: '2026-04-01T12:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + } + } + + beforeEach(() => { + paginationProps.length = 0 + useNamespaceDetailMock.mockReset() + useReviewListMock.mockReset() + + useNamespaceDetailMock.mockReturnValue({ + data: { + id: 100, + slug: 'test-ns', + displayName: 'Test Namespace', + type: 'CUSTOM', + status: 'ACTIVE', + }, + isLoading: false, + }) + + useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { + if (!enabled || status !== 'PENDING') { + return { data: null, isLoading: false } + } + return { + data: { + items: [createReviewItem(1)], + totalElements: 11, + totalPages: 2, + page, + size: 10, + total: 11, + }, + isLoading: false, + } + }) + }) + it('exports a named component function', () => { expect(typeof NamespaceReviewsPage).toBe('function') }) + + it('renders pagination for namespace review list when totalPages > 1', () => { + const html = renderToStaticMarkup(createElement(NamespaceReviewsPage)) + + expect(html).toContain('nsReviews.pageSummary') + expect(paginationProps).toHaveLength(1) + expect(paginationProps[0]?.page).toBe(0) + expect(paginationProps[0]?.totalPages).toBe(2) + }) + + it('does not render pagination when there is only one page', () => { + useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { + if (!enabled || status !== 'PENDING') { + return { data: null, isLoading: false } + } + return { + data: { + items: [createReviewItem(2)], + totalElements: 1, + totalPages: 1, + page, + size: 10, + total: 1, + }, + isLoading: false, + } + }) + + renderToStaticMarkup(createElement(NamespaceReviewsPage)) + + expect(paginationProps).toHaveLength(0) + }) }) diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 5dc37627..009d5f4f 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -2,13 +2,13 @@ import { useState } from 'react' import { useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { formatLocalDateTime } from '@/shared/lib/date-time' -import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { useReviewList } from '@/features/review/use-review-list' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Pagination } from '@/shared/components/pagination' import { NamespaceHeader } from '@/features/namespace/namespace-header' type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' @@ -51,26 +51,7 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { return (

{t('nsReviews.pageSummary', { total: totalElements, page: currentPage + 1 })}

-
- - -
+ changePage(status, nextPage)} />
) } diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index e3117551..992c102b 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { createElement } from 'react' vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), @@ -19,10 +21,6 @@ vi.mock('react-i18next', async () => { } }) -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - vi.mock('@/shared/ui/card', () => ({ Card: ({ children }: { children: unknown }) => children, CardContent: ({ children }: { children: unknown }) => children, @@ -55,12 +53,22 @@ vi.mock('@/shared/ui/table', () => ({ TableRow: ({ children }: { children: unknown }) => children, })) -vi.mock('@/features/review/use-review-list', () => ({ - useReviewList: () => ({ data: null, isLoading: false }), +const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = [] +vi.mock('@/shared/components/pagination', () => ({ + Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => { + paginationProps.push(props) + return null + }, })) +const useReviewListMock = vi.fn() +vi.mock('@/features/review/use-review-list', () => ({ + useReviewList: (...args: unknown[]) => useReviewListMock(...args), +})) + +const hasRoleMock = vi.fn() vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: () => false }), + useAuth: () => ({ hasRole: hasRoleMock }), })) vi.mock('@/shared/components/dashboard-page-header', () => ({ @@ -78,7 +86,86 @@ vi.mock('./profile-review-table', () => ({ import { ReviewsPage } from './reviews' describe('ReviewsPage', () => { + function createReviewItem(id: number) { + return { + id, + namespace: 'demo', + skillSlug: `skill-${id}`, + version: '1.0.0', + submittedBy: 'user-1', + submittedByName: 'User 1', + submittedAt: '2026-04-01T12:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + } + } + + beforeEach(() => { + paginationProps.length = 0 + hasRoleMock.mockReset() + useReviewListMock.mockReset() + hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN') + useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { + if (!enabled) { + return { data: null, isLoading: false } + } + + if (status === 'PENDING') { + return { + data: { + items: [createReviewItem(1)], + totalElements: 21, + totalPages: 2, + page, + size: 20, + total: 21, + }, + isLoading: false, + } + } + + return { data: null, isLoading: false } + }) + }) + it('exports a named component function', () => { expect(typeof ReviewsPage).toBe('function') }) + + it('renders pagination for pending reviews when totalPages > 1', () => { + const html = renderToStaticMarkup(createElement(ReviewsPage)) + + expect(html).toContain('reviews.pageSummary') + expect(paginationProps).toHaveLength(1) + expect(paginationProps[0]?.page).toBe(0) + expect(paginationProps[0]?.totalPages).toBe(2) + }) + + it('renders disabled-style pagination when there is only one page', () => { + useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { + if (!enabled || status !== 'PENDING') { + return { data: null, isLoading: false } + } + + return { + data: { + items: [createReviewItem(2)], + totalElements: 1, + totalPages: 1, + page, + size: 20, + total: 1, + }, + isLoading: false, + } + }) + + renderToStaticMarkup(createElement(ReviewsPage)) + + expect(paginationProps).toHaveLength(1) + expect(paginationProps[0]?.page).toBe(0) + expect(paginationProps[0]?.totalPages).toBe(1) + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 95183720..426da9b8 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' @@ -17,6 +16,7 @@ import { import { useReviewList } from '@/features/review/use-review-list' import { useAuth } from '@/features/auth/use-auth' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Pagination } from '@/shared/components/pagination' import { formatLocalDateTime } from '@/shared/lib/date-time' import { ProfileReviewTable } from './profile-review-table' @@ -72,31 +72,11 @@ export function ReviewsPage() { } function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) { - if (totalPages <= 1) return null const currentPage = pages[status] return (

{t('reviews.pageSummary', { total: totalElements, page: currentPage + 1 })}

-
- - -
+ changePage(status, nextPage)} />
) }