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:
unknown
2026-09-07 07:12:22 +02:00
co-authored by Claude Opus 5
parent 52d69e5654
commit 4e3cbb81ce
13 changed files with 813 additions and 6 deletions
+17
View File
@@ -0,0 +1,17 @@
/**
* Baseline environment for unit tests.
*
* src/env.ts validates on import and refuses to start on a bad configuration -
* which is the point in production, but in tests every module that reads the
* environment would fail before its first assertion. These are placeholders,
* never anything a test asserts on.
*
* BASE_URL is overwritten unconditionally: Vite uses that very name for its
* public base path and sets it to "/", which is not a URL. The clash only
* exists under Vite-based tooling, never in the app itself.
*/
process.env.NODE_ENV ??= 'test';
process.env.BASE_URL = 'http://localhost:3000';
process.env.BETTER_AUTH_SECRET =
process.env.BETTER_AUTH_SECRET || 'test-secret-mindestens-32-zeichen-lang-0123';
process.env.DATA_DIR = process.env.DATA_DIR || './data-test';
+8
View File
@@ -0,0 +1,8 @@
/**
* Stub for the `server-only` marker package.
*
* The real module throws on import outside a React Server Component, which
* would make every server module untestable. Importing it is a build-time
* guard, not behaviour, so an empty module is the honest replacement here.
*/
export {};
+179
View File
@@ -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();
});
});
+44
View File
@@ -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');
});
});