diff --git a/web/utils/app-redirection.spec.ts b/web/utils/app-redirection.spec.ts new file mode 100644 index 0000000000..8a41d4d010 --- /dev/null +++ b/web/utils/app-redirection.spec.ts @@ -0,0 +1,106 @@ +/** + * Test suite for app redirection utility functions + * Tests navigation path generation based on user permissions and app modes + */ +import { getRedirection, getRedirectionPath } from './app-redirection' + +describe('app-redirection', () => { + /** + * Tests getRedirectionPath which determines the correct path based on: + * - User's editor permissions + * - App mode (workflow, advanced-chat, chat, completion, agent-chat) + */ + describe('getRedirectionPath', () => { + test('returns overview path when user is not editor', () => { + const app = { id: 'app-123', mode: 'chat' as const } + const result = getRedirectionPath(false, app) + expect(result).toBe('/app/app-123/overview') + }) + + test('returns workflow path for workflow mode when user is editor', () => { + const app = { id: 'app-123', mode: 'workflow' as const } + const result = getRedirectionPath(true, app) + expect(result).toBe('/app/app-123/workflow') + }) + + test('returns workflow path for advanced-chat mode when user is editor', () => { + const app = { id: 'app-123', mode: 'advanced-chat' as const } + const result = getRedirectionPath(true, app) + expect(result).toBe('/app/app-123/workflow') + }) + + test('returns configuration path for chat mode when user is editor', () => { + const app = { id: 'app-123', mode: 'chat' as const } + const result = getRedirectionPath(true, app) + expect(result).toBe('/app/app-123/configuration') + }) + + test('returns configuration path for completion mode when user is editor', () => { + const app = { id: 'app-123', mode: 'completion' as const } + const result = getRedirectionPath(true, app) + expect(result).toBe('/app/app-123/configuration') + }) + + test('returns configuration path for agent-chat mode when user is editor', () => { + const app = { id: 'app-456', mode: 'agent-chat' as const } + const result = getRedirectionPath(true, app) + expect(result).toBe('/app/app-456/configuration') + }) + + test('handles different app IDs', () => { + const app1 = { id: 'abc-123', mode: 'chat' as const } + const app2 = { id: 'xyz-789', mode: 'workflow' as const } + + expect(getRedirectionPath(false, app1)).toBe('/app/abc-123/overview') + expect(getRedirectionPath(true, app2)).toBe('/app/xyz-789/workflow') + }) + }) + + /** + * Tests getRedirection which combines path generation with a redirect callback + */ + describe('getRedirection', () => { + /** + * Tests that the redirection function is called with the correct path + */ + test('calls redirection function with correct path for non-editor', () => { + const app = { id: 'app-123', mode: 'chat' as const } + const mockRedirect = jest.fn() + + getRedirection(false, app, mockRedirect) + + expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/overview') + expect(mockRedirect).toHaveBeenCalledTimes(1) + }) + + test('calls redirection function with workflow path for editor', () => { + const app = { id: 'app-123', mode: 'workflow' as const } + const mockRedirect = jest.fn() + + getRedirection(true, app, mockRedirect) + + expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/workflow') + expect(mockRedirect).toHaveBeenCalledTimes(1) + }) + + test('calls redirection function with configuration path for chat mode editor', () => { + const app = { id: 'app-123', mode: 'chat' as const } + const mockRedirect = jest.fn() + + getRedirection(true, app, mockRedirect) + + expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/configuration') + expect(mockRedirect).toHaveBeenCalledTimes(1) + }) + + test('works with different redirection functions', () => { + const app = { id: 'app-123', mode: 'workflow' as const } + const paths: string[] = [] + const customRedirect = (path: string) => paths.push(path) + + getRedirection(true, app, customRedirect) + + expect(paths).toEqual(['/app/app-123/workflow']) + }) + }) +}) diff --git a/web/utils/classnames.spec.ts b/web/utils/classnames.spec.ts index a0b40684c9..55dc1cfd68 100644 --- a/web/utils/classnames.spec.ts +++ b/web/utils/classnames.spec.ts @@ -1,6 +1,18 @@ +/** + * Test suite for the classnames utility function + * This utility combines the classnames library with tailwind-merge + * to handle conditional CSS classes and merge conflicting Tailwind classes + */ import cn from './classnames' describe('classnames', () => { + /** + * Tests basic classnames library features: + * - String concatenation + * - Array handling + * - Falsy value filtering + * - Object-based conditional classes + */ test('classnames libs feature', () => { expect(cn('foo')).toBe('foo') expect(cn('foo', 'bar')).toBe('foo bar') @@ -17,6 +29,14 @@ describe('classnames', () => { })).toBe('foo baz') }) + /** + * Tests tailwind-merge functionality: + * - Conflicting class resolution (last one wins) + * - Modifier handling (hover, focus, etc.) + * - Important prefix (!) + * - Custom color classes + * - Arbitrary values + */ test('tailwind-merge', () => { /* eslint-disable tailwindcss/classnames-order */ expect(cn('p-0')).toBe('p-0') @@ -44,6 +64,10 @@ describe('classnames', () => { expect(cn('text-3.5xl text-black')).toBe('text-3.5xl text-black') }) + /** + * Tests the integration of classnames and tailwind-merge: + * - Object-based conditional classes with Tailwind conflict resolution + */ test('classnames combined with tailwind-merge', () => { expect(cn('text-right', { 'text-center': true, @@ -53,4 +77,81 @@ describe('classnames', () => { 'text-center': false, })).toBe('text-right') }) + + /** + * Tests handling of multiple mixed argument types: + * - Strings, arrays, and objects in a single call + * - Tailwind merge working across different argument types + */ + test('multiple mixed argument types', () => { + expect(cn('foo', ['bar', 'baz'], { qux: true, quux: false })).toBe('foo bar baz qux') + expect(cn('p-4', ['p-2', 'm-4'], { 'text-left': true, 'text-right': true })).toBe('p-2 m-4 text-right') + }) + + /** + * Tests nested array handling: + * - Deep array flattening + * - Tailwind merge with nested structures + */ + test('nested arrays', () => { + expect(cn(['foo', ['bar', 'baz']])).toBe('foo bar baz') + expect(cn(['p-4', ['p-2', 'text-center']])).toBe('p-2 text-center') + }) + + /** + * Tests empty input handling: + * - Empty strings, arrays, and objects + * - Mixed empty and non-empty values + */ + test('empty inputs', () => { + expect(cn('')).toBe('') + expect(cn([])).toBe('') + expect(cn({})).toBe('') + expect(cn('', [], {})).toBe('') + expect(cn('foo', '', 'bar')).toBe('foo bar') + }) + + /** + * Tests number input handling: + * - Truthy numbers converted to strings + * - Zero treated as falsy + */ + test('numbers as inputs', () => { + expect(cn(1)).toBe('1') + expect(cn(0)).toBe('') + expect(cn('foo', 1, 'bar')).toBe('foo 1 bar') + }) + + /** + * Tests multiple object arguments: + * - Object merging + * - Tailwind conflict resolution across objects + */ + test('multiple objects', () => { + expect(cn({ foo: true }, { bar: true })).toBe('foo bar') + expect(cn({ foo: true, bar: false }, { bar: true, baz: true })).toBe('foo bar baz') + expect(cn({ 'p-4': true }, { 'p-2': true })).toBe('p-2') + }) + + /** + * Tests complex edge cases: + * - Mixed falsy values + * - Nested arrays with falsy values + * - Multiple conflicting Tailwind classes + */ + test('complex edge cases', () => { + expect(cn('foo', null, undefined, false, 'bar', 0, 1, '')).toBe('foo bar 1') + expect(cn(['foo', null, ['bar', undefined, 'baz']])).toBe('foo bar baz') + expect(cn('text-sm', { 'text-lg': false, 'text-xl': true }, 'text-2xl')).toBe('text-2xl') + }) + + /** + * Tests important (!) modifier behavior: + * - Important modifiers in objects + * - Conflict resolution with important prefix + */ + test('important modifier with objects', () => { + expect(cn({ '!font-medium': true }, { '!font-bold': true })).toBe('!font-bold') + expect(cn('font-normal', { '!font-bold': true })).toBe('font-normal !font-bold') + }) }) diff --git a/web/utils/completion-params.spec.ts b/web/utils/completion-params.spec.ts new file mode 100644 index 0000000000..56aa1c0586 --- /dev/null +++ b/web/utils/completion-params.spec.ts @@ -0,0 +1,230 @@ +import { mergeValidCompletionParams } from './completion-params' +import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' + +describe('completion-params', () => { + describe('mergeValidCompletionParams', () => { + test('returns empty params and removedDetails for undefined oldParams', () => { + const rules: ModelParameterRule[] = [] + const result = mergeValidCompletionParams(undefined, rules) + + expect(result.params).toEqual({}) + expect(result.removedDetails).toEqual({}) + }) + + test('returns empty params and removedDetails for empty oldParams', () => { + const rules: ModelParameterRule[] = [] + const result = mergeValidCompletionParams({}, rules) + + expect(result.params).toEqual({}) + expect(result.removedDetails).toEqual({}) + }) + + test('validates int type parameter within range', () => { + const rules: ModelParameterRule[] = [ + { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false }, + ] + const oldParams: FormValue = { max_tokens: 100 } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ max_tokens: 100 }) + expect(result.removedDetails).toEqual({}) + }) + + test('removes int parameter below minimum', () => { + const rules: ModelParameterRule[] = [ + { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false }, + ] + const oldParams: FormValue = { max_tokens: 0 } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({}) + expect(result.removedDetails).toEqual({ max_tokens: 'out of range (1-4096)' }) + }) + + test('removes int parameter above maximum', () => { + const rules: ModelParameterRule[] = [ + { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false }, + ] + const oldParams: FormValue = { max_tokens: 5000 } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({}) + expect(result.removedDetails).toEqual({ max_tokens: 'out of range (1-4096)' }) + }) + + test('removes int parameter with invalid type', () => { + const rules: ModelParameterRule[] = [ + { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false }, + ] + const oldParams: FormValue = { max_tokens: 'not a number' as any } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({}) + expect(result.removedDetails).toEqual({ max_tokens: 'invalid type' }) + }) + + test('validates float type parameter', () => { + const rules: ModelParameterRule[] = [ + { name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false }, + ] + const oldParams: FormValue = { temperature: 0.7 } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ temperature: 0.7 }) + expect(result.removedDetails).toEqual({}) + }) + + test('validates float at boundary values', () => { + const rules: ModelParameterRule[] = [ + { name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false }, + ] + + const result1 = mergeValidCompletionParams({ temperature: 0 }, rules) + expect(result1.params).toEqual({ temperature: 0 }) + + const result2 = mergeValidCompletionParams({ temperature: 2 }, rules) + expect(result2.params).toEqual({ temperature: 2 }) + }) + + test('validates boolean type parameter', () => { + const rules: ModelParameterRule[] = [ + { name: 'stream', type: 'boolean', label: { en_US: 'Stream', zh_Hans: '流' }, required: false }, + ] + const oldParams: FormValue = { stream: true } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ stream: true }) + expect(result.removedDetails).toEqual({}) + }) + + test('removes boolean parameter with invalid type', () => { + const rules: ModelParameterRule[] = [ + { name: 'stream', type: 'boolean', label: { en_US: 'Stream', zh_Hans: '流' }, required: false }, + ] + const oldParams: FormValue = { stream: 'yes' as any } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({}) + expect(result.removedDetails).toEqual({ stream: 'invalid type' }) + }) + + test('validates string type parameter', () => { + const rules: ModelParameterRule[] = [ + { name: 'model', type: 'string', label: { en_US: 'Model', zh_Hans: '模型' }, required: false }, + ] + const oldParams: FormValue = { model: 'gpt-4' } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ model: 'gpt-4' }) + expect(result.removedDetails).toEqual({}) + }) + + test('validates string parameter with options', () => { + const rules: ModelParameterRule[] = [ + { name: 'model', type: 'string', options: ['gpt-3.5-turbo', 'gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false }, + ] + const oldParams: FormValue = { model: 'gpt-4' } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ model: 'gpt-4' }) + expect(result.removedDetails).toEqual({}) + }) + + test('removes string parameter with invalid option', () => { + const rules: ModelParameterRule[] = [ + { name: 'model', type: 'string', options: ['gpt-3.5-turbo', 'gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false }, + ] + const oldParams: FormValue = { model: 'invalid-model' } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({}) + expect(result.removedDetails).toEqual({ model: 'unsupported option' }) + }) + + test('validates text type parameter', () => { + const rules: ModelParameterRule[] = [ + { name: 'prompt', type: 'text', label: { en_US: 'Prompt', zh_Hans: '提示' }, required: false }, + ] + const oldParams: FormValue = { prompt: 'Hello world' } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ prompt: 'Hello world' }) + expect(result.removedDetails).toEqual({}) + }) + + test('removes unsupported parameters', () => { + const rules: ModelParameterRule[] = [ + { name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false }, + ] + const oldParams: FormValue = { temperature: 0.7, unsupported_param: 'value' } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ temperature: 0.7 }) + expect(result.removedDetails).toEqual({ unsupported_param: 'unsupported' }) + }) + + test('keeps stop parameter in advanced mode even without rule', () => { + const rules: ModelParameterRule[] = [] + const oldParams: FormValue = { stop: ['END'] } + const result = mergeValidCompletionParams(oldParams, rules, true) + + expect(result.params).toEqual({ stop: ['END'] }) + expect(result.removedDetails).toEqual({}) + }) + + test('removes stop parameter in normal mode without rule', () => { + const rules: ModelParameterRule[] = [] + const oldParams: FormValue = { stop: ['END'] } + const result = mergeValidCompletionParams(oldParams, rules, false) + + expect(result.params).toEqual({}) + expect(result.removedDetails).toEqual({ stop: 'unsupported' }) + }) + + test('handles multiple parameters with mixed validity', () => { + const rules: ModelParameterRule[] = [ + { name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false }, + { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false }, + { name: 'model', type: 'string', options: ['gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false }, + ] + const oldParams: FormValue = { + temperature: 0.7, + max_tokens: 5000, + model: 'gpt-4', + unsupported: 'value', + } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ + temperature: 0.7, + model: 'gpt-4', + }) + expect(result.removedDetails).toEqual({ + max_tokens: 'out of range (1-4096)', + unsupported: 'unsupported', + }) + }) + + test('handles parameters without min/max constraints', () => { + const rules: ModelParameterRule[] = [ + { name: 'value', type: 'int', label: { en_US: 'Value', zh_Hans: '值' }, required: false }, + ] + const oldParams: FormValue = { value: 999999 } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({ value: 999999 }) + expect(result.removedDetails).toEqual({}) + }) + + test('removes parameter with unsupported rule type', () => { + const rules: ModelParameterRule[] = [ + { name: 'custom', type: 'unknown_type', label: { en_US: 'Custom', zh_Hans: '自定义' }, required: false } as any, + ] + const oldParams: FormValue = { custom: 'value' } + const result = mergeValidCompletionParams(oldParams, rules) + + expect(result.params).toEqual({}) + expect(result.removedDetails.custom).toContain('unsupported rule type') + }) + }) +}) diff --git a/web/utils/get-icon.spec.ts b/web/utils/get-icon.spec.ts new file mode 100644 index 0000000000..bd9c23d8e5 --- /dev/null +++ b/web/utils/get-icon.spec.ts @@ -0,0 +1,49 @@ +/** + * Test suite for icon utility functions + * Tests the generation of marketplace plugin icon URLs + */ +import { getIconFromMarketPlace } from './get-icon' +import { MARKETPLACE_API_PREFIX } from '@/config' + +describe('get-icon', () => { + describe('getIconFromMarketPlace', () => { + /** + * Tests basic URL generation for marketplace plugin icons + */ + test('returns correct marketplace icon URL', () => { + const pluginId = 'test-plugin-123' + const result = getIconFromMarketPlace(pluginId) + expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`) + }) + + /** + * Tests URL generation with plugin IDs containing special characters + * like dashes and underscores + */ + test('handles plugin ID with special characters', () => { + const pluginId = 'plugin-with-dashes_and_underscores' + const result = getIconFromMarketPlace(pluginId) + expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`) + }) + + /** + * Tests behavior with empty plugin ID + * Note: This creates a malformed URL but doesn't throw an error + */ + test('handles empty plugin ID', () => { + const pluginId = '' + const result = getIconFromMarketPlace(pluginId) + expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins//icon`) + }) + + /** + * Tests URL generation with plugin IDs containing spaces + * Spaces will be URL-encoded when actually used + */ + test('handles plugin ID with spaces', () => { + const pluginId = 'plugin with spaces' + const result = getIconFromMarketPlace(pluginId) + expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`) + }) + }) +}) diff --git a/web/utils/mcp.spec.ts b/web/utils/mcp.spec.ts new file mode 100644 index 0000000000..d3c5ef1eab --- /dev/null +++ b/web/utils/mcp.spec.ts @@ -0,0 +1,88 @@ +/** + * Test suite for MCP (Model Context Protocol) utility functions + * Tests icon detection logic for MCP-related features + */ +import { shouldUseMcpIcon, shouldUseMcpIconForAppIcon } from './mcp' + +describe('mcp', () => { + /** + * Tests shouldUseMcpIcon function which determines if the MCP icon + * should be used based on the icon source format + */ + describe('shouldUseMcpIcon', () => { + /** + * The link emoji (🔗) is used as a special marker for MCP icons + */ + test('returns true for emoji object with 🔗 content', () => { + const src = { content: '🔗', background: '#fff' } + expect(shouldUseMcpIcon(src)).toBe(true) + }) + + test('returns false for emoji object with different content', () => { + const src = { content: '🎉', background: '#fff' } + expect(shouldUseMcpIcon(src)).toBe(false) + }) + + test('returns false for string URL', () => { + const src = 'https://example.com/icon.png' + expect(shouldUseMcpIcon(src)).toBe(false) + }) + + test('returns false for null', () => { + expect(shouldUseMcpIcon(null)).toBe(false) + }) + + test('returns false for undefined', () => { + expect(shouldUseMcpIcon(undefined)).toBe(false) + }) + + test('returns false for empty object', () => { + expect(shouldUseMcpIcon({})).toBe(false) + }) + + test('returns false for object without content property', () => { + const src = { background: '#fff' } + expect(shouldUseMcpIcon(src)).toBe(false) + }) + + test('returns false for object with null content', () => { + const src = { content: null, background: '#fff' } + expect(shouldUseMcpIcon(src)).toBe(false) + }) + }) + + /** + * Tests shouldUseMcpIconForAppIcon function which checks if an app icon + * should use the MCP icon based on icon type and content + */ + describe('shouldUseMcpIconForAppIcon', () => { + /** + * MCP icon should only be used when both conditions are met: + * - Icon type is 'emoji' + * - Icon content is the link emoji (🔗) + */ + test('returns true when iconType is emoji and icon is 🔗', () => { + expect(shouldUseMcpIconForAppIcon('emoji', '🔗')).toBe(true) + }) + + test('returns false when iconType is emoji but icon is different', () => { + expect(shouldUseMcpIconForAppIcon('emoji', '🎉')).toBe(false) + }) + + test('returns false when iconType is image', () => { + expect(shouldUseMcpIconForAppIcon('image', '🔗')).toBe(false) + }) + + test('returns false when iconType is image and icon is different', () => { + expect(shouldUseMcpIconForAppIcon('image', 'file-id-123')).toBe(false) + }) + + test('returns false for empty strings', () => { + expect(shouldUseMcpIconForAppIcon('', '')).toBe(false) + }) + + test('returns false when iconType is empty but icon is 🔗', () => { + expect(shouldUseMcpIconForAppIcon('', '🔗')).toBe(false) + }) + }) +}) diff --git a/web/utils/navigation.spec.ts b/web/utils/navigation.spec.ts new file mode 100644 index 0000000000..bbd8f36767 --- /dev/null +++ b/web/utils/navigation.spec.ts @@ -0,0 +1,297 @@ +/** + * Test suite for navigation utility functions + * Tests URL and query parameter manipulation for consistent navigation behavior + * Includes helpers for preserving state during navigation (pagination, filters, etc.) + */ +import { + createBackNavigation, + createNavigationPath, + createNavigationPathWithParams, + datasetNavigation, + extractQueryParams, + mergeQueryParams, +} from './navigation' + +describe('navigation', () => { + const originalWindow = globalThis.window + + beforeEach(() => { + // Mock window.location with sample query parameters + delete (globalThis as any).window + globalThis.window = { + location: { + search: '?page=3&limit=10&keyword=test', + }, + } as any + }) + + afterEach(() => { + globalThis.window = originalWindow + }) + + /** + * Tests createNavigationPath which builds URLs with optional query parameter preservation + */ + describe('createNavigationPath', () => { + test('preserves query parameters by default', () => { + const result = createNavigationPath('/datasets/123/documents') + expect(result).toBe('/datasets/123/documents?page=3&limit=10&keyword=test') + }) + + test('returns clean path when preserveParams is false', () => { + const result = createNavigationPath('/datasets/123/documents', false) + expect(result).toBe('/datasets/123/documents') + }) + + test('handles empty query string', () => { + globalThis.window.location.search = '' + const result = createNavigationPath('/datasets/123/documents') + expect(result).toBe('/datasets/123/documents') + }) + + test('handles path with trailing slash', () => { + const result = createNavigationPath('/datasets/123/documents/') + expect(result).toBe('/datasets/123/documents/?page=3&limit=10&keyword=test') + }) + + test('handles root path', () => { + const result = createNavigationPath('/') + expect(result).toBe('/?page=3&limit=10&keyword=test') + }) + }) + + /** + * Tests createBackNavigation which creates a navigation callback function + */ + describe('createBackNavigation', () => { + /** + * Tests that the returned function properly navigates with preserved params + */ + test('returns function that calls router.push with correct path', () => { + const mockRouter = { push: jest.fn() } + const backNav = createBackNavigation(mockRouter, '/datasets/123/documents') + + backNav() + + expect(mockRouter.push).toHaveBeenCalledWith('/datasets/123/documents?page=3&limit=10&keyword=test') + }) + + test('returns function that navigates without params when preserveParams is false', () => { + const mockRouter = { push: jest.fn() } + const backNav = createBackNavigation(mockRouter, '/datasets/123/documents', false) + + backNav() + + expect(mockRouter.push).toHaveBeenCalledWith('/datasets/123/documents') + }) + + test('can be called multiple times', () => { + const mockRouter = { push: jest.fn() } + const backNav = createBackNavigation(mockRouter, '/datasets/123/documents') + + backNav() + backNav() + + expect(mockRouter.push).toHaveBeenCalledTimes(2) + }) + }) + + /** + * Tests extractQueryParams which extracts specific parameters from current URL + */ + describe('extractQueryParams', () => { + /** + * Tests selective parameter extraction + */ + test('extracts specified parameters', () => { + const result = extractQueryParams(['page', 'limit']) + expect(result).toEqual({ page: '3', limit: '10' }) + }) + + test('extracts all specified parameters including keyword', () => { + const result = extractQueryParams(['page', 'limit', 'keyword']) + expect(result).toEqual({ page: '3', limit: '10', keyword: 'test' }) + }) + + test('ignores non-existent parameters', () => { + const result = extractQueryParams(['page', 'nonexistent']) + expect(result).toEqual({ page: '3' }) + }) + + test('returns empty object when no parameters match', () => { + const result = extractQueryParams(['foo', 'bar']) + expect(result).toEqual({}) + }) + + test('returns empty object for empty array', () => { + const result = extractQueryParams([]) + expect(result).toEqual({}) + }) + + test('handles empty query string', () => { + globalThis.window.location.search = '' + const result = extractQueryParams(['page', 'limit']) + expect(result).toEqual({}) + }) + }) + + /** + * Tests createNavigationPathWithParams which builds URLs with specific parameters + */ + describe('createNavigationPathWithParams', () => { + /** + * Tests URL construction with custom parameters + */ + test('creates path with specified parameters', () => { + const result = createNavigationPathWithParams('/datasets/123/documents', { + page: '1', + limit: '25', + }) + expect(result).toBe('/datasets/123/documents?page=1&limit=25') + }) + + test('handles string and number values', () => { + const result = createNavigationPathWithParams('/datasets/123/documents', { + page: 1, + limit: 25, + keyword: 'search', + }) + expect(result).toBe('/datasets/123/documents?page=1&limit=25&keyword=search') + }) + + test('filters out empty string values', () => { + const result = createNavigationPathWithParams('/datasets/123/documents', { + page: '1', + keyword: '', + }) + expect(result).toBe('/datasets/123/documents?page=1') + }) + + test('filters out null and undefined values', () => { + const result = createNavigationPathWithParams('/datasets/123/documents', { + page: '1', + keyword: null as any, + filter: undefined as any, + }) + expect(result).toBe('/datasets/123/documents?page=1') + }) + + test('returns base path when params are empty', () => { + const result = createNavigationPathWithParams('/datasets/123/documents', {}) + expect(result).toBe('/datasets/123/documents') + }) + + test('encodes special characters in values', () => { + const result = createNavigationPathWithParams('/datasets/123/documents', { + keyword: 'search term', + }) + expect(result).toBe('/datasets/123/documents?keyword=search+term') + }) + }) + + /** + * Tests mergeQueryParams which combines new parameters with existing URL params + */ + describe('mergeQueryParams', () => { + /** + * Tests parameter merging and overriding + */ + test('merges new params with existing ones', () => { + const result = mergeQueryParams({ keyword: 'new', page: '1' }) + expect(result.get('page')).toBe('1') + expect(result.get('limit')).toBe('10') + expect(result.get('keyword')).toBe('new') + }) + + test('overrides existing parameters', () => { + const result = mergeQueryParams({ page: '5' }) + expect(result.get('page')).toBe('5') + expect(result.get('limit')).toBe('10') + }) + + test('adds new parameters', () => { + const result = mergeQueryParams({ filter: 'active' }) + expect(result.get('filter')).toBe('active') + expect(result.get('page')).toBe('3') + }) + + test('removes parameters with null value', () => { + const result = mergeQueryParams({ page: null }) + expect(result.get('page')).toBeNull() + expect(result.get('limit')).toBe('10') + }) + + test('removes parameters with undefined value', () => { + const result = mergeQueryParams({ page: undefined }) + expect(result.get('page')).toBeNull() + expect(result.get('limit')).toBe('10') + }) + + test('does not preserve existing when preserveExisting is false', () => { + const result = mergeQueryParams({ filter: 'active' }, false) + expect(result.get('filter')).toBe('active') + expect(result.get('page')).toBeNull() + expect(result.get('limit')).toBeNull() + }) + + test('handles number values', () => { + const result = mergeQueryParams({ page: 5, limit: 20 }) + expect(result.get('page')).toBe('5') + expect(result.get('limit')).toBe('20') + }) + + test('does not add empty string values', () => { + const result = mergeQueryParams({ newParam: '' }) + expect(result.get('newParam')).toBeNull() + // Existing params are preserved + expect(result.get('keyword')).toBe('test') + }) + }) + + /** + * Tests datasetNavigation helper object with common dataset navigation patterns + */ + describe('datasetNavigation', () => { + /** + * Tests navigation back to dataset documents list + */ + describe('backToDocuments', () => { + test('creates navigation function with preserved params', () => { + const mockRouter = { push: jest.fn() } + const backNav = datasetNavigation.backToDocuments(mockRouter, 'dataset-123') + + backNav() + + expect(mockRouter.push).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=3&limit=10&keyword=test') + }) + }) + + /** + * Tests navigation to document detail page + */ + describe('toDocumentDetail', () => { + test('creates navigation function to document detail', () => { + const mockRouter = { push: jest.fn() } + const navFunc = datasetNavigation.toDocumentDetail(mockRouter, 'dataset-123', 'doc-456') + + navFunc() + + expect(mockRouter.push).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456') + }) + }) + + /** + * Tests navigation to document settings page + */ + describe('toDocumentSettings', () => { + test('creates navigation function to document settings', () => { + const mockRouter = { push: jest.fn() } + const navFunc = datasetNavigation.toDocumentSettings(mockRouter, 'dataset-123', 'doc-456') + + navFunc() + + expect(mockRouter.push).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456/settings') + }) + }) + }) +}) diff --git a/web/utils/permission.spec.ts b/web/utils/permission.spec.ts new file mode 100644 index 0000000000..758c38037e --- /dev/null +++ b/web/utils/permission.spec.ts @@ -0,0 +1,95 @@ +/** + * Test suite for permission utility functions + * Tests dataset edit permission logic based on user roles and dataset settings + */ +import { hasEditPermissionForDataset } from './permission' +import { DatasetPermission } from '@/models/datasets' + +describe('permission', () => { + /** + * Tests hasEditPermissionForDataset which checks if a user can edit a dataset + * Based on three permission levels: + * - onlyMe: Only the creator can edit + * - allTeamMembers: All team members can edit + * - partialMembers: Only specified members can edit + */ + describe('hasEditPermissionForDataset', () => { + const userId = 'user-123' + const creatorId = 'creator-456' + const otherUserId = 'user-789' + + test('returns true when permission is onlyMe and user is creator', () => { + const config = { + createdBy: userId, + partialMemberList: [], + permission: DatasetPermission.onlyMe, + } + expect(hasEditPermissionForDataset(userId, config)).toBe(true) + }) + + test('returns false when permission is onlyMe and user is not creator', () => { + const config = { + createdBy: creatorId, + partialMemberList: [], + permission: DatasetPermission.onlyMe, + } + expect(hasEditPermissionForDataset(userId, config)).toBe(false) + }) + + test('returns true when permission is allTeamMembers for any user', () => { + const config = { + createdBy: creatorId, + partialMemberList: [], + permission: DatasetPermission.allTeamMembers, + } + expect(hasEditPermissionForDataset(userId, config)).toBe(true) + expect(hasEditPermissionForDataset(otherUserId, config)).toBe(true) + expect(hasEditPermissionForDataset(creatorId, config)).toBe(true) + }) + + test('returns true when permission is partialMembers and user is in list', () => { + const config = { + createdBy: creatorId, + partialMemberList: [userId, otherUserId], + permission: DatasetPermission.partialMembers, + } + expect(hasEditPermissionForDataset(userId, config)).toBe(true) + }) + + test('returns false when permission is partialMembers and user is not in list', () => { + const config = { + createdBy: creatorId, + partialMemberList: [otherUserId], + permission: DatasetPermission.partialMembers, + } + expect(hasEditPermissionForDataset(userId, config)).toBe(false) + }) + + test('returns false when permission is partialMembers with empty list', () => { + const config = { + createdBy: creatorId, + partialMemberList: [], + permission: DatasetPermission.partialMembers, + } + expect(hasEditPermissionForDataset(userId, config)).toBe(false) + }) + + test('creator is not automatically granted access with partialMembers permission', () => { + const config = { + createdBy: creatorId, + partialMemberList: [userId], + permission: DatasetPermission.partialMembers, + } + expect(hasEditPermissionForDataset(creatorId, config)).toBe(false) + }) + + test('creator has access when included in partialMemberList', () => { + const config = { + createdBy: creatorId, + partialMemberList: [creatorId, userId], + permission: DatasetPermission.partialMembers, + } + expect(hasEditPermissionForDataset(creatorId, config)).toBe(true) + }) + }) +}) diff --git a/web/utils/time.spec.ts b/web/utils/time.spec.ts new file mode 100644 index 0000000000..2468799da1 --- /dev/null +++ b/web/utils/time.spec.ts @@ -0,0 +1,99 @@ +/** + * Test suite for time utility functions + * Tests date comparison and formatting using dayjs + */ +import { formatTime, isAfter } from './time' + +describe('time', () => { + /** + * Tests isAfter function which compares two dates + * Returns true if the first date is after the second + */ + describe('isAfter', () => { + test('returns true when first date is after second date', () => { + const date1 = '2024-01-02' + const date2 = '2024-01-01' + expect(isAfter(date1, date2)).toBe(true) + }) + + test('returns false when first date is before second date', () => { + const date1 = '2024-01-01' + const date2 = '2024-01-02' + expect(isAfter(date1, date2)).toBe(false) + }) + + test('returns false when dates are equal', () => { + const date = '2024-01-01' + expect(isAfter(date, date)).toBe(false) + }) + + test('works with Date objects', () => { + const date1 = new Date('2024-01-02') + const date2 = new Date('2024-01-01') + expect(isAfter(date1, date2)).toBe(true) + }) + + test('works with timestamps', () => { + const date1 = 1704240000000 // 2024-01-03 + const date2 = 1704153600000 // 2024-01-02 + expect(isAfter(date1, date2)).toBe(true) + }) + + test('handles time differences within same day', () => { + const date1 = '2024-01-01 12:00:00' + const date2 = '2024-01-01 11:00:00' + expect(isAfter(date1, date2)).toBe(true) + }) + }) + + /** + * Tests formatTime function which formats dates using dayjs + * Supports various date formats and input types + */ + describe('formatTime', () => { + /** + * Tests basic date formatting with standard format + */ + test('formats date with YYYY-MM-DD format', () => { + const date = '2024-01-15' + const result = formatTime({ date, dateFormat: 'YYYY-MM-DD' }) + expect(result).toBe('2024-01-15') + }) + + test('formats date with custom format', () => { + const date = '2024-01-15 14:30:00' + const result = formatTime({ date, dateFormat: 'MMM DD, YYYY HH:mm' }) + expect(result).toBe('Jan 15, 2024 14:30') + }) + + test('formats date with full month name', () => { + const date = '2024-01-15' + const result = formatTime({ date, dateFormat: 'MMMM DD, YYYY' }) + expect(result).toBe('January 15, 2024') + }) + + test('formats date with time only', () => { + const date = '2024-01-15 14:30:45' + const result = formatTime({ date, dateFormat: 'HH:mm:ss' }) + expect(result).toBe('14:30:45') + }) + + test('works with Date objects', () => { + const date = new Date(2024, 0, 15) // Month is 0-indexed + const result = formatTime({ date, dateFormat: 'YYYY-MM-DD' }) + expect(result).toBe('2024-01-15') + }) + + test('works with timestamps', () => { + const date = 1705276800000 // 2024-01-15 00:00:00 UTC + const result = formatTime({ date, dateFormat: 'YYYY-MM-DD' }) + expect(result).toContain('2024-01-1') // Account for timezone differences + }) + + test('handles ISO 8601 format', () => { + const date = '2024-01-15T14:30:00Z' + const result = formatTime({ date, dateFormat: 'YYYY-MM-DD HH:mm' }) + expect(result).toContain('2024-01-15') + }) + }) +}) diff --git a/web/utils/tool-call.spec.ts b/web/utils/tool-call.spec.ts new file mode 100644 index 0000000000..ccfb06f0cc --- /dev/null +++ b/web/utils/tool-call.spec.ts @@ -0,0 +1,79 @@ +/** + * Test suite for tool call utility functions + * Tests detection of function/tool call support in AI models + */ +import { supportFunctionCall } from './tool-call' +import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' + +describe('tool-call', () => { + /** + * Tests supportFunctionCall which checks if a model supports any form of + * function calling (toolCall, multiToolCall, or streamToolCall) + */ + describe('supportFunctionCall', () => { + /** + * Tests detection of basic tool call support + */ + test('returns true when features include toolCall', () => { + const features = [ModelFeatureEnum.toolCall] + expect(supportFunctionCall(features)).toBe(true) + }) + + /** + * Tests detection of multi-tool call support (calling multiple tools in one request) + */ + test('returns true when features include multiToolCall', () => { + const features = [ModelFeatureEnum.multiToolCall] + expect(supportFunctionCall(features)).toBe(true) + }) + + /** + * Tests detection of streaming tool call support + */ + test('returns true when features include streamToolCall', () => { + const features = [ModelFeatureEnum.streamToolCall] + expect(supportFunctionCall(features)).toBe(true) + }) + + test('returns true when features include multiple tool call types', () => { + const features = [ + ModelFeatureEnum.toolCall, + ModelFeatureEnum.multiToolCall, + ModelFeatureEnum.streamToolCall, + ] + expect(supportFunctionCall(features)).toBe(true) + }) + + /** + * Tests that tool call support is detected even when mixed with other features + */ + test('returns true when features include tool call among other features', () => { + const features = [ + ModelFeatureEnum.agentThought, + ModelFeatureEnum.toolCall, + ModelFeatureEnum.vision, + ] + expect(supportFunctionCall(features)).toBe(true) + }) + + /** + * Tests that false is returned when no tool call features are present + */ + test('returns false when features do not include any tool call type', () => { + const features = [ModelFeatureEnum.agentThought, ModelFeatureEnum.vision] + expect(supportFunctionCall(features)).toBe(false) + }) + + test('returns false for empty array', () => { + expect(supportFunctionCall([])).toBe(false) + }) + + test('returns false for undefined', () => { + expect(supportFunctionCall(undefined)).toBe(false) + }) + + test('returns false for null', () => { + expect(supportFunctionCall(null as any)).toBe(false) + }) + }) +})