// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage from './ImportPage.jsx';
import {initialChoice, stepOf} from '../utils/imports.js';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
const hades2 = {igdbId: 2, name: 'Hades II', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false};
const celeste = {igdbId: 3, name: 'Celeste', year: 2018, imageUrl: null, platforms: ['PC', 'PS4'], inLibrary: true};
const row = (lineNo, title, candidates, extra = {}) => ({
lineNo, title, platform: null, playtimeMinutes: null, status: null, searchFailed: false, exact: true, candidates, ...extra,
});
const ready = {
id: 5, source: 'TEXT', status: 'READY', total: 3, matched: 3, rows: [
row(1, 'Hades', [hades, hades2]),
row(2, 'Celeste', [celeste]),
row(3, 'Hade', [hades2, hades], {exact: false, playtimeMinutes: 90}),
],
};
// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite
function renderPage(routes, entry = '/import') {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}),
...Object.fromEntries(Object.entries(routes).map(([key, answer]) => {
const [method, path] = key.split(' ');
return [`${method} /api/library/imports${path}`,
typeof answer === 'function' ? (url, init) => answer(init) : answer];
})),
});
render(
}/>
login
}/>
,
);
}
const posted = (method, path) => fetch.mock.calls.find(([url, init]) => init?.method === method && url.endsWith(path));
const polls = (path) => fetch.mock.calls.filter(([url, init]) => !init?.method && url.endsWith(path)).length;
const wait = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.useRealTimers();
localStorage.clear();
vi.clearAllMocks();
});
describe('initialChoice', () => {
it('checks only a sure match with a platform', () => {
expect(initialChoice(ready.rows[0], 'Switch')).toMatchObject({igdbId: 1, platform: 'Switch', checked: true});
expect(initialChoice(ready.rows[0], null)).toMatchObject({platform: '', checked: false});
expect(initialChoice(ready.rows[1], 'PC')).toMatchObject({platform: 'PC', checked: false});
expect(initialChoice(ready.rows[2], 'PC')).toMatchObject({platform: 'PC', checked: false, hours: '1.5'});
expect(initialChoice(row(4, 'x', []), 'PC')).toMatchObject({igdbId: null, checked: false});
});
});
describe('stepOf', () => {
it('maps the status to the step, and a failed import to the step it stopped in', () => {
expect(stepOf({status: 'QUEUED'})).toBe('QUEUED');
expect(stepOf({status: 'READY'})).toBe('REVIEW');
expect(stepOf({status: 'SAVING', step: null})).toBe('SAVING');
expect(stepOf({status: 'SAVING', step: 'DETAILS'})).toBe('DETAILS');
expect(stepOf({status: 'FAILED', chosen: 0})).toBe('MATCHING');
expect(stepOf({status: 'FAILED', chosen: 3})).toBe('SAVING');
expect(stepOf({status: 'UNDONE'})).toBe('DONE');
});
});
describe('ImportPage', () => {
it('shows a queued import with the imports before it', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'GET /8': json(200, {id: 8, source: 'CSV', status: 'QUEUED', total: 40, matched: 0, chosen: 0, saved: 0,
added: 0, ahead: 2, attempts: 0}),
}, '/import?id=8');
expect(await screen.findByText('IMPORT.WAITING:2')).toBeTruthy();
expect(screen.getByText('IMPORT.STEP.QUEUED').closest('li').getAttribute('aria-current')).toBe('step');
});
it('tries a failed import again and polls it', async () => {
let retried = false;
renderPage({
'GET ': json(200, {imports: []}),
'GET /6': () => json(200, {id: 6, source: 'TEXT', total: 10, matched: retried ? 4 : 2, chosen: 0, saved: 0,
added: 0, ahead: null, attempts: retried ? 0 : 3, status: retried ? 'MATCHING' : 'FAILED'}),
'POST /6/retry': () => {
retried = true;
return json(202, null);
},
}, '/import?id=6');
expect(await screen.findByText('IMPORT.FAILED:3')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.RETRY'}));
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(posted('POST', '/6/retry')).toBeTruthy();
});
it('starts an import from the list and shows the progress', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'POST ': json(202, {id: 9}),
'GET /9': json(200, {id: 9, source: 'TEXT', status: 'MATCHING', total: 2, matched: 1}),
});
fireEvent.change(await screen.findByLabelText('IMPORT.TEXT_LABEL'), {target: {value: 'Hades\n\n Celeste \n'}});
expect(screen.getByText('IMPORT.LINES:2')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({
source: 'TEXT', rows: [{title: 'Hades'}, {title: 'Celeste'}],
});
});
it('polls while matching and then shows the review', async () => {
let calls = 0;
renderPage({
'GET ': json(200, {imports: [{...ready, status: 'MATCHING', rows: null}]}),
'GET /5': () => (++calls === 1 ? json(200, {...ready, status: 'MATCHING', matched: 1, rows: null}) : json(200, ready)),
});
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
await wait(2000);
expect(await screen.findByText('IMPORT.LINE: Hades')).toBeTruthy();
});
it('preselects the favorite, checks only sure matches, and saves the checked lines', async () => {
let saved = false;
renderPage({
'GET ': json(200, {imports: []}),
'GET /5': () => json(200, saved ? {...ready, status: 'SAVED', chosen: 2, saved: 2, added: 1, rows: null} : ready),
'POST /5/save': () => {
saved = true;
return json(202, null);
},
}, '/import?id=5');
const first = await screen.findByLabelText('IMPORT.LINE: Hades');
await waitFor(() => expect(within(first.closest('li')).getByLabelText('IMPORT.PLATFORM').value).toBe('Switch'));
expect(first.checked).toBe(true);
// In the library already, and not the same name.
expect(screen.getByLabelText('IMPORT.LINE: Celeste').checked).toBe(false);
const third = screen.getByLabelText('IMPORT.LINE: Hade');
expect(third.checked).toBe(false);
const line3 = third.closest('li');
fireEvent.change(within(line3).getByLabelText('IMPORT.MATCH'), {target: {value: '1'}});
expect(within(line3).getByLabelText('IMPORT.PLATFORM').value).toBe('Switch');
fireEvent.change(within(line3).getByLabelText('IMPORT.STATUS_LABEL'), {target: {value: 'PLAYING'}});
fireEvent.click(third);
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.SAVE:2'}));
// The save runs in the background; the page polls until it is done.
expect(await screen.findByText('IMPORT.DONE')).toBeTruthy();
expect(JSON.parse(posted('POST', '/5/save')[1].body)).toEqual({rows: [
{lineNo: 1, igdbId: 1, platform: 'Switch', status: 'BACKLOG', playtimeMinutes: 0},
{lineNo: 3, igdbId: 1, platform: 'Switch', status: 'PLAYING', playtimeMinutes: 90},
]});
});
it('checks every line with a new match at once, and none', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /5': json(200, ready)}, '/import?id=5');
const checked = () => ['Hades', 'Celeste', 'Hade'].map((title) => screen.getByLabelText(`IMPORT.LINE: ${title}`).checked);
await screen.findByLabelText('IMPORT.LINE: Hades');
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.SELECT_ALL'}));
// Celeste is in the library already.
expect(checked()).toEqual([true, false, true]);
expect(screen.getByRole('button', {name: 'IMPORT.SAVE:2'})).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.SELECT_NONE'}));
expect(checked()).toEqual([false, false, false]);
expect(screen.getByRole('button', {name: 'IMPORT.SAVE:0'}).disabled).toBe(true);
});
it('keeps a line in the library that was checked by hand on select all', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /5': json(200, ready)}, '/import?id=5');
const celesteBox = await screen.findByLabelText('IMPORT.LINE: Celeste');
fireEvent.click(celesteBox);
expect(celesteBox.checked).toBe(true);
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.SELECT_ALL'}));
expect(screen.getByLabelText('IMPORT.LINE: Celeste').checked).toBe(true);
});
it('undoes a saved import after a confirm with the changed count', async () => {
renderPage({
'GET ': json(200, {imports: [{id: 4, source: 'CSV', status: 'SAVED', total: 3, matched: 3, createdAt: '2026-09-29T10:00:00Z'}]}),
'GET /4/undo-check': json(200, {games: 3, changed: 1}),
'DELETE /4/games': json(204, null),
});
fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.UNDO'}));
expect(await screen.findByText('IMPORT.UNDO_CONFIRM:3')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.UNDO_YES'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('IMPORT.UNDONE:3'));
expect(posted('DELETE', '/4/games')).toBeTruthy();
});
it('says so when another import is still matching', async () => {
renderPage({'GET ': json(200, {imports: []}), 'POST ': json(409, {code: 'IMPORT_RUNNING'})});
fireEvent.change(await screen.findByLabelText('IMPORT.TEXT_LABEL'), {target: {value: 'Hades'}});
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.RUNNING'));
});
it('stops polling on a 4xx and says so', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /abc': json(400, null)}, '/import?id=abc');
expect(await screen.findByText('IMPORT.LOAD_FAILED')).toBeTruthy();
});
it('keeps showing an import opened without an id when it leaves the worker', async () => {
let polls = 0;
const matching = {id: 5, source: 'TEXT', status: 'MATCHING', total: 3, matched: 1, chosen: 0, saved: 0, added: 0,
ahead: null, attempts: 0, rows: null};
renderPage({
'GET ': () => json(200, {imports: [polls > 1 ? {...ready, rows: null} : matching]}),
'GET /5': () => (++polls > 1 ? json(200, ready) : json(200, matching)),
});
await screen.findByText('IMPORT.MATCHING');
await wait(2000);
expect(await screen.findByText('IMPORT.LINE: Hades')).toBeTruthy();
});
it('keeps polling after a 502 while Core restarts', async () => {
let calls = 0;
renderPage({
'GET ': json(200, {imports: []}),
'GET /8': () => (++calls === 1 ? json(502, null) : json(200, {id: 8, source: 'TEXT', status: 'MATCHING',
total: 4, matched: 1, chosen: 0, saved: 0, added: 0, ahead: null, attempts: 0})),
}, '/import?id=8');
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(4000);
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
});
it('stops polling after repeated 500', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /8': json(500, null)}, '/import?id=8');
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(10 * 30_000);
expect(screen.getByText('IMPORT.LOAD_FAILED')).toBeTruthy();
expect(polls('/8')).toBe(10);
await wait(60_000);
expect(polls('/8')).toBe(10);
});
it('doubles the delay after each failure', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /8': json(503, null)}, '/import?id=8');
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(3500);
expect(polls('/8')).toBe(1);
await wait(1000);
expect(polls('/8')).toBe(2);
await wait(7000);
expect(polls('/8')).toBe(2);
await wait(1500);
expect(polls('/8')).toBe(3);
});
it('pauses polling in a hidden tab and goes on when it is visible again', async () => {
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
renderPage({'GET ': json(200, {imports: []}), 'GET /9': json(200, {id: 9, source: 'TEXT', status: 'MATCHING',
total: 2, matched: 1})}, '/import?id=9');
await screen.findByText('IMPORT.MATCHING');
await wait(20_000);
expect(polls('/9')).toBe(1);
hidden.mockReturnValue(false);
await act(async () => document.dispatchEvent(new Event('visibilitychange')));
expect(polls('/9')).toBe(2);
await wait(2000);
expect(polls('/9')).toBe(3);
});
});
describe('review findings', () => {
it('shows the review when Auth cannot be reached for the favorite', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
fetchRoutes({
'GET /api/users/me': () => Promise.reject(new TypeError('Failed to fetch')),
'GET /api/library/imports': json(200, {imports: []}),
'GET /api/library/imports/5': json(200, ready),
});
render(
}/>
,
);
expect(await screen.findByLabelText('IMPORT.LINE: Hades')).toBeTruthy();
});
});
describe('ImportPage session', () => {
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/imports': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
const {router} = renderWithProviders(, {path: '/import', entries: ['/import'], guarded: true,
routes: {'/login': login
}});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/import'});
expect(toast.error).not.toHaveBeenCalled();
});
});