diff --git a/apps/web/src/controllers/HomePage/HomePageController.test.tsx b/apps/web/src/controllers/HomePage/HomePageController.test.tsx new file mode 100644 index 000000000..6efda55cd --- /dev/null +++ b/apps/web/src/controllers/HomePage/HomePageController.test.tsx @@ -0,0 +1,195 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HomePageController } from './HomePageController'; + +// Mock the dependencies +vi.mock('@/api/buster_rest/users/useGetUserInfo', () => ({ + useGetUserBasicInfo: vi.fn(), +})); + +vi.mock('./useNewChatWarning', () => ({ + useNewChatWarning: vi.fn(), +})); + +vi.mock('./NewChatWarning', () => ({ + NewChatWarning: vi.fn(({ showWarning, hasDatasets, hasDatasources, isAdmin }) => ( +
+ Warning Component - showWarning: {showWarning.toString()}, hasDatasets:{' '} + {hasDatasets.toString()}, hasDatasources: {hasDatasources.toString()}, isAdmin:{' '} + {isAdmin?.toString() || 'undefined'} +
+ )), +})); + +vi.mock('./NewChatInput', () => ({ + NewChatInput: vi.fn(({ initialValue, autoSubmit }) => ( +
+ Chat Input - initialValue: {initialValue || 'none'}, autoSubmit:{' '} + {autoSubmit?.toString() || 'false'} +
+ )), +})); + +// Mock ClientOnly to render children directly in tests +vi.mock('@tanstack/react-router', () => ({ + ClientOnly: vi.fn(({ children }) => <>{children}), +})); + +import { useGetUserBasicInfo } from '@/api/buster_rest/users/useGetUserInfo'; +import { useNewChatWarning } from './useNewChatWarning'; + +const mockUseGetUserBasicInfo = vi.mocked(useGetUserBasicInfo); +const mockUseNewChatWarning = vi.mocked(useNewChatWarning); + +describe('HomePageController', () => { + beforeEach(() => { + // Default user info + mockUseGetUserBasicInfo.mockReturnValue({ + id: 'user-1', + name: 'John Doe', + email: 'john.doe@example.com', + avatar_url: null, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + attributes: { + organization_id: 'org-1', + organization_role: 'querier', + user_email: 'john.doe@example.com', + user_id: 'user-1', + }, + favorites: [], + }); + + // Mock time to ensure consistent greeting + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-01-01 10:00:00')); // 10 AM - morning + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('should render NewChatWarning when showWarning is true', () => { + // Mock the hook to return showWarning: true + mockUseNewChatWarning.mockReturnValue({ + showWarning: true, + hasDatasets: false, + hasDatasources: false, + isFetched: true, + isAdmin: true, + userRole: 'workspace_admin', + }); + + render(); + + // Should show the warning component + expect(screen.getByTestId('new-chat-warning')).toBeInTheDocument(); + expect(screen.getByText(/Warning Component.*showWarning: true/)).toBeInTheDocument(); + + // Should NOT show the main interface components + expect(screen.queryByTestId('new-chat-input')).not.toBeInTheDocument(); + expect(screen.queryByText('Good morning, John Doe')).not.toBeInTheDocument(); + expect(screen.queryByText('How can I help you today?')).not.toBeInTheDocument(); + }); + + it('should render main interface when showWarning is false', () => { + // Mock the hook to return showWarning: false + mockUseNewChatWarning.mockReturnValue({ + showWarning: false, + hasDatasets: true, + hasDatasources: true, + isFetched: true, + isAdmin: false, + userRole: 'querier', + }); + + render(); + + // Should show the main interface components + expect(screen.getByText('Good morning, John Doe')).toBeInTheDocument(); + expect(screen.getByText('How can I help you today?')).toBeInTheDocument(); + expect(screen.getByTestId('new-chat-input')).toBeInTheDocument(); + expect( + screen.getByText(/Chat Input.*initialValue: hello world.*autoSubmit: false/) + ).toBeInTheDocument(); + + // Should NOT show the warning component + expect(screen.queryByTestId('new-chat-warning')).not.toBeInTheDocument(); + }); + + it('should pass correct props to NewChatInput', () => { + mockUseNewChatWarning.mockReturnValue({ + showWarning: false, + hasDatasets: true, + hasDatasources: true, + isFetched: true, + isAdmin: false, + userRole: 'querier', + }); + + render(); + + expect( + screen.getByText(/Chat Input.*initialValue: custom input.*autoSubmit: true/) + ).toBeInTheDocument(); + }); + + it('should pass all newChatWarningProps to NewChatWarning', () => { + const warningProps = { + showWarning: true, + hasDatasets: false, + hasDatasources: true, + isFetched: true, + isAdmin: true, + userRole: 'workspace_admin' as const, + }; + + mockUseNewChatWarning.mockReturnValue(warningProps); + + render(); + + expect( + screen.getByText( + /Warning Component.*showWarning: true.*hasDatasets: false.*hasDatasources: true.*isAdmin: true/ + ) + ).toBeInTheDocument(); + }); + + describe('greeting logic', () => { + beforeEach(() => { + mockUseNewChatWarning.mockReturnValue({ + showWarning: false, + hasDatasets: true, + hasDatasources: true, + isFetched: true, + isAdmin: false, + userRole: 'querier', + }); + }); + + it('should show morning greeting at 10 AM', () => { + vi.setSystemTime(new Date('2023-01-01 10:00:00')); + render(); + expect(screen.getByText('Good morning, John Doe')).toBeInTheDocument(); + }); + + it('should show afternoon greeting at 3 PM', () => { + vi.setSystemTime(new Date('2023-01-01 15:00:00')); + render(); + expect(screen.getByText('Good afternoon, John Doe')).toBeInTheDocument(); + }); + + it('should show evening greeting at 8 PM', () => { + vi.setSystemTime(new Date('2023-01-01 20:00:00')); + render(); + expect(screen.getByText('Good evening, John Doe')).toBeInTheDocument(); + }); + + it('should show night greeting at 2 AM', () => { + vi.setSystemTime(new Date('2023-01-01 02:00:00')); + render(); + expect(screen.getByText('Good night, John Doe')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/controllers/HomePage/useNewChatWarning.test.ts b/apps/web/src/controllers/HomePage/useNewChatWarning.test.ts new file mode 100644 index 000000000..4f96e4c86 --- /dev/null +++ b/apps/web/src/controllers/HomePage/useNewChatWarning.test.ts @@ -0,0 +1,185 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useNewChatWarning } from './useNewChatWarning'; + +// Mock the API hooks +vi.mock('@/api/buster_rest/data_source', () => ({ + useListDatasources: vi.fn(), +})); + +vi.mock('@/api/buster_rest/datasets', () => ({ + useGetDatasets: vi.fn(), +})); + +vi.mock('@/api/buster_rest/users/useGetUserInfo', () => ({ + useIsUserAdmin: vi.fn(), + useGetUserRole: vi.fn(), +})); + +import { useListDatasources } from '@/api/buster_rest/data_source'; +import { useGetDatasets } from '@/api/buster_rest/datasets'; +import { useGetUserRole, useIsUserAdmin } from '@/api/buster_rest/users/useGetUserInfo'; + +const mockUseListDatasources = vi.mocked(useListDatasources); +const mockUseGetDatasets = vi.mocked(useGetDatasets); +const mockUseIsUserAdmin = vi.mocked(useIsUserAdmin); +const mockUseGetUserRole = vi.mocked(useGetUserRole); + +describe('useNewChatWarning', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should show warning when datasets are empty and data is fetched', () => { + mockUseGetDatasets.mockReturnValue({ + data: [], + isFetched: true, + } as any); + mockUseListDatasources.mockReturnValue({ + data: [{ id: '1', name: 'Test Datasource' }], + isFetched: true, + } as any); + mockUseIsUserAdmin.mockReturnValue(true); + mockUseGetUserRole.mockReturnValue('workspace_admin'); + + const { result } = renderHook(() => useNewChatWarning()); + + expect(result.current.showWarning).toBe(true); + expect(result.current.hasDatasets).toBe(false); + expect(result.current.hasDatasources).toBe(true); + expect(result.current.isFetched).toBe(true); + }); + + it('should show warning when datasources are empty and data is fetched', () => { + mockUseGetDatasets.mockReturnValue({ + data: [{ id: '1', name: 'Test Dataset' }], + isFetched: true, + } as any); + mockUseListDatasources.mockReturnValue({ + data: [], + isFetched: true, + } as any); + mockUseIsUserAdmin.mockReturnValue(false); + mockUseGetUserRole.mockReturnValue('querier'); + + const { result } = renderHook(() => useNewChatWarning()); + + expect(result.current.showWarning).toBe(true); + expect(result.current.hasDatasets).toBe(true); + expect(result.current.hasDatasources).toBe(false); + expect(result.current.isFetched).toBe(true); + }); + + it('should show warning when both datasets and datasources are empty', () => { + mockUseGetDatasets.mockReturnValue({ + data: [], + isFetched: true, + } as any); + mockUseListDatasources.mockReturnValue({ + data: [], + isFetched: true, + } as any); + mockUseIsUserAdmin.mockReturnValue(true); + mockUseGetUserRole.mockReturnValue('workspace_admin'); + + const { result } = renderHook(() => useNewChatWarning()); + + expect(result.current.showWarning).toBe(true); + expect(result.current.hasDatasets).toBe(false); + expect(result.current.hasDatasources).toBe(false); + expect(result.current.isFetched).toBe(true); + }); + + it('should not show warning when both datasets and datasources have data', () => { + mockUseGetDatasets.mockReturnValue({ + data: [{ id: '1', name: 'Test Dataset' }], + isFetched: true, + } as any); + mockUseListDatasources.mockReturnValue({ + data: [{ id: '1', name: 'Test Datasource' }], + isFetched: true, + } as any); + mockUseIsUserAdmin.mockReturnValue(false); + mockUseGetUserRole.mockReturnValue('viewer'); + + const { result } = renderHook(() => useNewChatWarning()); + + expect(result.current.showWarning).toBe(false); + expect(result.current.hasDatasets).toBe(true); + expect(result.current.hasDatasources).toBe(true); + expect(result.current.isFetched).toBe(true); + }); + + it('should not show warning when datasets are not yet fetched', () => { + mockUseGetDatasets.mockReturnValue({ + data: undefined, + isFetched: false, + } as any); + mockUseListDatasources.mockReturnValue({ + data: [], + isFetched: true, + } as any); + mockUseIsUserAdmin.mockReturnValue(true); + mockUseGetUserRole.mockReturnValue('workspace_admin'); + + const { result } = renderHook(() => useNewChatWarning()); + + expect(result.current.showWarning).toBe(false); + expect(result.current.isFetched).toBe(false); + }); + + it('should not show warning when datasources are not yet fetched', () => { + mockUseGetDatasets.mockReturnValue({ + data: [], + isFetched: true, + } as any); + mockUseListDatasources.mockReturnValue({ + data: undefined, + isFetched: false, + } as any); + mockUseIsUserAdmin.mockReturnValue(false); + mockUseGetUserRole.mockReturnValue('querier'); + + const { result } = renderHook(() => useNewChatWarning()); + + expect(result.current.showWarning).toBe(false); + expect(result.current.isFetched).toBe(false); + }); + + it('should not show warning when neither datasets nor datasources are fetched', () => { + mockUseGetDatasets.mockReturnValue({ + data: undefined, + isFetched: false, + } as any); + mockUseListDatasources.mockReturnValue({ + data: undefined, + isFetched: false, + } as any); + mockUseIsUserAdmin.mockReturnValue(true); + mockUseGetUserRole.mockReturnValue('workspace_admin'); + + const { result } = renderHook(() => useNewChatWarning()); + + expect(result.current.showWarning).toBe(false); + expect(result.current.isFetched).toBe(false); + }); + + it('should correctly return admin status and user role', () => { + mockUseGetDatasets.mockReturnValue({ + data: [{ id: '1', name: 'Test Dataset' }], + isFetched: true, + } as any); + mockUseListDatasources.mockReturnValue({ + data: [{ id: '1', name: 'Test Datasource' }], + isFetched: true, + } as any); + mockUseIsUserAdmin.mockReturnValue(true); + mockUseGetUserRole.mockReturnValue('data_admin'); + + const { result } = renderHook(() => useNewChatWarning()); + + expect(result.current.isAdmin).toBe(true); + expect(result.current.userRole).toBe('data_admin'); + expect(result.current.showWarning).toBe(false); + }); +}); diff --git a/apps/web/src/lib/user.test.ts b/apps/web/src/lib/user.test.ts new file mode 100644 index 000000000..560758007 --- /dev/null +++ b/apps/web/src/lib/user.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { checkIfUserIsAdmin } from './user'; + +describe('checkIfUserIsAdmin', () => { + it('should return false when userOrganization is undefined', () => { + expect(checkIfUserIsAdmin(undefined)).toBe(false); + }); + + it('should return false when userOrganization is null', () => { + expect(checkIfUserIsAdmin(null)).toBe(false); + }); + + it('should return true when user role is data_admin', () => { + const userOrganization = { role: 'data_admin' as const }; + expect(checkIfUserIsAdmin(userOrganization)).toBe(true); + }); + + it('should return true when user role is workspace_admin', () => { + const userOrganization = { role: 'workspace_admin' as const }; + expect(checkIfUserIsAdmin(userOrganization)).toBe(true); + }); + + it('should return false when user role is neither data_admin nor workspace_admin', () => { + const userOrganization = { role: 'restricted_querier' as const }; + expect(checkIfUserIsAdmin(userOrganization)).toBe(false); + }); +}); diff --git a/packages/ai/src/agents/analyst-agent/analyst-agent.ts b/packages/ai/src/agents/analyst-agent/analyst-agent.ts index 57f8d9108..23ab7f86a 100644 --- a/packages/ai/src/agents/analyst-agent/analyst-agent.ts +++ b/packages/ai/src/agents/analyst-agent/analyst-agent.ts @@ -98,10 +98,10 @@ export function createAnalystAgent(analystAgentOptions: AnalystAgentOptions) { const docsSystemMessage = docsContent ? ({ - role: 'system', - content: `\n${docsContent}\n`, - providerOptions: DEFAULT_ANTHROPIC_OPTIONS, - } as ModelMessage) + role: 'system', + content: `\n${docsContent}\n`, + providerOptions: DEFAULT_ANTHROPIC_OPTIONS, + } as ModelMessage) : null; async function stream({ messages }: AnalystStreamOptions) { @@ -134,19 +134,19 @@ export function createAnalystAgent(analystAgentOptions: AnalystAgentOptions) { // Create analyst instructions system message with proper escaping const analystInstructionsMessage = analystInstructions ? ({ - role: 'system', - content: `\n${analystInstructions}\n`, - providerOptions: DEFAULT_ANTHROPIC_OPTIONS, - } as ModelMessage) + role: 'system', + content: `\n${analystInstructions}\n`, + providerOptions: DEFAULT_ANTHROPIC_OPTIONS, + } as ModelMessage) : null; // Create user personalization system message const userPersonalizationSystemMessage = userPersonalizationMessageContent ? ({ - role: 'system', - content: userPersonalizationMessageContent, - providerOptions: DEFAULT_ANTHROPIC_OPTIONS, - } as ModelMessage) + role: 'system', + content: userPersonalizationMessageContent, + providerOptions: DEFAULT_ANTHROPIC_OPTIONS, + } as ModelMessage) : null; return wrapTraced( diff --git a/packages/ai/src/agents/analyst-agent/get-analyst-agent-system-prompt.test.ts b/packages/ai/src/agents/analyst-agent/get-analyst-agent-system-prompt.test.ts index d95e585ef..71b8947b5 100644 --- a/packages/ai/src/agents/analyst-agent/get-analyst-agent-system-prompt.test.ts +++ b/packages/ai/src/agents/analyst-agent/get-analyst-agent-system-prompt.test.ts @@ -83,10 +83,14 @@ describe('Analyst Agent Instructions', () => { expect(result).toContain('MANDATORY SQL NAMING CONVENTIONS'); // Ensure table references require full qualification - expect(result).toContain('All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`'); + expect(result).toContain( + 'All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`' + ); // Ensure column references use table aliases (not full qualifiers) - expect(result).toContain('All Column References: MUST be qualified with their table alias (e.g., `c.customerid`)'); + expect(result).toContain( + 'All Column References: MUST be qualified with their table alias (e.g., `c.customerid`)' + ); // Ensure examples show table alias usage without full qualification expect(result).toContain('c.customerid'); diff --git a/packages/ai/src/agents/think-and-prep-agent/get-think-and-prep-agent-system-prompt.test.ts b/packages/ai/src/agents/think-and-prep-agent/get-think-and-prep-agent-system-prompt.test.ts index 8a4bca719..0486169ce 100644 --- a/packages/ai/src/agents/think-and-prep-agent/get-think-and-prep-agent-system-prompt.test.ts +++ b/packages/ai/src/agents/think-and-prep-agent/get-think-and-prep-agent-system-prompt.test.ts @@ -151,16 +151,23 @@ describe('Think and Prep Agent Instructions', () => { ['investigation', 'investigation'], ])('SQL naming conventions in %s mode', (modeName, mode) => { it(`should contain mandatory SQL naming conventions in ${modeName} mode`, () => { - const result = getThinkAndPrepAgentSystemPrompt('Test guidance', mode as 'standard' | 'investigation'); + const result = getThinkAndPrepAgentSystemPrompt( + 'Test guidance', + mode as 'standard' | 'investigation' + ); // Check for MANDATORY SQL NAMING CONVENTIONS section expect(result).toContain('MANDATORY SQL NAMING CONVENTIONS'); // Ensure table references require full qualification - expect(result).toContain('All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`'); + expect(result).toContain( + 'All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`' + ); // Ensure column references use table aliases (not full qualifiers) - expect(result).toContain('All Column References: MUST be qualified with their table alias (e.g., `c.customerid`)'); + expect(result).toContain( + 'All Column References: MUST be qualified with their table alias (e.g., `c.customerid`)' + ); // Ensure examples show table alias usage without full qualification expect(result).toContain('c.customerid'); @@ -172,7 +179,10 @@ describe('Think and Prep Agent Instructions', () => { }); it(`should use column names qualified with table aliases in ${modeName} mode`, () => { - const result = getThinkAndPrepAgentSystemPrompt('Test guidance', mode as 'standard' | 'investigation'); + const result = getThinkAndPrepAgentSystemPrompt( + 'Test guidance', + mode as 'standard' | 'investigation' + ); // Check for the updated description expect(result).toContain('Use column names qualified with table aliases'); diff --git a/packages/ai/src/llm/providers/gateway.ts b/packages/ai/src/llm/providers/gateway.ts index ba263c218..3198fc468 100644 --- a/packages/ai/src/llm/providers/gateway.ts +++ b/packages/ai/src/llm/providers/gateway.ts @@ -14,7 +14,7 @@ export const DEFAULT_ANTHROPIC_OPTIONS = { additionalModelRequestFields: { anthropic_beta: ['fine-grained-tool-streaming-2025-05-14'], }, - } + }, }; export const DEFAULT_OPENAI_OPTIONS = {