Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand Down Expand Up @@ -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

Expand Down
39 changes: 32 additions & 7 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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] = {
Expand Down
7 changes: 6 additions & 1 deletion lib/data/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) } };
}
Expand Down
98 changes: 93 additions & 5 deletions test/api-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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`);
Expand Down
87 changes: 85 additions & 2 deletions test/unit-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
Expand Down