467 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|