- 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.
277 lines
8.2 KiB
TypeScript
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);
|
|
});
|