208 lines
5.8 KiB
TypeScript
208 lines
5.8 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 { closeDb, getDb } from "./database.ts";
|
|
|
|
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker-sellability");
|
|
const originalFetch = globalThis.fetch;
|
|
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
|
|
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
|
return new Map(
|
|
asins.map((asin) => [
|
|
asin,
|
|
asin === "B111111111"
|
|
? {
|
|
canSell: true,
|
|
sellabilityStatus: "available" as const,
|
|
sellabilityReason: "No listing restrictions reported",
|
|
}
|
|
: {
|
|
canSell: false,
|
|
sellabilityStatus: "restricted" as const,
|
|
sellabilityReason: "approval required",
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
|
|
mock.module("./sp-api.ts", () => ({
|
|
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
|
}));
|
|
|
|
const modulePromise = import("./stalker.ts");
|
|
|
|
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";
|
|
fetchSellabilityBatchMock.mockClear();
|
|
});
|
|
|
|
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("sellability checks matched seller inventory, not the source ASIN", async () => {
|
|
const { runStalker } = await modulePromise;
|
|
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);
|
|
|
|
globalThis.fetch = 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") {
|
|
if (url.searchParams.get("asin") === "B111111111") {
|
|
return new Response(
|
|
JSON.stringify({
|
|
products: [
|
|
{
|
|
asin: "B111111111",
|
|
title: "Sellable Storefront Product",
|
|
brand: "Good Brand",
|
|
categoryTree: [{ name: "Kitchen" }, { name: "Storage" }],
|
|
monthlySold: 42,
|
|
stats: {
|
|
current: [null, null, null, 12345, null, null, null, null, null, null, null, 7],
|
|
avg: [2500],
|
|
},
|
|
csv: [[5000000, 1999, 5000100]],
|
|
},
|
|
],
|
|
tokensLeft: 10,
|
|
refillRate: 10,
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
products: [
|
|
{
|
|
asin: "B000000001",
|
|
title: "Source Product",
|
|
offers: [{ sellerId: "AQUALIFIED", price: 1999 }],
|
|
},
|
|
],
|
|
tokensLeft: 10,
|
|
refillRate: 10,
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
|
|
if (url.pathname === "/seller") {
|
|
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
|
return new Response(
|
|
JSON.stringify({
|
|
sellers: {
|
|
AQUALIFIED: {
|
|
sellerName: "New Seller",
|
|
currentRatingCount: 12,
|
|
asinList: wantsStorefront ? ["B111111111", "B222222222"] : [],
|
|
},
|
|
},
|
|
tokensLeft: 10,
|
|
refillRate: 10,
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
|
|
return new Response("not found", { status: 404 });
|
|
}) 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,
|
|
sellability: true,
|
|
analyzeSellable: false,
|
|
});
|
|
|
|
expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1);
|
|
expect(fetchSellabilityBatchMock.mock.calls[0]?.[0]).toEqual([
|
|
"B111111111",
|
|
"B222222222",
|
|
]);
|
|
expect(stats.inventorySellabilityCheckedAsins).toBe(2);
|
|
expect(stats.inventorySellabilityAvailableAsins).toBe(1);
|
|
expect(stats.inventorySellabilityExcludedAsins).toBe(1);
|
|
expect(stats.persistedInventoryAsins).toBe(1);
|
|
|
|
const db = getDb(dbPath);
|
|
const scan = db.query("SELECT source_asin FROM stalker_asin_scans").get() as {
|
|
source_asin: string;
|
|
};
|
|
expect(scan.source_asin).toBe("B000000001");
|
|
|
|
const inventory = db
|
|
.query(
|
|
`SELECT asin, can_sell, sellability_status, product_title, brand,
|
|
category_tree, current_price, avg_price_90d, sales_rank, monthly_sold,
|
|
seller_count
|
|
FROM stalker_seller_inventory ORDER BY asin`,
|
|
)
|
|
.all() as Array<{
|
|
asin: string;
|
|
can_sell: number | null;
|
|
sellability_status: string | null;
|
|
product_title: string | null;
|
|
brand: string | null;
|
|
category_tree: string | null;
|
|
current_price: number | null;
|
|
avg_price_90d: number | null;
|
|
sales_rank: number | null;
|
|
monthly_sold: number | null;
|
|
seller_count: number | null;
|
|
}>;
|
|
expect(inventory).toEqual([
|
|
{
|
|
asin: "B111111111",
|
|
can_sell: 1,
|
|
sellability_status: "available",
|
|
product_title: "Sellable Storefront Product",
|
|
brand: "Good Brand",
|
|
category_tree: JSON.stringify(["Kitchen", "Storage"]),
|
|
current_price: 19.99,
|
|
avg_price_90d: 25,
|
|
sales_rank: 12345,
|
|
monthly_sold: 42,
|
|
seller_count: 7,
|
|
},
|
|
]);
|
|
});
|