diff --git a/app/controllers/scraped_parts_controller.ts b/app/controllers/scraped_parts_controller.ts index 15c0883..0326cea 100644 --- a/app/controllers/scraped_parts_controller.ts +++ b/app/controllers/scraped_parts_controller.ts @@ -2,7 +2,11 @@ import type { HttpContext } from '@adonisjs/core/http' import vine from '@vinejs/vine' import ScrapedPart from '#models/scraped_part' import ComponentType from '#models/component_type' -import { ensureFreshCategory, updatePriceIfCheaper } from '#services/part_scraper_service' +import { + ensureFreshCategory, + updatePriceIfCheaper, + clearPriceIfNoOffers, +} from '#services/part_scraper_service' import { findCheapestOffers } from '#services/merchant_price_service' const SEARCH_RESULTS_LIMIT = 150 @@ -76,6 +80,11 @@ export default class ScrapedPartsController { * (or no price was known yet), it's persisted as the new default price * (see updatePriceIfCheaper) — so this comparison benefits every future * search/import for this part, not just the current user. + * + * If NO merchant confirms a price at all, the part's price is cleared + * to null (see clearPriceIfNoOffers) rather than silently keeping a + * pc-builder.io catalog price the user just explicitly asked to verify + * and that no longer checks out anywhere. */ async compare({ request, auth, response }: HttpContext) { await auth.authenticate() @@ -91,8 +100,10 @@ export default class ScrapedPartsController { if (offers.length > 0) { await updatePriceIfCheaper(part, offers[0]) + } else { + await clearPriceIfNoOffers(part) } - return response.json({ offers }) + return response.json({ offers, price: part.price }) } } diff --git a/app/services/part_scraper_service.ts b/app/services/part_scraper_service.ts index 2dde524..c18f2da 100644 --- a/app/services/part_scraper_service.ts +++ b/app/services/part_scraper_service.ts @@ -415,12 +415,34 @@ async function updatePriceIfCheaper( await part.save() } +/** + * Clears a part's price when a live comparison found no matching offer on + * any supported merchant (see ScrapedPartsController#compare), regardless + * of where the price previously came from (pc-builder.io catalog or an + * earlier verified merchant match). The pc-builder.io catalog price this + * part started with is frequently stale (see shouldKeepExistingPrice's + * doc for a confirmed example: pc-builder.io showed €128 for a CPU + * actually selling for €66.89) — once a user explicitly asks to verify a + * price and no merchant confirms it anymore, keeping the old number + * around is misleading, so it's dropped to null instead of silently + * falling back to "using catalog price €X". + */ +async function clearPriceIfNoOffers(part: ScrapedPart): Promise { + if (part.price === null) return + + part.price = null + part.priceSource = null + part.priceCheckedAt = DateTime.now() + await part.save() +} + export { scrapeAllCategories, scrapeComponentType, ensureFreshCategory, isCategoryStale, updatePriceIfCheaper, + clearPriceIfNoOffers, shouldKeepExistingPrice, CATEGORY_CONFIG, CATEGORY_SLUGS, diff --git a/inertia/pages/widgets/NewPartSearch.vue b/inertia/pages/widgets/NewPartSearch.vue index 770ae37..7cfd296 100644 --- a/inertia/pages/widgets/NewPartSearch.vue +++ b/inertia/pages/widgets/NewPartSearch.vue @@ -62,21 +62,20 @@ async function comparePrices(part: ScrapedPart) { } const data = await response.json() offers[part.id] = data.offers ?? [] + + // The backend already persists this exact value server-side (see + // ScrapedPartsController#compare): the cheapest offer's price when + // one was found (updatePriceIfCheaper), or null when no merchant + // confirmed a price at all (clearPriceIfNoOffers) — an unverified + // pc-builder.io catalog price is not kept around just because the + // comparison came back empty. Mirror that here on the locally held + // result so the display (e.g. "€979.95" or "No price yet") reflects + // it immediately, instead of the initial search-result price sticking + // around until the user re-runs the search from scratch. + part.price = data.price ?? null + if (offers[part.id].length === 0) { compareErrors[part.id] = true - return - } - - // The backend already persists the cheapest offer found as this - // part's new best-known price when it beats the catalog price (see - // ScrapedPartsController#compare / updatePriceIfCheaper). Mirror that - // here on the locally held result so the displayed catalog price - // (e.g. "€979.95") reflects it immediately, instead of staying stuck - // at whatever price the initial search returned until the user - // re-runs the search from scratch. - const cheapest = offers[part.id][0] - if (part.price === null || cheapest.price < part.price) { - part.price = cheapest.price } } catch { compareErrors[part.id] = true @@ -185,10 +184,7 @@ function importPart(part: ScrapedPart) { - - No live price found, using catalog price €{{ part.price.toFixed(2) }} - - + No price found on any merchant for this part. diff --git a/tests/functional/scraped_parts.spec.ts b/tests/functional/scraped_parts.spec.ts index e5d4d82..901c0c6 100644 --- a/tests/functional/scraped_parts.spec.ts +++ b/tests/functional/scraped_parts.spec.ts @@ -7,6 +7,7 @@ import { isCategoryStale, ensureFreshCategory, updatePriceIfCheaper, + clearPriceIfNoOffers, shouldKeepExistingPrice, CACHE_TTL_HOURS, } from '#services/part_scraper_service' @@ -217,6 +218,53 @@ test.group('Best-known-price persistence', () => { }) }) +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