import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { mkdtemp, rm, readFile } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { openInMemoryForTest } from '../../src/lib/server/db'; import { importRecipe, previewRecipe, ImporterError } from '../../src/lib/server/recipes/importer'; const here = dirname(fileURLToPath(import.meta.url)); const fixtureHtml = readFileSync(join(here, '../fixtures', 'chefkoch-schupfnudeln.html'), 'utf8'); // 1×1 PNG const PNG = Buffer.from( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' + '0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082', 'hex' ); let server: Server; let baseUrl: string; let imgDir: string; beforeEach(async () => { server = createServer((req, res) => { if (req.url?.startsWith('/recipe')) { // Return our fixture but swap the image URL to point to our local server const patched = fixtureHtml.replace( /https?:\/\/img\.chefkoch-cdn\.de\/[^"']+/g, `${baseUrl}/image.png` ); res.writeHead(200, { 'content-type': 'text/html' }); res.end(patched); return; } if (req.url?.startsWith('/image')) { res.writeHead(200, { 'content-type': 'image/png' }); res.end(PNG); return; } if (req.url === '/bare') { res.writeHead(200, { 'content-type': 'text/html' }); res.end('no recipe'); return; } res.writeHead(404); res.end(); }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${addr.port}`; imgDir = await mkdtemp(join(tmpdir(), 'kochwas-imp-')); }); afterEach(async () => { await new Promise((r) => server.close(() => r())); await rm(imgDir, { recursive: true, force: true }); }); describe('previewRecipe', () => { it('accepts any domain — manuelle URL-Importe sind nicht auf die Whitelist beschränkt', async () => { const db = openInMemoryForTest(); // keine Domain in der Whitelist — preview muss trotzdem klappen const r = await previewRecipe(db, `${baseUrl}/recipe`); expect(r.title.toLowerCase()).toContain('schupfnudel'); expect(r.source_url).toBe(`${baseUrl}/recipe`); expect(r.ingredients.length).toBeGreaterThan(0); }); it('throws NO_RECIPE_FOUND when HTML has no Recipe JSON-LD', async () => { const db = openInMemoryForTest(); await expect(previewRecipe(db, `${baseUrl}/bare`)).rejects.toMatchObject({ code: 'NO_RECIPE_FOUND' }); }); it('throws INVALID_URL for malformed input', async () => { const db = openInMemoryForTest(); await expect(previewRecipe(db, 'not a url')).rejects.toMatchObject({ code: 'INVALID_URL' }); }); }); describe('importRecipe', () => { it('imports, persists, and is idempotent', async () => { const db = openInMemoryForTest(); const first = await importRecipe(db, imgDir, `${baseUrl}/recipe`); expect(first.duplicate).toBe(false); expect(first.id).toBeGreaterThan(0); expect(first.recipe.ingredients.length).toBeGreaterThan(0); expect(first.recipe.image_path).not.toBeNull(); const saved = await readFile(join(imgDir, first.recipe.image_path!)); expect(saved.equals(PNG)).toBe(true); const second = await importRecipe(db, imgDir, `${baseUrl}/recipe`); expect(second.duplicate).toBe(true); expect(second.id).toBe(first.id); }); it('surfaces ImporterError type when no recipe on page', async () => { const db = openInMemoryForTest(); await expect(importRecipe(db, imgDir, `${baseUrl}/bare`)).rejects.toBeInstanceOf( ImporterError ); }); });