Files
asin-check/src/stalker.test.ts
Victor Noguera b982edd160 Refactor database interactions to use Drizzle ORM
- Replaced direct SQLite database calls with Drizzle ORM methods in `top-monthly-sold-by-category.ts`, `writer.ts`, and `upc-file-analysis.ts`.
- Updated test cases in `top-monthly-sold-by-category.test.ts` to mock the new database interactions.
- Removed unnecessary database initialization and cleanup code.
- Improved code readability and maintainability by using ORM features for inserting and updating records.
2026-05-25 00:08:30 -04:00

277 lines
8.2 KiB
TypeScript

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<any>) => 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);
});