Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion apps/web/__tests__/api/activation-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ vi.mock("@/lib/analytics/server", () => ({
identifyServerActivationUser: vi.fn().mockResolvedValue(true),
}));

vi.mock("@/lib/rate-limit", () => ({
rateLimit: vi.fn().mockResolvedValue(null),
}));

import { POST } from "@/app/api/analytics/activation/route";
import { ACTIVATION_ANONYMOUS_COOKIE } from "@/lib/analytics/activation";
import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server";
import { rateLimit } from "@/lib/rate-limit";
import { createClient } from "@/lib/supabase/server";

function mockAuthUser(userId: string | null) {
Expand All @@ -24,11 +29,18 @@ function mockAuthUser(userId: string | null) {
} as any);
}

function request(body: unknown, cookie?: string) {
function request(
body: unknown,
options?: string | { cookie?: string; headers?: Record<string, string> },
) {
const cookie = typeof options === "string" ? options : options?.cookie;
const extraHeaders = typeof options === "string" ? {} : (options?.headers ?? {});

return new Request("http://localhost/api/analytics/activation", {
method: "POST",
headers: {
"Content-Type": "application/json",
...extraHeaders,
...(cookie ? { cookie } : {}),
},
body: JSON.stringify(body),
Expand All @@ -38,6 +50,7 @@ function request(body: unknown, cookie?: string) {
describe("POST /api/analytics/activation", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(rateLimit).mockResolvedValue(null);
mockAuthUser(null);
});

Expand Down Expand Up @@ -107,6 +120,67 @@ describe("POST /api/analytics/activation", () => {

expect(res.status).toBe(400);
expect(json.error).toBe("Invalid activation event");
expect(rateLimit).not.toHaveBeenCalled();
expect(captureServerActivationEvent).not.toHaveBeenCalled();
});

it("returns 429 for rate-limited requests without capturing", async () => {
mockAuthUser("user-1");
vi.mocked(rateLimit).mockResolvedValue(
new Response(JSON.stringify({ error: "Too many requests" }), {
status: 429,
headers: { "Content-Type": "application/json" },
}) as any,
);

const res = await POST(request({
event: "sync_command_copied",
properties: { surface: "onboarding" },
}));
const json = await res.json();

expect(res.status).toBe(429);
expect(json.error).toBe("Too many requests");
expect(rateLimit).toHaveBeenCalledWith(
"activation-analytics",
"user-1",
{ limit: 20, windowSeconds: 60 },
);
expect(captureServerActivationEvent).not.toHaveBeenCalled();
});

it("rate limits authenticated requests by user id", async () => {
mockAuthUser("user-1");

const res = await POST(request({
event: "sync_command_copied",
properties: { surface: "onboarding" },
}));

expect(res.status).toBe(200);
expect(rateLimit).toHaveBeenCalledWith(
"activation-analytics",
"user-1",
{ limit: 20, windowSeconds: 60 },
);
expect(captureServerActivationEvent).toHaveBeenCalled();
});

it("rate limits anonymous requests by the first forwarded IP", async () => {
const res = await POST(request(
{
event: "signup_started",
properties: { surface: "signup" },
},
{ headers: { "x-forwarded-for": "203.0.113.7, 198.51.100.4" } },
));

expect(res.status).toBe(200);
expect(rateLimit).toHaveBeenCalledWith(
"activation-analytics",
"203.0.113.7",
{ limit: 20, windowSeconds: 60 },
);
expect(captureServerActivationEvent).toHaveBeenCalled();
});
});
88 changes: 88 additions & 0 deletions apps/web/__tests__/api/cron-refresh-open-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/lib/open-stats", () => ({
refreshOpenStatsSnapshot: vi.fn(),
}));

import { NextRequest } from "next/server";
import { GET } from "@/app/api/cron/refresh-open-stats/route";
import { refreshOpenStatsSnapshot } from "@/lib/open-stats";

function request(token?: string) {
return new NextRequest(
new URL("/api/cron/refresh-open-stats", "http://localhost"),
{
method: "GET",
headers: token ? { authorization: `Bearer ${token}` } : undefined,
},
);
}

describe("GET /api/cron/refresh-open-stats", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("CRON_SECRET", "cron-secret");
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("returns 401 without a bearer token", async () => {
const res = await GET(request());
const json = await res.json();

expect(res.status).toBe(401);
expect(json).toEqual({ error: "Unauthorized" });
expect(refreshOpenStatsSnapshot).not.toHaveBeenCalled();
});

it("returns 401 with the wrong bearer token", async () => {
const res = await GET(request("wrong-secret"));
const json = await res.json();

expect(res.status).toBe(401);
expect(json).toEqual({ error: "Unauthorized" });
expect(refreshOpenStatsSnapshot).not.toHaveBeenCalled();
});

it("refreshes and returns the persisted snapshot summary", async () => {
vi.mocked(refreshOpenStatsSnapshot).mockResolvedValue({
snapshotDate: "2026-07-04",
totalSpend: 123.45,
trackedUsers: 12,
} as any);

const res = await GET(request("cron-secret"));
const json = await res.json();

expect(res.status).toBe(200);
expect(refreshOpenStatsSnapshot).toHaveBeenCalledTimes(1);
expect(json).toEqual({
ok: true,
snapshotDate: "2026-07-04",
totalSpend: 123.45,
trackedUsers: 12,
});
});

it("returns 500 and logs when the refresh throws", async () => {
const error = new Error("refresh failed");
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
vi.mocked(refreshOpenStatsSnapshot).mockRejectedValue(error);

const res = await GET(request("cron-secret"));
const json = await res.json();

expect(res.status).toBe(500);
expect(json).toEqual({ error: "refresh failed" });
expect(consoleError).toHaveBeenCalledWith(
"refresh open stats snapshot failed:",
error,
);

consoleError.mockRestore();
});
});
65 changes: 62 additions & 3 deletions apps/web/__tests__/api/usage-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import { createClient } from "@/lib/supabase/server";

function mockUsageStatus({
latestUsage,
earliestUsage = null,
totals = { total_cost: 0, total_tokens: 0 },
latestPost = null,
latestUsageError = null,
}: {
latestUsage: unknown;
earliestUsage?: unknown;
totals?: unknown;
latestPost?: unknown;
latestUsageError?: unknown;
Expand All @@ -33,6 +35,16 @@ function mockUsageStatus({
error: latestUsageError,
}),
};
const earliestUsageChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: earliestUsage,
error: null,
}),
};
const latestPostChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
Expand All @@ -47,21 +59,28 @@ function mockUsageStatus({
error: null,
}),
};
const dailyUsageChains = [latestUsageChain, earliestUsageChain];
vi.mocked(createClient).mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "user-1" } },
}),
},
from: vi.fn((table: string) => {
if (table === "daily_usage") return latestUsageChain;
if (table === "daily_usage") {
return dailyUsageChains.shift() ?? latestUsageChain;
}
if (table === "posts") return latestPostChain;
throw new Error(`Unexpected table ${table}`);
}),
rpc: vi.fn(() => rpcChain),
} as any);

return { latestUsageChain, latestPostChain, rpcChain };
return { latestUsageChain, earliestUsageChain, latestPostChain, rpcChain };
}

function hoursAgoIso(hours: number) {
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
}

describe("GET /api/usage/status", () => {
Expand All @@ -81,6 +100,45 @@ describe("GET /api/usage/status", () => {
expect(captureServerActivationEvent).not.toHaveBeenCalled();
});

it("does not capture first sync confirmation when the first usage row is older than 24 hours", async () => {
mockUsageStatus({
latestUsage: {
id: "usage-latest",
date: "2026-07-02",
created_at: hoursAgoIso(1),
cost_usd: 1.25,
total_tokens: 2500,
output_tokens: 1200,
session_count: 2,
models: ["claude-sonnet-4-5-20250929"],
},
earliestUsage: { created_at: hoursAgoIso(24 * 30) },
totals: {
total_cost: 7.75,
total_tokens: 9000,
},
latestPost: { id: "post-1" },
});

const res = await GET();
const json = await res.json();

expect(res.status).toBe(200);
expect(json).toEqual({
has_data: true,
has_usage: true,
cost_usd: 7.75,
total_tokens: 9000,
session_count: 2,
top_model: "claude-sonnet-4-5-20250929",
latest_usage_id: "usage-latest",
latest_usage_at: expect.any(String),
latest_usage_date: "2026-07-02",
latest_post_url: "/post/post-1",
});
expect(captureServerActivationEvent).not.toHaveBeenCalled();
});

it("captures first sync confirmation when web observes usage", async () => {
mockUsageStatus({
latestUsage: {
Expand All @@ -93,6 +151,7 @@ describe("GET /api/usage/status", () => {
session_count: 2,
models: ["claude-sonnet-4-5-20250929"],
},
earliestUsage: { created_at: hoursAgoIso(1) },
totals: {
total_cost: 7.75,
total_tokens: 9000,
Expand Down Expand Up @@ -122,7 +181,7 @@ describe("GET /api/usage/status", () => {
session_count: 2,
total_tokens: 9000,
total_cost_usd: 7.75,
"$insert_id": "first_sync_confirmed:user-1:usage-1",
"$insert_id": "first_sync_confirmed:user-1",
}),
});
});
Expand Down
12 changes: 1 addition & 11 deletions apps/web/app/(auth)/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
import { NextResponse } from "next/server";
import { after } from "@/lib/utils/after";
import { ACTIVATION_ANONYMOUS_COOKIE, deriveActivationState } from "@/lib/analytics/activation";
import { ACTIVATION_ANONYMOUS_COOKIE, deriveActivationState, getCookieValue } from "@/lib/analytics/activation";
import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server";
import { createClient } from "@/lib/supabase/server";
import { getServiceClient } from "@/lib/supabase/service";

function getCookieValue(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const target = `${name}=`;
const entry = cookieHeader
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(target));
return entry ? decodeURIComponent(entry.slice(target.length)) : null;
}

export async function GET(request: Request) {
const { searchParams, origin: requestOrigin } = new URL(request.url);
const origin = requestOrigin;
Expand Down
Loading
Loading