Backend (Phase 8.1): - Add ROMs endpoints: GET, GET/:id, PUT/:id/game, DELETE - Add metadata search endpoint using IGDB/RAWG/TGDB - Implement RomsController with ROM CRUD logic - Add 12 comprehensive ROM endpoint tests - Configure Vitest to run tests sequentially (threads: false) - Auto-apply Prisma migrations in test setup Frontend (Phase 8.2 + 8.3): - Create ROM types: RomFile, Artwork, EnrichedGame - Extend API client with roms and metadata namespaces - Implement 5 custom hooks with TanStack Query - Create ScanDialog, MetadataSearchDialog, RomCard components - Rewrite roms.tsx page with table and all actions - Add 37 comprehensive component and page tests All 122 tests passing: 63 backend + 59 frontend Lint: 0 errors, only unused directive warnings
260 lines
7.1 KiB
TypeScript
260 lines
7.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { userEvent } from '@testing-library/user-event';
|
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|
import { queryClient } from '../../src/lib/queryClient';
|
|
import * as useRomsModule from '../../src/hooks/useRoms';
|
|
import Roms from '../../src/routes/roms';
|
|
import { RomFile } from '../../src/types/rom';
|
|
|
|
// Mock the useRoms hooks
|
|
vi.spyOn(useRomsModule, 'useRoms');
|
|
vi.spyOn(useRomsModule, 'useScanDirectory');
|
|
vi.spyOn(useRomsModule, 'useEnrichMetadata');
|
|
vi.spyOn(useRomsModule, 'useLinkGameToRom');
|
|
vi.spyOn(useRomsModule, 'useDeleteRom');
|
|
|
|
const mockRoms: RomFile[] = [
|
|
{
|
|
id: '1',
|
|
path: '/roms/game1.zip',
|
|
filename: 'game1.zip',
|
|
checksum: 'abc123def456',
|
|
size: 1024000,
|
|
format: 'zip',
|
|
status: 'active',
|
|
addedAt: '2026-01-01T00:00:00Z',
|
|
game: {
|
|
id: 'g1',
|
|
title: 'Game One',
|
|
slug: 'game-one',
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-01-01T00:00:00Z',
|
|
},
|
|
},
|
|
{
|
|
id: '2',
|
|
path: '/roms/game2.rar',
|
|
filename: 'game2.rar',
|
|
checksum: 'xyz789uvw012',
|
|
size: 2048000,
|
|
format: 'rar',
|
|
status: 'active',
|
|
addedAt: '2026-01-02T00:00:00Z',
|
|
},
|
|
];
|
|
|
|
describe('ROMs Page', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Default mocks
|
|
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
data: mockRoms,
|
|
isLoading: false,
|
|
error: null,
|
|
} as any);
|
|
|
|
vi.mocked(useRomsModule.useScanDirectory).mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
} as any);
|
|
|
|
vi.mocked(useRomsModule.useEnrichMetadata).mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
} as any);
|
|
|
|
vi.mocked(useRomsModule.useLinkGameToRom).mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
} as any);
|
|
|
|
vi.mocked(useRomsModule.useDeleteRom).mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
} as any);
|
|
});
|
|
|
|
it('should render empty state when no roms', () => {
|
|
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
data: [],
|
|
isLoading: false,
|
|
error: null,
|
|
} as any);
|
|
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
expect(screen.getByText(/no roms yet/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render loading state', () => {
|
|
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
data: undefined,
|
|
isLoading: true,
|
|
error: null,
|
|
} as any);
|
|
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
expect(screen.getByText(/loading roms/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render error state', () => {
|
|
const error = new Error('Failed to fetch');
|
|
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
data: undefined,
|
|
isLoading: false,
|
|
error,
|
|
} as any);
|
|
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render table with roms', () => {
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
expect(screen.getByText('game1.zip')).toBeInTheDocument();
|
|
expect(screen.getByText('game2.rar')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render "Scan Directory" button', () => {
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
expect(screen.getByRole('button', { name: /scan directory/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should open scan dialog when "Scan Directory" is clicked', async () => {
|
|
const user = await userEvent.setup();
|
|
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
const scanButton = screen.getByRole('button', { name: /scan directory/i });
|
|
await user.click(scanButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/scan roms directory/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should render rom with linked game', () => {
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render "Link Metadata" button for rom without game', () => {
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
// game2.rar doesn't have a linked game
|
|
const linkButtons = screen.getAllByRole('button', { name: /link metadata/i });
|
|
expect(linkButtons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should open metadata search dialog when "Link Metadata" is clicked', async () => {
|
|
const user = await userEvent.setup();
|
|
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
const linkButton = screen.getAllByRole('button', { name: /link metadata/i })[0];
|
|
await user.click(linkButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/search metadata/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should show delete button and confirmation', async () => {
|
|
const user = await userEvent.setup();
|
|
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
expect(deleteButtons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle table columns correctly', () => {
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
// Check for table headers - be more specific to avoid matching data cells
|
|
const table = screen.getByRole('table');
|
|
expect(table.querySelector('th:nth-child(1)')).toHaveTextContent(/filename/i);
|
|
expect(table.querySelector('th:nth-child(2)')).toHaveTextContent(/size/i);
|
|
expect(table.querySelector('th:nth-child(3)')).toHaveTextContent(/checksum/i);
|
|
expect(table.querySelector('th:nth-child(4)')).toHaveTextContent(/status/i);
|
|
expect(table.querySelector('th:nth-child(5)')).toHaveTextContent(/game/i);
|
|
expect(table.querySelector('th:nth-child(6)')).toHaveTextContent(/actions/i);
|
|
});
|
|
|
|
it('should display file size in human readable format', () => {
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
// 1024000 bytes should be displayed as 1000 KB
|
|
expect(screen.getByText(/1000\s*kb/i)).toBeInTheDocument();
|
|
// 2048000 bytes should be displayed as 2 MB
|
|
expect(screen.getByText(/2\s*mb/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display checksum truncated with ellipsis', () => {
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Roms />
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
// First 8 chars should be shown + ...
|
|
expect(screen.getByText(/abc123de\.\.\./)).toBeInTheDocument();
|
|
});
|
|
});
|