import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; import { createImportRoutes } from '../routes/import.js'; describe('Import API', () => { let app: express.Application; let infoSpy: ReturnType; beforeEach(() => { infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); app = express(); app.use(express.json()); app.use('/api/import', createImportRoutes()); }); afterEach(() => { vi.restoreAllMocks(); }); it('should validate URL request payload', async () => { const response = await request(app) .post('/api/import/url') .send({ url: 'not-a-url' }) .expect(400); expect(response.body.success).toBe(false); expect(response.body.error).toBeDefined(); }); it('should return imported foundation data and normalized draft for valid Schema.org recipe', async () => { const html = ` Hello `; vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), text: async () => html, } as Response); const response = await request(app) .post('/api/import/url') .send({ url: 'https://example.com/recipe' }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data.source_url).toBe('https://example.com/recipe'); expect(response.body.data.json_ld_blocks).toEqual([ '{"@type":"Recipe","name":"Pancakes","recipeIngredient":["Flour","Eggs"],"recipeInstructions":["Mix","Cook"]}' ]); expect(response.body.data.draft_recipe).toMatchObject({ title: 'Pancakes', ingredients: ['Flour', 'Eggs'], instructions: ['Mix', 'Cook'] }); expect(infoSpy).toHaveBeenCalledWith( '[import.telemetry]', expect.stringContaining('"event":"import_success"') ); expect(infoSpy).toHaveBeenCalledWith( '[import.telemetry]', expect.stringContaining('"parser":"schema_org"') ); }); it('should normalize whitespace and HowToStep instructions into draft format', async () => { const html = ` `; vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), text: async () => html, } as Response); const response = await request(app) .post('/api/import/url') .send({ url: 'https://example.com/soup-page' }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data.draft_recipe).toEqual({ title: 'Tomato Soup', description: 'Cozy weeknight soup.', ingredients: ['Tomato', 'Salt'], instructions: ['Simmer tomatoes.', 'Blend and serve.'], source_url: 'https://example.com/soup' }); }); it('should use heuristic fallback parser when Schema.org data is missing', async () => { const html = ` Easy Banana Bread | Example

Easy Banana Bread

Ingredients

Instructions

  1. Mash bananas.
  2. Bake at 350°F for 50 minutes.
`; vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), text: async () => html, } as Response); const response = await request(app) .post('/api/import/url') .send({ url: 'https://example.com/banana-bread' }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data.json_ld_blocks).toEqual([]); expect(response.body.data.draft_recipe).toEqual({ title: 'Easy Banana Bread', ingredients: ['3 ripe bananas', '2 cups flour'], instructions: ['Mash bananas.', 'Bake at 350°F for 50 minutes.'], source_url: 'https://example.com/banana-bread' }); }); it('should return draft_recipe as null for non-recipe JSON-LD', async () => { const html = ` `; vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), text: async () => html, } as Response); const response = await request(app) .post('/api/import/url') .send({ url: 'https://example.com/event' }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data.draft_recipe).toBeNull(); }); it('should ignore malformed JSON-LD and return null draft when no valid recipe blocks exist', async () => { const html = ` `; vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), text: async () => html, } as Response); const response = await request(app) .post('/api/import/url') .send({ url: 'https://example.com/malformed-jsonld' }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data.json_ld_blocks).toEqual([ '{"@type":"Recipe","name":"Broken JSON"', '{"@type":"Thing","name":"Still not recipe"}' ]); expect(response.body.data.draft_recipe).toBeNull(); }); it('should retry transient fetch failures and eventually succeed', async () => { const html = '

Retry Recipe

Ingredients

Instructions

  1. Cook it.
'; let callCount = 0; vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { callCount += 1; if (callCount < 3) { throw new Error('temporary network issue'); } return { ok: true, status: 200, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), text: async () => html, } as Response; }); const response = await request(app) .post('/api/import/url') .send({ url: 'https://example.com/retry-recipe' }) .expect(200); expect(callCount).toBe(3); expect(response.body.success).toBe(true); expect(response.body.data.draft_recipe).toMatchObject({ title: 'Retry Recipe', ingredients: ['1 egg'], instructions: ['Cook it.'], }); }); it('should return timeout-friendly message after retries are exhausted', async () => { const timeoutError = new Error('aborted'); timeoutError.name = 'AbortError'; vi.spyOn(globalThis, 'fetch').mockRejectedValue(timeoutError); const response = await request(app) .post('/api/import/url') .send({ url: 'https://example.com/slow-recipe' }) .expect(504); expect(response.body.success).toBe(false); expect(response.body.error).toContain('timed out'); expect(infoSpy).toHaveBeenCalledWith( '[import.telemetry]', expect.stringContaining('"failureCode":"IMPORT_TIMEOUT"') ); }); it('should return an error for non-HTML responses', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), text: async () => '{"ok":true}', } as Response); const response = await request(app) .post('/api/import/url') .send({ url: 'https://example.com/data.json' }) .expect(400); expect(response.body.success).toBe(false); expect(response.body.error).toContain('HTML'); }); });