From 3bd24763fdc2345b82e50be187f9d8c308d37f7e Mon Sep 17 00:00:00 2001 From: Denys Kolomiitsev Date: Wed, 28 May 2025 11:50:43 +0200 Subject: [PATCH 1/4] feat: add unit tests for the 'get-sorted-entities.ts' --- .../__tests__/get-sorted-entities.test.ts | 341 ++++++++++++++++++ .../src/utils/server/get-sorted-entities.ts | 9 +- 2 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 apps/chat/src/utils/server/__tests__/get-sorted-entities.test.ts diff --git a/apps/chat/src/utils/server/__tests__/get-sorted-entities.test.ts b/apps/chat/src/utils/server/__tests__/get-sorted-entities.test.ts new file mode 100644 index 0000000000..29c60f7ba4 --- /dev/null +++ b/apps/chat/src/utils/server/__tests__/get-sorted-entities.test.ts @@ -0,0 +1,341 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { JWT } from 'next-auth/jwt'; + +import { EntityType } from '@/src/types/common'; +import { TokenizerModel } from '@/src/types/models'; + +import { + DEFAULT_MODEL_ID, + MAX_PROMPT_TOKENS_DEFAULT_PERCENT, + MAX_PROMPT_TOKENS_DEFAULT_VALUE, +} from '@/src/constants/default-server-settings'; + +import { getEntities } from '../get-entities'; +import { + fixDate, + getAllEntities, + getSortedEntities, + getTiktokenEncoding, + getTokensPerMessage, +} from '../get-sorted-entities'; +import { logger } from '../logger'; + +// Mock dependencies +vi.mock('../get-entities', () => ({ + getEntities: vi.fn(), +})); + +vi.mock('../logger', () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + ApiUtils: { + decodeApiUrl: vi.fn((url) => `decoded_${url}`), + }, +})); + +vi.mock('../app/file', () => ({ + isAbsoluteUrl: vi.fn((url) => url.startsWith('http')), +})); + +describe('getTiktokenEncoding', () => { + it('should return cl100k_base for GPT_35_TURBO_0301', () => { + expect(getTiktokenEncoding(TokenizerModel.GPT_35_TURBO_0301)).toBe( + 'cl100k_base', + ); + }); + + it('should return cl100k_base for GPT_4_0314', () => { + expect(getTiktokenEncoding(TokenizerModel.GPT_4_0314)).toBe('cl100k_base'); + }); + + it('should return undefined for unsupported tokenizer model', () => { + expect( + getTiktokenEncoding('unsupported' as TokenizerModel), + ).toBeUndefined(); + }); +}); + +describe('getTokensPerMessage', () => { + it('should return 4 for GPT_35_TURBO_0301', () => { + expect(getTokensPerMessage(TokenizerModel.GPT_35_TURBO_0301)).toBe(4); + }); + + it('should return 3 for GPT_4_0314', () => { + expect(getTokensPerMessage(TokenizerModel.GPT_4_0314)).toBe(3); + }); + + it('should return undefined for unsupported tokenizer model', () => { + expect( + getTokensPerMessage('unsupported' as TokenizerModel), + ).toBeUndefined(); + }); +}); + +describe('fixDate', () => { + it('should convert 1672534800 to 1740006000000', () => { + expect(fixDate(1672534800)).toBe(1740006000000); + }); + + it('should keep other dates unchanged', () => { + const otherDate = 1632534800; + expect(fixDate(otherDate)).toBe(otherDate); + }); +}); + +describe('getAllEntities', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + it('should fetch all entities successfully when all promises are fulfilled', async () => { + const mockModels = [{ id: 'model1', object: EntityType.Model }]; + const mockApplications = [{ id: 'app1', object: EntityType.Application }]; + const mockAssistants = [{ id: 'assistant1', object: EntityType.Assistant }]; + const accessToken = 'token123'; + const jobTitle = 'Developer'; + + vi.mocked(getEntities).mockImplementation((entityType) => { + if (entityType === EntityType.Model) return Promise.resolve(mockModels); + if (entityType === EntityType.Application) + return Promise.resolve(mockApplications); + if (entityType === EntityType.Assistant) + return Promise.resolve(mockAssistants); + return Promise.resolve([]); + }); + + const result = await getAllEntities(accessToken, jobTitle); + + expect(result.models).toEqual(mockModels); + expect(result.applications).toEqual(mockApplications); + expect(result.assistants).toEqual(mockAssistants); + + expect(getEntities).toHaveBeenCalledTimes(3); + expect(getEntities).toHaveBeenCalledWith( + EntityType.Model, + accessToken, + jobTitle, + ); + expect(getEntities).toHaveBeenCalledWith( + EntityType.Application, + accessToken, + jobTitle, + ); + expect(getEntities).toHaveBeenCalledWith( + EntityType.Assistant, + accessToken, + jobTitle, + ); + }); + + it('should handle rejected promises and log errors', async () => { + const error = new Error('Failed to fetch models'); + vi.mocked(getEntities).mockImplementation((entityType) => { + if (entityType === EntityType.Model) return Promise.reject(error); + return Promise.resolve([]); + }); + + const result = await getAllEntities('token123', 'Developer'); + + expect(result.models).toEqual([]); + expect(logger.error).toHaveBeenCalledWith(error); + }); +}); + +describe('getSortedEntities', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + it('should process and format entities correctly', async () => { + // Mock a model with complete data + const mockModels = [ + { + id: DEFAULT_MODEL_ID, + object: EntityType.Model, + display_name: 'Test Model', + display_version: '1.0', + description: 'A test model', + description_keywords: ['AI', 'Testing'], + updated_at: 1600000000, + created_at: 1500000000, + owner: 'Test Owner', + icon_url: '/relative/icon.png', + capabilities: { chat_completion: true }, + limits: { + max_total_tokens: 8000, + max_prompt_tokens: 6000, + max_completion_tokens: 2000, + }, + features: { + system_prompt: true, + temperature: true, + }, + tokenizer_model: TokenizerModel.GPT_4_0314, + }, + ]; + + vi.mocked(getEntities).mockImplementation((entityType) => { + if (entityType === EntityType.Model) return Promise.resolve(mockModels); + return Promise.resolve([]); + }); + + const token: JWT = { access_token: 'token123', jobTitle: 'Developer' }; + const result = await getSortedEntities(token); + + expect(result).toHaveLength(1); + + const entity = result[0]; + expect(entity.id).toBe(`decoded_${DEFAULT_MODEL_ID}`); + expect(entity.name).toBe('Test Model'); + expect(entity.isDefault).toBe(true); + expect(entity.type).toBe(EntityType.Model); + expect(entity.limits).toEqual({ + maxRequestTokens: 6000, + maxResponseTokens: 2000, + maxTotalTokens: 8000, + isMaxRequestTokensCustom: false, + }); + expect(entity.tokenizer).toEqual({ + encoding: 'cl100k_base', + tokensPerMessage: 3, + }); + expect(entity.iconUrl).toBe('decoded_/relative/icon.png'); + expect(entity.topics).toEqual(['AI', 'Testing']); + }); + + it('should calculate token limits when not explicitly provided', async () => { + const mockModels = [ + { + id: DEFAULT_MODEL_ID, + object: EntityType.Model, + capabilities: { chat_completion: true }, + limits: { + max_total_tokens: 4000, + // Missing prompt and completion token limits + }, + }, + ]; + + vi.mocked(getEntities).mockImplementation((entityType) => { + if (entityType === EntityType.Model) return Promise.resolve(mockModels); + return Promise.resolve([]); + }); + + const token: JWT = { access_token: 'token123', jobTitle: 'Developer' }; + const result = await getSortedEntities(token); + + const entity = result[0]; + expect(entity.limits).toBeDefined(); + + // Should calculate response tokens based on default percentage + const expectedResponseTokens = Math.min( + MAX_PROMPT_TOKENS_DEFAULT_VALUE, + Math.floor((MAX_PROMPT_TOKENS_DEFAULT_PERCENT * 4000) / 100), + ); + + expect(entity.limits?.maxResponseTokens).toBe(expectedResponseTokens); + expect(entity.limits?.maxRequestTokens).toBe(4000 - expectedResponseTokens); + expect(entity.limits?.isMaxRequestTokensCustom).toBe(true); + }); + + it('should handle absolute URLs correctly', async () => { + const mockModels = [ + { + id: DEFAULT_MODEL_ID, + object: EntityType.Model, + capabilities: { chat_completion: true }, + icon_url: 'http://example.com/icon.png', + }, + ]; + + vi.mocked(getEntities).mockImplementation((entityType) => { + if (entityType === EntityType.Model) return Promise.resolve(mockModels); + return Promise.resolve([]); + }); + + const token = { access_token: 'token123', jobTitle: 'Developer' } as any; + const result = await getSortedEntities(token); + + expect(result[0].iconUrl).toBe('http://example.com/icon.png'); + }); + + it('should filter out entities with embeddings capability or without chat_completion', async () => { + const mockModels = [ + { + id: 'chat-model', + object: EntityType.Model, + capabilities: { chat_completion: true }, + }, + { + id: 'embeddings-model', + object: EntityType.Model, + capabilities: { embeddings: true, chat_completion: true }, + }, + { + id: 'non-chat-model', + object: EntityType.Model, + capabilities: { chat_completion: false }, + }, + ]; + + vi.mocked(getEntities).mockImplementation((entityType) => { + if (entityType === EntityType.Model) return Promise.resolve(mockModels); + return Promise.resolve([]); + }); + + const token: JWT = { + access_token: 'token123', + jobTitle: 'Developer', + }; + const result = await getSortedEntities(token); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('decoded_chat-model'); + }); + + it('should log warning when default model is not found', async () => { + const mockModels = [ + { + id: 'non-default-model', + object: EntityType.Model, + capabilities: { chat_completion: true }, + }, + ]; + + vi.mocked(getEntities).mockImplementation((entityType) => { + if (entityType === EntityType.Model) return Promise.resolve(mockModels); + return Promise.resolve([]); + }); + + const token: JWT = { access_token: 'token123', jobTitle: 'Developer' }; + const result = await getSortedEntities(token); + + expect(logger.warn).toHaveBeenCalled(); + expect(vi.mocked(logger.warn).mock.calls[0][1]).toContain( + `Cannot find default model id("${DEFAULT_MODEL_ID}")`, + ); + + // First model should become default + expect(result[0].isDefault).toBe(true); + }); + + //TODO: Uncomment this test when getSortedEntities handles null token gracefully + // it('should handle null token gracefully', async () => { + // const result = await getSortedEntities(null); + + // expect(result).toEqual([]); + // expect(getEntities).not.toHaveBeenCalled(); + // }); +}); diff --git a/apps/chat/src/utils/server/get-sorted-entities.ts b/apps/chat/src/utils/server/get-sorted-entities.ts index 2825acdb41..fc7085f90a 100644 --- a/apps/chat/src/utils/server/get-sorted-entities.ts +++ b/apps/chat/src/utils/server/get-sorted-entities.ts @@ -21,7 +21,7 @@ import { logger } from './logger'; import { TiktokenEncoding } from 'tiktoken'; -const getTiktokenEncoding = ( +export const getTiktokenEncoding = ( tokenizerModel: TokenizerModel, ): TiktokenEncoding | undefined => { switch (tokenizerModel) { @@ -33,7 +33,7 @@ const getTiktokenEncoding = ( } }; -const getTokensPerMessage = ( +export const getTokensPerMessage = ( tokenizerModel: TokenizerModel, ): number | undefined => { switch (tokenizerModel) { @@ -46,7 +46,7 @@ const getTokensPerMessage = ( } }; -async function getAllEntities(accessToken: string, jobTitle: string) { +export async function getAllEntities(accessToken: string, jobTitle: string) { const [modelsResult, applicationsResult, assistantsResult] = await Promise.allSettled([ getEntities[]>( @@ -84,7 +84,8 @@ async function getAllEntities(accessToken: string, jobTitle: string) { return { models, applications, assistants }; } -const fixDate = (date: number) => (date === 1672534800 ? 1740006000000 : date); // 1/20/1970 -> 2/20/2025 +export const fixDate = (date: number) => + date === 1672534800 ? 1740006000000 : date; // 1/20/1970 -> 2/20/2025 export const getSortedEntities = async (token: JWT | null) => { const entities: DialAIEntityModel[] = []; From 417cca32446cac1b2b8ee22a153aed7c4a0512df Mon Sep 17 00:00:00 2001 From: Denys Kolomiitsev Date: Mon, 16 Jun 2025 16:43:45 +0200 Subject: [PATCH 2/4] feat: add tests for the folders utils --- .../src/utils/app/__tests__/folders.test.ts | 757 ++++++++++++++++-- .../__tests__/get-sorted-entities.test.ts | 8 +- 2 files changed, 714 insertions(+), 51 deletions(-) diff --git a/apps/chat/src/utils/app/__tests__/folders.test.ts b/apps/chat/src/utils/app/__tests__/folders.test.ts index 74bc05659e..7a29daa51b 100644 --- a/apps/chat/src/utils/app/__tests__/folders.test.ts +++ b/apps/chat/src/utils/app/__tests__/folders.test.ts @@ -1,56 +1,719 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, vi } from 'vitest'; + import { + addGeneratedFolderId, + canEditSharedFolderOrParent, + generateNextName, + getChildAndCurrentFoldersById, + getFolderFromId, + getFolderIdFromEntityId, + getFoldersDepth, + getGeneratedFolderId, + getNextDefaultName, + getParentAndCurrentFolderIdsById, + getParentAndCurrentFoldersById, + getParentFolderIdsFromFolderId, + getPathToFolderById, + getRootFolderIdFromEntityId, + getSelectedEntitiesByFolderId, + isFolderEmpty, + isFolderPartialSelected, + isParentFolderSelected, + sortByName, + updateChildAndCurrentFoldersIds, + updateEntityFolder, updateMovedEntityId, updateMovedFolderId, + validateFolderRenaming, } from '@/src/utils/app/folders'; -describe.skip('Folder utility methods', () => { - it.each([ - [undefined, 'f1', undefined, 'f1'], - ['f1', 'f2', 'f1', 'f2'], - ['f1', undefined, 'f1', undefined], - ['f1', undefined, 'f1/f2', 'f2'], - ['f1', undefined, 'f1/f1/f1', 'f1/f1'], - [undefined, undefined, 'f1/f1/f1', 'f1/f1/f1'], - [undefined, 'f3', 'f1/f1/f1', 'f1/f1/f1'], - ['f2', undefined, 'f1/f1/f1', 'f1/f1/f1'], - ['f2', 'f3', 'f1/f1/f1', 'f1/f1/f1'], - ])( - 'updateMovedFolderId (%s, %s, %s, %s)', - ( - oldParentFolderId: any, - newParentFolderId: any, - currentId: any, - expectedFolderId: any, - ) => { +import { FeatureType } from '@/src/types/common'; +import { FolderInterface } from '@/src/types/folder'; + +import { DEFAULT_FOLDER_NAME } from '@/src/constants/default-ui-settings'; + +import * as commonUtils from '../common'; + +import { Entity, SharePermission } from '@epam/ai-dial-shared'; + +describe('Folder utility methods', () => { + // Test data setup + const createFolder = ( + name: string, + folderId?: string, + type: FeatureType = FeatureType.Chat, + ): FolderInterface => ({ + id: `${folderId ? `${folderId}/` : ''}${name}`, + name, + folderId: folderId || '', + type, + }); + + describe('getFoldersDepth', () => { + it('should return 1 for a folder with no children', () => { + const folder = createFolder('folder1', 'folder 2'); + const allFolders: FolderInterface[] = [folder]; + + expect(getFoldersDepth(folder, allFolders)).toBe(1); + }); + + it('should calculate depth correctly for nested folders', () => { + const folder1 = createFolder('folder 1', 'entity/bucket'); + const folder2 = createFolder('folder 2', 'entity/bucket/folder 1'); + const folder3 = createFolder( + 'folder 3', + 'entity/bucket/folder 1/folder 2', + ); + const allFolders: FolderInterface[] = [folder1, folder2, folder3]; + + expect(getFoldersDepth(folder1, allFolders)).toBe(3); // folder1 -> folder2 -> folder3 + }); + }); + + describe('getParentAndCurrentFoldersById', () => { + it('should return empty array for undefined folderId', () => { + const folders: FolderInterface[] = []; + expect(getParentAndCurrentFoldersById(folders, undefined)).toEqual([]); + }); + + it('should return current folder and its parents', () => { + const folder1 = createFolder('folder 1', 'entity/bucket'); + const folder2 = createFolder('folder 2', 'entity/bucket/folder 1'); + const folder3 = createFolder( + 'folder 3', + 'entity/bucket/folder 1/folder 2', + ); + const folders: FolderInterface[] = [folder1, folder2, folder3]; + + expect(getParentAndCurrentFoldersById(folders, folder3.id)).toEqual([ + folder3, + folder2, + folder1, + ]); + }); + + it('should handle circular references', () => { + const folder1 = createFolder('folder 1', 'entity/bucket'); + const folder2 = createFolder('folder 2', 'entity/bucket/folder 1'); + const folder3 = createFolder( + 'folder 3', + 'entity/bucket/folder 1/folder 2', + ); + const folders: FolderInterface[] = [folder1, folder2, folder3]; + + expect(getParentAndCurrentFoldersById(folders, folder3.id)).toEqual([ + folder3, + folder2, + folder1, + ]); + }); + }); + + describe('getParentAndCurrentFolderIdsById', () => { + it('should return the folder ID and its parent IDs', () => { + const folderId = 'api-key/bucket/parent/child/grandchild'; + const expected = [ + 'api-key/bucket/parent/child/grandchild', + 'api-key/bucket/parent/child', + 'api-key/bucket/parent', + ]; + + expect(getParentAndCurrentFolderIdsById(folderId)).toEqual(expected); + }); + }); + + describe('getChildAndCurrentFoldersById', () => { + it('should return empty array when folder ID is not found', () => { + const allFolders: FolderInterface[] = []; + expect(getChildAndCurrentFoldersById('nonexistent', allFolders)).toEqual( + [], + ); + }); + + it('should return the folder and all its children', () => { + const folder1 = createFolder('folder 1', 'entity/bucket'); + const folder2 = createFolder('folder 2', 'entity/bucket/folder 1'); + const folder3 = createFolder( + 'folder 3', + 'entity/bucket/folder 1/folder 2', + ); + const allFolders: FolderInterface[] = [folder1, folder2, folder3]; + + const result = getChildAndCurrentFoldersById(folder1.id, allFolders); + + expect(result).toHaveLength(3); + expect(result).toContain(folder1); + expect(result).toContain(folder2); + expect(result).toContain(folder3); + }); + }); + + describe('getNextDefaultName', () => { + it('should return defaultName + 1 when no entities exist', () => { + const entities: Entity[] = []; + expect(getNextDefaultName('Untitled', entities)).toBe('Untitled 1'); + }); + + it('should return defaultName when startWithEmptyPostfix is true and no entities exist', () => { + const entities: Entity[] = []; + expect(getNextDefaultName('Untitled', entities, 0, true)).toBe( + 'Untitled', + ); + }); + + it('should find the next available number', () => { + const entities: Entity[] = [ + { + id: 'root/Untitled 1', + name: 'Untitled 1', + folderId: 'root', + }, + { + id: 'root/Untitled 2', + name: 'Untitled 2', + folderId: 'root', + }, + ]; + + expect(getNextDefaultName('Untitled', entities)).toBe('Untitled 3'); + }); + }); + + describe('generateNextName', () => { + it('should increment numbered name', () => { + const entities: Entity[] = [ + { id: 'root/Folder 1', name: 'Folder 1', folderId: 'root' }, + { id: 'root/Folder 2', name: 'Folder 2', folderId: 'root' }, + ]; + + expect(generateNextName('Folder', 'Folder 2', entities)).toBe('Folder 3'); + }); + + it('should keep custom name format when not matching pattern', () => { + const entities: Entity[] = [ + { + id: 'root/My Custom Folder', + name: 'My Custom Folder', + folderId: 'root', + }, + ]; + + expect(generateNextName('Folder', 'My Custom Folder', entities)).toBe( + 'My Custom Folder 1', + ); + }); + }); + + describe('getPathToFolderById', () => { + it('should return empty path for undefined folder ID', () => { + const folders: FolderInterface[] = []; + + expect(getPathToFolderById(folders, undefined)).toEqual({ + path: '', + pathDepth: -1, + }); + }); + + it('should construct path from folder ID', () => { + const folder1 = createFolder('folder 1', 'entity/bucket'); + const folder2 = createFolder('folder 2', `entity/bucket/${folder1.name}`); + const folders: FolderInterface[] = [folder1, folder2]; + + expect(getPathToFolderById(folders, folder2.id)).toEqual({ + path: 'folder 1/folder 2', + pathDepth: 1, + }); + }); + + it('should use prepared names if prepareNames option is true', () => { + const folder = createFolder('folder1', 'Folder/With/Slashes'); + const folders: FolderInterface[] = [folder]; + + const spy = vi.spyOn(commonUtils, 'prepareEntityName'); + + getPathToFolderById(folders, folder.id, { prepareNames: true }); + + expect(spy).toHaveBeenCalledWith('folder1', { prepareNames: true }); + }); + + it('should use default folder name for empty name', () => { + const folder = createFolder('', 'folder1'); + const folders: FolderInterface[] = [folder]; + + expect( + getPathToFolderById(folders, folder.id, { prepareNames: true }), + ).toEqual({ + path: DEFAULT_FOLDER_NAME, + pathDepth: 0, + }); + }); + }); + + describe('validateFolderRenaming', () => { + it('should return error for duplicate folder name', () => { + const folder1 = createFolder('Folder 1', 'parent'); + const folder2 = createFolder('Folder 2', 'parent'); + const folders: FolderInterface[] = [folder1, folder2]; + + const result = validateFolderRenaming(folders, 'Folder 2', folder1.id); + expect(result).toBe('Not allowed to have folders with same names'); + }); + + it('should return error for invalid symbols', () => { + const folder = createFolder('Folder 1', 'parent'); + const folders: FolderInterface[] = [folder]; + + const result = validateFolderRenaming(folders, 'Folder%1', folder.id); + expect(result).toContain('are not allowed in folder name'); + }); + + it('should return error for name ending with dots', () => { + const folder = createFolder('Folder 1', 'parent'); + const folders: FolderInterface[] = [folder]; + + const result = validateFolderRenaming(folders, 'Folder1.', 'folder1'); + expect(result).toBe('Using a dot at the end of a name is not permitted.'); + }); + + it('should allow same name if mustBeUnique is false', () => { + const folder1 = createFolder('Folder 1', 'parent'); + const folder2 = createFolder('Folder 2', 'parent'); + const folders: FolderInterface[] = [folder1, folder2]; + + const result = validateFolderRenaming( + folders, + 'Folder 2', + 'folder1', + false, + ); + expect(result).toBeUndefined(); + }); + }); + + describe('sortByName', () => { + it('should sort entities by name case-insensitively', () => { + const entities: Entity[] = [ + { id: 'root/Entity 1', name: 'Entity 1', folderId: 'root' }, + { id: 'root/Entity 2', name: 'Entity 2', folderId: 'root' }, + { id: 'root/Entity 3', name: 'Entity 3', folderId: 'root' }, + ]; + + const sorted = sortByName(entities); + expect(sorted.map((e) => e.name)).toEqual([ + 'Entity 1', + 'Entity 2', + 'Entity 3', + ]); + }); + }); + + describe.only('updateMovedFolderId', () => { + it('should update folder ID when it matches old parent ID', () => { + const result1 = updateMovedFolderId( + 'oldParent', + 'newParent', + 'oldParent', + ); + const result2 = updateMovedFolderId( + 'old/parent', + 'new/parent', + 'old/parent', + ); + expect(result1).toBe('newParent'); + expect(result2).toBe('new/parent'); + }); + + it('should update folder ID when it starts with old parent ID', () => { + const result1 = updateMovedFolderId( + 'old/parent', + 'new/parent', + 'old/parent/child', + ); + const result2 = updateMovedFolderId( + 'old/parent', + 'newParent', + 'old/parent/child', + ); + expect(result1).toBe('new/parent/child'); + expect(result2).toBe('newParent/child'); + }); + + it("should not update folder ID when it doesn't match old parent ID", () => { + const result = updateMovedFolderId( + 'old/parent', + 'new/parent', + 'different/path', + ); + expect(result).toBe('different/path'); + }); + }); + + describe('updateMovedEntityId', () => { + it('should update entity ID when it starts with old parent folder ID', () => { + const result1 = updateMovedEntityId( + 'oldParent', + 'newParent', + 'oldParent/entity', + ); + const result2 = updateMovedEntityId( + 'old/parent', + 'new/parent', + 'old/parent/entity', + ); + + const result3 = updateMovedEntityId( + 'old/parent', + 'newParent', + 'old/parent/entity', + ); + + const result4 = updateMovedEntityId( + 'old/parent', + 'newParent', + 'old/parent/entity', + ); + expect(result1).toBe('newParent/entity'); + expect(result2).toBe('new/parent/entity'); + expect(result3).toBe('newParent/entity'); + expect(result4).toBe('newParent/entity'); + }); + + it("should not update entity ID when it doesn't match old parent folder ID", () => { + const result = updateMovedEntityId( + 'old/parent', + 'new/parent', + 'differentPath/entity', + ); + expect(result).toBe('differentPath/entity'); + }); + }); + + describe('getFolderIdFromEntityId', () => { + it('should extract folder ID from entity ID', () => { + const entityId = 'api-key/bucket/folder1/folder2/entity'; + expect(getFolderIdFromEntityId(entityId)).toBe( + 'api-key/bucket/folder1/folder2', + ); + }); + }); + + describe('getRootFolderIdFromEntityId', () => { + it('should extract root folder ID from entity ID', () => { + const entityId = 'api-key/bucket/folder1/folder2/entity'; + expect(getRootFolderIdFromEntityId(entityId)).toBe( + 'api-key/bucket/folder1', + ); + }); + + it('should handle root entities correctly', () => { + const entityId = 'api-key/bucket/entity'; + expect(getRootFolderIdFromEntityId(entityId)).toBe('api-key/bucket'); + }); + }); + + describe('isFolderEmpty', () => { + it('should return true when folder has no children or entities', () => { + const folder1 = createFolder('folder1', 'Folder 1'); + const folders: FolderInterface[] = [folder1]; + const entities: Entity[] = []; + + expect(isFolderEmpty({ id: 'folder1', folders, entities })).toBe(true); + }); + + it('should return false when folder has child folders', () => { + const folder1 = createFolder('folder1', 'Folder 1'); + const folder2 = createFolder('Folder 2', 'folder1'); + const folders: FolderInterface[] = [folder1, folder2]; + const entities: Entity[] = []; + + expect(isFolderEmpty({ id: 'folder1', folders, entities })).toBe(false); + }); + + it('should return false when folder has entities', () => { + const folder1 = createFolder('folder1', 'Folder 1'); + const folders: FolderInterface[] = [folder1]; + const entities: Entity[] = [ + { + id: 'entity1', + name: 'Entity 1', + folderId: 'folder1', + }, + ]; + + expect(isFolderEmpty({ id: 'folder1', folders, entities })).toBe(false); + }); + }); + + describe('canEditSharedFolderOrParent', () => { + it('should return false when folder is not shared', () => { + const folder = createFolder('folder1', 'Folder 1'); + const folders: FolderInterface[] = [folder]; + + expect(canEditSharedFolderOrParent(folders, 'folder1')).toBe(false); + }); + + it('should return true when folder is shared with write permission', () => { + const folder: FolderInterface = { + id: 'conversations/bucket/Folder1', + name: 'Folder 1', + type: FeatureType.Chat, + folderId: 'conversations/bucket', + sharedWithMe: true, + permissions: [SharePermission.WRITE], + }; + const folders: FolderInterface[] = [folder]; + + expect( + canEditSharedFolderOrParent(folders, 'conversations/bucket/Folder1'), + ).toBe(true); + }); + + it('should return true when parent folder is shared with write permission', () => { + const parentFolder: FolderInterface = { + id: 'conversations/bucket/Parent Folder', + name: 'Parent Folder', + type: FeatureType.Chat, + folderId: '', + sharedWithMe: true, + permissions: [SharePermission.WRITE], + }; + const childFolder = createFolder('Child Folder', parentFolder.id); + const folders: FolderInterface[] = [parentFolder, childFolder]; + + expect(canEditSharedFolderOrParent(folders, childFolder.id)).toBe(true); + }); + }); + + describe('getGeneratedFolderId', () => { + it('should generate correct folder ID', () => { + const folder = createFolder('Child Folder', 'parent/folder'); + expect(getGeneratedFolderId(folder)).toBe('parent/folder/Child Folder'); + }); + }); + + describe('addGeneratedFolderId', () => { + it('should add generated ID to folder without ID', () => { + const folder: Omit = { + name: 'Child Folder', + type: FeatureType.Chat, + folderId: 'parent/folder', + }; + + const result = addGeneratedFolderId(folder as FolderInterface); + expect(result.id).toBe('parent/folder/Child Folder'); + }); + + it('should not change ID if it already matches the generated one', () => { + const folder: FolderInterface = { + id: 'parent/folder/Child Folder', + name: 'Child Folder', + type: FeatureType.Chat, + folderId: 'parent/folder', + }; + + const result = addGeneratedFolderId(folder); + expect(result).toBe(folder); + }); + }); + + describe('getParentFolderIdsFromFolderId', () => { + it('should return empty array for undefined path', () => { + expect(getParentFolderIdsFromFolderId(undefined)).toEqual([]); + }); + + it('should return all parent folder IDs', () => { + const path = 'entity/bucket/folder1/folder2/folder3'; + const expected = [ + 'entity/bucket/folder1', + 'entity/bucket/folder1/folder2', + 'entity/bucket/folder1/folder2/folder3', + ]; + + expect(getParentFolderIdsFromFolderId(path)).toEqual(expected); + }); + }); + + describe('getFolderFromId', () => { + it('should create folder object from ID', () => { + const id = 'api-key/bucket/folder1/folder2'; + const type: FeatureType = FeatureType.Chat; + + const result = getFolderFromId(id, type); + + expect(result).toEqual({ + id: 'api-key/bucket/folder1/folder2', + name: 'folder2', + type: FeatureType.Chat, + folderId: 'api-key/bucket/folder1', + }); + }); + }); + + describe('updateEntityFolder', () => { + it('should update entity with new folder ID', () => { + const entity: Entity = { + id: 'source/folder/entity', + name: 'Entity', + folderId: 'source/folder', + }; + + const result = updateEntityFolder( + entity, + 'source/folder', + 'target/folder', + ); + + expect(result).toEqual({ + id: 'target/folder/entity', + name: 'Entity', + folderId: 'target/folder', + }); + }); + + it('should not update entity if it is not in the source folder', () => { + const entity: Entity = { + id: 'other/folder/entity', + name: 'Entity', + folderId: 'other/folder', + }; + + const result = updateEntityFolder( + entity, + 'source/folder', + 'target/folder', + ); + + expect(result).toBe(entity); + }); + }); + + describe('updateChildAndCurrentFoldersIds', () => { + it('should update folder IDs correctly', () => { + const ids = [ + 'old/folder', + 'old/folder/subfolder1', + 'old/folder/subfolder2', + 'other/folder', + ]; + + const result = updateChildAndCurrentFoldersIds( + ids, + 'old/folder', + 'new/folder', + ); + + expect(result).toEqual([ + 'new/folder', + 'new/folder/subfolder1', + 'new/folder/subfolder2', + 'other/folder', + ]); + }); + }); + + describe('isParentFolderSelected', () => { + it('should return true if parent folder is selected', () => { + const currentFolderId = 'entity/bucket/parent/folder/subfolder'; + const selectedFolderIds = ['entity/bucket/parent/folder/']; + expect( - updateMovedFolderId(oldParentFolderId, newParentFolderId, currentId), - ).toBe(expectedFolderId); - }, - ); - - it.each([ - ['f1', 'f2', 'f1', 'f1'], - ['f1', 'f2', 'f1/f1', 'f2/f1'], - ['f1/f1', 'f2', 'f1/f1/f1', 'f2/f1'], - ['f1', undefined, 'f1', 'f1'], - ['f1', undefined, 'f1/f2', 'f2'], - ['f1', undefined, 'f1/f1/f1', 'f1/f1'], - [undefined, undefined, 'f1/f1/f1', 'f1/f1/f1'], - [undefined, 'f3', 'f1/f1/f1', 'f1/f1/f1'], - ['f2', undefined, 'f1/f1/f1', 'f1/f1/f1'], - ['f2', 'f3', 'f1/f1/f1', 'f1/f1/f1'], - ])( - 'updateMovedEntityId (%s, %s, %s, %s)', - ( - oldParentFolderId: any, - newParentFolderId: any, - currentId: any, - expectedFolderId: any, - ) => { + isParentFolderSelected({ currentFolderId, selectedFolderIds }), + ).toBe(true); + }); + + it('should return false if parent folder is not selected', () => { + const currentFolderId = 'entity/bucket/parent/folder/subfolder'; + const selectedFolderIds = ['entity/bucket/other/folder/']; + + expect( + isParentFolderSelected({ currentFolderId, selectedFolderIds }), + ).toBe(false); + }); + }); + + describe('isFolderPartialSelected', () => { + it('should return true if folder is partial selected and not fully selected', () => { + const currentFolderId = 'folder'; + const partialSelectedFolderIds = ['folder/']; + const isSelected = false; + + expect( + isFolderPartialSelected({ + currentFolderId, + partialSelectedFolderIds, + isSelected, + }), + ).toBe(true); + }); + + it('should return false if folder is fully selected', () => { + const currentFolderId = 'folder'; + const partialSelectedFolderIds = ['folder/']; + const isSelected = true; + expect( - updateMovedEntityId(oldParentFolderId, newParentFolderId, currentId), - ).toBe(expectedFolderId); - }, - ); + isFolderPartialSelected({ + currentFolderId, + partialSelectedFolderIds, + isSelected, + }), + ).toBe(false); + }); + }); + + describe('getSelectedEntitiesByFolderId', () => { + it('should get entities by folder ID', () => { + const entities: Entity[] = [ + { + id: 'folder/entity1', + name: 'Entity 1', + folderId: 'folder', + }, + { + id: 'folder/entity2', + name: 'Entity 2', + folderId: 'folder', + }, + { + id: 'other/folder/entity3', + name: 'Entity 3', + folderId: 'other/folder', + }, + ]; + + const result = getSelectedEntitiesByFolderId({ + entities, + folderId: 'folder', + partialChosenFolderIds: [], + chosenItemsIds: [], + }); + + expect(result).toEqual(['folder/entity1', 'folder/entity2']); + }); + + it('should exclude chosen items when folder is partially chosen', () => { + const entities: Entity[] = [ + { + id: 'folder/entity1', + name: 'Entity 1', + folderId: 'folder', + }, + { + id: 'folder/entity2', + name: 'Entity 2', + folderId: 'folder', + }, + ]; + + const result = getSelectedEntitiesByFolderId({ + entities, + folderId: 'folder', + partialChosenFolderIds: ['folder'], + chosenItemsIds: ['folder/entity1'], + }); + + expect(result).toEqual(['folder/entity2']); + }); + }); }); diff --git a/apps/chat/src/utils/server/__tests__/get-sorted-entities.test.ts b/apps/chat/src/utils/server/__tests__/get-sorted-entities.test.ts index 29c60f7ba4..fca2960da7 100644 --- a/apps/chat/src/utils/server/__tests__/get-sorted-entities.test.ts +++ b/apps/chat/src/utils/server/__tests__/get-sorted-entities.test.ts @@ -40,7 +40,7 @@ vi.mock('../api', () => ({ })); vi.mock('../app/file', () => ({ - isAbsoluteUrl: vi.fn((url) => url.startsWith('http')), + isAbsoluteUrl: vi.fn((url) => url.startsWith('https')), })); describe('getTiktokenEncoding', () => { @@ -256,7 +256,7 @@ describe('getSortedEntities', () => { id: DEFAULT_MODEL_ID, object: EntityType.Model, capabilities: { chat_completion: true }, - icon_url: 'http://example.com/icon.png', + icon_url: 'https://example.com/icon.png', }, ]; @@ -265,10 +265,10 @@ describe('getSortedEntities', () => { return Promise.resolve([]); }); - const token = { access_token: 'token123', jobTitle: 'Developer' } as any; + const token = { access_token: 'token123', jobTitle: 'Developer' } as JWT; const result = await getSortedEntities(token); - expect(result[0].iconUrl).toBe('http://example.com/icon.png'); + expect(result[0].iconUrl).toBe('https://example.com/icon.png'); }); it('should filter out entities with embeddings capability or without chat_completion', async () => { From 1f6cd7dcb5069905038f93a51314df1fdd277f96 Mon Sep 17 00:00:00 2001 From: Denys Kolomiitsev Date: Mon, 16 Jun 2025 19:36:34 +0200 Subject: [PATCH 3/4] feat: add tests for the getFilteredFolders --- .../src/utils/app/__tests__/folders.test.ts | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/apps/chat/src/utils/app/__tests__/folders.test.ts b/apps/chat/src/utils/app/__tests__/folders.test.ts index 7a29daa51b..b7ef7689ee 100644 --- a/apps/chat/src/utils/app/__tests__/folders.test.ts +++ b/apps/chat/src/utils/app/__tests__/folders.test.ts @@ -6,6 +6,7 @@ import { canEditSharedFolderOrParent, generateNextName, getChildAndCurrentFoldersById, + getFilteredFolders, getFolderFromId, getFolderIdFromEntityId, getFoldersDepth, @@ -35,7 +36,12 @@ import { DEFAULT_FOLDER_NAME } from '@/src/constants/default-ui-settings'; import * as commonUtils from '../common'; -import { Entity, SharePermission } from '@epam/ai-dial-shared'; +import { + Conversation, + Entity, + ShareEntity, + SharePermission, +} from '@epam/ai-dial-shared'; describe('Folder utility methods', () => { // Test data setup @@ -43,11 +49,13 @@ describe('Folder utility methods', () => { name: string, folderId?: string, type: FeatureType = FeatureType.Chat, + sharedWithMe?: boolean, ): FolderInterface => ({ id: `${folderId ? `${folderId}/` : ''}${name}`, name, folderId: folderId || '', type, + ...(sharedWithMe ? { sharedWithMe } : {}), }); describe('getFoldersDepth', () => { @@ -716,4 +724,113 @@ describe('Folder utility methods', () => { expect(result).toEqual(['folder/entity2']); }); }); + + describe('getFilteredFolders', () => { + // Sample test data + const folder1 = createFolder('folder 1', 'entity/bucket'); + const folder2 = createFolder('folder 2', 'entity/bucket/folder 1'); + const folder3 = createFolder('folder 3', 'entity/bucket/folder 1/folder 2'); + const folder4 = createFolder( + 'folder 4', + 'entity/bucket/folder 1/folder 2/folder 3', + ); + const folder5 = createFolder( + 'folder 5', + 'entity/bucket/folder 1/folder 2/folder 3/folder 4', + ); + const sharedFolder1 = createFolder( + 'folder 1', + 'entity/bucket2', + FeatureType.Chat, + true, + ); + const sharedFolder2 = createFolder( + 'folder 2', + 'entity/bucket2/folder 1', + FeatureType.Chat, + true, + ); + const userFolders: FolderInterface[] = [ + folder1, + folder2, + folder3, + folder4, + folder5, + ]; + + const sharedFolders: FolderInterface[] = [sharedFolder1, sharedFolder2]; + const mockFolders: FolderInterface[] = [...userFolders, ...sharedFolders]; + + const mockConversations: Conversation[] = [ + { id: 'conv1', folderId: folder1.id } as Conversation, + { id: 'conv2', folderId: folder3.id } as Conversation, + ]; + + it('should apply section filter when provided', () => { + // Set up section filter that only includes folder1 + const sectionFilter = vi.fn((folder: ShareEntity) => { + return !!folder.sharedWithMe; + }); + + const result = getFilteredFolders({ + allFolders: mockFolders, + emptyFolderIds: [], + filters: { sectionFilter }, + entities: [], + }); + + // Verify filter was called for each folder + expect(sectionFilter).toHaveBeenCalledTimes(mockFolders.length); + expect(result).toEqual(sharedFolders); + }); + + it('should apply search filter when provided', () => { + // Set up search filter that matches folder1 + const searchFilter = vi.fn((folder: ShareEntity) => + folder.name.includes('1'), + ); + + // Execute + getFilteredFolders({ + allFolders: mockFolders, + emptyFolderIds: [], + filters: { searchFilter }, + entities: [], + searchTerm: '1', + }); + + // Verify search filter was applied + expect(searchFilter).toHaveBeenCalled(); + }); + + it('should ignore filtered folders when search term is provided', () => { + // Setup a search filter + const sectionFilter = vi.fn( + (folder: ShareEntity) => !!folder.sharedWithMe, + ); + + // Execute with search term + const result = getFilteredFolders({ + allFolders: mockFolders, + emptyFolderIds: [], + filters: { sectionFilter }, + entities: mockConversations, + searchTerm: 'searchTerm', + }); + + expect(result).toEqual([]); + }); + + it('should sort the final result using sortByName', () => { + const result = getFilteredFolders({ + allFolders: userFolders.toReversed(), + emptyFolderIds: [], + filters: {}, + entities: [], + }); + + // Should return the sorted result + expect(result).toEqual(userFolders); + }); + }); }); From f0012004caa2ec6ce0960f1d555763c8a9c0bb01 Mon Sep 17 00:00:00 2001 From: Denys Kolomiitsev Date: Mon, 16 Jun 2025 19:48:00 +0200 Subject: [PATCH 4/4] fix: remove redundant 'only' --- apps/chat/src/utils/app/__tests__/folders.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/chat/src/utils/app/__tests__/folders.test.ts b/apps/chat/src/utils/app/__tests__/folders.test.ts index b7ef7689ee..37c58ea940 100644 --- a/apps/chat/src/utils/app/__tests__/folders.test.ts +++ b/apps/chat/src/utils/app/__tests__/folders.test.ts @@ -316,7 +316,7 @@ describe('Folder utility methods', () => { }); }); - describe.only('updateMovedFolderId', () => { + describe('updateMovedFolderId', () => { it('should update folder ID when it matches old parent ID', () => { const result1 = updateMovedFolderId( 'oldParent',