Files
pc_builder/tests/functional/scraped_parts.spec.ts
T
Kevin L'Hereec 21d56a5da4
Build & Deploy / build (push) Failing after 3s
Build & Deploy / docker (push) Skipped
fix: stop protecting stale, unverified pc-builder.io catalog prices
Bug found: "Gigabyte GAMING OC GeForce RTX 5070 Ti 16 GB Video Card" kept
showing a stale price of 431.17 even after pc-builder.io itself no longer
had a fixed offer for it (its "Add to Build" HTML only had a generic
Amazon search link, no .price-box element at all, confirmed by fetching
the live page directly).

Root cause: scrapeComponentType's "never lose a better price" rule (added
to make ScrapedPartsController#compare's persisted merchant prices
durable across catalog refreshes) was too broad. It treated ANY existing
price as worth protecting whenever the incoming catalog price was null or
higher - including a price that had only ever come from a *previous*
pc-builder.io scrape itself (priceSource: null), with no independent
verification. Since pc-builder.io's own listings change constantly (a
GPU can lose its priced Amazon link if the referenced ASIN goes out of
stock), that logic effectively froze old pc-builder.io prices forever
even after the site removed them.

Fix: extracted the decision into shouldKeepExistingPrice(), which now
only protects a price when priceSource is a real merchant name (set by
updatePriceIfCheaper after a live comparison confirmed it on the
merchant's own page) - never when priceSource is null (an unverified,
previous pc-builder.io catalog price). A plain re-scrape can always
correct/clear an unverified price, matching whatever pc-builder.io
currently shows.

4 new unit tests for shouldKeepExistingPrice covering: unverified
pc-builder.io price gets overwritten (the regression case), verified
merchant price is protected from worse/missing catalog data, verified
merchant price is still replaced by a genuinely cheaper catalog price,
and no-existing-part returns false.

Also re-ran `node ace scrape:parts` to apply the fix to already-cached
data with stale unverified prices (affected ~1500 rows across the
catalog before the fix, mostly likely unaffected in practice but no
longer silently trusted going forward).
2026-07-23 16:34:48 +02:00

254 lines
8.8 KiB
TypeScript

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))
})
})