metatron-open-seo/src/server/lib/dataforseoClient.test.ts

467 lines
12 KiB
TypeScript

/* eslint-disable max-lines */
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_CREDITS_PER_USD,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
SEO_DATA_COST_MARKUP,
} from "@/shared/billing";
interface TrackCallArg {
customerId: string;
featureId: string;
value: number;
properties?: { balanceFeatureId: string };
}
const { checkMock, trackMock, getOrCreateMock, isHostedServerAuthModeMock } =
vi.hoisted(() => ({
checkMock: vi.fn(),
trackMock: vi.fn<(arg: TrackCallArg) => void>(),
getOrCreateMock: vi.fn(),
isHostedServerAuthModeMock: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({
waitUntil: vi.fn(),
}));
vi.mock("@/server/billing/autumn", () => ({
autumn: {
check: checkMock,
track: trackMock,
},
}));
vi.mock("@/server/billing/subscription", () => ({
getOrCreateOrganizationCustomer: getOrCreateMock,
}));
vi.mock("@/server/lib/runtime-env", () => ({
isHostedServerAuthMode: isHostedServerAuthModeMock,
}));
vi.mock("@/server/lib/posthog", () => ({
captureServerEvent: vi.fn(),
}));
vi.mock("@/server/lib/dataforseo", () => ({
fetchKeywordIdeasRaw: vi.fn(),
fetchKeywordSuggestionsRaw: vi.fn(),
fetchRelatedKeywordsRaw: vi.fn(),
fetchBusinessListingsSearchRaw: vi.fn(),
fetchBusinessQuestionsAnswersRaw: vi.fn(),
fetchDomainRankOverviewRaw: vi.fn(),
fetchKeywordSearchVolumeRaw: vi.fn(),
fetchLocalSerpItemsRaw: vi.fn(),
fetchSerpCompetitorsRaw: vi.fn(),
fetchRankedKeywordsRaw: vi.fn(),
fetchLiveSerpItemsRaw: vi.fn(),
}));
vi.mock("@/server/lib/dataforseoLighthouse", () => ({
fetchDataforseoLighthouseResultRaw: vi.fn(),
}));
vi.mock("@/server/lib/dataforseoBacklinks", () => ({
fetchBacklinksHistoryRaw: vi.fn(),
fetchBacklinksRowsRaw: vi.fn(),
fetchBacklinksSummaryRaw: vi.fn(),
fetchDomainPagesSummaryRaw: vi.fn(),
fetchReferringDomainsRaw: vi.fn(),
}));
vi.mock("@/server/lib/dataforseoLlm", () => ({
fetchLlmResponseRaw: vi.fn(),
fetchLlmAggregatedMetricsRaw: vi.fn(),
fetchLlmMentionsSearchRaw: vi.fn(),
fetchLlmTopPagesRaw: vi.fn(),
}));
import {
createDataforseoClient,
mapDataforseoPathToCreditFeature,
} from "./dataforseoClient";
import { DataforseoChargedTaskError } from "./dataforseoCost";
import { fetchBacklinksSummaryRaw } from "./dataforseoBacklinks";
const billingCustomer = {
organizationId: "org_123",
userId: "user_123",
userEmail: "alice@example.com",
};
const backlinksInput = {
target: "example.com",
};
function setupHostedMode() {
isHostedServerAuthModeMock.mockResolvedValue(true);
getOrCreateMock.mockResolvedValue({ id: "org_123" });
}
function mockBalances(monthly: number, topup: number) {
checkMock.mockImplementation(async (args: { featureId: string }) => {
if (args.featureId === AUTUMN_SEO_DATA_BALANCE_FEATURE_ID) {
return { allowed: true, balance: { remaining: monthly } };
}
if (args.featureId === AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID) {
return { allowed: true, balance: { remaining: topup } };
}
return { allowed: false, balance: null };
});
}
function mockDataforseoResult(costUsd: number) {
vi.mocked(fetchBacklinksSummaryRaw).mockResolvedValue({
data: { rank: 42 },
billing: { costUsd, path: ["backlinks", "summary"], resultCount: 1 },
});
}
describe("meterDataforseoCall with split balances", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("skips billing in non-hosted mode", async () => {
isHostedServerAuthModeMock.mockResolvedValue(false);
mockDataforseoResult(0.05);
const client = createDataforseoClient(billingCustomer);
const result = await client.backlinks.summary(backlinksInput);
expect(result).toEqual({ rank: 42 });
expect(checkMock).not.toHaveBeenCalled();
expect(trackMock).not.toHaveBeenCalled();
});
it("checks both monthly and topup balances in parallel", async () => {
setupHostedMode();
mockBalances(5000, 3000);
mockDataforseoResult(0.05);
const client = createDataforseoClient(billingCustomer);
await client.backlinks.summary(backlinksInput);
expect(checkMock).toHaveBeenCalledTimes(2);
expect(checkMock).toHaveBeenCalledWith({
customerId: "org_123",
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
});
expect(checkMock).toHaveBeenCalledWith({
customerId: "org_123",
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
});
});
const RAW_COST = 0.05;
const EXPECTED_CREDITS = Math.ceil(
RAW_COST * SEO_DATA_COST_MARKUP * AUTUMN_SEO_DATA_CREDITS_PER_USD,
);
it("deducts entirely from monthly when monthly has enough", async () => {
setupHostedMode();
mockBalances(5000, 3000);
mockDataforseoResult(RAW_COST);
const client = createDataforseoClient(billingCustomer);
await client.backlinks.summary(backlinksInput);
expect(trackMock).toHaveBeenCalledTimes(1);
expect(trackMock).toHaveBeenCalledWith(
expect.objectContaining({
customerId: "org_123",
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS,
}),
);
});
it("deducts entirely from topup when monthly is empty", async () => {
setupHostedMode();
mockBalances(0, 5000);
mockDataforseoResult(RAW_COST);
const client = createDataforseoClient(billingCustomer);
await client.backlinks.summary(backlinksInput);
expect(trackMock).toHaveBeenCalledTimes(1);
expect(trackMock).toHaveBeenCalledWith(
expect.objectContaining({
customerId: "org_123",
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS,
}),
);
});
it("splits deduction across monthly and topup when monthly is partially sufficient", async () => {
setupHostedMode();
const monthlyAvailable = 30;
mockBalances(monthlyAvailable, 5000);
mockDataforseoResult(RAW_COST);
const client = createDataforseoClient(billingCustomer);
await client.backlinks.summary(backlinksInput);
expect(trackMock).toHaveBeenCalledTimes(2);
expect(trackMock).toHaveBeenCalledWith(
expect.objectContaining({
customerId: "org_123",
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: monthlyAvailable,
}),
);
expect(trackMock).toHaveBeenCalledWith(
expect.objectContaining({
customerId: "org_123",
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS - monthlyAvailable,
}),
);
});
it("throws INSUFFICIENT_CREDITS when both balances are exactly zero", async () => {
setupHostedMode();
mockBalances(0, 0);
mockDataforseoResult(0.05);
const client = createDataforseoClient(billingCustomer);
await expect(
client.backlinks.summary(backlinksInput),
).rejects.toMatchObject({ code: "INSUFFICIENT_CREDITS" });
expect(trackMock).not.toHaveBeenCalled();
});
it("meters charged DataForSEO task errors before rethrowing", async () => {
setupHostedMode();
mockBalances(5000, 3000);
vi.mocked(fetchBacklinksSummaryRaw).mockRejectedValue(
new DataforseoChargedTaskError("DataForSEO task failed", {
costUsd: RAW_COST,
path: ["v3", "backlinks", "summary", "live"],
resultCount: 0,
}),
);
const client = createDataforseoClient(billingCustomer);
await expect(client.backlinks.summary(backlinksInput)).rejects.toThrow(
"DataForSEO task failed",
);
expect(trackMock).toHaveBeenCalledTimes(1);
expect(trackMock).toHaveBeenCalledWith(
expect.objectContaining({
customerId: "org_123",
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS,
}),
);
});
it("includes balanceFeatureId in track properties", async () => {
setupHostedMode();
mockBalances(30, 5000);
mockDataforseoResult(0.05);
const client = createDataforseoClient(billingCustomer);
await client.backlinks.summary(backlinksInput);
const monthlyCall = trackMock.mock.calls.find(
([arg]) => arg.featureId === AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
);
const topupCall = trackMock.mock.calls.find(
([arg]) => arg.featureId === AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
);
expect(monthlyCall![0].properties?.balanceFeatureId).toBe(
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
);
expect(topupCall![0].properties?.balanceFeatureId).toBe(
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
);
});
});
describe("mapDataforseoPathToCreditFeature", () => {
it("maps real keyword research paths", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"related_keywords",
"live",
]),
).toBe("keyword_research");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"keyword_suggestions",
"live",
]),
).toBe("keyword_research");
});
it("maps real serp paths", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"serp",
"google",
"organic",
"live",
"regular",
]),
).toBe("keyword_research");
});
it("maps real domain paths", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"domain_rank_overview",
"live",
]),
).toBe("domain_overview");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"ranked_keywords",
"live",
]),
).toBe("domain_overview");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"relevant_pages",
"live",
]),
).toBe("domain_overview");
});
it("maps real backlinks paths", () => {
expect(
mapDataforseoPathToCreditFeature(["v3", "backlinks", "summary", "live"]),
).toBe("backlinks");
expect(mapDataforseoPathToCreditFeature(["backlinks", "summary"])).toBe(
"backlinks",
);
expect(
mapDataforseoPathToCreditFeature([
"v3",
"backlinks",
"referring_domains",
"live",
]),
).toBe("backlinks");
});
it("maps real lighthouse/on_page paths to site_audit", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"on_page",
"lighthouse",
"live",
"json",
]),
).toBe("site_audit");
});
it("maps ai_optimization llm_mentions paths to ai_citations", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"ai_optimization",
"llm_mentions",
"search",
"live",
]),
).toBe("ai_citations");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"ai_optimization",
"llm_mentions",
"aggregated_metrics",
"live",
]),
).toBe("ai_citations");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"ai_optimization",
"llm_mentions",
"top_pages",
"live",
]),
).toBe("ai_citations");
});
it("maps ai_optimization provider llm_responses paths to ai_prompt_responses", () => {
for (const provider of ["chat_gpt", "claude", "gemini", "perplexity"]) {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"ai_optimization",
provider,
"llm_responses",
"live",
]),
).toBe("ai_prompt_responses");
}
});
it("maps local and supporting paths to the intended credit features", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"business_data",
"business_listings",
"search",
"live",
]),
).toBe("local_seo");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"serp",
"google",
"local_finder",
"live",
"advanced",
]),
).toBe("local_seo");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"serp",
"google",
"maps",
"live",
"advanced",
]),
).toBe("local_seo");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"keywords_data",
"google_ads",
"search_volume",
"live",
]),
).toBe("keyword_research");
});
});