272 lines
8.6 KiB
TypeScript
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');
|
|
});
|
|
});
|