Initial commit: Price Hunter — self-hosted price comparison engine
Complete application scaffolding with: - Backend: Node.js + Fastify + sql.js (SQLite) - Frontend: SvelteKit + Tailwind CSS - Scraper engine with parallel fan-out, rate limiting, cheerio-based parsing - Store management with CSS selector config and per-store test pages - Docker setup for single-command deployment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
tests/server/scraper/http-scraper.test.ts
Normal file
70
tests/server/scraper/http-scraper.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scrapeStore } from '../../../src/server/scraper/http-scraper.js';
|
||||
import type { Store } from '../../../src/server/models/store.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sampleHtml = fs.readFileSync(path.join(__dirname, '../../fixtures/sample-store.html'), 'utf-8');
|
||||
|
||||
const mockStore: Store = {
|
||||
id: 1,
|
||||
name: 'Test Store',
|
||||
slug: 'test-store',
|
||||
base_url: 'https://teststore.com',
|
||||
search_url: 'https://teststore.com/search?q={query}',
|
||||
enabled: 1,
|
||||
sel_container: '.product-card',
|
||||
sel_name: '.product-title',
|
||||
sel_price: '.product-price',
|
||||
sel_link: 'a',
|
||||
sel_image: '.product-image',
|
||||
rate_limit: 2,
|
||||
rate_window: 1000,
|
||||
proxy_url: null,
|
||||
user_agent: null,
|
||||
headers_json: null,
|
||||
currency: 'EUR',
|
||||
category_id: null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('scrapeStore', () => {
|
||||
it('extracts products from HTML using CSS selectors', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
text: () => Promise.resolve(sampleHtml),
|
||||
status: 200,
|
||||
}));
|
||||
|
||||
const result = await scrapeStore(mockStore, 'https://teststore.com/search?q=headphones');
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.items).toHaveLength(4);
|
||||
expect(result.items[0]).toEqual({
|
||||
name: 'Sony WH-1000XM5 Wireless Headphones',
|
||||
priceText: '€299,99',
|
||||
link: '/products/sony-wh1000xm5',
|
||||
image: '/images/xm5.jpg',
|
||||
});
|
||||
|
||||
// Item without image
|
||||
expect(result.items[3].name).toBe('Bose QuietComfort 45');
|
||||
expect(result.items[3].image).toBe(null);
|
||||
});
|
||||
|
||||
it('returns empty items when no containers match', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
text: () => Promise.resolve('<html><body>No products here</body></html>'),
|
||||
status: 200,
|
||||
}));
|
||||
|
||||
const result = await scrapeStore(mockStore, 'https://teststore.com/search?q=nothing');
|
||||
expect(result.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
51
tests/server/scraper/result-parser.test.ts
Normal file
51
tests/server/scraper/result-parser.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePrice, normalizeUrl } from '../../../src/server/scraper/result-parser.js';
|
||||
|
||||
describe('parsePrice', () => {
|
||||
it('parses US format prices', () => {
|
||||
expect(parsePrice('$12.99')).toBe(12.99);
|
||||
expect(parsePrice('$1,299.00')).toBe(1299);
|
||||
expect(parsePrice('$0.50')).toBe(0.5);
|
||||
});
|
||||
|
||||
it('parses European format prices', () => {
|
||||
expect(parsePrice('€12,99')).toBe(12.99);
|
||||
expect(parsePrice('1.299,00 EUR')).toBe(1299);
|
||||
expect(parsePrice('€299,99')).toBe(299.99);
|
||||
});
|
||||
|
||||
it('handles free', () => {
|
||||
expect(parsePrice('free')).toBe(0);
|
||||
expect(parsePrice('FREE')).toBe(0);
|
||||
expect(parsePrice('gratis')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles price ranges', () => {
|
||||
expect(parsePrice('$12 - $15')).toBe(12);
|
||||
expect(parsePrice('€10,00 – €20,00')).toBe(10);
|
||||
});
|
||||
|
||||
it('handles plain numbers', () => {
|
||||
expect(parsePrice('42')).toBe(42);
|
||||
expect(parsePrice('12.99')).toBe(12.99);
|
||||
});
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(parsePrice('')).toBe(null);
|
||||
expect(parsePrice('abc')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeUrl', () => {
|
||||
it('returns absolute URLs as-is', () => {
|
||||
expect(normalizeUrl('https://example.com/product', 'https://base.com')).toBe('https://example.com/product');
|
||||
});
|
||||
|
||||
it('resolves relative URLs against base', () => {
|
||||
expect(normalizeUrl('/products/123', 'https://store.com')).toBe('https://store.com/products/123');
|
||||
});
|
||||
|
||||
it('returns base URL for empty href', () => {
|
||||
expect(normalizeUrl('', 'https://store.com')).toBe('https://store.com');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user