Public Access
F16 F17 F18 F19 F20 F21: import polling backs off and pauses in a hidden tab, one date format module, fake timers in the import tests, error codes instead of raw messages, one safe storage wrapper, dead code.
344 lines
16 KiB
React
344 lines
16 KiB
React
// @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(
|
|
<MemoryRouter initialEntries={[entry]}>
|
|
<Routes>
|
|
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
|
|
<Route path="/login" element={<p>login</p>}/>
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
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(
|
|
<MemoryRouter initialEntries={['/import?id=5']}>
|
|
<Routes>
|
|
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
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(<ImportPage/>, {path: '/import', entries: ['/import'], guarded: true,
|
|
routes: {'/login': <p>login</p>}});
|
|
|
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/import'});
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
});
|
|
});
|