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);
+ }
+}