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 { extractLiveOfferSellerCandidates, isQualifyingSeller, readAsinsFromXlsx, runStalker, } from "./stalker.ts"; let nextId = 0; function chainable(resolveWith: any[] = []): any { const p: any = Promise.resolve(resolveWith); p.limit = (_n: any) => chainable(resolveWith); p.where = (_cond: any) => chainable(resolveWith); p.from = (_table: any) => chainable(resolveWith); return p; } // Transaction mock returns rows for selects (needed for upsert-then-select patterns). const makeMockTx = (): any => ({ insert: (_table: any) => ({ values: (_vals: any) => ({ returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]), onConflictDoUpdate: (_conf: any) => Promise.resolve([]), }), }), update: (_table: any) => ({ set: (_vals: any) => ({ where: (_cond: any) => Promise.resolve([]), }), }), select: (_sel?: any) => ({ from: (_table: any) => ({ where: (_cond: any) => chainable([{ id: ++nextId }]), limit: (_n: any) => chainable([{ id: nextId }]), }), }), selectDistinct: (_sel: any) => ({ from: (_table: any) => chainable([]), }), execute: (_query: any) => Promise.resolve([]), }); const makeMockDb = (): any => ({ insert: (_table: any) => ({ values: (_vals: any) => ({ returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]), onConflictDoUpdate: (_conf: any) => Promise.resolve([]), }), }), update: (_table: any) => ({ set: (_vals: any) => ({ where: (_cond: any) => Promise.resolve([]), }), }), select: (_sel?: any) => ({ from: (_table: any) => ({ where: (_cond: any) => chainable(), limit: (_n: any) => chainable(), }), }), selectDistinct: (_sel: any) => ({ from: (_table: any) => chainable(), }), execute: (_query: any) => Promise.resolve([]), transaction: async (fn: (tx: any) => Promise) => fn(makeMockTx()), }); mock.module("./db/index.ts", () => ({ db: makeMockDb(), client: {} })); const TEST_DIR = path.join(process.cwd(), "test_output", "stalker"); const originalFetch = globalThis.fetch; const originalKeepaKey = Bun.env.KEEPA_API_KEY; beforeEach(() => { nextId = 0; 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; } 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("runStalker fetches product offers, filters sellers, and tracks stats", async () => { const inputPath = path.join(TEST_DIR, "input.xlsx"); 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, maxAsins: null, storefrontUpdateHours: 168, offerLimit: 20, sellerLimit: 30, inventoryLimit: 200, sellerCacheHours: 168, includeStock: false, dryRun: false, resume: true, maxSellerRequests: null, sellability: false, analyzeSellable: false, useClaude: false, }); expect(stats.scannedAsins).toBe(1); expect(stats.sourceAsinsWithMatches).toBe(1); expect(stats.matchedSellers).toBe(1); expect(stats.persistedInventoryAsins).toBe(0); 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); });