recipe-manager/src/backend/tests/import.test.ts

272 lines
8.6 KiB
TypeScript

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<typeof vi.spyOn>;
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 = `
<html>
<head>
<script type="application/ld+json">{"@type":"Recipe","name":"Pancakes","recipeIngredient":["Flour","Eggs"],"recipeInstructions":["Mix","Cook"]}</script>
</head>
<body>Hello</body>
</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/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 = `
<html>
<head>
<script type="application/ld+json">{"@type":["Thing","Recipe"],"name":" Tomato Soup ","description":" Cozy weeknight soup. ","recipeIngredient":[" Tomato ",""," Salt "],"recipeInstructions":[{"text":" Simmer tomatoes. "},{"text":" Blend and serve. "}],"url":" https://example.com/soup "}</script>
</head>
</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 = `
<html>
<head><title>Easy Banana Bread | Example</title></head>
<body>
<h1>Easy Banana Bread</h1>
<h2>Ingredients</h2>
<ul>
<li>3 ripe bananas</li>
<li>2 cups flour</li>
</ul>
<h2>Instructions</h2>
<ol>
<li>Mash bananas.</li>
<li>Bake at 350°F for 50 minutes.</li>
</ol>
</body>
</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/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 = `
<html>
<head>
<script type="application/ld+json">{"@type":"Event","name":"Not a Recipe"}</script>
</head>
</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 = `
<html>
<head>
<script type="application/ld+json">{"@type":"Recipe","name":"Broken JSON"</script>
<script type="application/ld+json">{"@type":"Thing","name":"Still not recipe"}</script>
</head>
</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 = '<html><body><h1>Retry Recipe</h1><h2>Ingredients</h2><ul><li>1 egg</li></ul><h2>Instructions</h2><ol><li>Cook it.</li></ol></body></html>';
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');
});
});