Files
asin-check/src/top-monthly-sold-by-category.test.ts
Victor Noguera b5ac408539 feat: add top monthly sold by category analysis pipeline
- Introduces a new script to discover categories and identify products with high monthly sales volume.
- Filters candidates based on sellability status and a configurable monthly sold threshold.
- Enriches product data with Keepa and SP-API metrics before performing LLM-based analysis.
- Persists results to SQLite and includes unit tests for the core processing logic.
2026-04-14 17:22:31 -04:00

317 lines
7.8 KiB
TypeScript

import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
import { Database } from "bun:sqlite";
import { getDb, initDb, closeDb } from "./database.ts";
import path from "node:path";
import { rmSync, mkdirSync } from "node:fs";
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map(
asins.map((asin) => {
if (asin === "B000000003") {
return [
asin,
{
canSell: false,
sellabilityStatus: "restricted" as const,
sellabilityReason: "restricted",
},
];
}
return [
asin,
{
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "ok",
},
];
}),
);
});
const fetchSpApiPricingAndFeesMock = mock(async () => ({
fbaFee: 4,
fbmFee: 2,
referralFeePercent: 15,
estimatedSalePrice: 25,
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "ok",
}));
const analyzeProductsMock = mock(async (products: any[]) => {
return products.map((p) => ({
asin: p.record.asin,
verdict: "FBA",
confidence: 90,
reasoning: "mocked",
}));
});
mock.module("./sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
}));
mock.module("./llm.ts", () => ({
analyzeProducts: analyzeProductsMock,
}));
const modulePromise = import("./top-monthly-sold-by-category.ts");
const DB_TEST_PATH = path.join(
process.cwd(),
"test_output",
"test_monthly_sold_analysis.sqlite",
);
let db: Database;
let processCategory: (
db: Database,
runId: number,
category: any,
perCategoryTop: number,
categoryCandidatePool: number,
minMonthlySold: number,
) => Promise<any>;
let insertCategoryRunSummary: (
db: Database,
summary: any,
runTimestamp: string,
) => Promise<number>;
let originalFetch: typeof globalThis.fetch;
beforeAll(async () => {
const mod = await modulePromise;
processCategory = mod.processCategory;
insertCategoryRunSummary = mod.insertCategoryRunSummary;
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
initDb(DB_TEST_PATH);
db = getDb(DB_TEST_PATH);
originalFetch = globalThis.fetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
closeDb();
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
});
beforeEach(() => {
db.run("DELETE FROM product_analysis_results");
db.run("DELETE FROM category_analysis_runs");
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 === "/bestsellers") {
return new Response(
JSON.stringify({
bestSellersList: [
"B000000001",
"B000000002",
"B000000003",
"B000000004",
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
if (url.pathname === "/product") {
return new Response(
JSON.stringify({
products: [
{
asin: "B000000001",
title: "Product One",
monthlySold: 600,
stats: {
current: [
null,
null,
null,
1000,
null,
null,
null,
null,
null,
null,
null,
2,
null,
null,
null,
null,
null,
null,
2599,
],
avg: [2400, null, null, 1200],
},
csv: [[1, 2599]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000002",
title: "Product Two",
monthlySold: 250,
stats: {
current: [
null,
null,
null,
2000,
null,
null,
null,
null,
null,
null,
null,
3,
null,
null,
null,
null,
null,
null,
1999,
],
avg: [1800, null, null, 2200],
},
csv: [[1, 1999]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000003",
title: "Product Three",
monthlySold: 800,
stats: {
current: [
null,
null,
null,
1500,
null,
null,
null,
null,
null,
null,
null,
1,
null,
null,
null,
null,
null,
null,
2099,
],
avg: [2000, null, null, 1800],
},
csv: [[1, 2099]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000004",
title: "Product Four",
monthlySold: 400,
stats: {
current: [
null,
null,
null,
3000,
null,
null,
null,
null,
null,
null,
null,
4,
null,
null,
null,
null,
null,
null,
2899,
],
avg: [2600, null, null, 2800],
},
csv: [[1, 2899]],
categoryTree: [{ name: "Category 1" }],
},
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
}) as unknown as typeof globalThis.fetch;
});
test("processCategory filters to sellable ASINs with monthly sold >= threshold and keeps top-N", async () => {
const mockCategory = {
id: 1,
label: "Category 1",
parentId: 0,
childCount: 0,
};
const runId = await insertCategoryRunSummary(
db,
{
categoryId: mockCategory.id,
categoryLabel: mockCategory.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
},
new Date().toISOString(),
);
const summary = await processCategory(db, runId, mockCategory, 2, 4, 300);
expect(summary.status).toBe("ok");
expect(summary.topAsinsChecked).toBe(4);
expect(summary.availableAsins).toBe(2);
expect(summary.results?.length).toBe(2);
const productResults = db
.query(
"SELECT asin, monthly_sold FROM product_analysis_results ORDER BY monthly_sold DESC",
)
.all() as Array<{ asin: string; monthly_sold: number }>;
expect(productResults.length).toBe(2);
expect(productResults[0]?.asin).toBe("B000000001");
expect(productResults[0]?.monthly_sold).toBe(600);
expect(productResults[1]?.asin).toBe("B000000004");
expect(productResults[1]?.monthly_sold).toBe(400);
});