feat(import): lego-sets ueber die rebrickable-api uebernehmen
Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr, Teile, Minifiguren und Thema kommen aus der API, das Bild wird lokal gespeichert und nicht nur verlinkt. Hersteller und Thema werden bei Bedarf angelegt, ein Oberthema inklusive. Alle Aufrufe laufen serverseitig; der Browser spricht nie mit rebrickable.com. Ohne REBRICKABLE_API_KEY bleibt der Import sichtbar, aber abgeschaltet, mit dem Hinweis, was zu tun ist - von Hand anlegen geht unveraendert. Rebrickable liefert keine Anleitungsdateien. Statt das zu verschleiern, legt der Import einen externen Link auf die LEGO-Serviceseite zur Setnummer an. Nebenbei: src/env.ts behandelt leere Werte jetzt wie nicht gesetzt - ein "BASE_URL=" in der .env fuehrte sonst zum Start-Abbruch mit "Invalid URL" statt zum Standardwert. 14 neue Unit-Tests decken Antwortverarbeitung, Setnummern-Varianten und alle Fehlerfaelle der API ab (404, 401, 429, Netzwerk). Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The module reads the key through src/env, which validates on import - so
|
||||
// the variable has to be in place before the dynamic import below.
|
||||
process.env.REBRICKABLE_API_KEY = 'test-key';
|
||||
|
||||
const {
|
||||
fetchImageBytes,
|
||||
fetchMinifigCount,
|
||||
fetchSet,
|
||||
fetchTheme,
|
||||
isRebrickableEnabled,
|
||||
RebrickableError,
|
||||
} = await import('@/lib/rebrickable');
|
||||
|
||||
type FetchArgs = Parameters<typeof fetch>;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function stubFetch(handler: (url: string, init?: FetchArgs[1]) => Response | Promise<Response>) {
|
||||
const spy = vi.fn(async (input: FetchArgs[0], init?: FetchArgs[1]) =>
|
||||
handler(String(input), init),
|
||||
);
|
||||
globalThis.fetch = spy as unknown as typeof fetch;
|
||||
return spy;
|
||||
}
|
||||
|
||||
const SET_RESPONSE = {
|
||||
set_num: '60398-1',
|
||||
name: 'Familienhaus und Elektroauto',
|
||||
year: 2023,
|
||||
theme_id: 52,
|
||||
num_parts: 462,
|
||||
set_img_url: 'https://cdn.rebrickable.com/media/sets/60398-1.jpg',
|
||||
set_url: 'https://rebrickable.com/sets/60398-1/',
|
||||
};
|
||||
|
||||
describe('Verfuegbarkeit', () => {
|
||||
it('ist mit gesetztem API-Key aktiv', () => {
|
||||
expect(isRebrickableEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Set abrufen', () => {
|
||||
it('uebersetzt die Antwort in unser Format', async () => {
|
||||
stubFetch(() => jsonResponse(SET_RESPONSE));
|
||||
|
||||
const result = await fetchSet('60398');
|
||||
|
||||
expect(result).toEqual({
|
||||
setNumber: '60398',
|
||||
name: 'Familienhaus und Elektroauto',
|
||||
year: 2023,
|
||||
pieceCount: 462,
|
||||
themeId: 52,
|
||||
imageUrl: 'https://cdn.rebrickable.com/media/sets/60398-1.jpg',
|
||||
externalUrl: 'https://rebrickable.com/sets/60398-1/',
|
||||
});
|
||||
});
|
||||
|
||||
it('fragt die Nummer mit Variante ab und schickt den Key im Header', async () => {
|
||||
const spy = stubFetch(() => jsonResponse(SET_RESPONSE));
|
||||
|
||||
await fetchSet('60398');
|
||||
|
||||
const [url, init] = spy.mock.calls[0]!;
|
||||
expect(String(url)).toContain('/sets/60398-1/');
|
||||
// Der Key gehoert in den Header, nicht in die URL - sonst steht er in
|
||||
// jedem Proxy-Log.
|
||||
expect(String(url)).not.toContain('test-key');
|
||||
expect((init?.headers as Record<string, string>).Authorization).toBe('key test-key');
|
||||
});
|
||||
|
||||
it('liefert null, wenn Rebrickable die Nummer nicht kennt', async () => {
|
||||
stubFetch(() => new Response('', { status: 404 }));
|
||||
|
||||
await expect(fetchSet('99999')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('meldet einen abgelehnten API-Key verstaendlich', async () => {
|
||||
stubFetch(() => new Response('', { status: 401 }));
|
||||
|
||||
await expect(fetchSet('60398')).rejects.toBeInstanceOf(RebrickableError);
|
||||
await expect(fetchSet('60398')).rejects.toThrow(/API-Key/);
|
||||
});
|
||||
|
||||
it('meldet eine Drosselung als eigenen Fall', async () => {
|
||||
stubFetch(() => new Response('', { status: 429 }));
|
||||
|
||||
await expect(fetchSet('60398')).rejects.toThrow(/drosselt/);
|
||||
});
|
||||
|
||||
it('macht aus einem Netzwerkfehler keine Ausnahme mit Innenleben', async () => {
|
||||
stubFetch(() => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
|
||||
await expect(fetchSet('60398')).rejects.toThrow(/nicht erreichbar/);
|
||||
});
|
||||
|
||||
it('kommt mit fehlenden Feldern klar', async () => {
|
||||
stubFetch(() =>
|
||||
jsonResponse({ set_num: '1234-1', name: 'Ohne alles', year: null, theme_id: null }),
|
||||
);
|
||||
|
||||
const result = await fetchSet('1234');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
setNumber: '1234',
|
||||
pieceCount: null,
|
||||
themeId: null,
|
||||
imageUrl: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Thema abrufen', () => {
|
||||
it('liefert Name und Oberthema', async () => {
|
||||
stubFetch(() => jsonResponse({ id: 52, name: 'City', parent_id: 50 }));
|
||||
|
||||
await expect(fetchTheme(52)).resolves.toEqual({ id: 52, name: 'City', parentId: 50 });
|
||||
});
|
||||
|
||||
it('behandelt ein Thema ohne Oberthema', async () => {
|
||||
stubFetch(() => jsonResponse({ id: 1, name: 'Technic', parent_id: null }));
|
||||
|
||||
await expect(fetchTheme(1)).resolves.toEqual({ id: 1, name: 'Technic', parentId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Minifiguren zaehlen', () => {
|
||||
it('summiert die Mengen', async () => {
|
||||
stubFetch(() => jsonResponse({ results: [{ quantity: 2 }, { quantity: 3 }] }));
|
||||
|
||||
await expect(fetchMinifigCount('60398')).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it('laesst den Import nicht scheitern, wenn die Liste fehlt', async () => {
|
||||
stubFetch(() => new Response('', { status: 500 }));
|
||||
|
||||
// null statt Ausnahme: ein Set ohne Minifiguren-Angabe ist kein Fehler.
|
||||
await expect(fetchMinifigCount('60398')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bild laden', () => {
|
||||
it('gibt die Bytes zurueck', async () => {
|
||||
stubFetch(() => new Response(new Uint8Array([1, 2, 3])));
|
||||
|
||||
const bytes = await fetchImageBytes('https://example.test/bild.jpg');
|
||||
|
||||
expect(bytes?.byteLength).toBe(3);
|
||||
});
|
||||
|
||||
it('gibt null zurueck, statt den Import scheitern zu lassen', async () => {
|
||||
stubFetch(() => new Response('', { status: 404 }));
|
||||
await expect(fetchImageBytes('https://example.test/weg.jpg')).resolves.toBeNull();
|
||||
|
||||
stubFetch(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
await expect(fetchImageBytes('https://example.test/lahm.jpg')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from '@/lib/set-number';
|
||||
|
||||
describe('Setnummern fuer den Rebrickable-Import', () => {
|
||||
it('haengt die Variante an, wenn nur die Packungsnummer eingetippt wurde', () => {
|
||||
expect(normalizeSetNumber('60398')).toBe('60398-1');
|
||||
expect(normalizeSetNumber('71427')).toBe('71427-1');
|
||||
});
|
||||
|
||||
it('laesst eine bereits vollstaendige Nummer unveraendert', () => {
|
||||
expect(normalizeSetNumber('60398-1')).toBe('60398-1');
|
||||
expect(normalizeSetNumber('10265-2')).toBe('10265-2');
|
||||
});
|
||||
|
||||
it('ignoriert Leerzeichen und vereinheitlicht Grossschreibung', () => {
|
||||
expect(normalizeSetNumber(' 60398 ')).toBe('60398-1');
|
||||
expect(normalizeSetNumber('k8672')).toBe('K8672-1');
|
||||
});
|
||||
|
||||
it('entfernt fuer die Anzeige nur die erste Variante', () => {
|
||||
expect(displaySetNumber('60398-1')).toBe('60398');
|
||||
// -2 ist eine echte zweite Variante und muss sichtbar bleiben.
|
||||
expect(displaySetNumber('10265-2')).toBe('10265-2');
|
||||
expect(displaySetNumber('60398')).toBe('60398');
|
||||
});
|
||||
|
||||
it('ueberlebt den Rundweg Anzeige -> API -> Anzeige', () => {
|
||||
for (const number of ['60398', '71427', '42172']) {
|
||||
expect(displaySetNumber(normalizeSetNumber(number))).toBe(number);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Link auf die LEGO-Bauanleitung', () => {
|
||||
it('nutzt die Nummer ohne Variante', () => {
|
||||
expect(legoInstructionsUrl('60398-1')).toBe(
|
||||
'https://www.lego.com/de-de/service/building-instructions/60398',
|
||||
);
|
||||
});
|
||||
|
||||
it('maskiert Sonderzeichen in der Nummer', () => {
|
||||
expect(legoInstructionsUrl('2 30500')).toContain('2%2030500');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user