Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3da2f55
refactor: introduce repository seam pattern for AI route tests
claude Dec 14, 2025
f701962
refactor: add conversationRepository seam for conversation routes
claude Dec 14, 2025
05c999b
refactor: add chatMessageRepository seam for chat routes
claude Dec 14, 2025
c51d27c
refactor: add globalConversationRepository seam for global routes
claude Dec 14, 2025
544e81f
refactor: add aiSettingsRepository seam and fix CodeRabbit feedback
claude Dec 14, 2025
a5cbf52
fix: remove unused imports to fix ESLint errors
claude Dec 15, 2025
4e05828
fix: move processMessageContentUpdate to repository to fix Next.js ro…
claude Dec 15, 2025
b1bbf04
fix: remove invalid re-export from global messages route
claude Dec 15, 2025
b282e8e
fix: remove invalid calculateUsageSummary re-export from usage route
claude Dec 15, 2025
051d3fe
fix: move pure functions to repositories to fix Next.js route exports
claude Dec 15, 2025
fb95714
fix: type error in settings route and sanitize error messages
claude Dec 15, 2025
4499af1
test: improve error sanitization test to verify no sensitive info leaks
claude Dec 15, 2025
1ca5f94
fix: resolve type errors and consolidate processMessageContentUpdate
claude Dec 15, 2025
b77380e
fix: use correct camelCase column name in raw SQL
claude Dec 15, 2025
841c6f6
fix: use strict equality for error message assertion
claude Dec 15, 2025
bf5968c
fix: throw error in updateAgentConfig when no rows updated
claude Dec 15, 2025
d49d255
fix: remove unrealistic test scenarios and complete lmstudio assertions
claude Dec 15, 2025
0edd7bb
chore: address code review feedback for AI route tests
claude Dec 15, 2025
b081ed6
fix: add explicit types to createPageEventPayload mock parameters
claude Dec 15, 2025
f34b429
chore: address additional code review feedback for AI route tests
claude Dec 15, 2025
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
638 changes: 353 additions & 285 deletions apps/web/src/app/api/ai/chat/messages/[messageId]/__tests__/route.test.ts

Large diffs are not rendered by default.

43 changes: 10 additions & 33 deletions apps/web/src/app/api/ai/chat/messages/[messageId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { NextResponse } from 'next/server';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { db, chatMessages, eq } from '@pagespace/db';
import { canUserEditPage } from '@pagespace/lib/server';
import { loggers } from '@pagespace/lib/server';
import { maskIdentifier } from '@/lib/logging/mask';
import {
chatMessageRepository,
processMessageContentUpdate,
} from '@/lib/repositories/chat-message-repository';

const AUTH_OPTIONS = { allow: ['jwt', 'mcp'] as const, requireCSRF: true };

Expand Down Expand Up @@ -33,10 +36,7 @@ export async function PATCH(
}

// Get the message to check permissions
const [message] = await db
.select()
.from(chatMessages)
.where(eq(chatMessages.id, messageId));
const message = await chatMessageRepository.getMessageById(messageId);

if (!message) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
Expand All @@ -56,28 +56,11 @@ export async function PATCH(
);
}

// Parse existing content to check if it's structured
let updatedContent = content;
try {
const parsed = JSON.parse(message.content);
if (parsed.textParts && parsed.partsOrder) {
// Update only textParts, preserve structure
parsed.textParts = [content];
parsed.originalContent = content;
updatedContent = JSON.stringify(parsed);
}
} catch {
// Plain text, use as-is
}
// Process content, preserving structured format if present
const updatedContent = processMessageContentUpdate(message.content, content);

// Update the message content and set editedAt
await db
.update(chatMessages)
.set({
content: updatedContent,
editedAt: new Date()
})
.where(eq(chatMessages.id, messageId));
await chatMessageRepository.updateMessageContent(messageId, updatedContent);

loggers.api.info('Message edited successfully', {
userId: maskIdentifier(userId),
Expand Down Expand Up @@ -115,10 +98,7 @@ export async function DELETE(
const { messageId } = await context.params;

// Get the message to check permissions
const [message] = await db
.select()
.from(chatMessages)
.where(eq(chatMessages.id, messageId));
const message = await chatMessageRepository.getMessageById(messageId);

if (!message) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
Expand All @@ -139,10 +119,7 @@ export async function DELETE(
}

// Soft delete the message
await db
.update(chatMessages)
.set({ isActive: false })
.where(eq(chatMessages.id, messageId));
await chatMessageRepository.softDeleteMessage(messageId);

loggers.api.info('Message deleted successfully', {
userId: maskIdentifier(userId),
Expand Down
178 changes: 91 additions & 87 deletions apps/web/src/app/api/ai/chat/messages/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,133 +1,134 @@
/**
* Contract tests for GET /api/ai/chat/messages
*
* These tests verify the Request → Response contract and boundary obligations.
* Database operations are mocked at the repository seam.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { NextResponse } from 'next/server';
import { GET } from '../route';
import type { WebAuthResult, AuthError } from '@/lib/auth';
import type { ChatMessage } from '@/lib/repositories/chat-message-repository';

// Mock dependencies
vi.mock('@pagespace/db', () => {
const orderByMock = vi.fn().mockResolvedValue([]);
const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
const fromMock = vi.fn().mockReturnValue({ where: whereMock });
const selectMock = vi.fn().mockReturnValue({ from: fromMock });
// Mock the repository seam (boundary)
vi.mock('@/lib/repositories/chat-message-repository', () => ({
chatMessageRepository: {
getMessagesForPage: vi.fn(),
},
}));

return {
db: {
select: selectMock,
},
chatMessages: {},
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
};
});
// Mock auth (boundary)
vi.mock('@/lib/auth', () => ({
authenticateHybridRequest: vi.fn(),
isAuthError: vi.fn(),
}));

// Mock permissions (boundary)
vi.mock('@pagespace/lib/server', () => ({
canUserViewPage: vi.fn(),
loggers: {
ai: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
},
canUserViewPage: vi.fn(),
}));

vi.mock('@/lib/auth', () => ({
authenticateHybridRequest: vi.fn(),
isAuthError: vi.fn(),
}));

// Mock message converter (boundary)
vi.mock('@/lib/ai/core', () => ({
convertDbMessageToUIMessage: vi.fn((msg) => ({
convertDbMessageToUIMessage: vi.fn((msg: ChatMessage) => ({
id: msg.id,
role: msg.role,
content: msg.content,
})),
}));

import { db } from '@pagespace/db';
import { loggers, canUserViewPage } from '@pagespace/lib/server';
import { chatMessageRepository } from '@/lib/repositories/chat-message-repository';
import { authenticateHybridRequest, isAuthError } from '@/lib/auth';
import { canUserViewPage, loggers } from '@pagespace/lib/server';
import { convertDbMessageToUIMessage } from '@/lib/ai/core';

// Helper to create mock WebAuthResult
const mockWebAuth = (userId: string, tokenVersion = 0): WebAuthResult => ({
// Test fixtures
const mockUserId = 'user_123';
const mockPageId = 'page_123';

const mockWebAuth = (userId: string): WebAuthResult => ({
userId,
tokenVersion,
tokenVersion: 0,
tokenType: 'jwt',
source: 'cookie',
role: 'user',
});

// Helper to create mock AuthError
const mockAuthError = (status = 401): AuthError => ({
error: NextResponse.json({ error: 'Unauthorized' }, { status }),
});

// Helper to create mock chat message
const mockChatMessage = (overrides: Partial<{
id: string;
pageId: string;
conversationId: string;
userId: string | null;
role: string;
content: string;
isActive: boolean;
createdAt: Date;
messageType: 'standard' | 'todo_list';
}> = {}) => ({
id: overrides.id || 'msg_123',
pageId: overrides.pageId || 'page_123',
pageId: overrides.pageId || mockPageId,
conversationId: overrides.conversationId || 'conv_123',
userId: overrides.userId ?? mockUserId,
role: overrides.role || 'user',
content: overrides.content || 'Hello, AI!',
isActive: overrides.isActive ?? true,
createdAt: overrides.createdAt || new Date(),
messageType: overrides.messageType || 'standard' as const,
isActive: true,
createdAt: new Date(),
editedAt: null,
toolCalls: null,
toolResults: null,
});

describe('GET /api/ai/chat/messages', () => {
const mockUserId = 'user_123';
const mockPageId = 'page_123';
const createRequest = (pageId?: string, conversationId?: string) => {
let url = 'https://example.com/api/ai/chat/messages';
const params = [];
if (pageId) params.push(`pageId=${pageId}`);
if (conversationId) params.push(`conversationId=${conversationId}`);
if (params.length) url += '?' + params.join('&');

// Helper to setup select mock for messages
const setupMessagesSelectMock = (messages: ReturnType<typeof mockChatMessage>[]) => {
const orderByMock = vi.fn().mockResolvedValue(messages);
const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
const fromMock = vi.fn().mockReturnValue({ where: whereMock });
vi.mocked(db.select).mockReturnValue({ from: fromMock } as unknown as ReturnType<typeof db.select>);
};
return new Request(url, { method: 'GET' });
};

describe('GET /api/ai/chat/messages', () => {
beforeEach(() => {
vi.clearAllMocks();

// Default auth success
// Default: authenticated user
vi.mocked(authenticateHybridRequest).mockResolvedValue(mockWebAuth(mockUserId));
vi.mocked(isAuthError).mockReturnValue(false);

// Default permission granted
// Default: permission granted
vi.mocked(canUserViewPage).mockResolvedValue(true);

// Default empty messages
setupMessagesSelectMock([]);
// Default: empty messages
vi.mocked(chatMessageRepository.getMessagesForPage).mockResolvedValue([]);
});

describe('authentication', () => {
it('should return 401 when not authenticated', async () => {
vi.mocked(isAuthError).mockReturnValue(true);
vi.mocked(authenticateHybridRequest).mockResolvedValue(mockAuthError(401));

const request = new Request(`https://example.com/api/ai/chat/messages?pageId=${mockPageId}`, {
method: 'GET',
});
const request = createRequest(mockPageId);

const response = await GET(request);

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

describe('validation', () => {
it('should return 400 when pageId is missing', async () => {
const request = new Request('https://example.com/api/ai/chat/messages', {
method: 'GET',
});
const request = createRequest();

const response = await GET(request);
const body = await response.json();
Expand All @@ -141,15 +142,20 @@ describe('GET /api/ai/chat/messages', () => {
it('should return 403 when user lacks view permission', async () => {
vi.mocked(canUserViewPage).mockResolvedValue(false);

const request = new Request(`https://example.com/api/ai/chat/messages?pageId=${mockPageId}`, {
method: 'GET',
});
const request = createRequest(mockPageId);

const response = await GET(request);
const body = await response.json();

expect(response.status).toBe(403);
expect(body.error).toContain('view permission');
});

it('should check permission with correct pageId', async () => {
const request = createRequest(mockPageId);

await GET(request);

expect(canUserViewPage).toHaveBeenCalledWith(mockUserId, mockPageId);
});
});
Expand All @@ -160,11 +166,9 @@ describe('GET /api/ai/chat/messages', () => {
mockChatMessage({ id: 'msg_1', role: 'user', content: 'Hello' }),
mockChatMessage({ id: 'msg_2', role: 'assistant', content: 'Hi there!' }),
];
setupMessagesSelectMock(messages);
vi.mocked(chatMessageRepository.getMessagesForPage).mockResolvedValue(messages);

const request = new Request(`https://example.com/api/ai/chat/messages?pageId=${mockPageId}`, {
method: 'GET',
});
const request = createRequest(mockPageId);

const response = await GET(request);
const body = await response.json();
Expand All @@ -176,11 +180,7 @@ describe('GET /api/ai/chat/messages', () => {
});

it('should return empty array when no messages exist', async () => {
setupMessagesSelectMock([]);

const request = new Request(`https://example.com/api/ai/chat/messages?pageId=${mockPageId}`, {
method: 'GET',
});
const request = createRequest(mockPageId);

const response = await GET(request);
const body = await response.json();
Expand All @@ -189,33 +189,37 @@ describe('GET /api/ai/chat/messages', () => {
expect(body).toEqual([]);
});

it('should filter by conversationId when provided', async () => {
it('should pass conversationId to repository when provided', async () => {
const conversationId = 'conv_456';
const messages = [
mockChatMessage({ id: 'msg_1', conversationId }),
];
setupMessagesSelectMock(messages);
const request = createRequest(mockPageId, conversationId);

const request = new Request(
`https://example.com/api/ai/chat/messages?pageId=${mockPageId}&conversationId=${conversationId}`,
{ method: 'GET' }
await GET(request);

expect(chatMessageRepository.getMessagesForPage).toHaveBeenCalledWith(
mockPageId,
conversationId
);
});

const response = await GET(request);
expect(response.status).toBe(200);
it('should pass undefined conversationId when not provided', async () => {
const request = createRequest(mockPageId);

await GET(request);

expect(chatMessageRepository.getMessagesForPage).toHaveBeenCalledWith(
mockPageId,
undefined
);
});
});

describe('error handling', () => {
it('should handle database errors gracefully', async () => {
const orderByMock = vi.fn().mockRejectedValue(new Error('Database error'));
const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
const fromMock = vi.fn().mockReturnValue({ where: whereMock });
vi.mocked(db.select).mockReturnValue({ from: fromMock } as unknown as ReturnType<typeof db.select>);

const request = new Request(`https://example.com/api/ai/chat/messages?pageId=${mockPageId}`, {
method: 'GET',
});
it('should return 500 when repository throws', async () => {
vi.mocked(chatMessageRepository.getMessagesForPage).mockRejectedValue(
new Error('Database error')
);

const request = createRequest(mockPageId);

const response = await GET(request);
const body = await response.json();
Expand Down
Loading