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:
+4
-2
@@ -27,6 +27,8 @@ TZ=Europe/Berlin
|
||||
# BIND_ADDRESS=127.0.0.1
|
||||
# HOST_PORT=3000
|
||||
|
||||
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import.
|
||||
# Ohne Key funktioniert alles manuell weiter.
|
||||
# Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der
|
||||
# Seite "Neues Set" die Setnummer, und Name, Jahr, Teile, Minifiguren, Thema
|
||||
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
|
||||
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
||||
# REBRICKABLE_API_KEY=
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { instruction, manufacturer, set, theme } from '@/db/schema';
|
||||
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
||||
import {
|
||||
fetchImageBytes,
|
||||
fetchMinifigCount,
|
||||
fetchSet,
|
||||
fetchTheme,
|
||||
isRebrickableEnabled,
|
||||
legoInstructionsUrl,
|
||||
RebrickableError,
|
||||
} from '@/lib/rebrickable';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { uniqueSlug } from '@/lib/slug';
|
||||
import { storeImageBytes, UploadError } from '@/lib/uploads';
|
||||
|
||||
/**
|
||||
* Import of a Lego set from Rebrickable.
|
||||
*
|
||||
* Runs entirely on the server, creates the manufacturer and the theme if they
|
||||
* are missing, and stores the picture locally so the catalog keeps working
|
||||
* when Rebrickable changes it.
|
||||
*/
|
||||
|
||||
const LEGO_SLUG = 'lego';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
/** Returns the Lego manufacturer, creating it on first import. */
|
||||
function ensureLegoManufacturer(): string {
|
||||
const existing = db
|
||||
.select({ id: manufacturer.id })
|
||||
.from(manufacturer)
|
||||
.where(eq(manufacturer.slug, LEGO_SLUG))
|
||||
.get();
|
||||
if (existing) return existing.id;
|
||||
|
||||
const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all();
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
db.insert(manufacturer)
|
||||
.values({
|
||||
id,
|
||||
name: 'Lego',
|
||||
slug: uniqueSlug('Lego', taken.map((row) => row.slug)),
|
||||
website: 'https://www.lego.com/de-de',
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Rebrickable theme onto a local one, creating it (and its parent) when
|
||||
* needed. Rebrickable nests themes deeply; one level of parent is enough to
|
||||
* keep "Technic > Cars" readable without importing the whole tree.
|
||||
*/
|
||||
async function ensureTheme(manufacturerId: string, themeId: number | null): Promise<string | null> {
|
||||
if (themeId === null) return null;
|
||||
|
||||
const remote = await fetchTheme(themeId);
|
||||
if (!remote) return null;
|
||||
|
||||
const existing = db
|
||||
.select({ id: theme.id })
|
||||
.from(theme)
|
||||
.where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, remote.name)))
|
||||
.get();
|
||||
if (existing) return existing.id;
|
||||
|
||||
let parentThemeId: string | null = null;
|
||||
if (remote.parentId !== null) {
|
||||
const parent = await fetchTheme(remote.parentId);
|
||||
if (parent) {
|
||||
const existingParent = db
|
||||
.select({ id: theme.id })
|
||||
.from(theme)
|
||||
.where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, parent.name)))
|
||||
.get();
|
||||
|
||||
if (existingParent) {
|
||||
parentThemeId = existingParent.id;
|
||||
} else {
|
||||
parentThemeId = crypto.randomUUID();
|
||||
db.insert(theme)
|
||||
.values({
|
||||
id: parentThemeId,
|
||||
manufacturerId,
|
||||
name: parent.name,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(theme)
|
||||
.values({
|
||||
id,
|
||||
manufacturerId,
|
||||
name: remote.name,
|
||||
parentThemeId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export type ImportResult = {
|
||||
setId: string;
|
||||
setNumber: string;
|
||||
name: string;
|
||||
imported: {
|
||||
image: boolean;
|
||||
theme: boolean;
|
||||
minifigs: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function importSetFromRebrickable(
|
||||
rawSetNumber: unknown,
|
||||
): Promise<ActionResult<ImportResult>> {
|
||||
await requireUser();
|
||||
|
||||
if (!isRebrickableEnabled()) {
|
||||
return actionError(
|
||||
'Der Import ist nicht eingerichtet. Trag REBRICKABLE_API_KEY in der .env ein und starte den Container neu.',
|
||||
);
|
||||
}
|
||||
|
||||
const setNumber = typeof rawSetNumber === 'string' ? rawSetNumber.trim() : '';
|
||||
if (!setNumber) return actionError('Bitte gib eine Setnummer ein.');
|
||||
if (setNumber.length > 40) return actionError('Das sieht nicht nach einer Setnummer aus.');
|
||||
|
||||
let remote;
|
||||
try {
|
||||
remote = await fetchSet(setNumber);
|
||||
} catch (error) {
|
||||
if (error instanceof RebrickableError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!remote) {
|
||||
return actionError(
|
||||
`Rebrickable kennt die Setnummer ${setNumber} nicht. Du kannst das Set von Hand anlegen.`,
|
||||
);
|
||||
}
|
||||
|
||||
const manufacturerId = ensureLegoManufacturer();
|
||||
|
||||
const duplicate = db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, remote.setNumber)))
|
||||
.get();
|
||||
if (duplicate) {
|
||||
return actionError(`${remote.setNumber} ist schon im Katalog: ${remote.name}.`);
|
||||
}
|
||||
|
||||
const themeId = await ensureTheme(manufacturerId, remote.themeId);
|
||||
const minifigCount = await fetchMinifigCount(remote.setNumber);
|
||||
|
||||
const setId = crypto.randomUUID();
|
||||
|
||||
// Cache the picture locally; a failed download must not fail the import.
|
||||
let imagePath: string | null = null;
|
||||
if (remote.imageUrl) {
|
||||
const bytes = await fetchImageBytes(remote.imageUrl);
|
||||
if (bytes) {
|
||||
try {
|
||||
imagePath = (await storeImageBytes(bytes, 'set-image', setId)).filePath;
|
||||
} catch (error) {
|
||||
if (!(error instanceof UploadError)) throw error;
|
||||
console.error('[import] Bild konnte nicht gespeichert werden', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.insert(set)
|
||||
.values({
|
||||
id: setId,
|
||||
manufacturerId,
|
||||
themeId,
|
||||
setNumber: remote.setNumber,
|
||||
name: remote.name,
|
||||
year: remote.year,
|
||||
pieceCount: remote.pieceCount,
|
||||
minifigCount,
|
||||
imagePath,
|
||||
// Keep the source URL as a fallback if the local file is ever lost.
|
||||
imageUrl: imagePath ? null : remote.imageUrl,
|
||||
externalIds: { rebrickable: `${remote.setNumber}-1` },
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
// Rebrickable does not serve instruction files, so this points at Lego's
|
||||
// own service page for the set number.
|
||||
db.insert(instruction)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
kind: 'external_url',
|
||||
url: legoInstructionsUrl(remote.setNumber),
|
||||
label: 'Bauanleitung bei LEGO',
|
||||
sortOrder: 0,
|
||||
createdAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidatePath('/katalog');
|
||||
revalidatePath('/verwaltung/themen');
|
||||
|
||||
return actionData({
|
||||
setId,
|
||||
setNumber: remote.setNumber,
|
||||
name: remote.name,
|
||||
imported: {
|
||||
image: imagePath !== null,
|
||||
theme: themeId !== null,
|
||||
minifigs: minifigCount !== null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
|
||||
import { SetForm } from '@/components/katalog/set-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Neues Set' };
|
||||
@@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
||||
manufacturerId: row.manufacturerId,
|
||||
}));
|
||||
|
||||
const importEnabled = isRebrickableEnabled();
|
||||
|
||||
if (manufacturers.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
|
||||
|
||||
{/* The Lego import creates its manufacturer itself, so it works even
|
||||
on a completely empty instance. */}
|
||||
<RebrickableImport enabled={importEnabled} />
|
||||
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder
|
||||
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||
was auch immer im Kinderzimmer steht.
|
||||
</p>
|
||||
<Button asChild size="lg">
|
||||
@@ -45,6 +54,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
||||
Sammlung hinzufügen“ fest.
|
||||
</p>
|
||||
</div>
|
||||
<RebrickableImport enabled={importEnabled} />
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
oder von Hand
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { importSetFromRebrickable } from '@/app/(app)/katalog/import-actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
/**
|
||||
* One-field Lego import: type the number from the box, get a finished catalog
|
||||
* entry. Hidden entirely when no API key is configured.
|
||||
*/
|
||||
export function RebrickableImport({ enabled }: { enabled: boolean }) {
|
||||
const router = useRouter();
|
||||
const [setNumber, setSetNumber] = useState('');
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle>Lego-Import</CardTitle>
|
||||
<CardDescription>
|
||||
Mit einem Rebrickable-API-Key füllt sich das Formular von selbst: Setnummer eingeben,
|
||||
und Name, Jahr, Teile, Thema und Bild kommen automatisch. Dafür{' '}
|
||||
<code className="font-mono text-xs">REBRICKABLE_API_KEY</code> in die{' '}
|
||||
<code className="font-mono text-xs">.env</code> eintragen und den Container neu
|
||||
starten.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await importSetFromRebrickable(setNumber);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = [
|
||||
result.data.imported.image ? null : 'Bild',
|
||||
result.data.imported.theme ? null : 'Thema',
|
||||
].filter(Boolean);
|
||||
|
||||
toast.success(
|
||||
missing.length === 0
|
||||
? `${result.data.setNumber} „${result.data.name}“ übernommen.`
|
||||
: `${result.data.setNumber} übernommen – ohne ${missing.join(' und ')}.`,
|
||||
);
|
||||
|
||||
router.push(`/katalog/${result.data.setId}`);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lego-Import</CardTitle>
|
||||
<CardDescription>
|
||||
Setnummer von der Packung eintippen – Name, Jahr, Teile, Minifiguren, Thema und Bild
|
||||
werden übernommen. Das Bild wird lokal gespeichert.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className="flex items-end gap-2">
|
||||
<Field label="Setnummer" htmlFor="import-number" className="flex-1">
|
||||
<Input
|
||||
id="import-number"
|
||||
value={setNumber}
|
||||
onChange={(event) => setSetNumber(event.target.value)}
|
||||
placeholder="60398"
|
||||
inputMode="numeric"
|
||||
maxLength={40}
|
||||
className="font-mono"
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" size="lg" disabled={pending || setNumber.trim().length === 0}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Download />}
|
||||
Holen
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+16
-1
@@ -31,8 +31,23 @@ const schema = z.object({
|
||||
REBRICKABLE_API_KEY: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* An empty value counts as "not set".
|
||||
*
|
||||
* A line like `BASE_URL=` in a .env file is a common slip, and so is an empty
|
||||
* variable injected by a container runtime. Falling back to the default beats
|
||||
* failing to start with "Invalid URL".
|
||||
*/
|
||||
function withoutEmptyValues(source: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (typeof value === 'string' && value.trim() !== '') result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseEnv() {
|
||||
const parsed = schema.safeParse(process.env);
|
||||
const parsed = schema.safeParse(withoutEmptyValues(process.env));
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'server-only';
|
||||
import { env } from '@/env';
|
||||
import { displaySetNumber, normalizeSetNumber } from './set-number';
|
||||
|
||||
export { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from './set-number';
|
||||
|
||||
/**
|
||||
* Rebrickable lookup for Lego sets.
|
||||
*
|
||||
* Entirely optional: without REBRICKABLE_API_KEY the import stays switched
|
||||
* off and everything keeps working by hand. Every request runs here on the
|
||||
* server - the browser never talks to rebrickable.com.
|
||||
*/
|
||||
|
||||
const API_BASE = 'https://rebrickable.com/api/v3/lego';
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export function isRebrickableEnabled(): boolean {
|
||||
return Boolean(env.REBRICKABLE_API_KEY);
|
||||
}
|
||||
|
||||
export class RebrickableError extends Error {}
|
||||
|
||||
export type RebrickableSet = {
|
||||
setNumber: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
pieceCount: number | null;
|
||||
themeId: number | null;
|
||||
imageUrl: string | null;
|
||||
externalUrl: string | null;
|
||||
};
|
||||
|
||||
export type RebrickableTheme = {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
};
|
||||
|
||||
async function request<T>(path: string): Promise<T | null> {
|
||||
const key = env.REBRICKABLE_API_KEY;
|
||||
if (!key) throw new RebrickableError('Für den Import fehlt der Rebrickable-API-Key.');
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
Authorization: `key ${key}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
// The catalog is not a moving target; a day of caching is plenty.
|
||||
next: { revalidate: 86_400 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[rebrickable] Anfrage fehlgeschlagen', path, error);
|
||||
throw new RebrickableError('Rebrickable ist gerade nicht erreichbar.');
|
||||
}
|
||||
|
||||
if (response.status === 404) return null;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new RebrickableError('Der Rebrickable-API-Key wird abgelehnt. Bitte prüf ihn.');
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new RebrickableError('Rebrickable drosselt gerade. Bitte in einer Minute noch einmal.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RebrickableError(`Rebrickable antwortet mit Status ${response.status}.`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
type SetResponse = {
|
||||
set_num: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
theme_id: number | null;
|
||||
num_parts: number | null;
|
||||
set_img_url: string | null;
|
||||
set_url: string | null;
|
||||
};
|
||||
|
||||
export async function fetchSet(setNumber: string): Promise<RebrickableSet | null> {
|
||||
const data = await request<SetResponse>(`/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/`);
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
setNumber: displaySetNumber(data.set_num),
|
||||
name: data.name,
|
||||
year: data.year ?? null,
|
||||
pieceCount: data.num_parts ?? null,
|
||||
themeId: data.theme_id ?? null,
|
||||
imageUrl: data.set_img_url ?? null,
|
||||
externalUrl: data.set_url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type ThemeResponse = { id: number; name: string; parent_id: number | null };
|
||||
|
||||
export async function fetchTheme(themeId: number): Promise<RebrickableTheme | null> {
|
||||
const data = await request<ThemeResponse>(`/themes/${themeId}/`);
|
||||
if (!data) return null;
|
||||
return { id: data.id, name: data.name, parentId: data.parent_id ?? null };
|
||||
}
|
||||
|
||||
type MinifigsResponse = { results: Array<{ quantity: number | null }> };
|
||||
|
||||
/** Number of minifigures, or null when the lookup does not work out. */
|
||||
export async function fetchMinifigCount(setNumber: string): Promise<number | null> {
|
||||
try {
|
||||
const data = await request<MinifigsResponse>(
|
||||
`/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/minifigs/`,
|
||||
);
|
||||
if (!data) return null;
|
||||
return data.results.reduce((total, row) => total + (row.quantity ?? 0), 0);
|
||||
} catch {
|
||||
// A missing minifig list must not fail the whole import.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the set image so it keeps working when Rebrickable changes or
|
||||
* removes it. Returns null instead of throwing: an import without a picture
|
||||
* is still a good import.
|
||||
*/
|
||||
export async function fetchImageBytes(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return buffer.byteLength > 0 ? buffer : null;
|
||||
} catch (error) {
|
||||
console.error('[rebrickable] Bild konnte nicht geladen werden', url, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Set-number handling for the Lego import.
|
||||
*
|
||||
* Deliberately free of server-only imports so it can be unit tested and, if
|
||||
* ever needed, used on the client.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rebrickable numbers a set's variant explicitly: the box says "60398", the
|
||||
* API knows "60398-1". People type what is printed on the box, so the suffix
|
||||
* is added when it is missing.
|
||||
*/
|
||||
export function normalizeSetNumber(input: string): string {
|
||||
const trimmed = input.trim().toUpperCase();
|
||||
return /-\d+$/.test(trimmed) ? trimmed : `${trimmed}-1`;
|
||||
}
|
||||
|
||||
/** The number as it should be stored and shown: without the "-1" variant. */
|
||||
export function displaySetNumber(setNum: string): string {
|
||||
return setNum.trim().replace(/-1$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lego publishes building instructions per set number. Rebrickable's API does
|
||||
* not expose instruction files, so the import links to Lego's own service page
|
||||
* rather than pretending to have the PDF.
|
||||
*/
|
||||
export function legoInstructionsUrl(setNumber: string): string {
|
||||
return `https://www.lego.com/de-de/service/building-instructions/${encodeURIComponent(
|
||||
displaySetNumber(setNumber),
|
||||
)}`;
|
||||
}
|
||||
+18
-1
@@ -88,8 +88,25 @@ export type StoredImage = {
|
||||
*/
|
||||
export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise<StoredImage> {
|
||||
assertSize(file);
|
||||
return storeImageBytes(Buffer.from(await file.arrayBuffer()), kind, ownerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `storeImage`, but for bytes we already hold - for instance an image
|
||||
* the server fetched from an import source. The size limit still applies.
|
||||
*/
|
||||
export async function storeImageBytes(
|
||||
bytes: Buffer,
|
||||
kind: UploadKind,
|
||||
ownerId: string,
|
||||
): Promise<StoredImage> {
|
||||
if (bytes.byteLength === 0) throw new UploadError('Die Datei ist leer.');
|
||||
if (bytes.byteLength > env.MAX_UPLOAD_BYTES) {
|
||||
throw new UploadError(
|
||||
`Das Bild ist größer als ${env.MAX_UPLOAD_MB} MB und wurde nicht übernommen.`,
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const mime = detectMimeType(bytes);
|
||||
if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) {
|
||||
throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.');
|
||||
|
||||
@@ -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';
|
||||
@@ -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 {};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,14 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(root, './src'),
|
||||
// `server-only` is a build-time marker that throws when imported outside
|
||||
// a server component. Stubbing it keeps server modules testable.
|
||||
'server-only': path.resolve(root, './tests/stubs/server-only.ts'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
setupFiles: ['./tests/setup-env.ts'],
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user