Refactor chat permission checks and update database helper functions

Co-authored-by: dallin <dallin@buster.so>
This commit is contained in:
Cursor Agent 2025-07-04 15:21:36 +00:00
parent 79e7cb4b95
commit 964d107bb9
5 changed files with 685 additions and 677 deletions

View File

@ -4,30 +4,37 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies
vi.mock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn(),
},
tasks: {
trigger: vi.fn(),
},
}));
vi.mock('./services/chat-service', () => ({
initializeChat: vi.fn(),
initializeChat: vi.fn(),
}));
vi.mock('./services/chat-helpers', () => ({
handleAssetChat: vi.fn(),
handleAssetChat: vi.fn(),
}));
vi.mock('@buster/database', () => ({
getUserOrganizationId: vi.fn(),
checkChatPermission: vi.fn(),
createMessage: vi.fn(),
db: {
transaction: vi.fn((callback: any) => callback({ insert: vi.fn() })),
},
getChatWithDetails: vi.fn(),
getMessagesForChat: vi.fn(),
chats: {},
messages: {},
getUserOrganizationId: vi.fn(),
createChat: vi.fn(),
getChatWithDetails: vi.fn(),
createMessage: vi.fn(),
generateAssetMessages: vi.fn(),
getMessagesForChat: vi.fn(),
db: {
transaction: vi.fn().mockImplementation((callback: any) =>
callback({
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ id: 'chat-123' }]),
}),
),
},
chats: {},
messages: {},
}));
import { getUserOrganizationId } from '@buster/database';
@ -37,168 +44,168 @@ import { handleAssetChat } from './services/chat-helpers';
import { initializeChat } from './services/chat-service';
describe('createChatHandler', () => {
const mockUser = {
id: '550e8400-e29b-41d4-a716-446655440001',
name: 'Test User',
email: 'test@example.com',
avatarUrl: null,
};
const mockUser = {
id: '550e8400-e29b-41d4-a716-446655440001',
name: 'Test User',
email: 'test@example.com',
avatarUrl: null,
};
const mockChat: ChatWithMessages = {
id: 'chat-123',
title: 'Test Chat',
is_favorited: false,
message_ids: ['msg-123'],
messages: {
'msg-123': {
id: 'msg-123',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
request_message: {
request: 'Hello',
sender_id: 'user-123',
sender_name: 'Test User',
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
},
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: 'user-123',
created_by_id: 'user-123',
created_by_name: 'Test User',
created_by_avatar: null,
publicly_accessible: false,
};
const mockChat: ChatWithMessages = {
id: 'chat-123',
title: 'Test Chat',
is_favorited: false,
message_ids: ['msg-123'],
messages: {
'msg-123': {
id: 'msg-123',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
request_message: {
request: 'Hello',
sender_id: 'user-123',
sender_name: 'Test User',
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
},
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: 'user-123',
created_by_id: 'user-123',
created_by_name: 'Test User',
created_by_avatar: null,
publicly_accessible: false,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(initializeChat).mockResolvedValue({
chatId: 'chat-123',
messageId: 'msg-123',
chat: mockChat,
});
vi.mocked(getUserOrganizationId).mockResolvedValue({
organizationId: '550e8400-e29b-41d4-a716-446655440000',
role: 'admin',
});
vi.mocked(tasks.trigger).mockResolvedValue({ id: 'task-handle-123' } as any);
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(initializeChat).mockResolvedValue({
chatId: 'chat-123',
messageId: 'msg-123',
chat: mockChat,
});
vi.mocked(getUserOrganizationId).mockResolvedValue({
organizationId: '550e8400-e29b-41d4-a716-446655440000',
role: 'admin',
});
vi.mocked(tasks.trigger).mockResolvedValue({ id: 'task-handle-123' } as any);
});
it('should create a new chat with prompt', async () => {
const result = await createChatHandler({ prompt: 'Hello' }, mockUser);
it('should create a new chat with prompt', async () => {
const result = await createChatHandler({ prompt: 'Hello' }, mockUser);
expect(initializeChat).toHaveBeenCalledWith(
{ prompt: 'Hello' },
mockUser,
'550e8400-e29b-41d4-a716-446655440000'
);
expect(tasks.trigger).toHaveBeenCalledWith('analyst-agent-task', {
message_id: 'msg-123',
});
expect(result).toEqual(mockChat);
});
expect(initializeChat).toHaveBeenCalledWith(
{ prompt: 'Hello' },
mockUser,
'550e8400-e29b-41d4-a716-446655440000',
);
expect(tasks.trigger).toHaveBeenCalledWith('analyst-agent-task', {
message_id: 'msg-123',
});
expect(result).toEqual(mockChat);
});
it('should handle asset-based chat creation', async () => {
const assetChat = { ...mockChat, title: 'Asset Chat' };
vi.mocked(handleAssetChat).mockResolvedValue(assetChat);
it('should handle asset-based chat creation', async () => {
const assetChat = { ...mockChat, title: 'Asset Chat' };
vi.mocked(handleAssetChat).mockResolvedValue(assetChat);
const result = await createChatHandler(
{ asset_id: 'asset-123', asset_type: 'metric_file' },
mockUser
);
const result = await createChatHandler(
{ asset_id: 'asset-123', asset_type: 'metric_file' },
mockUser,
);
expect(handleAssetChat).toHaveBeenCalledWith(
'chat-123',
'msg-123',
'asset-123',
'metric_file',
mockUser,
mockChat
);
expect(tasks.trigger).toHaveBeenCalledWith('analyst-agent-task', {
message_id: 'msg-123',
});
expect(result).toEqual(assetChat);
});
expect(handleAssetChat).toHaveBeenCalledWith(
'chat-123',
'msg-123',
'asset-123',
'metric_file',
mockUser,
mockChat,
);
expect(tasks.trigger).toHaveBeenCalledWith('analyst-agent-task', {
message_id: 'msg-123',
});
expect(result).toEqual(assetChat);
});
it('should not trigger analyst task when no content', async () => {
const result = await createChatHandler({}, mockUser).catch((e) => {
expect(tasks.trigger).not.toHaveBeenCalled();
expect(e).toBeInstanceOf(ChatError);
});
it('should not trigger analyst task when no content', async () => {
const result = await createChatHandler({}, mockUser).catch((e) => {
expect(tasks.trigger).not.toHaveBeenCalled();
expect(e).toBeInstanceOf(ChatError);
});
expect(tasks.trigger).not.toHaveBeenCalled();
});
expect(tasks.trigger).not.toHaveBeenCalled();
});
it('should not call handleAssetChat when prompt is provided with asset', async () => {
const result = await createChatHandler(
{ prompt: 'Hello', asset_id: 'asset-123', asset_type: 'metric_file' },
mockUser
);
it('should not call handleAssetChat when prompt is provided with asset', async () => {
const result = await createChatHandler(
{ prompt: 'Hello', asset_id: 'asset-123', asset_type: 'metric_file' },
mockUser,
);
expect(handleAssetChat).not.toHaveBeenCalled();
expect(tasks.trigger).toHaveBeenCalledWith('analyst-agent-task', {
message_id: 'msg-123',
});
expect(result).toEqual(mockChat);
});
expect(handleAssetChat).not.toHaveBeenCalled();
expect(tasks.trigger).toHaveBeenCalledWith('analyst-agent-task', {
message_id: 'msg-123',
});
expect(result).toEqual(mockChat);
});
it('should handle trigger errors gracefully', async () => {
vi.mocked(tasks.trigger).mockReset().mockRejectedValue(new Error('Trigger failed'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
it('should handle trigger errors gracefully', async () => {
vi.mocked(tasks.trigger).mockReset().mockRejectedValue(new Error('Trigger failed'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(createChatHandler({ prompt: 'Hello' }, mockUser)).rejects.toMatchObject({
code: ChatErrorCode.INTERNAL_ERROR,
message: 'An unexpected error occurred while creating the chat',
details: { originalError: 'Trigger failed' },
});
await expect(createChatHandler({ prompt: 'Hello' }, mockUser)).rejects.toMatchObject({
code: ChatErrorCode.INTERNAL_ERROR,
message: 'An unexpected error occurred while creating the chat',
details: { originalError: 'Trigger failed' },
});
expect(consoleSpy).toHaveBeenCalledWith('Chat creation failed:', expect.any(Object));
expect(consoleSpy).toHaveBeenCalledWith('Chat creation failed:', expect.any(Object));
consoleSpy.mockRestore();
});
consoleSpy.mockRestore();
});
it('should throw MISSING_ORGANIZATION error when user has no org', async () => {
const userWithoutOrg = {
...mockUser,
user_metadata: {},
};
vi.mocked(getUserOrganizationId).mockResolvedValue(null);
it('should throw MISSING_ORGANIZATION error when user has no org', async () => {
const userWithoutOrg = {
...mockUser,
user_metadata: {},
};
vi.mocked(getUserOrganizationId).mockResolvedValue(null);
await expect(createChatHandler({ prompt: 'Hello' }, userWithoutOrg)).rejects.toMatchObject({
code: ChatErrorCode.MISSING_ORGANIZATION,
message: 'User is not associated with an organization',
});
});
await expect(createChatHandler({ prompt: 'Hello' }, userWithoutOrg)).rejects.toMatchObject({
code: ChatErrorCode.MISSING_ORGANIZATION,
message: 'User is not associated with an organization',
});
});
it('should re-throw ChatError instances', async () => {
const chatError = new ChatError(ChatErrorCode.PERMISSION_DENIED, 'No permission', 403);
vi.mocked(initializeChat).mockRejectedValue(chatError);
it('should re-throw ChatError instances', async () => {
const chatError = new ChatError(ChatErrorCode.PERMISSION_DENIED, 'No permission', 403);
vi.mocked(initializeChat).mockRejectedValue(chatError);
await expect(createChatHandler({ prompt: 'Hello' }, mockUser)).rejects.toMatchObject({
code: ChatErrorCode.PERMISSION_DENIED,
message: 'No permission',
details: undefined,
});
});
await expect(createChatHandler({ prompt: 'Hello' }, mockUser)).rejects.toMatchObject({
code: ChatErrorCode.PERMISSION_DENIED,
message: 'No permission',
details: undefined,
});
});
it('should wrap unexpected errors', async () => {
vi.mocked(initializeChat).mockRejectedValue(new Error('Database error'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
it('should wrap unexpected errors', async () => {
vi.mocked(initializeChat).mockRejectedValue(new Error('Database error'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(createChatHandler({ prompt: 'Hello' }, mockUser)).rejects.toMatchObject({
code: ChatErrorCode.INTERNAL_ERROR,
message: 'An unexpected error occurred while creating the chat',
details: { originalError: 'Database error' },
});
await expect(createChatHandler({ prompt: 'Hello' }, mockUser)).rejects.toMatchObject({
code: ChatErrorCode.INTERNAL_ERROR,
message: 'An unexpected error occurred while creating the chat',
details: { originalError: 'Database error' },
});
consoleSpy.mockRestore();
});
consoleSpy.mockRestore();
});
});

View File

@ -2,288 +2,293 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the database connection and database functions BEFORE any other imports
vi.mock('@buster/database/connection', () => ({
initializePool: vi.fn(),
getPool: vi.fn(),
initializePool: vi.fn(),
getPool: vi.fn(),
}));
vi.mock('@buster/database', () => ({
checkChatPermission: vi.fn(),
createMessage: vi.fn(),
db: {
transaction: vi.fn((callback: any) => callback({ insert: vi.fn() })),
},
getChatWithDetails: vi.fn(),
getMessagesForChat: vi.fn(),
chats: {},
messages: {},
createMessage: vi.fn(),
db: {
transaction: vi.fn((callback: any) => callback({ insert: vi.fn() })),
},
getChatWithDetails: vi.fn(),
getMessagesForChat: vi.fn(),
chats: {},
messages: {},
}));
// Mock the access-controls package
vi.mock('@buster/access-controls', () => ({
canUserAccessChatCached: vi.fn(),
}));
import { canUserAccessChatCached } from '@buster/access-controls';
import * as database from '@buster/database';
import type { Chat, Message } from '@buster/database';
import { ChatError, ChatErrorCode } from '@buster/server-shared/chats';
import { buildChatWithMessages, handleExistingChat, handleNewChat } from './chat-helpers';
const mockUser = {
id: '550e8400-e29b-41d4-a716-446655440001',
name: 'Test User',
email: 'test@example.com',
avatarUrl: null,
id: '550e8400-e29b-41d4-a716-446655440001',
name: 'Test User',
email: 'test@example.com',
avatarUrl: null,
};
const mockChat: Chat = {
id: 'chat-123',
title: 'Test Chat',
organizationId: '550e8400-e29b-41d4-a716-446655440000',
createdBy: '550e8400-e29b-41d4-a716-446655440001',
updatedBy: '550e8400-e29b-41d4-a716-446655440001',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
mostRecentVersionNumber: null,
id: 'chat-123',
title: 'Test Chat',
organizationId: '550e8400-e29b-41d4-a716-446655440000',
createdBy: '550e8400-e29b-41d4-a716-446655440001',
updatedBy: '550e8400-e29b-41d4-a716-446655440001',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
mostRecentVersionNumber: null,
};
const mockMessage: Message = {
id: 'msg-123',
chatId: 'chat-123',
createdBy: 'user-123',
requestMessage: 'Test message',
responseMessages: {},
reasoning: {},
title: 'Test message',
rawLlmMessages: {},
finalReasoningMessage: null,
isCompleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
feedback: null,
id: 'msg-123',
chatId: 'chat-123',
createdBy: 'user-123',
requestMessage: 'Test message',
responseMessages: {},
reasoning: {},
title: 'Test message',
rawLlmMessages: {},
finalReasoningMessage: null,
isCompleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
feedback: null,
};
describe('buildChatWithMessages', () => {
it('should build a ChatWithMessages object from database entities', () => {
const result = buildChatWithMessages(mockChat, [mockMessage], mockUser, true);
expect(result).toMatchObject({
id: 'chat-123',
title: 'Test Chat',
is_favorited: true,
message_ids: ['msg-123'],
messages: {
'msg-123': {
id: 'msg-123',
created_at: expect.any(String),
updated_at: expect.any(String),
request_message: {
request: 'Test message',
sender_id: 'user-123',
sender_name: 'Test User',
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
},
},
created_at: expect.any(String),
updated_at: expect.any(String),
created_by: '550e8400-e29b-41d4-a716-446655440001',
created_by_id: '550e8400-e29b-41d4-a716-446655440001',
created_by_name: 'Test User',
created_by_avatar: null,
publicly_accessible: false,
permission: 'owner',
});
});
it('should build a ChatWithMessages object from database entities', () => {
const result = buildChatWithMessages(mockChat, [mockMessage], mockUser, true);
expect(result).toMatchObject({
id: 'chat-123',
title: 'Test Chat',
is_favorited: true,
message_ids: ['msg-123'],
messages: {
'msg-123': {
id: 'msg-123',
created_at: expect.any(String),
updated_at: expect.any(String),
request_message: {
request: 'Test message',
sender_id: 'user-123',
sender_name: 'Test User',
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
},
},
created_at: expect.any(String),
updated_at: expect.any(String),
created_by: '550e8400-e29b-41d4-a716-446655440001',
created_by_id: '550e8400-e29b-41d4-a716-446655440001',
created_by_name: 'Test User',
created_by_avatar: null,
publicly_accessible: false,
permission: 'owner',
});
});
it('should handle missing creator details', () => {
const result = buildChatWithMessages(mockChat, [], null, false);
it('should handle missing creator details', () => {
const result = buildChatWithMessages(mockChat, [], null, false);
expect(result.created_by_name).toBe('Unknown User');
expect(result.created_by_avatar).toBeNull();
});
expect(result.created_by_name).toBe('Unknown User');
expect(result.created_by_avatar).toBeNull();
});
});
describe('handleExistingChat', () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
avatarUrl: null,
};
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
avatarUrl: null,
};
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should handle existing chat with new message', async () => {
const mockChat = {
id: 'chat-1',
title: 'Test Chat',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'user-1',
updatedBy: 'user-1',
organizationId: 'org-1',
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
} as Chat;
it('should handle existing chat with new message', async () => {
const mockChat = {
id: 'chat-1',
title: 'Test Chat',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'user-1',
updatedBy: 'user-1',
organizationId: 'org-1',
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
} as Chat;
const mockMessage = {
id: 'message-1',
chatId: 'chat-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'user-1',
requestMessage: 'Test message',
responseMessages: {},
reasoning: {},
title: 'Test message',
rawLlmMessages: {},
isCompleted: false,
deletedAt: null,
finalReasoningMessage: null,
feedback: null,
} as Message;
const mockMessage = {
id: 'message-1',
chatId: 'chat-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'user-1',
requestMessage: 'Test message',
responseMessages: {},
reasoning: {},
title: 'Test message',
rawLlmMessages: {},
isCompleted: false,
deletedAt: null,
finalReasoningMessage: null,
feedback: null,
} as Message;
vi.mocked(database.getChatWithDetails).mockResolvedValue({
chat: mockChat,
user: mockUser as unknown as any,
isFavorited: false,
});
vi.mocked(database.getChatWithDetails).mockResolvedValue({
chat: mockChat,
user: mockUser as unknown as any,
isFavorited: false,
});
vi.mocked(database.checkChatPermission).mockResolvedValue(true);
vi.mocked(database.createMessage).mockResolvedValue(mockMessage);
vi.mocked(database.getMessagesForChat).mockResolvedValue([mockMessage]);
vi.mocked(canUserAccessChatCached).mockResolvedValue(true);
vi.mocked(database.createMessage).mockResolvedValue(mockMessage);
vi.mocked(database.getMessagesForChat).mockResolvedValue([mockMessage]);
const result = await handleExistingChat('chat-1', 'message-1', 'Test message', mockUser);
const result = await handleExistingChat('chat-1', 'message-1', 'Test message', mockUser);
expect(result.chatId).toBe('chat-1');
expect(result.messageId).toBe('message-1');
expect(result.chat.messages['message-1']).toBeDefined();
});
expect(result.chatId).toBe('chat-1');
expect(result.messageId).toBe('message-1');
expect(result.chat.messages['message-1']).toBeDefined();
});
it('should throw error if chat not found', async () => {
vi.mocked(database.getChatWithDetails).mockResolvedValue(null);
it('should throw error if chat not found', async () => {
vi.mocked(database.getChatWithDetails).mockResolvedValue(null);
await expect(
handleExistingChat('chat-1', 'message-1', 'Test message', mockUser)
).rejects.toThrow(new ChatError(ChatErrorCode.CHAT_NOT_FOUND, 'Chat not found', 404));
});
await expect(
handleExistingChat('chat-1', 'message-1', 'Test message', mockUser),
).rejects.toThrow(new ChatError(ChatErrorCode.CHAT_NOT_FOUND, 'Chat not found', 404));
});
it('should throw error if permission denied', async () => {
vi.mocked(database.getChatWithDetails).mockResolvedValue({
chat: { id: 'chat-1' } as Chat,
user: null,
isFavorited: false,
});
vi.mocked(database.checkChatPermission).mockResolvedValue(false);
it('should throw error if permission denied', async () => {
vi.mocked(database.getChatWithDetails).mockResolvedValue({
chat: { id: 'chat-1' } as Chat,
user: null,
isFavorited: false,
});
vi.mocked(canUserAccessChatCached).mockResolvedValue(false);
await expect(
handleExistingChat('chat-1', 'message-1', 'Test message', mockUser)
).rejects.toThrow(
new ChatError(
ChatErrorCode.PERMISSION_DENIED,
'You do not have permission to access this chat',
403
)
);
});
await expect(
handleExistingChat('chat-1', 'message-1', 'Test message', mockUser),
).rejects.toThrow(
new ChatError(
ChatErrorCode.PERMISSION_DENIED,
'You do not have permission to access this chat',
403,
),
);
});
});
describe('handleNewChat', () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
avatarUrl: null,
};
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
avatarUrl: null,
};
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should create new chat with message', async () => {
const mockChat = {
id: 'chat-1',
title: 'Test Chat',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'user-1',
updatedBy: 'user-1',
organizationId: 'org-1',
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
} as Chat;
it('should create new chat with message', async () => {
const mockChat = {
id: 'chat-1',
title: 'Test Chat',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'user-1',
updatedBy: 'user-1',
organizationId: 'org-1',
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
} as Chat;
const mockTx = {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockChat]),
};
const mockTx = {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockChat]),
};
vi.mocked(database.db.transaction).mockImplementation((callback: any) => callback(mockTx));
vi.mocked(database.db.transaction).mockImplementation((callback: any) => callback(mockTx));
const result = await handleNewChat({
title: 'Test Chat',
messageId: 'message-1',
prompt: 'Test message',
user: mockUser,
organizationId: 'org-1',
});
const result = await handleNewChat({
title: 'Test Chat',
messageId: 'message-1',
prompt: 'Test message',
user: mockUser,
organizationId: 'org-1',
});
expect(result.chatId).toBe('chat-1');
expect(result.messageId).toBe('message-1');
});
expect(result.chatId).toBe('chat-1');
expect(result.messageId).toBe('message-1');
});
it('should create new chat without message', async () => {
const mockChat = {
id: 'chat-1',
title: 'Test Chat',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'user-1',
updatedBy: 'user-1',
organizationId: 'org-1',
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
} as Chat;
it('should create new chat without message', async () => {
const mockChat = {
id: 'chat-1',
title: 'Test Chat',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'user-1',
updatedBy: 'user-1',
organizationId: 'org-1',
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
} as Chat;
const mockTx = {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockChat]),
};
const mockTx = {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockChat]),
};
vi.mocked(database.db.transaction).mockImplementation((callback: any) => callback(mockTx));
vi.mocked(database.db.transaction).mockImplementation((callback: any) => callback(mockTx));
const result = await handleNewChat({
title: 'Test Chat',
messageId: 'message-1',
prompt: undefined,
user: mockUser,
organizationId: 'org-1',
});
const result = await handleNewChat({
title: 'Test Chat',
messageId: 'message-1',
prompt: undefined,
user: mockUser,
organizationId: 'org-1',
});
expect(result.chatId).toBe('chat-1');
expect(result.messageId).toBe('message-1');
expect(Object.keys(result.chat.messages)).toHaveLength(0);
});
expect(result.chatId).toBe('chat-1');
expect(result.messageId).toBe('message-1');
expect(Object.keys(result.chat.messages)).toHaveLength(0);
});
});

View File

@ -5,148 +5,164 @@ import { initializeChat } from './chat-service';
// Import mocked functions
import {
checkChatPermission,
createChat,
createMessage,
generateAssetMessages,
getChatWithDetails,
getMessagesForChat,
createChat,
createMessage,
generateAssetMessages,
getChatWithDetails,
getMessagesForChat,
} from '@buster/database';
const mockUser = {
id: '550e8400-e29b-41d4-a716-446655440001',
email: 'test@example.com',
name: 'Test User',
avatarUrl: null,
id: '550e8400-e29b-41d4-a716-446655440001',
email: 'test@example.com',
name: 'Test User',
avatarUrl: null,
};
const mockChat: Chat = {
id: 'chat-123',
title: 'Test Chat',
organizationId: '550e8400-e29b-41d4-a716-446655440000',
createdBy: '550e8400-e29b-41d4-a716-446655440001',
updatedBy: '550e8400-e29b-41d4-a716-446655440001',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
mostRecentVersionNumber: null,
id: 'chat-123',
title: 'Test Chat',
organizationId: '550e8400-e29b-41d4-a716-446655440000',
createdBy: '550e8400-e29b-41d4-a716-446655440001',
updatedBy: '550e8400-e29b-41d4-a716-446655440001',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
publiclyAccessible: false,
deletedAt: null,
publiclyEnabledBy: null,
publicExpiryDate: null,
mostRecentFileId: null,
mostRecentFileType: null,
mostRecentVersionNumber: null,
};
const mockMessage: Message = {
id: 'msg-123',
chatId: 'chat-123',
createdBy: 'user-123',
requestMessage: 'Test message',
responseMessages: {},
reasoning: {},
title: 'Test message',
rawLlmMessages: {},
finalReasoningMessage: null,
isCompleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
feedback: null,
id: 'msg-123',
chatId: 'chat-123',
createdBy: 'user-123',
requestMessage: 'Test message',
responseMessages: {},
reasoning: {},
title: 'Test message',
rawLlmMessages: {},
finalReasoningMessage: null,
isCompleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
feedback: null,
};
// Mock database functions
vi.mock('@buster/database', () => ({
db: {
transaction: vi.fn().mockImplementation(async (callback) => {
return callback({
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockChat]),
});
}),
},
chats: {},
messages: {},
createChat: vi.fn(),
getChatWithDetails: vi.fn(),
createMessage: vi.fn(),
checkChatPermission: vi.fn(),
generateAssetMessages: vi.fn(),
getMessagesForChat: vi.fn(),
db: {
transaction: vi.fn().mockImplementation(async (callback) => {
return callback({
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockChat]),
});
}),
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([{ chatId: 'chat-123' }]),
},
chats: {},
messages: {},
createChat: vi.fn(),
getChatWithDetails: vi.fn(),
createMessage: vi.fn(),
generateAssetMessages: vi.fn(),
getMessagesForChat: vi.fn(),
}));
// Mock access-controls
vi.mock('@buster/access-controls', () => ({
canUserAccessChatCached: vi.fn(),
}));
import { canUserAccessChatCached } from '@buster/access-controls';
describe('chat-service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('initializeChat', () => {
it('should create a new chat when chat_id is not provided', async () => {
const result = await initializeChat(
{ prompt: 'Hello' },
mockUser,
'550e8400-e29b-41d4-a716-446655440000'
);
describe('initializeChat', () => {
it('should create a new chat when chat_id is not provided', async () => {
const result = await initializeChat(
{ prompt: 'Hello' },
mockUser,
'550e8400-e29b-41d4-a716-446655440000',
);
expect(result.chatId).toBe('chat-123');
expect(result.chat.title).toBe('Test Chat');
});
expect(result.chatId).toBe('chat-123');
expect(result.chat.title).toBe('Test Chat');
});
it('should add message to existing chat when chat_id is provided', async () => {
vi.mocked(checkChatPermission).mockResolvedValue(true);
vi.mocked(getChatWithDetails).mockResolvedValue({
chat: mockChat,
user: { id: 'user-123', name: 'Test User', avatarUrl: null } as any,
isFavorited: false,
});
vi.mocked(getMessagesForChat).mockResolvedValue([mockMessage]);
vi.mocked(createMessage).mockResolvedValue({
...mockMessage,
id: 'msg-456',
requestMessage: 'Follow up',
});
it('should add message to existing chat when chat_id is provided', async () => {
vi.mocked(canUserAccessChatCached).mockResolvedValue(true);
vi.mocked(getChatWithDetails).mockResolvedValue({
chat: mockChat,
user: { id: 'user-123', name: 'Test User', avatarUrl: null } as any,
isFavorited: false,
});
vi.mocked(getMessagesForChat).mockResolvedValue([mockMessage]);
vi.mocked(createMessage).mockResolvedValue({
...mockMessage,
id: 'msg-456',
requestMessage: 'Follow up',
});
const result = await initializeChat(
{ chat_id: 'chat-123', prompt: 'Follow up' },
mockUser,
'org-123'
);
const result = await initializeChat(
{ chat_id: 'chat-123', prompt: 'Follow up' },
mockUser,
'org-123',
);
expect(checkChatPermission).toHaveBeenCalledWith('chat-123', mockUser.id);
expect(createMessage).toHaveBeenCalledWith({
chatId: 'chat-123',
content: 'Follow up',
userId: '550e8400-e29b-41d4-a716-446655440001',
messageId: expect.any(String),
});
expect(result.chatId).toBe('chat-123');
});
expect(canUserAccessChatCached).toHaveBeenCalledWith({
userId: mockUser.id,
chatId: 'chat-123',
});
expect(createMessage).toHaveBeenCalledWith({
chatId: 'chat-123',
content: 'Follow up',
userId: '550e8400-e29b-41d4-a716-446655440001',
messageId: expect.any(String),
});
expect(result.chatId).toBe('chat-123');
});
it('should throw PERMISSION_DENIED error when user lacks permission', async () => {
vi.mocked(checkChatPermission).mockResolvedValue(false);
it('should throw PERMISSION_DENIED error when user lacks permission', async () => {
vi.mocked(canUserAccessChatCached).mockResolvedValue(false);
vi.mocked(getChatWithDetails).mockResolvedValue({
chat: mockChat,
user: { id: 'user-123', name: 'Test User', avatarUrl: null } as any,
isFavorited: false,
});
await expect(
initializeChat({ chat_id: 'chat-123', prompt: 'Hello' }, mockUser, 'org-123')
).rejects.toThrow(ChatError);
await expect(
initializeChat({ chat_id: 'chat-123', prompt: 'Hello' }, mockUser, 'org-123'),
).rejects.toThrow(ChatError);
await expect(
initializeChat({ chat_id: 'chat-123', prompt: 'Hello' }, mockUser, 'org-123')
).rejects.toMatchObject({
code: ChatErrorCode.PERMISSION_DENIED,
statusCode: 403,
});
});
await expect(
initializeChat({ chat_id: 'chat-123', prompt: 'Hello' }, mockUser, 'org-123'),
).rejects.toMatchObject({
code: ChatErrorCode.PERMISSION_DENIED,
statusCode: 403,
});
});
it('should throw CHAT_NOT_FOUND error when chat does not exist', async () => {
vi.mocked(checkChatPermission).mockResolvedValue(true);
vi.mocked(getChatWithDetails).mockResolvedValue(null);
it('should throw CHAT_NOT_FOUND error when chat does not exist', async () => {
vi.mocked(getChatWithDetails).mockResolvedValue(null);
await expect(
initializeChat({ chat_id: 'chat-123', prompt: 'Hello' }, mockUser, 'org-123')
).rejects.toMatchObject({
code: ChatErrorCode.CHAT_NOT_FOUND,
statusCode: 404,
});
});
});
await expect(
initializeChat({ chat_id: 'chat-123', prompt: 'Hello' }, mockUser, 'org-123'),
).rejects.toMatchObject({
code: ChatErrorCode.CHAT_NOT_FOUND,
statusCode: 404,
});
});
});
});

View File

@ -11,28 +11,28 @@ export type User = InferSelectModel<typeof users>;
// Create a type for updateable chat fields by excluding auto-managed fields
type UpdateableChatFields = Partial<
Omit<typeof chats.$inferInsert, 'id' | 'createdAt' | 'deletedAt'>
Omit<typeof chats.$inferInsert, 'id' | 'createdAt' | 'deletedAt'>
>;
/**
* Input/output schemas for type safety
*/
export const CreateChatInputSchema = z.object({
title: z.string().min(1),
userId: z.string().uuid(),
organizationId: z.string().uuid(),
title: z.string().min(1),
userId: z.string().uuid(),
organizationId: z.string().uuid(),
});
export const GetChatInputSchema = z.object({
chatId: z.string().uuid(),
userId: z.string().uuid(),
chatId: z.string().uuid(),
userId: z.string().uuid(),
});
export const CreateMessageInputSchema = z.object({
chatId: z.string().uuid(),
content: z.string(),
userId: z.string().uuid(),
messageId: z.string().uuid().optional(),
chatId: z.string().uuid(),
content: z.string(),
userId: z.string().uuid(),
messageId: z.string().uuid().optional(),
});
export type CreateChatInput = z.infer<typeof CreateChatInputSchema>;
@ -43,155 +43,136 @@ export type CreateMessageInput = z.infer<typeof CreateMessageInputSchema>;
* Create a new chat
*/
export async function createChat(input: CreateChatInput): Promise<Chat> {
try {
const validated = CreateChatInputSchema.parse(input);
try {
const validated = CreateChatInputSchema.parse(input);
const [chat] = await db
.insert(chats)
.values({
title: validated.title,
organizationId: validated.organizationId,
createdBy: validated.userId,
updatedBy: validated.userId,
publiclyAccessible: false,
})
.returning();
const [chat] = await db
.insert(chats)
.values({
title: validated.title,
organizationId: validated.organizationId,
createdBy: validated.userId,
updatedBy: validated.userId,
publiclyAccessible: false,
})
.returning();
if (!chat) {
throw new Error('Failed to create chat');
}
if (!chat) {
throw new Error('Failed to create chat');
}
return chat;
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid chat input: ${error.errors.map((e) => e.message).join(', ')}`);
}
throw error;
}
return chat;
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid chat input: ${error.errors.map((e) => e.message).join(', ')}`);
}
throw error;
}
}
/**
* Get a chat with user and favorite information
*/
export async function getChatWithDetails(input: GetChatInput): Promise<{
chat: Chat;
user: User | null;
isFavorited: boolean;
chat: Chat;
user: User | null;
isFavorited: boolean;
} | null> {
const validated = GetChatInputSchema.parse(input);
const validated = GetChatInputSchema.parse(input);
// Get chat with creator info
const result = await db
.select({
chat: chats,
user: users,
})
.from(chats)
.leftJoin(users, eq(chats.createdBy, users.id))
.where(and(eq(chats.id, validated.chatId), isNull(chats.deletedAt)))
.limit(1);
// Get chat with creator info
const result = await db
.select({
chat: chats,
user: users,
})
.from(chats)
.leftJoin(users, eq(chats.createdBy, users.id))
.where(and(eq(chats.id, validated.chatId), isNull(chats.deletedAt)))
.limit(1);
if (!result.length || !result[0]?.chat) {
return null;
}
if (!result.length || !result[0]?.chat) {
return null;
}
// Check if favorited
const favorite = await db
.select()
.from(userFavorites)
.where(
and(
eq(userFavorites.userId, validated.userId),
eq(userFavorites.assetId, validated.chatId),
eq(userFavorites.assetType, 'chat'),
),
)
.limit(1);
// Check if favorited
const favorite = await db
.select()
.from(userFavorites)
.where(
and(
eq(userFavorites.userId, validated.userId),
eq(userFavorites.assetId, validated.chatId),
eq(userFavorites.assetType, 'chat')
)
)
.limit(1);
const { chat, user } = result[0];
return {
chat,
user,
isFavorited: favorite.length > 0,
};
const { chat, user } = result[0];
return {
chat,
user,
isFavorited: favorite.length > 0,
};
}
/**
* Create a new message in a chat
*/
export async function createMessage(input: CreateMessageInput): Promise<Message> {
try {
const validated = CreateMessageInputSchema.parse(input);
try {
const validated = CreateMessageInputSchema.parse(input);
const messageId = validated.messageId || crypto.randomUUID();
const messageId = validated.messageId || crypto.randomUUID();
// Use transaction to ensure atomicity
const result = await db.transaction(async (tx) => {
const [message] = await tx
.insert(messages)
.values({
id: messageId,
chatId: validated.chatId,
createdBy: validated.userId,
requestMessage: validated.content,
responseMessages: {},
reasoning: {},
title: validated.content.substring(0, 255), // Ensure title fits in database
rawLlmMessages: {},
isCompleted: false,
})
.returning();
// Use transaction to ensure atomicity
const result = await db.transaction(async (tx) => {
const [message] = await tx
.insert(messages)
.values({
id: messageId,
chatId: validated.chatId,
createdBy: validated.userId,
requestMessage: validated.content,
responseMessages: {},
reasoning: {},
title: validated.content.substring(0, 255), // Ensure title fits in database
rawLlmMessages: {},
isCompleted: false,
})
.returning();
if (!message) {
throw new Error('Failed to create message');
}
if (!message) {
throw new Error('Failed to create message');
}
// Update chat's updated_at timestamp
await tx
.update(chats)
.set({ updatedAt: new Date().toISOString() })
.where(eq(chats.id, validated.chatId));
// Update chat's updated_at timestamp
await tx
.update(chats)
.set({ updatedAt: new Date().toISOString() })
.where(eq(chats.id, validated.chatId));
return message;
});
return message;
});
return result;
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid message input: ${error.errors.map((e) => e.message).join(', ')}`);
}
throw error;
}
}
/**
* Check if a user has permission to access a chat
*/
export async function checkChatPermission(chatId: string, userId: string): Promise<boolean> {
const chat = await db
.select()
.from(chats)
.where(and(eq(chats.id, chatId), isNull(chats.deletedAt)))
.limit(1);
if (!chat.length) {
return false;
}
// For now, only check if user is the creator
// TODO: Add more sophisticated permission checking with asset_permissions table
return chat[0]?.createdBy === userId;
return result;
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid message input: ${error.errors.map((e) => e.message).join(', ')}`);
}
throw error;
}
}
/**
* Get all messages for a chat
*/
export async function getMessagesForChat(chatId: string): Promise<Message[]> {
return db
.select()
.from(messages)
.where(and(eq(messages.chatId, chatId), isNull(messages.deletedAt)))
.orderBy(messages.createdAt);
return db
.select()
.from(messages)
.where(and(eq(messages.chatId, chatId), isNull(messages.deletedAt)))
.orderBy(messages.createdAt);
}
/**
@ -203,50 +184,50 @@ export async function getMessagesForChat(chatId: string): Promise<Message[]> {
* @returns Success status
*/
export async function updateChat(
chatId: string,
fields: UpdateableChatFields,
chatId: string,
fields: UpdateableChatFields
): Promise<{ success: boolean }> {
try {
// First verify the chat exists and is not deleted
const existingChat = await db
.select({ id: chats.id })
.from(chats)
.where(and(eq(chats.id, chatId), isNull(chats.deletedAt)))
.limit(1);
try {
// First verify the chat exists and is not deleted
const existingChat = await db
.select({ id: chats.id })
.from(chats)
.where(and(eq(chats.id, chatId), isNull(chats.deletedAt)))
.limit(1);
if (existingChat.length === 0) {
throw new Error(`Chat not found or has been deleted: ${chatId}`);
}
if (existingChat.length === 0) {
throw new Error(`Chat not found or has been deleted: ${chatId}`);
}
// Build update object with only provided fields
const updateData: Partial<UpdateableChatFields> & { updatedAt: string } = {
updatedAt: new Date().toISOString(),
};
// Build update object with only provided fields
const updateData: Partial<UpdateableChatFields> & { updatedAt: string } = {
updatedAt: new Date().toISOString(),
};
// Only add fields that are actually provided (not undefined)
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined && key !== 'id' && key !== 'createdAt' && key !== 'deletedAt') {
updateData[key] = value;
}
}
// Only add fields that are actually provided (not undefined)
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined && key !== 'id' && key !== 'createdAt' && key !== 'deletedAt') {
updateData[key] = value;
}
}
// If updatedAt was explicitly provided, use that instead
if ('updatedAt' in fields && fields.updatedAt !== undefined) {
updateData.updatedAt = fields.updatedAt;
}
// If updatedAt was explicitly provided, use that instead
if ('updatedAt' in fields && fields.updatedAt !== undefined) {
updateData.updatedAt = fields.updatedAt;
}
await db
.update(chats)
.set(updateData)
.where(and(eq(chats.id, chatId), isNull(chats.deletedAt)));
await db
.update(chats)
.set(updateData)
.where(and(eq(chats.id, chatId), isNull(chats.deletedAt)));
return { success: true };
} catch (error) {
console.error('Failed to update chat fields:', error);
// Re-throw our specific validation errors
if (error instanceof Error && error.message.includes('Chat not found')) {
throw error;
}
throw new Error(`Failed to update chat fields for chat ${chatId}`);
}
return { success: true };
} catch (error) {
console.error('Failed to update chat fields:', error);
// Re-throw our specific validation errors
if (error instanceof Error && error.message.includes('Chat not found')) {
throw error;
}
throw new Error(`Failed to update chat fields for chat ${chatId}`);
}
}

View File

@ -34,7 +34,6 @@ export {
updateChat,
getChatWithDetails,
createMessage,
checkChatPermission,
getMessagesForChat,
CreateChatInputSchema,
GetChatInputSchema,