Several memory sticks (e.g. Crucial CT8G48C40U5, Patriot Signature Line,
Kingston FURY Beast) kept displaying their pc-builder.io catalog price
("€35.99", "€39.99"...) even after clicking "Compare prices" and getting
"No live price found, using catalog price" - an unconfirmed number was
being shown as if it were still valid, which is misleading given
pc-builder.io's catalog is known to go stale (see the earlier RTX 5070
€128 vs €66.89 case).
New clearPriceIfNoOffers(): when ScrapedPartsController#compare finds
zero offers across all 7 supported merchants, the part's price/priceSource
are reset to null, regardless of whether the price previously came from
the pc-builder.io catalog or an earlier verified merchant match - per
explicit product decision, an empty comparison always wins over keeping a
possibly-stale number around.
- ScrapedPartsController#compare now calls clearPriceIfNoOffers() in the
empty-offers branch, and returns the resulting price in the JSON
response so the frontend can mirror it exactly
- NewPartSearch.vue: comparePrices() now sets part.price from the
response's `price` field (mirroring exactly what the backend just
persisted) instead of only updating it on a cheaper find; removed the
now-inaccurate "using catalog price €X" message since that price no
longer exists once compared with no result
- 3 new tests for clearPriceIfNoOffers: clears an unverified
pc-builder.io price, also clears a previously-verified merchant price
when no longer confirmed, and is a no-op when there was no price to
begin with
Verified end-to-end against the reported Crucial CT8G48C40U5 example:
price went from 35.99 (pc-builder.io) to null after a real comparison
found zero matching offers on any merchant.
302 lines
10 KiB
TypeScript
302 lines
10 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,
|
|
clearPriceIfNoOffers,
|
|
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('clearPriceIfNoOffers', () => {
|
|
test('clears an unverified pc-builder.io catalog price when no merchant confirms it', async ({
|
|
assert,
|
|
}) => {
|
|
// Regression: memory sticks kept showing pc-builder.io catalog prices
|
|
// (e.g. €35.99) even though comparing prices found no matching offer
|
|
// on any merchant ("No live price found, using catalog price €35.99")
|
|
// — an unconfirmed number should not be displayed as if it were still
|
|
// valid.
|
|
const type = await createType('Memory-clear-1')
|
|
const part = await createScrapedPart(type.id, 'ZZZ-Test-Clear-A', 'clear-a-p1', 35.99)
|
|
|
|
await clearPriceIfNoOffers(part)
|
|
await part.refresh()
|
|
|
|
assert.isNull(part.price)
|
|
assert.isNull(part.priceSource)
|
|
})
|
|
|
|
test('also clears a price previously verified by a merchant if no longer confirmed', async ({
|
|
assert,
|
|
}) => {
|
|
const type = await createType('Memory-clear-2')
|
|
const part = await createScrapedPart(type.id, 'ZZZ-Test-Clear-B', 'clear-b-p1', 50)
|
|
part.priceSource = 'LDLC'
|
|
await part.save()
|
|
|
|
await clearPriceIfNoOffers(part)
|
|
await part.refresh()
|
|
|
|
assert.isNull(part.price)
|
|
assert.isNull(part.priceSource)
|
|
})
|
|
|
|
test('is a no-op when the part already has no price', async ({ assert }) => {
|
|
const type = await createType('Memory-clear-3')
|
|
const part = await createScrapedPart(type.id, 'ZZZ-Test-Clear-C', 'clear-c-p1', null)
|
|
const originalCheckedAt = part.priceCheckedAt
|
|
|
|
await clearPriceIfNoOffers(part)
|
|
await part.refresh()
|
|
|
|
assert.isNull(part.price)
|
|
assert.equal(part.priceCheckedAt, originalCheckedAt)
|
|
})
|
|
})
|
|
|
|
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))
|
|
})
|
|
})
|