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
4 changes: 1 addition & 3 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
{
"permissions": {
"allow": [
"*"
]
"allow": []
},
"hooks": {
"PostToolUse": [
Expand Down
139 changes: 139 additions & 0 deletions apps/web/__tests__/api/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ vi.mock("@/lib/supabase/service", () => ({
getServiceClient: vi.fn(),
}));

vi.mock("@/lib/email/send-welcome-email", () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/lib/referral", () => ({
attributeReferral: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/lib/analytics/server", () => ({
captureServerActivationEvent: vi.fn().mockResolvedValue(true),
}));

vi.mock("@/lib/constants/regions", () => ({
COUNTRY_TO_REGION: {
US: "north_america",
Expand All @@ -18,6 +30,8 @@ vi.mock("@/lib/constants/regions", () => ({

import { GET as getPublicProfile } from "@/app/api/users/[username]/route";
import { GET as getOwnProfile, PATCH } from "@/app/api/users/me/route";
import { captureServerActivationEvent } from "@/lib/analytics/server";
import { sendWelcomeEmail } from "@/lib/email/send-welcome-email";
import { createClient } from "@/lib/supabase/server";
import { getServiceClient } from "@/lib/supabase/service";
import { NextRequest } from "next/server";
Expand Down Expand Up @@ -351,6 +365,131 @@ describe("PATCH /api/users/me", () => {
expect(json.username).toBe("new_name");
});

it("does not complete onboarding before first sync is present", async () => {
const authClient: Record<string, any> = {
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "u-1", email: "u1@example.com" } },
error: null,
}),
},
};
const updateMock = vi.fn();
const dailyUsageChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: null,
error: null,
}),
};
const db = {
from: vi.fn((table: string) => {
if (table === "daily_usage") return dailyUsageChain;
if (table === "users") return { update: updateMock };
throw new Error(`Unexpected table ${table}`);
}),
};
(createClient as any).mockResolvedValue(authClient);
(getServiceClient as any).mockReturnValue(db);

const res = await PATCH(
makeRequest("PATCH", "/api/users/me", { onboarding_completed: true })
);
const json = await res.json();

expect(res.status).toBe(409);
expect(json.error).toBe("Sync your first session before completing onboarding");
expect(updateMock).not.toHaveBeenCalled();
expect(sendWelcomeEmail).not.toHaveBeenCalled();
expect(captureServerActivationEvent).not.toHaveBeenCalled();
});

it("completes onboarding after first sync and captures activation", async () => {
const authClient: Record<string, any> = {
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "u-1", email: "u1@example.com" } },
error: null,
}),
},
};
const usageRow = {
id: "usage-1",
session_count: 2,
total_tokens: 2500,
};
const updatedProfile = {
id: "u-1",
username: "alice",
onboarding_completed: true,
};
const dailyUsageChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: usageRow,
error: null,
}),
};
const updateMock = vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: updatedProfile,
error: null,
}),
}),
}),
});
const leaderboardChain = {
select: vi.fn().mockReturnThis(),
neq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
};
const db = {
from: vi.fn((table: string) => {
if (table === "daily_usage") return dailyUsageChain;
if (table === "users") return { update: updateMock };
if (table === "leaderboard_weekly") return leaderboardChain;
throw new Error(`Unexpected table ${table}`);
}),
};
(createClient as any).mockResolvedValue(authClient);
(getServiceClient as any).mockReturnValue(db);

const res = await PATCH(
makeRequest("PATCH", "/api/users/me", { onboarding_completed: true })
);
const json = await res.json();

expect(res.status).toBe(200);
expect(json.onboarding_completed).toBe(true);
expect(updateMock).toHaveBeenCalledWith({ onboarding_completed: true });
expect(sendWelcomeEmail).toHaveBeenCalledWith({
userId: "u-1",
email: "u1@example.com",
username: "alice",
});
expect(captureServerActivationEvent).toHaveBeenCalledWith({
event: "activation_completed",
distinctId: "u-1",
properties: expect.objectContaining({
surface: "onboarding",
activation_state: "activated",
is_authenticated: true,
session_count: 2,
total_tokens: 2500,
"$insert_id": "activation_completed:usage-1",
}),
});
});

it("validates username format", async () => {
const client: Record<string, any> = {
auth: {
Expand Down
72 changes: 60 additions & 12 deletions apps/web/__tests__/api/usage-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,38 @@ import { GET } from "@/app/api/usage/status/route";
import { captureServerActivationEvent } from "@/lib/analytics/server";
import { createClient } from "@/lib/supabase/server";

function mockUsageRows(rows: unknown[]) {
const chain = {
function mockUsageStatus({
latestUsage,
totals = { total_cost: 0, total_tokens: 0 },
latestPost = null,
latestUsageError = null,
}: {
latestUsage: unknown;
totals?: unknown;
latestPost?: unknown;
latestUsageError?: unknown;
}) {
const latestUsageChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({
data: rows,
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: latestUsage,
error: latestUsageError,
}),
};
const latestPostChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: latestPost,
error: null,
}),
};
const rpcChain = {
single: vi.fn().mockResolvedValue({
data: totals,
error: null,
}),
};
Expand All @@ -27,8 +53,15 @@ function mockUsageRows(rows: unknown[]) {
data: { user: { id: "user-1" } },
}),
},
from: vi.fn(() => chain),
from: vi.fn((table: string) => {
if (table === "daily_usage") return latestUsageChain;
if (table === "posts") return latestPostChain;
throw new Error(`Unexpected table ${table}`);
}),
rpc: vi.fn(() => rpcChain),
} as any);

return { latestUsageChain, latestPostChain, rpcChain };
}

describe("GET /api/usage/status", () => {
Expand All @@ -37,33 +70,48 @@ describe("GET /api/usage/status", () => {
});

it("does not activate users without usage", async () => {
mockUsageRows([]);
const { latestUsageChain } = mockUsageStatus({ latestUsage: null });

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

expect(res.status).toBe(200);
expect(json).toEqual({ has_data: false });
expect(json).toEqual({ has_data: false, has_usage: false });
expect(latestUsageChain.limit).toHaveBeenCalledWith(1);
expect(captureServerActivationEvent).not.toHaveBeenCalled();
});

it("captures first sync confirmation when web observes usage", async () => {
mockUsageRows([
{
mockUsageStatus({
latestUsage: {
id: "usage-1",
date: "2026-07-02",
created_at: "2026-07-02T10:00:00.000Z",
cost_usd: 1.25,
total_tokens: 2500,
output_tokens: 1200,
session_count: 2,
models: ["claude-sonnet-4-5-20250929"],
},
]);
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.has_data).toBe(true);
expect(json.has_usage).toBe(true);
expect(json.cost_usd).toBe(7.75);
expect(json.total_tokens).toBe(9000);
expect(json.session_count).toBe(2);
expect(json.latest_usage_id).toBe("usage-1");
expect(json.latest_usage_at).toBe("2026-07-02T10:00:00.000Z");
expect(json.latest_post_url).toBe("/post/post-1");
expect(captureServerActivationEvent).toHaveBeenCalledWith({
event: "first_sync_confirmed",
distinctId: "user-1",
Expand All @@ -72,8 +120,8 @@ describe("GET /api/usage/status", () => {
activation_state: "activated",
is_authenticated: true,
session_count: 2,
total_tokens: 2500,
total_cost_usd: 1.25,
total_tokens: 9000,
total_cost_usd: 7.75,
"$insert_id": "first_sync_confirmed:user-1:usage-1",
}),
});
Expand Down
6 changes: 3 additions & 3 deletions apps/web/__tests__/api/usage-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ describe("POST /api/usage/submit", () => {
expect(json.error).toContain("Unsupported ccusage agents");
});

it("rejects non-online ccusage pricing metadata", async () => {
it("rejects unsupported ccusage pricing metadata", async () => {
(verifyCliToken as any).mockReturnValue("user-1");
mockServiceClient();

Expand Down Expand Up @@ -1599,7 +1599,7 @@ describe("POST /api/usage/submit", () => {
claude: "ccusage-claude-v20",
ccusage_version: "20.0.6",
ccusage_agents: ["claude"],
pricing_mode: "online",
pricing_mode: "offline",
},
})
);
Expand All @@ -1608,7 +1608,7 @@ describe("POST /api/usage/submit", () => {
claude: "ccusage-claude-v20",
ccusage_version: "20.0.6",
ccusage_agents: ["claude"],
pricing_mode: "online",
pricing_mode: "offline",
};
expect(res.status).toBe(200);
expect(svc.upsert.mock.calls[0][0].collector_meta).toEqual(expectedMeta);
Expand Down
13 changes: 9 additions & 4 deletions apps/web/__tests__/components/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, render, waitFor } from "@testing-library/react";
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/components/providers/ThemeProvider";
Expand Down Expand Up @@ -40,6 +40,7 @@ vi.mock("kbar", () => ({
}) => <div className={className}>{children}</div>,
KBarSearch: ({ className }: { className?: string }) => <input className={className} />,
KBarResults: () => null,
useKBar: () => ({ query: { toggle: vi.fn() } }),
useMatches: () => ({ results: [] }),
}));

Expand Down Expand Up @@ -92,9 +93,13 @@ describe("CommandPalette", () => {
</ThemeProvider>,
);

expect(capturedActions.map((action) => action.id)).toEqual(
expect.arrayContaining(["theme-light", "theme-dark", "theme-system"]),
);
fireEvent.keyDown(window, { key: "k", metaKey: true });

await waitFor(() => {
expect(capturedActions.map((action) => action.id)).toEqual(
expect.arrayContaining(["theme-light", "theme-dark", "theme-system"]),
);
});

const darkAction = capturedActions.find((action) => action.id === "theme-dark");
act(() => {
Expand Down
Loading
Loading