// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {toast} from 'sonner'; import ImportPage, {initialChoice, stepOf} from './ImportPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); const json = (status, body) => ({ok: status < 400, status, json: async () => body}); 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; any other URL (Auth) answers the favorite function renderPage(routes, entry = '/import') { globalThis.fetch = vi.fn(async (url, init) => { if (!url.includes('/api/library/imports')) return json(200, {settings: {favoritePlatform: 'Switch'}}); const path = new URL(url, 'http://x').pathname.replace(/.*\/api\/library\/imports/, ''); const answer = routes[`${init?.method ?? 'GET'} ${path}`]; if (!answer) throw new Error(`no route ${init?.method ?? 'GET'} ${path}`); return typeof answer === 'function' ? answer(init) : answer; }); render( }/> login

}/>
, ); } const posted = (method, path) => fetch.mock.calls.find(([url, init]) => init?.method === method && url.endsWith(path)); beforeEach(() => { localStorage.setItem('tbp_session', '1'); }); afterEach(() => { cleanup(); 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(); expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 3000})).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('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)), }); expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 4000})).toBeTruthy(); }, 10000); 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'); expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy(); }, 10000); });