diff --git a/src/app/api/messages/load/route.js b/src/app/api/messages/load/route.js index 3828fec7..aea37a5f 100644 --- a/src/app/api/messages/load/route.js +++ b/src/app/api/messages/load/route.js @@ -7,9 +7,14 @@ import { NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/middleware/auth.js'; import { sseManager } from '@/lib/api/sse-manager.js'; +function normalizeConversationId(conversationId) { + return typeof conversationId === 'string' ? conversationId.trim() : ''; +} + export const POST = withAuth(async ({ request, locals }) => { try { - const { conversationId, limit = 50, before } = await request.json(); + const { conversationId: rawConversationId, limit = 50, before } = await request.json(); + const conversationId = normalizeConversationId(rawConversationId); if (!conversationId) { return NextResponse.json({ error: 'Missing conversationId' }, { status: 400 }); diff --git a/src/app/api/messages/load/route.test.js b/src/app/api/messages/load/route.test.js index 6418cbbe..85518c6c 100644 --- a/src/app/api/messages/load/route.test.js +++ b/src/app/api/messages/load/route.test.js @@ -1,10 +1,14 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ mockSupabase: { - from: vi.fn() + from: vi.fn(), + rpc: vi.fn() }, - mockJoinRoom: vi.fn() + mockJoinRoom: vi.fn(), + userEq: vi.fn(), + participantEq: vi.fn(), + messageConversationEq: vi.fn() })); vi.mock('@/lib/api/middleware/auth.js', () => ({ @@ -25,7 +29,82 @@ vi.mock('@/lib/api/sse-manager.js', () => ({ } })); +function createUserQuery() { + const query = { + select: vi.fn(() => query), + eq: mocks.userEq, + single: vi.fn().mockResolvedValue({ + data: { id: 'internal-user-id' }, + error: null + }) + }; + mocks.userEq.mockReturnValue(query); + return query; +} + +function createParticipantQuery() { + const query = { + select: vi.fn(() => query), + eq: mocks.participantEq, + single: vi.fn().mockResolvedValue({ + data: { id: 'participant-row' }, + error: null + }) + }; + mocks.participantEq.mockReturnValue(query); + return query; +} + +function createMessagesQuery() { + const query = { + select: vi.fn(() => query), + eq: vi.fn(() => query), + is: vi.fn(() => query), + order: vi.fn(() => query), + limit: vi.fn().mockResolvedValue({ + data: [], + error: null + }) + }; + query.eq.mockImplementation((column, value) => { + if (column === 'conversation_id') { + mocks.messageConversationEq(column, value); + } + return query; + }); + return query; +} + describe('POST /api/messages/load validation', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.mockSupabase.rpc.mockResolvedValue({ data: null, error: null }); + mocks.mockSupabase.from.mockImplementation((table) => { + if (table === 'users') return createUserQuery(); + if (table === 'conversation_participants') return createParticipantQuery(); + if (table === 'messages') return createMessagesQuery(); + throw new Error(`Unexpected table: ${table}`); + }); + }); + + it('rejects blank conversation ids before database work', async () => { + const { POST } = await import('./route.js'); + const request = { + json: vi.fn().mockResolvedValue({ + conversationId: ' ' + }) + }; + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('Missing conversationId'); + expect(mocks.mockSupabase.from).not.toHaveBeenCalled(); + expect(mocks.mockJoinRoom).not.toHaveBeenCalled(); + }); + it('rejects non-integer limits before database work', async () => { const { POST } = await import('./route.js'); const request = { @@ -42,4 +121,23 @@ describe('POST /api/messages/load validation', () => { expect(body.error).toBe('limit must be an integer between 1 and 100'); expect(mocks.mockSupabase.from).not.toHaveBeenCalled(); }); + + it('trims conversation ids before querying messages and joining the room', async () => { + const { POST } = await import('./route.js'); + const request = { + json: vi.fn().mockResolvedValue({ + conversationId: ' conversation-1 ', + limit: 25 + }) + }; + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ success: true, messages: [], hasMore: false }); + expect(mocks.participantEq).toHaveBeenCalledWith('conversation_id', 'conversation-1'); + expect(mocks.messageConversationEq).toHaveBeenCalledWith('conversation_id', 'conversation-1'); + expect(mocks.mockJoinRoom).toHaveBeenCalledWith('internal-user-id', 'conversation-1'); + }); });