feat: add Stalker results page with filtering and pagination
- Introduced StalkerResultItem and StalkerResultsResponse types for handling API responses. - Implemented StalkerExplorer component for displaying Stalker results with search and filter options. - Added sorting functionality for Stalker results table. - Enhanced Dashboard to include a button for navigating to Stalker results. - Updated routing to support Stalker results page. - Improved styles for section headers and inventory columns in the results table.
This commit is contained in:
281
src/stalker.test.ts
Normal file
281
src/stalker.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as XLSX from "xlsx";
|
||||
import { closeDb, getDb, initDb } from "./database.ts";
|
||||
import {
|
||||
extractLiveOfferSellerCandidates,
|
||||
isQualifyingSeller,
|
||||
readAsinsFromXlsx,
|
||||
runStalker,
|
||||
} from "./stalker.ts";
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
closeDb();
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
globalThis.fetch = originalFetch;
|
||||
Bun.env.KEEPA_API_KEY = "test-keepa-key";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalKeepaKey == null) {
|
||||
delete Bun.env.KEEPA_API_KEY;
|
||||
} else {
|
||||
Bun.env.KEEPA_API_KEY = originalKeepaKey;
|
||||
}
|
||||
closeDb();
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("readAsinsFromXlsx extracts valid ASINs and deduplicates in order", () => {
|
||||
const filePath = path.join(TEST_DIR, "asins.xlsx");
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheet = XLSX.utils.json_to_sheet([
|
||||
{ ASIN: "b000000001" },
|
||||
{ ASIN: "invalid" },
|
||||
{ ASIN: "B000000002" },
|
||||
{ ASIN: "B000000001" },
|
||||
{ ASIN: "" },
|
||||
]);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Input");
|
||||
XLSX.writeFile(workbook, filePath);
|
||||
|
||||
expect(readAsinsFromXlsx(filePath)).toEqual(["B000000001", "B000000002"]);
|
||||
});
|
||||
|
||||
test("isQualifyingSeller accepts rating counts from 1 to 30 only", () => {
|
||||
expect(isQualifyingSeller({ ratingCount: null })).toBe(false);
|
||||
expect(isQualifyingSeller({ ratingCount: 0 })).toBe(false);
|
||||
expect(isQualifyingSeller({ ratingCount: 1 })).toBe(true);
|
||||
expect(isQualifyingSeller({ ratingCount: 30 })).toBe(true);
|
||||
expect(isQualifyingSeller({ ratingCount: 31 })).toBe(false);
|
||||
});
|
||||
|
||||
test("extractLiveOfferSellerCandidates ignores Amazon, missing seller IDs, and duplicates", () => {
|
||||
const offers = extractLiveOfferSellerCandidates({
|
||||
offers: [
|
||||
{ sellerId: "ATVPDKIKX0DER", price: 1999 },
|
||||
{ price: 1899 },
|
||||
{ sellerId: "A1SELLER", price: 1599, isFBA: true, stock: 4 },
|
||||
{ sellerId: "A1SELLER", price: 1499 },
|
||||
{ sellerID: "A2SELLER", currentPrice: 2499, isFba: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(offers.map((offer) => offer.sellerId)).toEqual([
|
||||
"A1SELLER",
|
||||
"A2SELLER",
|
||||
]);
|
||||
expect(offers[0]?.offerPrice).toBe(15.99);
|
||||
expect(offers[0]?.isFba).toBe(true);
|
||||
expect(offers[0]?.stock).toBe(4);
|
||||
});
|
||||
|
||||
test("initDb creates stalker tables and indexes", () => {
|
||||
const dbPath = path.join(TEST_DIR, "schema.sqlite");
|
||||
initDb(dbPath);
|
||||
const db = getDb(dbPath);
|
||||
|
||||
const tables = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'stalker_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(tables.map((row) => row.name)).toEqual([
|
||||
"stalker_asin_scans",
|
||||
"stalker_asin_sellers",
|
||||
"stalker_runs",
|
||||
"stalker_seller_inventory",
|
||||
"stalker_sellers",
|
||||
]);
|
||||
|
||||
const indexes = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_stalker_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(indexes.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
test("runStalker fetches product offers, filters sellers, and persists storefront inventory", async () => {
|
||||
const inputPath = path.join(TEST_DIR, "input.xlsx");
|
||||
const dbPath = path.join(TEST_DIR, "stalker.sqlite");
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
|
||||
"Input",
|
||||
);
|
||||
XLSX.writeFile(workbook, inputPath);
|
||||
|
||||
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||
const rawUrl =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
const url = new URL(rawUrl);
|
||||
|
||||
if (url.pathname === "/product") {
|
||||
expect(url.searchParams.get("asin")).toBe("B000000001");
|
||||
expect(url.searchParams.get("offers")).toBe("20");
|
||||
expect(url.searchParams.get("only-live-offers")).toBe("1");
|
||||
expect(url.searchParams.has("stock")).toBe(false);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
products: [
|
||||
{
|
||||
asin: "B000000001",
|
||||
title: "Tracked Product",
|
||||
offers: [
|
||||
{
|
||||
sellerId: "AQUALIFIED",
|
||||
price: 1999,
|
||||
condition: "New",
|
||||
isFBA: true,
|
||||
stock: 3,
|
||||
},
|
||||
{
|
||||
sellerId: "AOLDSELLER",
|
||||
price: 2099,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/seller") {
|
||||
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||
if (wantsStorefront) {
|
||||
expect(url.searchParams.get("update")).toBe("168");
|
||||
}
|
||||
const sellerId = url.searchParams.get("seller");
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sellers: {
|
||||
...(!wantsStorefront && sellerId === "AQUALIFIED,AOLDSELLER"
|
||||
? {
|
||||
AQUALIFIED: {
|
||||
sellerName: "New Seller",
|
||||
currentRating: 96,
|
||||
currentRatingCount: 12,
|
||||
},
|
||||
AOLDSELLER: {
|
||||
sellerName: "Old Seller",
|
||||
currentRating: 99,
|
||||
currentRatingCount: 120,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(wantsStorefront && sellerId === "AQUALIFIED"
|
||||
? {
|
||||
AQUALIFIED: {
|
||||
sellerName: "New Seller",
|
||||
currentRating: 96,
|
||||
currentRatingCount: 12,
|
||||
asinList: ["B111111111", "B222222222"],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||
|
||||
const stats = await runStalker({
|
||||
input: inputPath,
|
||||
dbPath,
|
||||
maxAsins: null,
|
||||
storefrontUpdateHours: 168,
|
||||
offerLimit: 20,
|
||||
sellerLimit: 30,
|
||||
inventoryLimit: 200,
|
||||
sellerCacheHours: 168,
|
||||
includeStock: false,
|
||||
dryRun: false,
|
||||
resume: true,
|
||||
maxSellerRequests: null,
|
||||
});
|
||||
|
||||
expect(stats.scannedAsins).toBe(1);
|
||||
expect(stats.sourceAsinsWithMatches).toBe(1);
|
||||
expect(stats.matchedSellers).toBe(1);
|
||||
expect(stats.persistedInventoryAsins).toBe(2);
|
||||
expect(stats.failedAsins).toBe(0);
|
||||
expect(stats.candidateSellers).toBe(2);
|
||||
expect(stats.qualifyingSellers).toBe(1);
|
||||
expect(stats.sellerMetadataRequests).toBe(1);
|
||||
expect(stats.sellerStorefrontRequests).toBe(1);
|
||||
const sellerCalls = fetchMock.mock.calls.filter((call) => {
|
||||
const rawUrl =
|
||||
typeof call[0] === "string"
|
||||
? call[0]
|
||||
: call[0] instanceof URL
|
||||
? call[0].toString()
|
||||
: (call[0] as Request).url;
|
||||
return new URL(rawUrl).pathname === "/seller";
|
||||
});
|
||||
expect(sellerCalls.length).toBe(2);
|
||||
|
||||
const db = getDb(dbPath);
|
||||
const run = db.query("SELECT * FROM stalker_runs").get() as any;
|
||||
expect(run.status).toBe("completed");
|
||||
expect(run.requested_asins).toBe(1);
|
||||
expect(run.scanned_asins).toBe(1);
|
||||
expect(run.source_asins_with_matches).toBe(1);
|
||||
expect(run.candidate_sellers).toBe(2);
|
||||
expect(run.qualifying_sellers).toBe(1);
|
||||
expect(run.matched_sellers).toBe(1);
|
||||
expect(run.seller_metadata_requests).toBe(1);
|
||||
expect(run.seller_storefront_requests).toBe(1);
|
||||
expect(run.persisted_inventory_asins).toBe(2);
|
||||
|
||||
const scan = db.query("SELECT * FROM stalker_asin_scans").get() as any;
|
||||
expect(scan.source_asin).toBe("B000000001");
|
||||
expect(scan.title).toBe("Tracked Product");
|
||||
expect(scan.offer_count).toBe(2);
|
||||
expect(scan.candidate_seller_count).toBe(2);
|
||||
expect(scan.matched_seller_count).toBe(1);
|
||||
|
||||
const sellers = db.query("SELECT * FROM stalker_sellers").all() as any[];
|
||||
expect(sellers.length).toBe(1);
|
||||
expect(sellers[0].seller_id).toBe("AQUALIFIED");
|
||||
expect(sellers[0].rating_count).toBe(12);
|
||||
expect(sellers[0].storefront_asin_total).toBe(2);
|
||||
expect(sellers[0].persisted_inventory_sample_count).toBe(2);
|
||||
|
||||
const asinSellers = db.query("SELECT * FROM stalker_asin_sellers").all() as any[];
|
||||
expect(asinSellers.length).toBe(1);
|
||||
expect(asinSellers[0].offer_price).toBe(19.99);
|
||||
expect(asinSellers[0].is_fba).toBe(1);
|
||||
expect(asinSellers[0].stock).toBe(3);
|
||||
|
||||
const inventory = db
|
||||
.query("SELECT asin FROM stalker_seller_inventory ORDER BY asin")
|
||||
.all() as Array<{ asin: string }>;
|
||||
expect(inventory.map((row) => row.asin)).toEqual([
|
||||
"B111111111",
|
||||
"B222222222",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user