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
127 changes: 127 additions & 0 deletions src/app/api/conversations/[id]/messages/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "./route";
import { getAuthContext } from "@/lib/auth/get-user";

vi.mock("@/lib/auth/get-user", () => ({
getAuthContext: vi.fn(),
}));

vi.mock("@supabase/supabase-js", () => ({
createClient: vi.fn(),
}));

vi.mock("@/lib/email", () => ({
sendEmail: vi.fn(),
newMessageEmail: vi.fn(),
}));

vi.mock("@/lib/webhooks/dispatch", () => ({
dispatchWebhookAsync: vi.fn(),
}));

vi.mock("@/lib/notification-settings", () => ({
isEmailNotificationEnabled: vi.fn(),
}));

const mockGetAuthContext = vi.mocked(getAuthContext);
const userId = "user-1";
const conversationId = "conversation-1";

function makeRequest(params: Record<string, string> = {}) {
const url = new URL(`http://localhost/api/conversations/${conversationId}/messages`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return new NextRequest(url);
}

function params() {
return { params: Promise.resolve({ id: conversationId }) };
}

function mockSupabase(limitSpy: ReturnType<typeof vi.fn>) {
return {
from: vi.fn((table: string) => {
if (table === "conversations") {
return {
select: () => ({
eq: () => ({
single: () =>
Promise.resolve({
data: { participant_ids: [userId, "user-2"] },
error: null,
}),
}),
}),
};
}

if (table === "messages") {
return {
select: () => ({
eq: () => ({
order: () => ({
limit: limitSpy,
}),
}),
}),
update: () => ({
eq: () => Promise.resolve({ data: null, error: null }),
}),
};
}

return {};
}),
};
}

async function expectMessageLimit(input: Record<string, string>, expectedLimit: number) {
const limitSpy = vi.fn(() => Promise.resolve({ data: [], error: null }));
const supabase = mockSupabase(limitSpy);

mockGetAuthContext.mockResolvedValue({
user: { id: userId, authMethod: "session" },
supabase,
} as any);

const res = await GET(makeRequest(input), params());

expect(res.status).toBe(200);
expect(limitSpy).toHaveBeenCalledWith(expectedLimit + 1);
}

describe("GET /api/conversations/[id]/messages", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns 401 when unauthenticated", async () => {
mockGetAuthContext.mockResolvedValue(null);

const res = await GET(makeRequest(), params());

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

it("clamps negative limit values before querying messages", async () => {
await expectMessageLimit({ limit: "-5" }, 1);
Comment on lines +107 to +109
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 limit=0 boundary not tested

parseLimit("0") calls Math.max(0, 1) and returns 1, so a client sending ?limit=0 silently gets the minimum page size. Given that the PR description calls out "clamp negative values to 1" as intentional, a test asserting expectMessageLimit({ limit: "0" }, 1) would make this edge-case explicit and prevent a future reader from accidentally changing the minimum.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

});

it("uses the default limit for non-numeric input", async () => {
await expectMessageLimit({ limit: "abc" }, 50);
});

it("uses the default limit when the parameter is missing", async () => {
await expectMessageLimit({}, 50);
});

it("passes through valid in-range limit values", async () => {
await expectMessageLimit({ limit: "25" }, 25);
});

it("caps large limit values before querying messages", async () => {
await expectMessageLimit({ limit: "999" }, 100);
});
});
8 changes: 7 additions & 1 deletion src/app/api/conversations/[id]/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { sendEmail, newMessageEmail } from "@/lib/email";
import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch";
import { isEmailNotificationEnabled } from "@/lib/notification-settings";

function parseLimit(value: string | null) {
const parsed = Number(value && value.trim() !== "" ? value : 50);
const finiteValue = Number.isFinite(parsed) ? parsed : 50;
return Math.min(Math.max(Math.trunc(finiteValue), 1), 100);
}

// GET /api/conversations/[id]/messages - Get messages in a conversation
export async function GET(
request: NextRequest,
Expand Down Expand Up @@ -40,7 +46,7 @@ export async function GET(
// Parse pagination params
const { searchParams } = new URL(request.url);
const cursor = searchParams.get("cursor");
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
const limit = parseLimit(searchParams.get("limit"));

// Build query
let query = supabase
Expand Down
Loading