import { test } from '@japa/runner' import { DateTime } from 'luxon' import User from '#models/user' import ComponentType from '#models/component_type' import ScrapedPart from '#models/scraped_part' import { isCategoryStale, ensureFreshCategory, updatePriceIfCheaper, shouldKeepExistingPrice, CACHE_TTL_HOURS, } from '#services/part_scraper_service' async function createUser(email: string) { return User.create({ name: 'Test User', email, password: 'secret@123A', }) } async function createType(name: string, unique = true) { return ComponentType.firstOrCreate({ name }, { name, unique }) } async function createScrapedPart( typeId: number, name: string, sourceRef: string, price: number | null = 199.99 ) { return ScrapedPart.create({ typeId, name, price, imageUrl: 'https://fr.pc-builder.io/images/cpu/example.jpg', source: 'pc-builder.io', sourceUrl: `https://fr.pc-builder.io/cpu/${sourceRef}`, sourceRef, scrapedAt: DateTime.now(), }) } test.group('Scraped parts search', () => { test('an authenticated user can search scraped parts by name', async ({ client, assert }) => { const user = await createUser('scraped-search-a@example.com') const type = await createType('Processor-scraped-1') await createScrapedPart(type.id, 'ZZZ-Test-Ryzen-Unique 5 7600X', 'amd-ryzen-5-7600x-p1311') await createScrapedPart(type.id, 'ZZZ-Test-Intel-Unique i5-13600K', 'intel-i5-13600k-p9999') const response = await client.get('/scraped-parts?q=ZZZ-Test-Ryzen-Unique').loginAs(user) response.assertStatus(200) const body = response.body() assert.lengthOf(body, 1) assert.equal(body[0].name, 'ZZZ-Test-Ryzen-Unique 5 7600X') assert.equal(body[0].type.id, type.id) }) test('search can be filtered by typeId', async ({ client, assert }) => { const user = await createUser('scraped-search-b@example.com') const cpuType = await createType('Processor-scraped-2') const gpuType = await createType('Graphic Card-scraped-2') await createScrapedPart(cpuType.id, 'ZZZ-Test-AMD Ryzen 7 7800X3D', 'amd-ryzen-7-7800x3d-p1') await createScrapedPart(gpuType.id, 'ZZZ-Test-AMD Radeon RX 7800', 'amd-radeon-rx-7800-p2') const response = await client .get(`/scraped-parts?q=ZZZ-Test-AMD&typeId=${gpuType.id}`) .loginAs(user) response.assertStatus(200) const body = response.body() assert.lengthOf(body, 1) assert.equal(body[0].name, 'ZZZ-Test-AMD Radeon RX 7800') }) test('an unauthenticated request is redirected to login', async ({ client }) => { const response = await client.get('/scraped-parts?q=ryzen') response.assertRedirectsTo('/auth/login') }) test('parts without a known price are kept and sorted last', async ({ client, assert }) => { const user = await createUser('scraped-search-c@example.com') const type = await createType('Graphic Card-scraped-3') await createScrapedPart(type.id, 'ZZZ-Test-RTX Cheap', 'rtx-cheap-p1', 300) await createScrapedPart(type.id, 'ZZZ-Test-RTX Expensive', 'rtx-expensive-p2', 900) await createScrapedPart(type.id, 'ZZZ-Test-RTX NoPrice', 'rtx-noprice-p3', null) const response = await client.get('/scraped-parts?q=ZZZ-Test-RTX').loginAs(user) response.assertStatus(200) const body = response.body() assert.lengthOf(body, 3) assert.equal(body[0].name, 'ZZZ-Test-RTX Cheap') assert.equal(body[1].name, 'ZZZ-Test-RTX Expensive') assert.equal(body[2].name, 'ZZZ-Test-RTX NoPrice') assert.isNull(body[2].price) }) }) test.group('Scraped parts 24h cache freshness', () => { test('a category with no cached parts is considered stale', async ({ assert }) => { const type = await createType('Processor-freshness-1') assert.isTrue(await isCategoryStale(type)) }) test('a category scraped within the last 24h is not stale', async ({ assert }) => { const type = await createType('Processor-freshness-2') await ScrapedPart.create({ typeId: type.id, name: 'ZZZ-Test-Fresh Part', price: 100, imageUrl: null, source: 'pc-builder.io', sourceUrl: 'https://fr.pc-builder.io/cpu/fresh-part', sourceRef: 'fresh-part-ref', scrapedAt: DateTime.now().minus({ hours: 1 }), }) assert.isFalse(await isCategoryStale(type)) }) test('a category scraped more than 24h ago is stale', async ({ assert }) => { const type = await createType('Processor-freshness-3') await ScrapedPart.create({ typeId: type.id, name: 'ZZZ-Test-Stale Part', price: 100, imageUrl: null, source: 'pc-builder.io', sourceUrl: 'https://fr.pc-builder.io/cpu/stale-part', sourceRef: 'stale-part-ref', scrapedAt: DateTime.now().minus({ hours: CACHE_TTL_HOURS + 1 }), }) assert.isTrue(await isCategoryStale(type)) }) test('ensureFreshCategory does not scrape unmapped types (no network call)', async ({ assert, }) => { // "Other" is a real, unmapped ComponentType (see CATEGORY_SLUGS): this // must resolve without making any outbound HTTP request. const type = await createType('Other-freshness-unmapped', false) await assert.doesNotReject(() => ensureFreshCategory(type)) }) }) test.group('Scraped parts merchant price comparison', () => { test('an unauthenticated request is redirected to login', async ({ client }) => { const response = await client.get('/scraped-parts/compare?id=1') response.assertRedirectsTo('/auth/login') }) test('comparing an unknown scraped part returns 404', async ({ client }) => { const user = await createUser('compare-unknown@example.com') const response = await client.get('/scraped-parts/compare?id=999999999').loginAs(user) response.assertStatus(404) }) test('an invalid id is rejected by validation', async ({ client }) => { const user = await createUser('compare-invalid@example.com') const response = await client .get('/scraped-parts/compare?id=not-a-number') .header('Accept', 'application/json') .loginAs(user) response.assertStatus(422) }) }) test.group('Best-known-price persistence', () => { test('a cheaper offer replaces the current price', async ({ assert }) => { const type = await createType('Graphic Card-price-1') const part = await createScrapedPart(type.id, 'ZZZ-Test-Price-A', 'price-a-p1', 500) await updatePriceIfCheaper(part, { merchant: 'LDLC', price: 350, url: 'https://ldlc.com/x' }) await part.refresh() assert.equal(part.price, 350) assert.equal(part.priceSource, 'LDLC') assert.equal(part.sourceUrl, 'https://ldlc.com/x') assert.isNotNull(part.priceCheckedAt) }) test('a more expensive offer does not replace the current price', async ({ assert }) => { const type = await createType('Graphic Card-price-2') const part = await createScrapedPart(type.id, 'ZZZ-Test-Price-B', 'price-b-p1', 300) await updatePriceIfCheaper(part, { merchant: 'LDLC', price: 450, url: 'https://ldlc.com/y' }) await part.refresh() assert.equal(part.price, 300) assert.isNull(part.priceSource) }) test('an offer is accepted when no price was known yet', async ({ assert }) => { const type = await createType('Graphic Card-price-3') const part = await createScrapedPart(type.id, 'ZZZ-Test-Price-C', 'price-c-p1', null) await updatePriceIfCheaper(part, { merchant: 'Grosbill', price: 599.99, url: 'https://grosbill.com/z', }) await part.refresh() assert.equal(part.price, 599.99) assert.equal(part.priceSource, 'Grosbill') }) }) test.group('Catalog refresh price protection (shouldKeepExistingPrice)', () => { test('an unverified pc-builder.io price is NOT protected and gets overwritten', ({ assert }) => { // Regression test: a GPU had price=431.17 from a previous pc-builder.io // catalog scrape (priceSource: null, never independently verified). // pc-builder.io later stopped showing a fixed price for it (only a // generic Amazon search link). The stale 431.17 must NOT be kept. const existing = { price: 431.17, priceSource: null } assert.isFalse(shouldKeepExistingPrice(existing, null)) assert.isFalse(shouldKeepExistingPrice(existing, 999)) }) test('a verified merchant price IS protected from a worse/missing catalog price', ({ assert, }) => { const existing = { price: 350, priceSource: 'LDLC' } assert.isTrue(shouldKeepExistingPrice(existing, null)) assert.isTrue(shouldKeepExistingPrice(existing, 500)) }) test('a verified merchant price is replaced if the catalog price is actually cheaper', ({ assert, }) => { const existing = { price: 350, priceSource: 'LDLC' } assert.isFalse(shouldKeepExistingPrice(existing, 200)) }) test('no existing part means nothing to keep', ({ assert }) => { assert.isFalse(shouldKeepExistingPrice(null, 100)) assert.isFalse(shouldKeepExistingPrice(null, null)) }) })