// @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, useLocation} 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(), info: 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 const ShownUrl = () => { const {pathname, search, hash} = useLocation(); return {pathname + search + hash}; }; 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 storeData = (games) => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(games)))); 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('reads an export file by its format and sends that as the source', async () => { renderPage({ 'GET ': json(200, {imports: []}), 'POST ': json(202, {id: 9}), 'GET /9': json(200, {id: 9, source: 'GROUVEE', status: 'MATCHING', total: 1, matched: 0}), }); fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.SOURCE_CSV'})); const file = new File(['id,name,shelves,platforms,dates\n1,Hades,"{""Playing"": {}}","{""PC"": {}}",None\n'], 'grouvee.csv', {type: 'text/csv'}); fireEvent.change(screen.getByLabelText('IMPORT.CSV_LABEL'), {target: {files: [file]}}); expect(await screen.findByText('IMPORT.FORMAT_READ')).toBeTruthy(); fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'})); await screen.findByText('IMPORT.MATCHING'); expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({ source: 'GROUVEE', rows: [{title: 'Hades', platform: 'PC', playtimeMinutes: null, status: 'PLAYING'}], }); }); it('takes the store list from the URL, drops it there and sends it as BOOKMARKLET', async () => { renderPage({ 'GET ': json(200, {imports: []}), 'POST ': json(202, {id: 9}), 'GET /9': json(200, {id: 9, source: 'BOOKMARKLET', status: 'MATCHING', total: 2, matched: 0}), }, `/import#data=${storeData([['Hades', 'PC'], ['Pokémon Scarlet', 'Switch']])}`); expect(await screen.findByText('IMPORT.STORE_READ:2')).toBeTruthy(); expect(screen.getByText('Pokémon Scarlet · Switch')).toBeTruthy(); // The router takes the new URL in a later render than the rows. await waitFor(() => expect(screen.getByLabelText('url').textContent).toBe('/import')); fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'})); await screen.findByText('IMPORT.MATCHING'); expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({source: 'BOOKMARKLET', rows: [ {title: 'Hades', platform: 'PC', playtimeMinutes: null, status: null}, {title: 'Pokémon Scarlet', platform: 'Switch', playtimeMinutes: null, status: null}, ]}); }); it('tells the user when the store list in the URL is broken', async () => { renderPage({'GET ': json(200, {imports: []})}, '/import#data=broken'); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.STORE_LIST_BROKEN')); await waitFor(() => expect(screen.getByLabelText('url').textContent).toBe('/import')); expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy(); }); it('says that the store list waits while another import runs', async () => { renderPage({ 'GET ': json(200, {imports: [{...ready, status: 'MATCHING', rows: null}]}), 'GET /5': json(200, {...ready, status: 'MATCHING', rows: null}), }, `/import#data=${storeData([['Hades', 'PC']])}`); await waitFor(() => expect(toast.info).toHaveBeenCalledWith('IMPORT.STORE_LIST_WAITS')); expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy(); }); it('treats a store list without a usable row as broken', async () => { renderPage({'GET ': json(200, {imports: []})}, `/import#data=${btoa('[[1, "PC"]]')}`); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.STORE_LIST_BROKEN')); expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy(); }); it('offers the bookmarklet as a link, not run on click', async () => { renderPage({'GET ': json(200, {imports: []})}); fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.SOURCE_BOOKMARKLET'})); const link = screen.getByText('IMPORT.STORE_LINK'); expect(link.getAttribute('href')).toMatch(/^javascript:[^#]*$/); expect(fireEvent.click(link)).toBe(false); }); 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(); }); it('keeps the store list in the URL for the login when the session has expired', async () => { fetchRoutes({ 'GET /api/users/me': json(200, {settings: {}}), 'GET /api/library/imports': json(401, {}), 'POST /api/auth/refresh': json(401, {}), }); const entry = `/import#data=${storeData([['Hades', 'PC']])}`; const {router} = renderWithProviders(, {path: '/import', entries: [entry], guarded: true, routes: {'/login':

login

}}); await waitFor(() => expect(router.state.location.pathname).toBe('/login')); expect(router.state.location.state).toEqual({sessionExpired: true, from: entry}); }); });