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).
254 lines
8.8 KiB
TypeScript
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))
|
|
})
|
|
})
|