feat(import): bookmarklet for store pages
Build, Test & Push Frontend / quality-check (push) Successful in 22s
Build, Test & Push Frontend / docker (push) Successful in 25s

This commit is contained in:
lobo committed 2026-09-30 15:59:05 +02:00
1 parent c6d76b4cb5
commit f01ad03f6e
8 files changed
+411 -22

No files matched your search

+77 -2
View File
@@ -1,7 +1,7 @@
// @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 {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage from './ImportPage.jsx';
import {initialChoice, stepOf} from '../utils/imports.js';
@@ -11,7 +11,7 @@ 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()}}));
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};
@@ -29,6 +29,11 @@ const ready = {
};
// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite
const ShownUrl = () => {
const {pathname, search, hash} = useLocation();
return <output aria-label="url">{pathname + search + hash}</output>;
};
function renderPage(routes, entry = '/import') {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}),
@@ -44,10 +49,13 @@ function renderPage(routes, entry = '/import') {
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
<Route path="/login" element={<p>login</p>}/>
</Routes>
<ShownUrl/>
</MemoryRouter>,
);
}
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));
@@ -154,6 +162,59 @@ describe('ImportPage', () => {
});
});
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();
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'));
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({
@@ -360,4 +421,18 @@ describe('ImportPage session', () => {
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(<ImportPage/>, {path: '/import', entries: [entry], 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: entry});
});
});