- 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.
317 lines
7.8 KiB
TypeScript
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);
|
|
});
|