diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae06ea..7579594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ - TCGPlayer search: full query first, fallback to simplified, price sanity check - Demo rate limit shown correctly as 360/min - PR template: added breaking changes + demo data check sections +- Yahoo Auctions: relevance filtering applied (removes 1-yen box auctions, unrelated cards) +- Card identity: cleaned up long names (strips pack names, condition text from titles) +- track-prices: now also tracks cards from active alerts, not just 3 hardcoded defaults +- Demo condition filter: checks detectedCondition in addition to raw condition field +- Tests: 208 total (92 unit + 76 API + 40 smoke), up from 183 +- Removed dead code: Redis import from api.js, updateCardField from card-identity.js ## 1.0.0-beta.1 (2026-05-10) diff --git a/README.md b/README.md index 4c09f42..c54c741 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,8 @@ public/admin/ Admin dashboard (keys, stats, errors) extension/ Chrome extension: queue auto-join, drop intel terraform/ GCP infra: Cloud Run ×2, Firestore, LB + CDN, Secret Manager test/ - unit-test.js 81 unit tests - api-test.js 62 API integration tests + unit-test.js 92 unit tests + api-test.js 76 API integration tests smoke-test.js 40 Playwright smoke tests (dashboard UI) ``` @@ -233,7 +233,7 @@ Load unpacked from `extension/` in `chrome://extensions`. ## Tests -183 tests: 81 unit (filters, grading, query builder, card identity, condition detection, demo data) + 62 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, demo validation) + 40 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport). +208 tests: 92 unit (filters, grading, query builder, card identity, condition detection, demo data, resolveCardIdToQuery, findDemoByNumber) + 76 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, alerts, share pages, demo validation) + 40 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport). ## Contributing diff --git a/api.js b/api.js index ded1486..4ce0d7d 100644 --- a/api.js +++ b/api.js @@ -12,7 +12,7 @@ import { searchMagi } from "./lib/sources/magi.js"; import { searchYahooAuctions } from "./lib/sources/yahooauctions.js"; import { getPsaGradingSignal } from "./lib/grading/psa.js"; import { gradeImage } from "./lib/grading/grading.js"; -import { parseListingLanguagesFromInput, filterByCondition, detectCondition, flagPriceOutliers } from "./lib/search/filters.js"; +import { parseListingLanguagesFromInput, filterByCondition, detectCondition, flagPriceOutliers, filterRelevantResults } from "./lib/search/filters.js"; import { buildEbaySearchQuery } from "./lib/search/listingQuery.js"; import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/search/ebayCategories.js"; import { saveGradeLog, getGradeLogs, saveDrop, getDrops, getDrop, saveWebhook, getWebhooks, deleteWebhook, getFirestoreStatus, saveAlert, getActiveAlerts, updateAlert, getAlertsByEmail, saveErrorLog, getErrorLogs, clearErrorLogs } from "./lib/data/firestore.js"; @@ -242,6 +242,11 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType = result = await searchMagi(q, config); } else if (source === "yahoo") { result = await searchYahooAuctions(q, config); + for (const country of Object.keys(result.activeByCountry || {})) { + result.activeByCountry[country] = filterRelevantResults(result.activeByCountry[country], result.ebaySearchQuery || q).filtered; + } + if (result.sold?.length) result.sold = filterRelevantResults(result.sold, result.ebaySearchQuery || q).filtered; + result.counts = { activeTotal: Object.values(result.activeByCountry || {}).reduce((n, arr) => n + arr.length, 0), sold: result.sold?.length || 0 }; } else { const ebayQuery = buildEbaySearchQuery(q, config); const cp = cachePrefix(req); @@ -327,7 +332,7 @@ app.get("/api/sold", apiAuthMiddleware, (req, res, next) => { req._errorType = " soldSource = r.soldSource; } else if (source === "yahoo") { const r = await searchYahooAuctions(q, config); - sold = r.sold; + sold = filterRelevantResults(r.sold || [], r.ebaySearchQuery || q).filtered; soldSource = r.soldSource; } else { const ebayQuery = buildEbaySearchQuery(q, config); @@ -525,8 +530,8 @@ v1.get("/comps", async (req, res) => { sold = r.sold || []; } else if (source === "yahoo") { const r = await searchYahooAuctions(query, config); - active = r.items || r.active || []; - sold = r.sold || []; + active = filterRelevantResults(r.items || r.active || Object.values(r.activeByCountry || {}).flat(), r.ebaySearchQuery || query).filtered; + sold = filterRelevantResults(r.sold || [], r.ebaySearchQuery || query).filtered; } else { const ebayQuery = buildEbaySearchQuery(query, config); config._cachePrefix = cachePrefix(req); @@ -637,6 +642,16 @@ app.get("/api/card", apiAuthMiddleware, async (req, res) => { if (identity.cardId) { identity.setName = SET_NAME_MAP[identity.setCode] || identity.setCode; + if (identity.name && identity.setName && identity.name.includes(identity.setName)) { + identity.name = identity.name.replace(identity.setName, "").replace(/\s+/g, " ").trim(); + } + if (identity.name) { + identity.name = identity.name + .replace(/\[.*?\]|\(.*?\)/g, "") + .replace(/\s*(Expansion Pack|High Class Pack|Booster|Collection)\b.*/i, "") + .replace(/\s+[A-Z]\s*[-—]\s*(Mint|NM|LP|MP|HP)\s*$/i, "") + .replace(/\s+/g, " ").trim(); + } } res.json({ ...identity, stored: false }); @@ -868,8 +883,9 @@ app.post("/api/check-alerts", ownerOnly, async (req, res) => { const activeRes = await searchActive({ query: ebayQuery, relevanceQuery: alert.query, deliveryCountries: config.deliveryCountries, languages: config.languages, config, refresh: false, noEbay: false, getToken, on401 }); data = { activeByCountry: activeRes.itemsByCountry || {}, source: "ebay" }; } - const items = []; + let items = []; for (const arr of Object.values(data.activeByCountry || {})) items.push(...arr); + if (source === "yahoo") items = filterRelevantResults(items, data.ebaySearchQuery || alert.query).filtered; if (items.length) { const prices = items.map(i => i.totalCost || i.price).filter(Boolean).sort((a, b) => a - b); pricesBySource[source] = { lowest: prices[0], count: prices.length }; @@ -944,11 +960,19 @@ app.post("/api/check-alerts", ownerOnly, async (req, res) => { // POST /api/track-prices — scheduled job to record prices for tracked cards app.post("/api/track-prices", authMiddleware, async (req, res) => { - const cards = req.body.cards || [ + const defaultCards = [ "Pikachu ex SAR 234/193 PSA 10", "Umbreon ex SAR 217/187", "Mega Greninja ex SAR", ]; + + let alertCards = []; + try { + const alerts = await getActiveAlerts(); + alertCards = [...new Set(alerts.map(a => a.query).filter(Boolean))]; + } catch {} + + const cards = req.body?.cards || [...new Set([...defaultCards, ...alertCards])]; const hasEbay = !!(clientId && clientSecret); const results = []; for (const card of cards) { @@ -1050,8 +1074,9 @@ app.get("/api/arbitrage", apiAuthMiddleware, async (req, res) => { } } - const items = []; + let items = []; for (const arr of Object.values(data.activeByCountry || {})) items.push(...arr); + if (source === "yahoo" && !isDemo) items = filterRelevantResults(items, data.ebaySearchQuery || q).filtered; if (items.length) { const prices = items.map(i => i.totalCost || i.price).filter(Boolean).sort((a, b) => a - b); pricesBySource[source] = { diff --git a/lib/data/demo.js b/lib/data/demo.js index fca3b15..327b7d2 100644 --- a/lib/data/demo.js +++ b/lib/data/demo.js @@ -314,9 +314,14 @@ export function getDemoSearchResult(q, { source, condition } = {}) { }; } if (condition && result.activeByCountry) { + const cond = condition.toUpperCase(); const filtered = {}; for (const [country, items] of Object.entries(result.activeByCountry)) { - filtered[country] = items.filter(i => (i.condition || "").toUpperCase().startsWith(condition.toUpperCase())); + filtered[country] = items.filter(i => { + const raw = (i.condition || "").toUpperCase(); + const detected = (i.detectedCondition || "").toUpperCase(); + return raw.startsWith(cond) || raw.includes(cond) || detected.startsWith(cond); + }); } result = { ...result, activeByCountry: filtered, counts: { ...result.counts, activeTotal: Object.values(filtered).reduce((n, arr) => n + arr.length, 0) } }; } diff --git a/test/api-test.js b/test/api-test.js index cee1a14..b615fb9 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -533,14 +533,16 @@ async function run() { assert(psa.tierReason, "missing tierReason"); }); - await test("Umbreon demo: 5 active AI graded + 4 sold", async () => { + await test("Umbreon demo: 8 active (multi-source) + 7 sold", async () => { const { body } = await jsonNoAuth("/api/search?q=Umbreon+ex+SAR+217/187&demo=true"); assert(body._demo === true, "not demo"); + assert(body.source === "multi", `expected multi, got ${body.source}`); const items = body.activeByCountry?.US || []; - assert(items.length === 5, `expected 5 active, got ${items.length}`); - assert(body.sold.length === 4, `expected 4 sold, got ${body.sold.length}`); - for (const item of items) { - assert(item.grade && !item.grade.error, `missing grade on ${item.itemId}`); + assert(items.length === 8, `expected 8 active, got ${items.length}`); + assert(body.sold.length === 7, `expected 7 sold, got ${body.sold.length}`); + const graded = items.filter(i => i.grade && !i.grade.error); + assert(graded.length === 5, `expected 5 graded, got ${graded.length}`); + for (const item of graded) { assert(item.grade.notes, `missing notes on ${item.itemId}`); assert(item.imageUrl, `missing imageUrl on ${item.itemId}`); } @@ -621,6 +623,92 @@ async function run() { assert(body.info.version.includes("beta"), `expected beta version, got ${body.info.version}`); }); + // ── Share pages ── + + await test("Share: /api/card/share/sv8a/217-187 returns Umbreon data", async () => { + const { body } = await jsonNoAuth("/api/card/share/sv8a/217-187"); + assert(body.cardId === "sv8a/217-187", `expected sv8a/217-187, got ${body.cardId}`); + assert(body.identity?.name === "Umbreon ex", `expected Umbreon ex, got ${body.identity?.name}`); + assert(body.identity?.rarity === "SAR", `expected SAR, got ${body.identity?.rarity}`); + assert(body.price?.lowest > 0, "missing lowest price"); + assert(body.price?.listingCount > 0, "no listings"); + assert(body.price?.bySources && Object.keys(body.price.bySources).length >= 2, "expected 2+ sources"); + assert(body.psaSignal?.totalPop > 0, "missing PSA signal"); + assert(body.priceHistory?.history?.length >= 3, "expected 3+ price history points"); + }); + + await test("Share: /api/card/share/m4/114-083 returns Greninja data", async () => { + const { body } = await jsonNoAuth("/api/card/share/m4/114-083"); + assert(body.cardId === "m4/114-083"); + assert(body.identity?.name === "Mega Greninja ex", `expected Mega Greninja ex, got ${body.identity?.name}`); + assert(body.price?.listingCount === 8, `expected 8 listings, got ${body.price?.listingCount}`); + assert(body.priceHistory?.history?.length === 6, `expected 6 history points`); + }); + + await test("Share: /api/card/share/m2a/234-193 returns Pikachu data", async () => { + const { body } = await jsonNoAuth("/api/card/share/m2a/234-193"); + assert(body.cardId === "m2a/234-193"); + assert(body.identity?.name === "Pikachu ex"); + assert(body.price?.lowest > 0); + }); + + // ── Demo price history ── + + await test("Demo price-history returns sold data with dates", async () => { + const { body } = await jsonNoAuth("/api/price-history?q=Umbreon+ex+SAR+217/187&days=90&demo=true"); + assert(body._demo === true, "not demo"); + assert(body.history?.length >= 3, `expected 3+ history points, got ${body.history?.length}`); + assert(body.stats?.avg > 0, "missing avg stat"); + const dates = body.history.map(h => h.recordedAt); + const unique = new Set(dates); + assert(unique.size >= 3, "expected 3+ unique dates"); + }); + + // ── Alerts ── + + await test("POST /api/alerts creates arbitrage alert", async () => { + const { res, body } = await json("/api/alerts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "test-api@test.com", query: "Umbreon ex SAR", type: "arbitrage", spreadThreshold: 15 }) }); + assert(res.status === 200, `expected 200, got ${res.status}`); + assert(body.ok === true); + }); + + await test("POST /api/alerts creates price alert", async () => { + const { res, body } = await json("/api/alerts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "test-api@test.com", query: "Pikachu ex SAR", type: "price", targetPrice: 500 }) }); + assert(res.status === 200, `expected 200, got ${res.status}`); + assert(body.ok === true); + }); + + // ── Condition filter ── + + await test("Demo condition filter: mint returns SNKRDUNK items", async () => { + const { body } = await jsonNoAuth("/api/search?q=Mega+Greninja+ex+SAR&demo=true&condition=mint"); + const items = body.activeByCountry?.US || []; + assert(items.length === 5, `expected 5 mint, got ${items.length}`); + assert(items.every(i => (i.detectedCondition || "").toLowerCase() === "mint"), "not all mint"); + }); + + await test("Demo condition filter: nm returns NM items", async () => { + const { body } = await jsonNoAuth("/api/search?q=Umbreon+ex+SAR+217/187&demo=true&condition=nm"); + const items = body.activeByCountry?.US || []; + assert(items.length > 0, "expected NM items"); + assert(items.every(i => (i.detectedCondition || "").toUpperCase() === "NM"), "not all NM"); + }); + + // ── detectedCondition + outlier ── + + await test("Demo items have detectedCondition", async () => { + const { body } = await jsonNoAuth("/api/search?q=Umbreon+ex+SAR+217/187&demo=true"); + const items = body.activeByCountry?.US || []; + const withCond = items.filter(i => i.detectedCondition); + assert(withCond.length >= 5, `expected 5+ with detectedCondition, got ${withCond.length}`); + }); + + await test("Demo items have _priceOutlier field", async () => { + const { body } = await jsonNoAuth("/api/search?q=Umbreon+ex+SAR+217/187&demo=true"); + const items = body.activeByCountry?.US || []; + assert(items.every(i => typeof i._priceOutlier === "boolean"), "missing _priceOutlier"); + }); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`); diff --git a/test/unit-test.js b/test/unit-test.js index 8f2b711..5d9b8bf 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -18,8 +18,8 @@ import { querySeeksJapaneseMarket, filterToLikelyTcgCards, } from "../lib/search/filters.js"; -import { isDemoQuery, getDemoResult, getDemoSearchResult, listDemoCards } from "../lib/data/demo.js"; -import { parseCardIdentity, buildCardId, SET_NAME_MAP } from "../lib/data/card-identity.js"; +import { isDemoQuery, getDemoResult, getDemoSearchResult, listDemoCards, findDemoByNumber } from "../lib/data/demo.js"; +import { parseCardIdentity, buildCardId, SET_NAME_MAP, resolveCardIdToQuery } from "../lib/data/card-identity.js"; let passed = 0; let failed = 0; @@ -620,6 +620,89 @@ test("SET_NAME_MAP has entries for major sets", () => { assert(SET_NAME_MAP["sv2a"], "missing sv2a"); }); +// ── resolveCardIdToQuery ── + +console.log("\n\x1b[1m=== resolveCardIdToQuery ===\x1b[0m"); + +test("resolves sv8a/217-187 to Terastal Festival query", () => { + const q = resolveCardIdToQuery("sv8a/217-187"); + assert(q.includes("217/187"), "missing card number"); + assert(q.includes("Terastal Festival"), "missing set name"); +}); + +test("resolves m4/114-083 to Ninja Spinner query", () => { + const q = resolveCardIdToQuery("m4/114-083"); + assert(q.includes("114/083")); + assert(q.includes("Ninja Spinner")); +}); + +test("returns input for invalid card ID", () => { + eq(resolveCardIdToQuery("not-a-card"), "not-a-card"); +}); + +// ── findDemoByNumber ── + +console.log("\n\x1b[1m=== findDemoByNumber ===\x1b[0m"); + +test("finds Umbreon by 217-187", () => { + const r = findDemoByNumber("217-187"); + assert(r, "not found"); + assert(r._demo); + assert(r.query.includes("Umbreon")); +}); + +test("finds Greninja by 114-083 (from listing titles)", () => { + const r = findDemoByNumber("114-083"); + assert(r, "not found"); + assert(r.query.includes("Greninja")); +}); + +test("finds Pikachu by 234-193", () => { + const r = findDemoByNumber("234-193"); + assert(r, "not found"); + assert(r.query.includes("Pikachu")); +}); + +test("returns null for unknown number", () => { + eq(findDemoByNumber("999-999"), null); +}); + +// ── Demo data: multi-source + sold dates ── + +console.log("\n\x1b[1m=== demo multi-source + sold dates ===\x1b[0m"); + +test("all demo cards are multi-source", () => { + for (const query of listDemoCards()) { + const r = getDemoResult(query); + eq(r.source, "multi", `${query} should be multi-source`); + } +}); + +test("all demo sold have soldDate spanning 7+ days", () => { + for (const query of listDemoCards()) { + const r = getDemoResult(query); + const dates = (r.sold || []).map(s => s.soldDate).filter(Boolean); + assert(dates.length >= 3, `${query}: expected 3+ sold dates, got ${dates.length}`); + const sorted = dates.sort(); + const first = new Date(sorted[0]); + const last = new Date(sorted[sorted.length - 1]); + const span = (last - first) / (1000 * 60 * 60 * 24); + assert(span >= 7, `${query}: date span ${span} days, expected 7+`); + } +}); + +test("Umbreon has detectedCondition on all listings", () => { + const r = getDemoResult("umbreon ex sar 217/187"); + const items = r.activeByCountry?.US || []; + assert(items.every(i => i.detectedCondition), "not all have detectedCondition"); +}); + +test("condition filter with detectedCondition works", () => { + const r = getDemoSearchResult("Mega Greninja ex SAR", { condition: "mint" }); + const items = r.activeByCountry?.US || []; + assert(items.length === 5, `expected 5 mint, got ${items.length}`); +}); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`);