Public Access
227 lines
11 KiB
React
227 lines
11 KiB
React
// @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(
|
|
<MemoryRouter initialEntries={[entry]}>
|
|
<Routes>
|
|
<Route path="/import" element={<PreferencesProvider><ImportPage/></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));
|
|
|
|
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);
|
|
});
|