metatron-open-seo/src/server/features/backlinks/services/BacklinksService.billing.test.ts
Ben Senescu ad3b732f60 hosted: add product analytics (#83)
* track core product analytics flows

Track auth, search, export, audit, and credit-consumption events with canonical route IDs so PostHog funnels and usage dashboards stay low-noise and privacy-safe.

* fix: keep auth actions usable after session loss

* refactor: simplify analytics and auth helpers

- Replace isRecord/getActiveOrganizationId type guards with simple cast
- Refactor getAnalyticsRouteContext from if/return chain to route tables
- Replace toVerificationIssueType switch with zod enum
- Merge duplicate credits_consume events into single event per API call
- Merge two PostHogBootstrap useEffects into one

* refactor: add projectId to middleware context to reduce boilerplate

The requireProjectContext middleware now includes projectId directly,
eliminating repeated manual construction of BillingCustomerContext
objects across all server function handlers.

* remove unused BILLING_* env var fallbacks from cost profile script

* remove before_send event enrichment to preserve native PostHog URL tracking

The before_send hook was stripping $pathname, $current_url, $referrer and
other URL properties, which breaks PostHog web analytics dashboards, paths
analysis, session replay, and attribution. The route_id/route_group injection
it provided is unnecessary since PostHog already captures $pathname natively.

* remove route mapping layer, pass raw redirect paths to analytics events

The route ID registry (STATIC_ROUTES, PROJECT_ROUTES, getAnalyticsRouteContext,
getRedirectRouteId) duplicated what PostHog already captures via $pathname.
Replace redirect_route_id with redirect_to containing the raw path, and remove
~80 lines of route mapping infrastructure.

* clean up analytics events: drop redundant submit events and derived properties

- Remove search_submit events for keywords, domain overview, and backlinks
  (the search_complete events capture the meaningful outcome data)
- Remove target_type from backlinks events (derived 1:1 from search_scope)
- Remove result_limit from keyword research (requested limit, not useful
  alongside actual result_count)
- Remove export_format from data:export events (always "csv")

* refactor: inline wrappers, colocate helpers, deduplicate getActiveOrganizationId

- Inline toVerificationIssueType into verify-email.tsx (single-use wrapper)
- Move mapDataforseoPathToCreditFeature into dataforseoClient.ts (only consumer)
- Extract shared getActiveOrganizationId into lib/auth-session.ts (was
  duplicated in __root.tsx and middleware/ensure-user/hosted.ts)
- Rename shared/analytics.ts → shared/internal-user.ts (only email helpers
  remain after removing route mapping, verification, and dataforseo helpers)

* remove internal user tracking and email domain properties

Drop is_internal_user super property, email_domain person property, and all
supporting code (shared/internal-user.ts, getEmailDomain, isInternalUserEmail).
Simplifies initPostHog and identifyAnalyticsUser signatures.

* remove backlinks:search_complete effect-based tracking

The reactive useEffect + useRef dedup pattern added ~30 lines of plumbing
inside a data hook for a single analytics event. Not worth the complexity.

* simplify: replace manual type guards with zod, deduplicate posthog and sign-out helpers

- Replace hand-rolled typeof checks in getActiveOrganizationId and
  isAuthenticatedServerFunctionContext with zod safeParse
- Extract withPostHogClient helper to deduplicate client posthog wrapper
- Move apiKey guard into getServerPostHogClient factory
- Extract signOutAndRedirect to avoid duplicated sign-out logic
- Drop derivable has_results from analytics events
- Remove unnecessary path normalization in mapDataforseoPathToCreditFeature

* fix: strip email from pageview URLs, restore sign-out guard, harden server posthog, fix path mapper

- Sanitize $current_url on pageviews to remove email query param (PII)
- Restore onSuccess for sign-out redirect to avoid bounce-back on failure
- Swallow shutdown() errors so PostHog outages can't fail billed work
- Rewrite mapDataforseoPathToCreditFeature to match real API path structure
  (path[1] = module, path[3] = endpoint) instead of scanning all segments

* simplify: remove redundant refs in verify-email, infer middleware context type

- Remove unnecessary useRef guards in verify-email effects (deps already prevent re-firing)
- Use z.ZodType<EnsuredUserContext> annotation to infer return type instead of casting
- Add comment explaining one-shot PostHog client on Workers

* fix: reset PostHog identity on sign-out before redirect

* fix: require POSTHOG_HOST env var instead of defaulting to us.i.posthog.com

* fix: annotate url as unknown to satisfy no-unsafe-assignment

* format
2026-04-08 14:09:02 -04:00

245 lines
6.3 KiB
TypeScript

import { beforeEach, expect, it, vi } from "vitest";
const backlinksSummaryMock = vi.fn();
const backlinksRowsMock = vi.fn();
const referringDomainsMock = vi.fn();
const domainPagesMock = vi.fn();
const backlinksHistoryMock = vi.fn();
vi.mock("@/server/lib/r2-cache", () => ({
buildCacheKey: vi.fn(
async (prefix: string, params: Record<string, unknown>) =>
`${prefix}:${JSON.stringify(params)}`,
),
getCached: vi.fn(async () => null),
setCached: vi.fn(async () => undefined),
}));
vi.mock("@/server/lib/dataforseoBacklinks", () => ({
normalizeBacklinksTarget: vi.fn(),
}));
vi.mock("@/server/lib/dataforseoClient", () => ({
createDataforseoClient: vi.fn(() => ({
backlinks: {
summary: backlinksSummaryMock,
rows: backlinksRowsMock,
referringDomains: referringDomainsMock,
domainPages: domainPagesMock,
history: backlinksHistoryMock,
},
})),
}));
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinks";
import { createBacklinksService } from "./BacklinksService";
const billingCustomer = {
organizationId: "org_123",
userId: "user_123",
userEmail: "team@example.com",
};
const cache = new Map<string, string>();
const service = createBacklinksService({
async get(key) {
const raw = cache.get(key);
return raw ? parseCachedValue(raw) : null;
},
async set(key, data) {
cache.set(key, JSON.stringify(data));
},
});
beforeEach(() => {
cache.clear();
vi.clearAllMocks();
});
it("profiles only the initial overview calls and reuses cache on repeat", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
backlinksSummaryMock.mockResolvedValue({
rank: 42,
backlinks: 1200,
referring_pages: 900,
referring_domains: 320,
broken_backlinks: 12,
broken_pages: 3,
backlinks_spam_score: 5,
info: { target_spam_score: 4 },
new_backlinks: 25,
lost_backlinks: 10,
new_referring_domains: 8,
lost_referring_domains: 2,
});
backlinksRowsMock.mockResolvedValue([
{
domain_from: "source.example",
url_from: "https://source.example/post",
url_to: "https://example.com/",
anchor: "Example",
item_type: "content",
dofollow: true,
rank: 77,
domain_from_rank: 65,
page_from_rank: 54,
backlink_spam_score: 3,
first_seen: "2026-01-01",
last_visited: "2026-03-01",
lost_date: null,
is_lost: false,
is_broken: false,
links_count: 1,
rel_attributes: ["noopener"],
},
]);
backlinksHistoryMock.mockResolvedValue([
{
date: "2026-02-01",
backlinks: 1100,
referring_domains: 300,
rank: 40,
new_backlinks: 20,
lost_backlinks: 5,
new_referring_domains: 3,
lost_referring_domains: 1,
},
]);
const first = await service.profileOverview(
{ target: "example.com" },
billingCustomer,
);
const second = await service.profileOverview(
{ target: "example.com" },
billingCustomer,
);
expect(first.overview.referringDomains).toEqual([]);
expect(first.overview.topPages).toEqual([]);
expect(referringDomainsMock).not.toHaveBeenCalled();
expect(domainPagesMock).not.toHaveBeenCalled();
expect(backlinksSummaryMock).toHaveBeenCalledOnce();
expect(backlinksHistoryMock).toHaveBeenCalledOnce();
expect(second).toEqual(first);
});
it("profiles referring domains and top pages separately", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "https://example.com/foo",
displayTarget: "https://example.com/foo",
scope: "page",
});
referringDomainsMock.mockResolvedValue([
{
domain: "source.example",
backlinks: 4,
referring_pages: 2,
rank: 65,
first_seen: "2026-01-01",
broken_backlinks: 0,
broken_pages: 0,
backlinks_spam_score: 2,
target_spam_score: 4,
},
]);
domainPagesMock.mockResolvedValue([
{
page: "https://example.com/foo",
backlinks: 100,
referring_domains: 20,
rank: 50,
broken_backlinks: 0,
},
]);
const domains = await service.profileReferringDomains(
{ target: "https://example.com/foo" },
billingCustomer,
);
const pages = await service.profileTopPages(
{ target: "https://example.com/foo" },
billingCustomer,
);
expect(domains.rows).toHaveLength(1);
expect(domains.rows[0]?.spamScore).toBe(2);
expect(pages.rows).toHaveLength(1);
});
it("does not fall back to target spam score for referring domains", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
referringDomainsMock.mockResolvedValue([
{
domain: "source.example",
backlinks: 4,
referring_pages: 2,
rank: 65,
first_seen: "2026-01-01",
broken_backlinks: 0,
broken_pages: 0,
backlinks_spam_score: null,
target_spam_score: 4,
},
]);
const domains = await service.profileReferringDomains(
{ target: "example.com" },
billingCustomer,
);
expect(domains.rows).toHaveLength(1);
expect(domains.rows[0]?.spamScore).toBeNull();
});
it("keeps cache entries isolated per organization", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
backlinksSummaryMock.mockResolvedValue({
rank: 42,
backlinks: 1200,
referring_pages: 900,
referring_domains: 320,
broken_backlinks: 12,
broken_pages: 3,
backlinks_spam_score: 5,
info: { target_spam_score: 4 },
new_backlinks: 25,
lost_backlinks: 10,
new_referring_domains: 8,
lost_referring_domains: 2,
});
backlinksRowsMock.mockResolvedValue([]);
backlinksHistoryMock.mockResolvedValue([]);
const input = { target: "example.com" };
await service.profileOverview(input, billingCustomer);
await service.profileOverview(input, {
organizationId: "org_456",
userId: "user_456",
userEmail: "other@example.com",
});
expect(backlinksSummaryMock).toHaveBeenCalledTimes(2);
});
function parseCachedValue(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}