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
281 lines
8.3 KiB
TypeScript
281 lines
8.3 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 MetadataSearchDialog from '../../src/components/roms/MetadataSearchDialog';
|
|
import { EnrichedGame } from '../../src/types/rom';
|
|
|
|
const mockEnrichMetadata = vi.fn();
|
|
|
|
vi.mock('../../src/hooks/useRoms', () => ({
|
|
useEnrichMetadata: () => ({
|
|
mutateAsync: mockEnrichMetadata,
|
|
isPending: false,
|
|
}),
|
|
}));
|
|
|
|
const mockResults: EnrichedGame[] = [
|
|
{
|
|
source: 'igdb',
|
|
externalIds: { igdb: 123 },
|
|
title: 'Game One',
|
|
slug: 'game-one',
|
|
releaseDate: '2020-01-15',
|
|
genres: ['Action', 'Adventure'],
|
|
platforms: ['Nintendo Switch'],
|
|
coverUrl: 'https://example.com/cover1.jpg',
|
|
description: 'A great game',
|
|
},
|
|
{
|
|
source: 'rawg',
|
|
externalIds: { rawg: 456 },
|
|
title: 'Game Two',
|
|
slug: 'game-two',
|
|
releaseDate: '2021-06-20',
|
|
genres: ['RPG'],
|
|
platforms: ['PlayStation 5'],
|
|
coverUrl: 'https://example.com/cover2.jpg',
|
|
description: 'Another game',
|
|
},
|
|
];
|
|
|
|
describe('MetadataSearchDialog Component', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should not render when isOpen is false', () => {
|
|
render(
|
|
<MetadataSearchDialog
|
|
romId="rom-1"
|
|
isOpen={false}
|
|
onOpenChange={vi.fn()}
|
|
onSelect={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText(/search metadata/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should render when isOpen is true', () => {
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
expect(screen.getByText(/search metadata/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should have search input field', () => {
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
expect(screen.getByPlaceholderText(/search game title/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should accept search input', async () => {
|
|
const user = await userEvent.setup();
|
|
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i) as HTMLInputElement;
|
|
await user.type(input, 'Game One');
|
|
|
|
expect(input.value).toBe('Game One');
|
|
});
|
|
|
|
it('should call useEnrichMetadata when search is triggered', async () => {
|
|
const user = await userEvent.setup();
|
|
mockEnrichMetadata.mockResolvedValue([mockResults[0]]);
|
|
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i);
|
|
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
|
|
await user.type(input, 'Game One');
|
|
await user.click(searchButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockEnrichMetadata).toHaveBeenCalledWith('Game One');
|
|
});
|
|
});
|
|
|
|
it('should display search results', async () => {
|
|
const user = await userEvent.setup();
|
|
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i);
|
|
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
|
|
await user.type(input, 'Game');
|
|
await user.click(searchButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
expect(screen.getByText('Game Two')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display source badge for each result', async () => {
|
|
const user = await userEvent.setup();
|
|
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i);
|
|
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
|
|
await user.type(input, 'Game');
|
|
await user.click(searchButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('IGDB')).toBeInTheDocument();
|
|
expect(screen.getByText('RAWG')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should show "No results" message when search returns empty', async () => {
|
|
const user = await userEvent.setup();
|
|
mockEnrichMetadata.mockResolvedValue([]);
|
|
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i);
|
|
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
|
|
await user.type(input, 'NonexistentGame');
|
|
await user.click(searchButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/no results found/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should call onSelect when result is selected', async () => {
|
|
const user = await userEvent.setup();
|
|
const onSelect = vi.fn();
|
|
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
|
|
render(
|
|
<MetadataSearchDialog
|
|
romId="rom-1"
|
|
isOpen={true}
|
|
onOpenChange={vi.fn()}
|
|
onSelect={onSelect}
|
|
/>
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i);
|
|
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
|
|
await user.type(input, 'Game');
|
|
await user.click(searchButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
});
|
|
|
|
const selectButton = screen.getAllByRole('button', { name: /select/i })[0];
|
|
await user.click(selectButton);
|
|
|
|
expect(onSelect).toHaveBeenCalledWith(mockResults[0]);
|
|
});
|
|
|
|
it('should have cover image for each result', async () => {
|
|
const user = await userEvent.setup();
|
|
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
|
|
const { container } = render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i);
|
|
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
|
|
await user.type(input, 'Game');
|
|
await user.click(searchButton);
|
|
|
|
await waitFor(() => {
|
|
const images = container.querySelectorAll('img');
|
|
expect(images.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it('should show loading state during search', async () => {
|
|
const user = await userEvent.setup();
|
|
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i);
|
|
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
|
|
await user.type(input, 'Game');
|
|
await user.click(searchButton);
|
|
|
|
// The button should be in the document during and after search
|
|
expect(searchButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call onOpenChange when closing dialog', async () => {
|
|
const user = await userEvent.setup();
|
|
const onOpenChange = vi.fn();
|
|
|
|
render(
|
|
<MetadataSearchDialog
|
|
romId="rom-1"
|
|
isOpen={true}
|
|
onOpenChange={onOpenChange}
|
|
onSelect={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
// Find and click close button
|
|
const buttons = screen.getAllByRole('button');
|
|
const closeButton = buttons.find(
|
|
(btn) =>
|
|
btn.getAttribute('aria-label')?.includes('close') ||
|
|
btn.textContent?.includes('✕') ||
|
|
btn.textContent?.includes('Cancel')
|
|
);
|
|
|
|
if (closeButton) {
|
|
await user.click(closeButton);
|
|
expect(onOpenChange).toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('should display release date for results', async () => {
|
|
const user = await userEvent.setup();
|
|
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
|
|
render(
|
|
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/search game title/i);
|
|
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
|
|
await user.type(input, 'Game');
|
|
await user.click(searchButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/2020/)).toBeInTheDocument();
|
|
expect(screen.getByText(/2021/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|