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

413 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createGa4AdminClient, createGa4DataClient } from "./ga4Client";
import {
Ga4AdminApiError,
Ga4DataApiError,
Ga4MalformedResponseError,
Ga4TokenError,
} from "./ga4Errors";
const mocks = vi.hoisted(() => ({
getAccessToken: vi.fn(),
fetch: vi.fn<typeof fetch>(),
}));
vi.mock("@/lib/auth", () => ({
getAuth: () => ({ api: { getAccessToken: mocks.getAccessToken } }),
}));
function jsonResponse(body: unknown, status = 200) {
return Response.json(body, { status });
}
function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
return input instanceof URL ? input.href : input.url;
}
describe("ga4Client admin API", () => {
beforeEach(() => {
mocks.getAccessToken.mockResolvedValue({ accessToken: "ga4_tok" });
vi.stubGlobal("fetch", mocks.fetch);
});
afterEach(() => vi.unstubAllGlobals());
it("uses the dedicated Analytics grant and paginates property discovery", async () => {
mocks.fetch
.mockResolvedValueOnce(
jsonResponse({
accountSummaries: [
{
account: "accounts/1",
displayName: "Agency",
propertySummaries: [
{ property: "properties/11", displayName: "Site A" },
],
},
],
nextPageToken: "page-2",
}),
)
.mockResolvedValueOnce(
jsonResponse({
accountSummaries: [
{
account: "accounts/2",
displayName: "Client",
propertySummaries: [
{ property: "properties/22", displayName: "Site B" },
],
},
],
}),
);
await expect(
createGa4AdminClient({
userId: "u1",
ga4AccountId: "google-sub-a",
}).listProperties(),
).resolves.toEqual([
{
propertyId: "properties/11",
displayName: "Site A",
accountDisplayName: "Agency",
},
{
propertyId: "properties/22",
displayName: "Site B",
accountDisplayName: "Client",
},
]);
expect(mocks.getAccessToken).toHaveBeenCalledWith({
body: {
providerId: "google-analytics",
userId: "u1",
accountId: "google-sub-a",
},
});
const secondUrl = mocks.fetch.mock.calls[1]?.[0];
const secondUrlText =
typeof secondUrl === "string"
? secondUrl
: secondUrl instanceof URL
? secondUrl.toString()
: secondUrl?.url;
expect(secondUrlText).toContain("pageToken=page-2");
expect(mocks.getAccessToken).toHaveBeenCalledTimes(1);
});
it("loads and validates the selected property's reporting metadata", async () => {
mocks.fetch.mockResolvedValue(
jsonResponse({
name: "properties/11",
displayName: "Site A",
timeZone: "America/New_York",
currencyCode: "USD",
}),
);
const property = await createGa4AdminClient({
userId: "u1",
ga4AccountId: "google-sub-a",
}).getProperty("properties/11");
expect(property.timeZone).toBe("America/New_York");
expect(mocks.fetch.mock.calls[0]?.[0]).toBe(
"https://analyticsadmin.googleapis.com/v1beta/properties/11",
);
});
it("classifies a rejected grant as a typed 401", async () => {
mocks.fetch.mockResolvedValue(jsonResponse({ error: "expired" }, 401));
await expect(
createGa4AdminClient({
userId: "u1",
ga4AccountId: "google-sub-a",
}).listProperties(),
).rejects.toBeInstanceOf(Ga4AdminApiError);
await expect(
createGa4AdminClient({
userId: "u1",
ga4AccountId: "google-sub-a",
}).listProperties(),
).rejects.toMatchObject({ status: 401 });
});
it("throws a token error when Better Auth cannot mint an access token", async () => {
mocks.getAccessToken.mockRejectedValue(new Error("revoked"));
await expect(
createGa4AdminClient({
userId: "u1",
ga4AccountId: "google-sub-a",
}).listProperties(),
).rejects.toBeInstanceOf(Ga4TokenError);
});
it("reads streams, enhanced measurement, key events, and custom definitions", async () => {
mocks.fetch
.mockResolvedValueOnce(
jsonResponse({
dataStreams: [
{
name: "properties/11/dataStreams/22",
type: "WEB_DATA_STREAM",
displayName: "Website",
webStreamData: {
measurementId: "G-ABC123",
defaultUri: "https://example.com",
},
},
],
}),
)
.mockResolvedValueOnce(
jsonResponse({
streamEnabled: true,
scrollsEnabled: true,
outboundClicksEnabled: true,
siteSearchEnabled: true,
videoEngagementEnabled: true,
fileDownloadsEnabled: true,
pageChangesEnabled: true,
formInteractionsEnabled: false,
searchQueryParameter: "q,s",
}),
)
.mockResolvedValueOnce(
jsonResponse({
keyEvents: [
{
eventName: "purchase",
countingMethod: "ONCE_PER_EVENT",
custom: false,
},
],
}),
)
.mockResolvedValueOnce(
jsonResponse({
customDimensions: [
{
parameterName: "content_type",
displayName: "Content type",
scope: "EVENT",
},
],
}),
)
.mockResolvedValueOnce(
jsonResponse({
customMetrics: [
{
parameterName: "quality_score",
displayName: "Quality score",
measurementUnit: "STANDARD",
scope: "EVENT",
},
],
}),
);
const client = createGa4AdminClient({
userId: "u1",
ga4AccountId: "google-sub-a",
});
const streams = await client.listDataStreams("properties/11");
const enhanced = await client.getEnhancedMeasurementSettings(
"properties/11/dataStreams/22",
);
const keyEvents = await client.listKeyEvents("properties/11");
const dimensions = await client.listCustomDimensions("properties/11");
const metrics = await client.listCustomMetrics("properties/11");
expect(streams[0]?.webStreamData?.measurementId).toBe("G-ABC123");
expect(enhanced.siteSearchEnabled).toBe(true);
expect(keyEvents[0]?.eventName).toBe("purchase");
expect(dimensions[0]?.parameterName).toBe("content_type");
expect(metrics[0]?.parameterName).toBe("quality_score");
expect(mocks.fetch.mock.calls.map((call) => requestUrl(call[0]))).toEqual([
"https://analyticsadmin.googleapis.com/v1alpha/properties/11/dataStreams?pageSize=200",
"https://analyticsadmin.googleapis.com/v1alpha/properties/11/dataStreams/22/enhancedMeasurementSettings",
"https://analyticsadmin.googleapis.com/v1beta/properties/11/keyEvents?pageSize=200",
"https://analyticsadmin.googleapis.com/v1beta/properties/11/customDimensions?pageSize=200",
"https://analyticsadmin.googleapis.com/v1beta/properties/11/customMetrics?pageSize=200",
]);
expect(mocks.getAccessToken).toHaveBeenCalledTimes(1);
});
it("converts transport failures to a typed upstream error", async () => {
mocks.fetch.mockRejectedValue(new TypeError("connection reset"));
await expect(
createGa4AdminClient({
userId: "u1",
ga4AccountId: "google-sub-a",
}).listProperties(),
).rejects.toMatchObject({ status: 0 });
});
});
const reportRequest = {
dateRanges: [{ startDate: "2026-07-01", endDate: "2026-07-28" }],
dimensions: [{ name: "hostName" }],
metrics: [{ name: "sessions" }],
offset: "0",
limit: "100",
orderBys: [{ metric: { metricName: "sessions" }, desc: true }],
keepEmptyRows: false as const,
returnPropertyQuota: true as const,
};
describe("ga4Client data API", () => {
beforeEach(() => {
mocks.getAccessToken.mockResolvedValue({ accessToken: "token" });
vi.stubGlobal("fetch", mocks.fetch);
});
afterEach(() => vi.unstubAllGlobals());
it("posts a fixed report to the selected property with its dedicated grant", async () => {
mocks.fetch.mockResolvedValue(
Response.json({
dimensionHeaders: [{ name: "hostName" }],
metricHeaders: [{ name: "sessions", type: "TYPE_INTEGER" }],
rows: [
{
dimensionValues: [{ value: "example.com" }],
metricValues: [{ value: "12" }],
},
],
rowCount: 1,
}),
);
const result = await createGa4DataClient({
userId: "user_1",
ga4AccountId: "account_1",
propertyId: "properties/123",
}).runReport(reportRequest);
expect(result.rowCount).toBe(1);
expect(mocks.getAccessToken).toHaveBeenCalledWith({
body: {
providerId: "google-analytics",
userId: "user_1",
accountId: "account_1",
},
});
expect(mocks.fetch).toHaveBeenCalledWith(
"https://analyticsdata.googleapis.com/v1beta/properties/123:runReport",
expect.objectContaining({
method: "POST",
body: JSON.stringify(reportRequest),
}),
);
});
it("reuses one token promise for concurrent reports on the same client", async () => {
mocks.fetch.mockImplementation(async () =>
Response.json({
dimensionHeaders: [{ name: "hostName" }],
metricHeaders: [{ name: "sessions", type: "TYPE_INTEGER" }],
rowCount: 0,
}),
);
const client = createGa4DataClient({
userId: "user_1",
ga4AccountId: "account_1",
propertyId: "properties/123",
});
await Promise.all([
client.runReport(reportRequest),
client.runReport(reportRequest),
]);
expect(mocks.fetch).toHaveBeenCalledTimes(2);
expect(mocks.getAccessToken).toHaveBeenCalledTimes(1);
});
it("classifies quota failures and retains a safe retry delay", async () => {
mocks.fetch.mockResolvedValue(
new Response('{"error":{"message":"private upstream detail"}}', {
status: 429,
headers: { "retry-after": "120" },
}),
);
const promise = createGa4DataClient({
userId: "user_1",
ga4AccountId: "account_1",
propertyId: "properties/123",
}).runReport(reportRequest);
await expect(promise).rejects.toBeInstanceOf(Ga4DataApiError);
await expect(promise).rejects.toMatchObject({
status: 429,
retryAfterSeconds: 120,
});
});
it("retains only safe Google error categories from a rejected request", async () => {
mocks.fetch.mockResolvedValue(
Response.json(
{
error: {
message: "contains project-specific private detail",
status: "PERMISSION_DENIED",
details: [
{
reason: "SERVICE_DISABLED",
metadata: { service: "analyticsdata.googleapis.com" },
},
],
},
},
{ status: 403 },
),
);
await expect(
createGa4DataClient({
userId: "user_1",
ga4AccountId: "account_1",
propertyId: "properties/123",
}).runReport(reportRequest),
).rejects.toMatchObject({
status: 403,
upstreamReason: "SERVICE_DISABLED",
});
});
it("rejects malformed successful responses", async () => {
mocks.fetch.mockResolvedValue(Response.json({ rows: "not-an-array" }));
await expect(
createGa4DataClient({
userId: "user_1",
ga4AccountId: "account_1",
propertyId: "properties/123",
}).runReport(reportRequest),
).rejects.toBeInstanceOf(Ga4MalformedResponseError);
});
it("rejects a non-canonical property identifier before fetching", async () => {
expect(() =>
createGa4DataClient({
userId: "user_1",
ga4AccountId: "account_1",
propertyId: "123",
}),
).toThrow();
expect(mocks.fetch).not.toHaveBeenCalled();
});
it("converts transport failures to a typed upstream error", async () => {
mocks.fetch.mockRejectedValue(new TypeError("DNS failure"));
await expect(
createGa4DataClient({
userId: "user_1",
ga4AccountId: "account_1",
propertyId: "properties/123",
}).runReport(reportRequest),
).rejects.toMatchObject({ status: 0 });
});
});