393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
Ga4RunReportRequest,
|
|
Ga4RunReportResponse,
|
|
} from "@/server/lib/ga4Client";
|
|
import { Ga4DataApiError, Ga4ReportError } from "@/server/lib/ga4Errors";
|
|
import { makeGa4Connection } from "./ga4-test-fixtures";
|
|
import { Ga4ReportingService } from "./Ga4ReportingService";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getByProjectId: vi.fn(),
|
|
runReport:
|
|
vi.fn<(request: Ga4RunReportRequest) => Promise<Ga4RunReportResponse>>(),
|
|
}));
|
|
|
|
vi.mock("@/server/features/ga4/repositories/Ga4ConnectionRepository", () => ({
|
|
Ga4ConnectionRepository: { getByProjectId: mocks.getByProjectId },
|
|
}));
|
|
|
|
vi.mock("@/server/lib/ga4Client", () => ({
|
|
createGa4DataClient: () => ({ runReport: mocks.runReport }),
|
|
}));
|
|
|
|
const connection = makeGa4Connection();
|
|
|
|
const landingHeaders = {
|
|
dimensionHeaders: [{ name: "hostName" }, { name: "landingPage" }],
|
|
metricHeaders: [
|
|
"sessions",
|
|
"activeUsers",
|
|
"engagedSessions",
|
|
"engagementRate",
|
|
"keyEvents",
|
|
"sessionKeyEventRate",
|
|
"transactions",
|
|
"purchaseRevenue",
|
|
].map((name) => ({ name })),
|
|
};
|
|
|
|
const acquisitionMetricNames = [
|
|
"sessions",
|
|
"activeUsers",
|
|
"engagedSessions",
|
|
"engagementRate",
|
|
"keyEvents",
|
|
"transactions",
|
|
"purchaseRevenue",
|
|
];
|
|
|
|
function acquisitionResponse(
|
|
start: number,
|
|
length: number,
|
|
): Ga4RunReportResponse {
|
|
return {
|
|
dimensionHeaders: [{ name: "sessionSourceMedium" }],
|
|
metricHeaders: acquisitionMetricNames.map((name) => ({ name })),
|
|
rows: Array.from({ length }, (_, index) => ({
|
|
dimensionValues: [{ value: `source-${start + index} / referral` }],
|
|
metricValues: acquisitionMetricNames.map(() => ({ value: "1" })),
|
|
})),
|
|
rowCount: 1_100,
|
|
};
|
|
}
|
|
|
|
describe("Ga4ReportingService", () => {
|
|
beforeEach(() => {
|
|
mocks.getByProjectId.mockResolvedValue(connection);
|
|
});
|
|
|
|
it("builds and normalizes the organic landing-page report", async () => {
|
|
mocks.runReport.mockResolvedValue({
|
|
...landingHeaders,
|
|
rows: [
|
|
{
|
|
dimensionValues: [{ value: "example.com" }, { value: "/guides/seo" }],
|
|
metricValues: ["20", "18", "14", "0.7", "3", "0.15", "1", "99.5"].map(
|
|
(value) => ({ value }),
|
|
),
|
|
},
|
|
],
|
|
rowCount: 3,
|
|
metadata: {
|
|
subjectToThresholding: true,
|
|
samplingMetadatas: [
|
|
{ samplesReadCount: "1000", samplingSpaceSize: "10000" },
|
|
],
|
|
},
|
|
propertyQuota: {
|
|
tokensPerDay: { consumed: 10, remaining: 90 },
|
|
},
|
|
});
|
|
const result = await Ga4ReportingService.runReport(
|
|
{
|
|
projectId: "project_1",
|
|
kind: "landing_pages",
|
|
limit: 1,
|
|
offset: 1,
|
|
},
|
|
{ now: new Date("2026-08-06T15:00:00Z") },
|
|
);
|
|
|
|
expect(mocks.runReport).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
dateRanges: [{ startDate: "2026-07-09", endDate: "2026-08-05" }],
|
|
dimensionFilter: {
|
|
filter: {
|
|
fieldName: "sessionDefaultChannelGroup",
|
|
stringFilter: { matchType: "EXACT", value: "Organic Search" },
|
|
},
|
|
},
|
|
offset: "1",
|
|
limit: "1",
|
|
}),
|
|
);
|
|
expect(result.rows[0]).toMatchObject({
|
|
hostName: "example.com",
|
|
landingPage: "/guides/seo",
|
|
sessions: 20,
|
|
purchaseRevenue: 99.5,
|
|
});
|
|
expect(result.pageInfo).toEqual({
|
|
offset: 1,
|
|
limit: 1,
|
|
hasMore: true,
|
|
nextOffset: 2,
|
|
});
|
|
expect(result.reportMetadata.hasLimitedData).toBe(true);
|
|
expect(result.quota?.tokensPerDay).toEqual({ consumed: 10, remaining: 90 });
|
|
expect(result.request).toMatchObject({
|
|
reportKind: "landing_pages",
|
|
breakdown: "landing_page",
|
|
dimensions: ["hostName", "landingPage"],
|
|
flags: { includeDate: false, onlyWithTransactions: false },
|
|
});
|
|
});
|
|
|
|
it("clamps explicit dates and nulls restricted metrics", async () => {
|
|
mocks.runReport.mockResolvedValue({
|
|
...landingHeaders,
|
|
rows: [
|
|
{
|
|
dimensionValues: [{ value: "example.com" }, { value: "/" }],
|
|
metricValues: ["1", "1", "1", "1", "1", "1", "1", "0"].map(
|
|
(value) => ({ value }),
|
|
),
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
metadata: {
|
|
schemaRestrictionResponse: {
|
|
activeMetricRestrictions: [
|
|
{
|
|
metricName: "purchaseRevenue",
|
|
restrictedMetricTypes: ["COST_DATA"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
const result = await Ga4ReportingService.runReport(
|
|
{
|
|
projectId: "project_1",
|
|
kind: "landing_pages",
|
|
startDate: "2025-01-01",
|
|
endDate: "2026-08-20",
|
|
},
|
|
{ now: new Date("2026-08-06T15:00:00Z") },
|
|
);
|
|
|
|
expect(result.request.resolvedDateRange).toEqual({
|
|
startDate: "2026-05-08",
|
|
endDate: "2026-08-05",
|
|
});
|
|
expect(result.warnings).toEqual(["end_date_clamped", "start_date_clamped"]);
|
|
expect(result.rows[0]?.purchaseRevenue).toBeNull();
|
|
});
|
|
|
|
it("uses the all-channel page report and optional date dimension", async () => {
|
|
mocks.runReport.mockResolvedValue({
|
|
dimensionHeaders: [
|
|
{ name: "hostName" },
|
|
{ name: "pagePath" },
|
|
{ name: "date" },
|
|
],
|
|
metricHeaders: [
|
|
"screenPageViews",
|
|
"activeUsers",
|
|
"userEngagementDuration",
|
|
"keyEvents",
|
|
].map((name) => ({ name })),
|
|
rowCount: 0,
|
|
});
|
|
await Ga4ReportingService.runReport(
|
|
{
|
|
projectId: "project_1",
|
|
kind: "page_performance",
|
|
channel: "all",
|
|
includeDate: true,
|
|
},
|
|
{ now: new Date("2026-08-06T15:00:00Z") },
|
|
);
|
|
expect(mocks.runReport).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
dimensions: [
|
|
{ name: "hostName" },
|
|
{ name: "pagePath" },
|
|
{ name: "date" },
|
|
],
|
|
dimensionFilter: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns stable connection and quota errors", async () => {
|
|
mocks.getByProjectId.mockResolvedValueOnce(null);
|
|
await expect(
|
|
Ga4ReportingService.runReport({
|
|
projectId: "project_1",
|
|
kind: "landing_pages",
|
|
}),
|
|
).rejects.toMatchObject({ code: "ga4_not_connected" });
|
|
|
|
mocks.runReport.mockRejectedValueOnce(
|
|
new Ga4DataApiError(429, "quota", 60),
|
|
);
|
|
const quotaFailure = Ga4ReportingService.runReport({
|
|
projectId: "project_1",
|
|
kind: "landing_pages",
|
|
});
|
|
await expect(quotaFailure).rejects.toBeInstanceOf(Ga4ReportError);
|
|
await expect(quotaFailure).rejects.toMatchObject({
|
|
code: "ga4_quota_exhausted",
|
|
retryAfterSeconds: 60,
|
|
});
|
|
});
|
|
|
|
it("rejects half-ranges and reversed dates before calling Google", async () => {
|
|
await expect(
|
|
Ga4ReportingService.runReport({
|
|
projectId: "project_1",
|
|
kind: "landing_pages",
|
|
startDate: "2026-07-01",
|
|
}),
|
|
).rejects.toMatchObject({ code: "validation_error" });
|
|
await expect(
|
|
Ga4ReportingService.runReport({
|
|
projectId: "project_1",
|
|
kind: "landing_pages",
|
|
startDate: "2026-07-20",
|
|
endDate: "2026-07-01",
|
|
}),
|
|
).rejects.toMatchObject({ code: "validation_error" });
|
|
expect(mocks.runReport).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"traffic_acquisition",
|
|
{ acquisitionBreakdown: "source_medium", channel: "all" },
|
|
"sessionSourceMedium",
|
|
[
|
|
"sessions",
|
|
"activeUsers",
|
|
"engagedSessions",
|
|
"engagementRate",
|
|
"keyEvents",
|
|
"transactions",
|
|
"purchaseRevenue",
|
|
],
|
|
],
|
|
[
|
|
"ecommerce_performance",
|
|
{ ecommerceBreakdown: "item", channel: "organic_search" },
|
|
"itemName",
|
|
["itemsViewed", "itemsAddedToCart", "itemsPurchased", "itemRevenue"],
|
|
],
|
|
[
|
|
"site_search",
|
|
{ channel: "all" },
|
|
"searchTerm",
|
|
[
|
|
"eventCount",
|
|
"activeUsers",
|
|
"sessions",
|
|
"engagedSessions",
|
|
"engagementRate",
|
|
],
|
|
],
|
|
[
|
|
"audience_breakdown",
|
|
{ audienceBreakdown: "new_vs_returning", channel: "organic_search" },
|
|
"newVsReturning",
|
|
["activeUsers", "sessions", "engagementRate", "keyEvents"],
|
|
],
|
|
] as const)(
|
|
"builds the fixed %s report",
|
|
async (kind, options, dimension, metrics) => {
|
|
mocks.runReport.mockResolvedValue({
|
|
dimensionHeaders:
|
|
kind === "ecommerce_performance"
|
|
? [{ name: "itemName" }, { name: "itemId" }]
|
|
: [{ name: dimension }],
|
|
metricHeaders: metrics.map((name) => ({ name })),
|
|
rowCount: 0,
|
|
});
|
|
await Ga4ReportingService.runReport(
|
|
{ projectId: "project_1", kind, ...options },
|
|
{ now: new Date("2026-08-06T15:00:00Z") },
|
|
);
|
|
const request = mocks.runReport.mock.calls[0]?.[0];
|
|
expect(request?.dimensions[0]?.name).toBe(dimension);
|
|
expect(request?.metrics).toEqual(metrics.map((name) => ({ name })));
|
|
if (kind === "site_search") {
|
|
expect(request?.dimensionFilter).toEqual({
|
|
andGroup: {
|
|
expressions: [
|
|
{
|
|
filter: {
|
|
fieldName: "eventName",
|
|
stringFilter: {
|
|
matchType: "EXACT",
|
|
value: "view_search_results",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
notExpression: {
|
|
filter: {
|
|
fieldName: "searchTerm",
|
|
stringFilter: {
|
|
matchType: "EXACT",
|
|
value: "(not set)",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
describe("diagnostic pagination", () => {
|
|
it.each([
|
|
{ offset: 950, hasMore: true, nextOffset: 1_050 },
|
|
{ offset: 1_000, hasMore: false, nextOffset: null },
|
|
])(
|
|
"fetches the real page beyond the diagnostic buffer at offset $offset",
|
|
async ({ offset, hasMore, nextOffset }) => {
|
|
mocks.runReport
|
|
.mockResolvedValueOnce(acquisitionResponse(0, 1_000))
|
|
.mockResolvedValueOnce(acquisitionResponse(offset, 100));
|
|
|
|
const result = await Ga4ReportingService.runReport(
|
|
{
|
|
projectId: "project_1",
|
|
kind: "traffic_acquisition",
|
|
acquisitionBreakdown: "source_medium",
|
|
channel: "all",
|
|
offset,
|
|
limit: 100,
|
|
},
|
|
{ now: new Date("2026-08-06T15:00:00Z") },
|
|
);
|
|
|
|
expect(mocks.runReport).toHaveBeenCalledTimes(2);
|
|
expect(mocks.runReport.mock.calls[1]?.[0]).toMatchObject({
|
|
offset: String(offset),
|
|
limit: "100",
|
|
});
|
|
expect(result.rows).toHaveLength(100);
|
|
expect(result.rows[0]?.sessionSourceMedium).toBe(
|
|
`source-${offset} / referral`,
|
|
);
|
|
expect(result.pageInfo).toEqual({
|
|
offset,
|
|
limit: 100,
|
|
hasMore,
|
|
nextOffset,
|
|
});
|
|
expect(result).toMatchObject({
|
|
diagnosticCoverage: {
|
|
complete: false,
|
|
fetchedRowCount: 1_000,
|
|
totalRowCount: 1_100,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|