diff --git a/src/app/[locale]/history/page.test.tsx b/src/app/[locale]/history/page.test.tsx new file mode 100644 index 0000000..653ac55 --- /dev/null +++ b/src/app/[locale]/history/page.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getAuthenticatedUser } from '@/utils/auth/get-authenticated-user'; +import { getRequestHistory } from '@/utils/history/get-request-history'; + +import HistoryPage from './page'; + +vi.mock('next-intl/server', () => ({ + getTranslations: async () => (key: string) => key, +})); + +vi.mock('@/utils/auth/get-authenticated-user', () => ({ + getAuthenticatedUser: vi.fn(), +})); + +vi.mock('@/utils/history/get-request-history', () => ({ + getRequestHistory: vi.fn(), +})); + +vi.mock('@/components/history-empty-state/history-empty-state', () => ({ + HistoryEmptyState: () =>
, +})); + +vi.mock('@/components/history-list/history-list', () => ({ + HistoryList: ({ entries }: { entries: unknown[] }) => ( +
{entries.length}
+ ), +})); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('HistoryPage', () => { + it('calls getAuthenticatedUser to protect the route', async () => { + vi.mocked(getRequestHistory).mockResolvedValue({ entries: [], hasError: false }); + + const jsx = await HistoryPage(); + render(jsx); + + expect(getAuthenticatedUser).toHaveBeenCalled(); + }); + + it('shows an error alert when the history fails to load', async () => { + vi.mocked(getRequestHistory).mockResolvedValue({ entries: [], hasError: true }); + + const jsx = await HistoryPage(); + render(jsx); + + expect(screen.getByText('historyLoadError')).toBeInTheDocument(); + }); + + it('shows the empty state when there are no entries', async () => { + vi.mocked(getRequestHistory).mockResolvedValue({ entries: [], hasError: false }); + + const jsx = await HistoryPage(); + render(jsx); + + expect(screen.getByTestId('empty-state-stub')).toBeInTheDocument(); + }); + + it('shows the history list when there are entries', async () => { + vi.mocked(getRequestHistory).mockResolvedValue({ + entries: [{ id: '1' }] as never, + hasError: false, + }); + + const jsx = await HistoryPage(); + render(jsx); + + expect(screen.getByTestId('history-list-stub')).toHaveTextContent('1'); + }); +}); diff --git a/src/app/[locale]/history/page.tsx b/src/app/[locale]/history/page.tsx index a478b90..0ffed47 100644 --- a/src/app/[locale]/history/page.tsx +++ b/src/app/[locale]/history/page.tsx @@ -1,7 +1,40 @@ +import { Alert } from '@mui/material'; +import { getTranslations } from 'next-intl/server'; + +import { HistoryEmptyState } from '@/components/history-empty-state/history-empty-state'; +import { HistoryList } from '@/components/history-list/history-list'; import { getAuthenticatedUser } from '@/utils/auth/get-authenticated-user'; +import { getRequestHistory } from '@/utils/history/get-request-history'; export default async function HistoryPage() { await getAuthenticatedUser(); - return
History
; + const { entries, hasError } = await getRequestHistory(); + const t = await getTranslations('HistoryPage'); + const tToast = await getTranslations('toaster'); + + if (hasError) { + return ( +
+

{t('title')}

+ {tToast('historyLoadError')} +
+ ); + } + + if (entries.length === 0) { + return ( +
+

{t('title')}

+ +
+ ); + } + + return ( +
+

{t('title')}

+ +
+ ); } diff --git a/src/components/history-empty-state/history-empty-state.test.tsx b/src/components/history-empty-state/history-empty-state.test.tsx new file mode 100644 index 0000000..8d44925 --- /dev/null +++ b/src/components/history-empty-state/history-empty-state.test.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; + +import { render, screen } from '@testing-library/react'; + +import { HistoryEmptyState } from './history-empty-state'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, href, ...props }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +describe('HistoryEmptyState', () => { + it('renders the informational message, hint, and link to the editor', () => { + render(); + + expect(screen.getByText('emptyMessage')).toBeInTheDocument(); + expect(screen.getByText('emptyLinksHint')).toBeInTheDocument(); + + const link = screen.getByRole('link', { name: 'goToEditor' }); + expect(link).toHaveAttribute('href', '/'); + }); +}); diff --git a/src/components/history-empty-state/history-empty-state.tsx b/src/components/history-empty-state/history-empty-state.tsx new file mode 100644 index 0000000..e46a363 --- /dev/null +++ b/src/components/history-empty-state/history-empty-state.tsx @@ -0,0 +1,22 @@ +import { Alert, Stack, Typography } from '@mui/material'; +import { useTranslations } from 'next-intl'; + +import { AppLinkButton } from '@/components/app-link/app-link'; +import { ROUTES } from '@/constants/routes'; + +export function HistoryEmptyState() { + const t = useTranslations('HistoryPage'); + + return ( + + {t('emptyMessage')} + + + {t('emptyLinksHint')} + + {t('goToEditor')} + + + + ); +} diff --git a/src/components/history-list/history-list.test.tsx b/src/components/history-list/history-list.test.tsx new file mode 100644 index 0000000..7d840a6 --- /dev/null +++ b/src/components/history-list/history-list.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import type { RequestHistoryEntry } from '@/utils/history/history-types'; + +import { HistoryList } from './history-list'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +function createEntry(overrides: Partial): RequestHistoryEntry { + return { + createdAt: '2026-07-12T09:46:10.444583+00', + durationMs: 245, + endpoint: 'https://api.example.com/users', + errorDetails: null, + id: '1', + method: 'GET', + requestSize: 0, + responseSize: 1024, + statusCode: 200, + ...overrides, + }; +} + +describe('HistoryList', () => { + it('renders column headers', () => { + render(); + + expect(screen.getByText('methodColumn')).toBeInTheDocument(); + expect(screen.getByText('endpointColumn')).toBeInTheDocument(); + expect(screen.getByText('statusColumn')).toBeInTheDocument(); + expect(screen.getByText('durationColumn')).toBeInTheDocument(); + expect(screen.getByText('requestSizeColumn')).toBeInTheDocument(); + expect(screen.getByText('responseSizeColumn')).toBeInTheDocument(); + expect(screen.getByText('timestampColumn')).toBeInTheDocument(); + expect(screen.getByText('errorColumn')).toBeInTheDocument(); + }); + + it('renders an entry with method, endpoint, status, duration, and sizes', () => { + render(); + + expect(screen.getByText('GET')).toBeInTheDocument(); + expect(screen.getByText('https://api.example.com/users')).toBeInTheDocument(); + expect(screen.getByText('200')).toBeInTheDocument(); + expect(screen.getByText('245ms')).toBeInTheDocument(); + expect(screen.getByText('0B')).toBeInTheDocument(); + expect(screen.getByText('1024B')).toBeInTheDocument(); + }); + + it('shows a dash when there are no error details', () => { + render(); + + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows the error details text when present', () => { + render(); + + expect(screen.getByText('Not Found')).toBeInTheDocument(); + }); + + it('truncates long error details and shows the full text in a tooltip', async () => { + const longError = 'A'.repeat(60); + + render(); + + const truncated = screen.getByText(`${'A'.repeat(40)}…`); + expect(truncated).toBeInTheDocument(); + + await userEvent.hover(truncated); + + expect(await screen.findByText(longError)).toBeInTheDocument(); + }); + + it('paginates entries, showing only the first page by default', async () => { + const entries = Array.from({ length: 15 }, (_, index) => + createEntry({ endpoint: `https://api.example.com/item-${index}`, id: String(index) }), + ); + + render(); + + expect(screen.getByText('https://api.example.com/item-0')).toBeInTheDocument(); + expect(screen.queryByText('https://api.example.com/item-10')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /next page/i })); + + expect(screen.getByText('https://api.example.com/item-10')).toBeInTheDocument(); + expect(screen.queryByText('https://api.example.com/item-0')).not.toBeInTheDocument(); + }); + + it('changes rows per page and resets to the first page', async () => { + const entries = Array.from({ length: 15 }, (_, index) => + createEntry({ endpoint: `https://api.example.com/item-${index}`, id: String(index) }), + ); + + render(); + + await userEvent.click(screen.getByRole('combobox', { name: /rows per page/i })); + await userEvent.click(await screen.findByRole('option', { name: '25' })); + + expect(screen.getByText('https://api.example.com/item-10')).toBeInTheDocument(); + }); +}); diff --git a/src/components/history-list/history-list.tsx b/src/components/history-list/history-list.tsx new file mode 100644 index 0000000..e528b26 --- /dev/null +++ b/src/components/history-list/history-list.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { Paper, Table, TableBody, TableContainer, TablePagination } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; + +import type { RequestHistoryEntry } from '@/utils/history/history-types'; + +import { HistoryTableHeader } from './history-table-header'; +import { HistoryTableRow } from './history-table-row'; + +type HistoryListProps = { + entries: RequestHistoryEntry[]; +}; + +const ROWS_PER_PAGE = [10, 25, 100]; + +export function HistoryList({ entries }: HistoryListProps) { + const t = useTranslations('HistoryPage'); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(ROWS_PER_PAGE[0]); + + function handleChangePage(_event: unknown, newPage: number) { + setPage(newPage); + } + + function handleChangeRowsPerPage(event: React.ChangeEvent) { + setRowsPerPage(Number(event.target.value)); + setPage(0); + } + + const pageStartIndex = page * rowsPerPage; + const pageEndIndex = pageStartIndex + rowsPerPage; + + const visibleEntries = entries.slice(pageStartIndex, pageEndIndex); + + return ( + + + + + + + {visibleEntries.map((entry) => ( + + ))} + +
+
+ + +
+ ); +} diff --git a/src/components/history-list/history-table-header.tsx b/src/components/history-list/history-table-header.tsx new file mode 100644 index 0000000..8110a2a --- /dev/null +++ b/src/components/history-list/history-table-header.tsx @@ -0,0 +1,34 @@ +import { TableCell, TableHead, TableRow } from '@mui/material'; +import { useTranslations } from 'next-intl'; + +const headerCellSx = { + bgcolor: 'background.paper', + whiteSpace: 'pre-line', +}; + +const columnTitles = [ + 'methodColumn', + 'endpointColumn', + 'statusColumn', + 'durationColumn', + 'requestSizeColumn', + 'responseSizeColumn', + 'timestampColumn', + 'errorColumn', +] as const; + +export function HistoryTableHeader() { + const t = useTranslations('HistoryPage'); + + return ( + + + {columnTitles.map((title) => ( + + {t(title)} + + ))} + + + ); +} diff --git a/src/components/history-list/history-table-row.tsx b/src/components/history-list/history-table-row.tsx new file mode 100644 index 0000000..cc6e0ea --- /dev/null +++ b/src/components/history-list/history-table-row.tsx @@ -0,0 +1,95 @@ +import { Chip, TableCell, TableRow, Tooltip, Typography } from '@mui/material'; + +import type { RequestHistoryEntry } from '@/utils/history/history-types'; + +const ERROR_PREVIEW_LENGTH = 40; + +const chipSx = { + '& .MuiChip-label': { + alignItems: 'center', + display: 'flex', + lineHeight: 1, + }, +}; + +type HistoryTableRowProps = { + entry: RequestHistoryEntry; +}; + +import type { ChipProps } from '@mui/material/Chip'; + +// TODO: убрать, когда feature/4-swagger-viewer смержится - использовать getMethodColor из @/utils/swagger-editor/get-method-color +const METHOD_COLOR = { + DELETE: 'error', + GET: 'success', + HEAD: 'default', + OPTIONS: 'default', + PATCH: 'warning', + POST: 'primary', + PUT: 'info', + TRACE: 'default', +} as const satisfies Record; + +export function getMethodColor(method: string): ChipProps['color'] { + return METHOD_COLOR[method as keyof typeof METHOD_COLOR] ?? 'default'; +} + +export function HistoryTableRow({ entry }: HistoryTableRowProps) { + return ( + + + + + + + {entry.endpoint} + + + + = 400 ? 'error' : 'success'} + label={entry.statusCode} + size="small" + sx={chipSx} + variant="outlined" + /> + + + + {entry.durationMs}ms + + + + {entry.requestSize}B + + + + {entry.responseSize}B + + + + {new Date(entry.createdAt).toLocaleString()} + + + + {entry.errorDetails ? ( + + + {entry.errorDetails.length > ERROR_PREVIEW_LENGTH + ? `${entry.errorDetails.slice(0, ERROR_PREVIEW_LENGTH)}…` + : entry.errorDetails} + + + ) : ( + + — + + )} + + + ); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7a33f8c..baafffe 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -37,6 +37,21 @@ "title": "Swagger/OpenAPI UI", "description": "Edit, view and test OpenAPI specifications in one app." }, + "HistoryPage": { + "title": "Request History", + "emptyMessage": "You haven't executed any requests yet", + "emptyLinksHint": "Get started by loading a schema and trying out an endpoint", + "goToEditor": "Go to Editor", + "errorLabel": "Error", + "durationColumn": "Duration", + "endpointColumn": "Endpoint", + "errorColumn": "Error", + "methodColumn": "Method", + "requestSizeColumn": "Request\nSize", + "responseSizeColumn": "Response\nSize", + "statusColumn": "Status", + "timestampColumn": "Timestamp" + }, "swaggerEditor": { "conversionError": "Failed to convert schema format.", "restoreError": "Failed to restore saved schema.", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index aac348f..07ff22c 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -37,6 +37,21 @@ "title": "Swagger/OpenAPI UI", "description": "Редактируйте, просматривайте и тестируйте OpenAPI-спецификации в одном приложении." }, + "HistoryPage": { + "title": "История запросов", + "emptyMessage": "Вы ещё не выполнили ни одного запроса", + "emptyLinksHint": "Начните с загрузки схемы и выполнения запроса к эндпоинту", + "goToEditor": "Перейти в редактор", + "errorLabel": "Ошибка", + "durationColumn": "Длительность", + "endpointColumn": "Эндпоинт", + "errorColumn": "Ошибка", + "methodColumn": "Метод", + "requestSizeColumn": "Размер\nзапроса", + "responseSizeColumn": "Размер\nответа", + "statusColumn": "Статус", + "timestampColumn": "Время" + }, "swaggerEditor": { "conversionError": "Не удалось преобразовать формат схемы.", "restoreError": "Не удалось восстановить сохранённую схему.", diff --git a/src/theme/components.ts b/src/theme/components.ts index e507332..9c208a4 100644 --- a/src/theme/components.ts +++ b/src/theme/components.ts @@ -5,6 +5,34 @@ import { alpha } from '@mui/material/styles'; const controlHeight = 36; export const components: Components = { + MuiAlert: { + styleOverrides: { + root: ({ ownerState, theme }) => { + if (ownerState.variant !== 'standard') { + return {}; + } + + const severityColors = { + error: theme.palette.error, + info: theme.palette.info, + success: theme.palette.success, + warning: theme.palette.warning, + } as const; + + const severity = (ownerState.severity ?? 'info') as keyof typeof severityColors; + const color = severityColors[severity]; + + return { + backgroundColor: alpha(color.main, 0.16), + color: color.dark, + ...theme.applyStyles('dark', { + color: color.light, + }), + }; + }, + }, + }, + MuiAppBar: { styleOverrides: { root: ({ theme }) => ({ diff --git a/src/utils/history/get-request-history.test.ts b/src/utils/history/get-request-history.test.ts new file mode 100644 index 0000000..346b69f --- /dev/null +++ b/src/utils/history/get-request-history.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createClient } from '@/lib/server'; + +import { getRequestHistory } from './get-request-history'; + +vi.mock('@/lib/server', () => ({ + createClient: vi.fn(), +})); + +function mockSupabaseClient(result: { data: unknown; error: unknown }) { + return { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + order: vi.fn().mockResolvedValue(result), + })), + })), + } as unknown as ReturnType; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getRequestHistory', () => { + it('returns mapped entries sorted from the query with hasError false', async () => { + const rows = [ + { + created_at: '2026-07-12T09:46:10.444583+00', + duration_ms: 245, + endpoint: 'https://api.example.com/users', + error_details: null, + id: '1', + method: 'GET', + request_size: 0, + response_size: 1024, + status_code: 200, + }, + ]; + + vi.mocked(createClient).mockResolvedValue( + await mockSupabaseClient({ data: rows, error: null }), + ); + + const result = await getRequestHistory(); + + expect(result.hasError).toBe(false); + expect(result.entries).toEqual([ + { + createdAt: '2026-07-12T09:46:10.444583+00', + durationMs: 245, + endpoint: 'https://api.example.com/users', + errorDetails: null, + id: '1', + method: 'GET', + requestSize: 0, + responseSize: 1024, + statusCode: 200, + }, + ]); + }); + + it('returns an empty array and hasError false when there are no rows', async () => { + vi.mocked(createClient).mockResolvedValue(await mockSupabaseClient({ data: [], error: null })); + + const result = await getRequestHistory(); + + expect(result.entries).toEqual([]); + expect(result.hasError).toBe(false); + }); + + it('returns hasError true and logs the error when the query fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const testError = new Error('connection failed'); + + vi.mocked(createClient).mockResolvedValue( + await mockSupabaseClient({ data: null, error: testError }), + ); + + const result = await getRequestHistory(); + + expect(result.hasError).toBe(true); + expect(result.entries).toEqual([]); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load request history:', testError); + }); + + it('returns an empty array when data is null despite no error', async () => { + vi.mocked(createClient).mockResolvedValue( + await mockSupabaseClient({ data: null, error: null }), + ); + + const result = await getRequestHistory(); + + expect(result.entries).toEqual([]); + expect(result.hasError).toBe(false); + }); +}); diff --git a/src/utils/history/get-request-history.ts b/src/utils/history/get-request-history.ts new file mode 100644 index 0000000..6100a41 --- /dev/null +++ b/src/utils/history/get-request-history.ts @@ -0,0 +1,40 @@ +import { createClient } from '@/lib/server'; +import { RequestHistoryResult } from '@/utils/history/history-types'; + +export async function getRequestHistory(): Promise { + try { + const supabase = await createClient(); + + const { data, error } = await supabase + .from('request_history') + .select('*') + .order('created_at', { ascending: false }); + + if (error) { + console.error('Failed to load request history:', error); + return { entries: [], hasError: true }; + } + + return { + entries: (data ?? []).map((row) => ({ + createdAt: row.created_at, + durationMs: row.duration_ms, + endpoint: row.endpoint, + errorDetails: row.error_details, + id: row.id, + method: row.method, + requestSize: row.request_size, + responseSize: row.response_size, + statusCode: row.status_code, + })), + hasError: false, + }; + } catch (error) { + console.error('Failed to load request history:', error); + + return { + entries: [], + hasError: true, + }; + } +} diff --git a/src/utils/history/history-types.ts b/src/utils/history/history-types.ts new file mode 100644 index 0000000..bba3dc1 --- /dev/null +++ b/src/utils/history/history-types.ts @@ -0,0 +1,26 @@ +export type RequestHistoryEntry = { + createdAt: string; + durationMs: number; + endpoint: string; + errorDetails: null | string; + id: string; + method: string; + requestSize: number; + responseSize: number; + statusCode: number; +}; + +export type RequestHistoryResult = { + entries: RequestHistoryEntry[]; + hasError: boolean; +}; + +export type SaveRequestHistoryData = { + durationMs: number; + endpoint: string; + errorDetails: null | string; + method: string; + requestSize: number; + responseSize: number; + statusCode: number; +}; diff --git a/src/utils/history/save-request-history.test.ts b/src/utils/history/save-request-history.test.ts new file mode 100644 index 0000000..370226e --- /dev/null +++ b/src/utils/history/save-request-history.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createClient } from '@/lib/server'; + +import { saveRequestHistory } from './save-request-history'; + +vi.mock('@/lib/server', () => ({ + createClient: vi.fn(), +})); + +describe('saveRequestHistory', () => { + const insert = vi.fn(); + const getUser = vi.fn(); + + const mockSupabase = { + auth: { + getUser, + }, + from: vi.fn(() => ({ + insert, + })), + }; + + const request = { + durationMs: 150, + endpoint: '/pets', + errorDetails: null, + method: 'GET', + requestSize: 120, + responseSize: 450, + statusCode: 200, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(createClient).mockResolvedValue(mockSupabase as never); + }); + + it('saves request history for authenticated user', async () => { + getUser.mockResolvedValue({ + data: { + user: { + id: 'user-id', + }, + }, + }); + + insert.mockResolvedValue({ error: null }); + + await saveRequestHistory(request); + + expect(mockSupabase.from).toHaveBeenCalledWith('request_history'); + + expect(insert).toHaveBeenCalledWith({ + duration_ms: 150, + endpoint: '/pets', + error_details: null, + method: 'GET', + request_size: 120, + response_size: 450, + status_code: 200, + user_id: 'user-id', + }); + }); + + it('does not save request history for unauthenticated user', async () => { + getUser.mockResolvedValue({ + data: { + user: null, + }, + }); + + await saveRequestHistory(request); + + expect(mockSupabase.from).not.toHaveBeenCalled(); + expect(insert).not.toHaveBeenCalled(); + }); + + it('logs an error when insert fails', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + getUser.mockResolvedValue({ + data: { + user: { + id: 'user-id', + }, + }, + }); + + const error = new Error('Insert failed'); + + insert.mockResolvedValue({ error }); + + await saveRequestHistory(request); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to save request history:', error); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/utils/history/save-request-history.ts b/src/utils/history/save-request-history.ts new file mode 100644 index 0000000..a5007e8 --- /dev/null +++ b/src/utils/history/save-request-history.ts @@ -0,0 +1,34 @@ +import { createClient } from '@/lib/server'; + +import type { SaveRequestHistoryData } from './history-types'; + +export async function saveRequestHistory(data: SaveRequestHistoryData) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return; + } + + const { error } = await supabase.from('request_history').insert({ + duration_ms: data.durationMs, + endpoint: data.endpoint, + error_details: data.errorDetails, + method: data.method, + request_size: data.requestSize, + response_size: data.responseSize, + status_code: data.statusCode, + user_id: user.id, + }); + + if (error) { + console.error('Failed to save request history:', error); + } + } catch (error) { + console.error('Failed to save request history:', error); + } +}