Skip to content
Open
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
40 changes: 40 additions & 0 deletions apps/web/__tests__/api/cli-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";

const mockServiceClient: Record<string, any> = {
auth: {
admin: {
getUserById: vi.fn().mockResolvedValue({
data: { user: { id: "user-123", banned_until: null } },
error: null,
}),
},
},
from: vi.fn(),
rpc: vi.fn(),
};
Expand Down Expand Up @@ -45,6 +53,38 @@ describe("GET /api/cli/dashboard", () => {
vi.useRealTimers();
});

it("rejects a signed token after the user is banned", async () => {
mockServiceClient.auth.admin.getUserById.mockResolvedValueOnce({
data: { user: { id: "user-123", banned_until: "2999-01-01T00:00:00Z" } },
error: null,
});

const response = await GET(
new Request("http://localhost/api/cli/dashboard", {
headers: { authorization: "Bearer token" },
}),
);

expect(response.status).toBe(401);
expect(mockServiceClient.from).not.toHaveBeenCalled();
});

it("returns 503 when the identity provider cannot verify the user", async () => {
mockServiceClient.auth.admin.getUserById.mockResolvedValueOnce({
data: { user: null },
error: { message: "Request timed out", code: "request_timeout" },
});

const response = await GET(
new Request("http://localhost/api/cli/dashboard", {
headers: { authorization: "Bearer token" },
}),
);

expect(response.status).toBe(503);
expect(mockServiceClient.from).not.toHaveBeenCalled();
});

it("aggregates model breakdown from the same last-7-days window as the scorecard", async () => {
const profile = chain({
single: vi.fn().mockResolvedValue({
Expand Down
16 changes: 16 additions & 0 deletions apps/web/__tests__/api/comment-email-notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ function makeSessionClient(commenterId: string, postOwnerId: string) {
}

function makeServiceClient({ emailNotifications }: { emailNotifications: boolean }) {
const commentsChain = {
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: {
id: "comment-1",
content: "Nice post",
user: { id: "commenter-1", username: "alexesprit" },
},
error: null,
}),
};
const usersChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
Expand All @@ -122,6 +134,10 @@ function makeServiceClient({ emailNotifications }: { emailNotifications: boolean
: { data: null, error: null },
)),
from: vi.fn().mockImplementation((table: string) => {
if (table === "comments") return commentsChain;
if (table === "notifications") {
return { insert: vi.fn().mockResolvedValue({ error: null }) };
}
if (table === "users") return usersChain;
if (table === "posts") return postsChain;
return {
Expand Down
20 changes: 19 additions & 1 deletion apps/web/__tests__/api/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ describe("GET /api/messages", () => {
}),
};
}

throw new Error(`Unexpected table ${table}`);
}),
};
Expand Down Expand Up @@ -276,6 +275,25 @@ describe("POST /api/messages", () => {
}),
};
}
if (table === "direct_messages") {
return {
insert: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: {
id: "m-1",
sender_id: "user-1",
recipient_id: "user-2",
content: "Hey Alice",
read_at: null,
created_at: "2026-03-06T12:00:00.000Z",
},
error: null,
}),
}),
}),
};
}

throw new Error(`Unexpected table ${table}`);
}),
Expand Down
21 changes: 16 additions & 5 deletions apps/web/__tests__/api/posts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(),
}));
const mockServiceFrom = vi.fn().mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { username: "author" }, error: null }),
let currentAuthClient: Record<string, any> | null = null;
const mockServiceFrom = vi.fn((table: string) => {
if (table === "posts" && currentAuthClient) {
return currentAuthClient.from(table);
}
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { username: "author" }, error: null }),
};
});
const mockServiceClient = {
from: mockServiceFrom,
Expand Down Expand Up @@ -69,6 +75,7 @@ function mockSupabase(opts: {
};

(createClient as any).mockResolvedValue(client);
currentAuthClient = client;
return client;
}

Expand All @@ -90,6 +97,7 @@ function makeRequest(

beforeEach(() => {
vi.clearAllMocks();
currentAuthClient = null;
// Reset service client to default mock between tests
mockServiceClient.from = mockServiceFrom;
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://test.supabase.co");
Expand Down Expand Up @@ -534,12 +542,14 @@ describe("DELETE /api/posts/[id]", () => {
}),
};
(createClient as any).mockResolvedValue(client);
currentAuthClient = client;

const res = await DELETE(makeRequest("DELETE"), makeContext("post-1"));
const json = await res.json();

expect(res.status).toBe(200);
expect(json.success).toBe(true);
expect(mockServiceFrom).toHaveBeenCalledWith("posts");
});

it("rejects unauthenticated DELETE", async () => {
Expand Down Expand Up @@ -586,6 +596,7 @@ describe("DELETE /api/posts/[id]", () => {
}),
};
(createClient as any).mockResolvedValue(client);
currentAuthClient = client;

const res = await DELETE(makeRequest("DELETE"), makeContext("post-1"));
const json = await res.json();
Expand Down
83 changes: 83 additions & 0 deletions apps/web/__tests__/api/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ vi.mock("@/lib/analytics/server", () => ({
captureServerActivationEvent: vi.fn().mockResolvedValue(true),
}));

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

vi.mock("@/lib/constants/regions", () => ({
COUNTRY_TO_REGION: {
US: "north_america",
Expand All @@ -34,6 +38,7 @@ 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 { rateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";

function makeContext(username: string) {
Expand All @@ -50,6 +55,7 @@ function makeRequest(method: string, url: string, body?: any) {

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(rateLimit).mockResolvedValue(null);
});

describe("GET /api/users/[username]", () => {
Expand Down Expand Up @@ -363,6 +369,7 @@ describe("PATCH /api/users/me", () => {

expect(res.status).toBe(200);
expect(json.username).toBe("new_name");
expect(rateLimit).toHaveBeenCalledWith("profile-update", "u-1", { limit: 20 });
});

it("does not complete onboarding before first sync is present", async () => {
Expand Down Expand Up @@ -548,6 +555,82 @@ describe("PATCH /api/users/me", () => {
expect(json.error).toContain("160 characters");
});

it("rejects oversized profile identity fields", async () => {
const client: Record<string, any> = {
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "u-1" } },
error: null,
}),
},
};
(createClient as any).mockResolvedValue(client);

const res = await PATCH(
makeRequest("PATCH", "/api/users/me", { display_name: "x".repeat(101) })
);

expect(res.status).toBe(400);
});

it("rejects avatar URLs outside approved providers", async () => {
const client: Record<string, any> = {
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "u-1" } },
error: null,
}),
},
};
(createClient as any).mockResolvedValue(client);

const res = await PATCH(
makeRequest("PATCH", "/api/users/me", {
avatar_url: "https://attacker.example/tracking.svg",
})
);

expect(res.status).toBe(400);
});

it("rejects invalid booleans, countries, and timezones", async () => {
const client: Record<string, any> = {
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "u-1" } },
error: null,
}),
},
};
(createClient as any).mockResolvedValue(client);

for (const body of [
{ is_public: "true" },
{ country: "XX" },
{ timezone: "Not/A_Timezone" },
]) {
const res = await PATCH(makeRequest("PATCH", "/api/users/me", body));
expect(res.status).toBe(400);
}
});

it("normalizes an empty timezone without rejecting the profile update", async () => {
const { updateMock } = mockAuthenticatedProfileUpdate();

const res = await PATCH(
makeRequest("PATCH", "/api/users/me", {
display_name: "Alice",
timezone: "",
})
);

expect(res.status).toBe(200);
expect(updateMock).toHaveBeenCalledWith({
display_name: "Alice",
timezone: "UTC",
});
});

it("validates how you heard about Straude length (max 500)", async () => {
const client: Record<string, any> = {
auth: {
Expand Down
18 changes: 18 additions & 0 deletions apps/web/__tests__/api/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import { createClient } from "@/lib/supabase/server";
import { getServiceClient } from "@/lib/supabase/service";
import { isAdmin } from "@/lib/admin";

function allowedRateLimitRpc() {
return vi.fn().mockResolvedValue({
data: [{ allowed: true, retry_after_seconds: 0 }],
error: null,
});
}

function makeRequest(method: string, url: string, body?: Record<string, unknown>) {
return new NextRequest(new URL(url, "http://localhost"), {
method,
Expand Down Expand Up @@ -71,6 +78,10 @@ describe("POST /api/prompts", () => {
}),
};
(createClient as any).mockResolvedValue(supabase);
(getServiceClient as any).mockReturnValue({
rpc: allowedRateLimitRpc(),
from: vi.fn().mockReturnValue(insertChain),
});

const res = await postPrompt(
makeRequest("POST", "/api/prompts", {
Expand Down Expand Up @@ -117,6 +128,10 @@ describe("POST /api/prompts", () => {
return call === 1 ? countChain : insertChain;
}),
});
(getServiceClient as any).mockReturnValue({
rpc: allowedRateLimitRpc(),
from: vi.fn().mockReturnValue(insertChain),
});

const res = await postPrompt(
makeRequest("POST", "/api/prompts", {
Expand Down Expand Up @@ -144,6 +159,9 @@ describe("POST /api/prompts", () => {
from: vi.fn().mockReturnValue(countChain),
};
(createClient as any).mockResolvedValue(supabase);
(getServiceClient as any).mockReturnValue({
rpc: allowedRateLimitRpc(),
});

const res = await postPrompt(
makeRequest("POST", "/api/prompts", {
Expand Down
Loading