import { FileSystemStorageAdapter, FileSystemStorageConfig } from '../../../../src/adapters/storage/FileSystemStorageAdapter'; import { QueryCondition, SortOption } from '../../../../src/interfaces/StorageAdapter'; import * as fs from 'fs-extra'; import * as path from 'path'; interface TestItem { id?: string; name: string; value: number; tags: string[]; status: 'active' | 'inactive' | 'pending'; createdAt?: number; updatedAt?: number; description?: string | null; } const testData: Omit[] = [ { name: 'Item A', value: 10, tags: ['tag1', 'tag2'], status: 'active' }, { name: 'Item B', value: 20, tags: ['tag2', 'tag3'], status: 'inactive' }, { name: 'Item C', value: 30, tags: ['tag1', 'tag3'], status: 'pending' }, { name: 'Item D', value: 15, tags: ['tag1'], status: 'active' }, { name: 'Item E', value: 25, tags: ['tag3'], status: 'inactive' }, { name: 'Item F', value: 5, tags: ['tag2'], status: 'pending' }, { name: 'Special Item', value: 50, tags: ['special', 'tag1'], status: 'active' }, ]; // Test directory for file system storage const testRootDir = path.join(process.cwd(), 'test-fs-storage'); describe('FileSystemStorageAdapter Unit Tests', () => { let adapter: FileSystemStorageAdapter; let testConfig: FileSystemStorageConfig; beforeEach(() => { adapter = new FileSystemStorageAdapter(); testConfig = { type: 'file', rootDir: testRootDir, fileExtension: 'json', enableTransactions: false, prettyPrint: false, maxConcurrentOps: 100, }; }); // Clean up test directory after each test afterEach(async () => { await adapter.close(); if (await fs.pathExists(testRootDir)) { await fs.remove(testRootDir); } }); describe('Basic Operations', () => { test('should initialize correctly with default config', async () => { await adapter.initialize(testConfig); expect(await adapter.isAvailable()).toBe(true); // Verify the root directory was created expect(await fs.pathExists(testRootDir)).toBe(true); }); test('should initialize correctly with custom config', async () => { const customConfig: FileSystemStorageConfig = { ...testConfig, fileExtension: 'txt', enableTransactions: true, prettyPrint: true, maxConcurrentOps: 50, }; await adapter.initialize(customConfig); expect(await adapter.isAvailable()).toBe(true); }); test('should close correctly and wait for operations to complete', async () => { await adapter.initialize(testConfig); await adapter.create('testItems', testData[0]); await adapter.close(); expect(await adapter.isAvailable()).toBe(false); }); test('should throw error when used before initialization', async () => { await expect(adapter.create('testItems', testData[0])).rejects.toThrow('FileSystemStorageAdapter is not initialized'); await expect(adapter.getById('testItems', '1')).rejects.toThrow('FileSystemStorageAdapter is not initialized'); await expect(adapter.find('testItems')).rejects.toThrow('FileSystemStorageAdapter is not initialized'); await expect(adapter.update('testItems', '1', {})).rejects.toThrow('FileSystemStorageAdapter is not initialized'); await expect(adapter.delete('testItems', '1')).rejects.toThrow('FileSystemStorageAdapter is not initialized'); await expect(adapter.bulkCreate('testItems', [testData[0]])).rejects.toThrow('FileSystemStorageAdapter is not initialized'); }); }); describe('Create and Retrieve Operations', () => { beforeEach(async () => { await adapter.initialize(testConfig); }); test('should create and retrieve items with generated id', async () => { // Create an item without providing an id const createdItem = await adapter.create('testItems', testData[0]); // Verify the item was created with an id expect(createdItem).toBeDefined(); expect(createdItem.id).toBeDefined(); expect(createdItem.name).toBe(testData[0].name); expect(createdItem.createdAt).toBeDefined(); expect(createdItem.updatedAt).toBeDefined(); expect(createdItem.createdAt).toBe(createdItem.updatedAt); // Verify the file was created on disk const itemFilePath = path.join(testRootDir, 'testItems', `${createdItem.id}.json`); expect(await fs.pathExists(itemFilePath)).toBe(true); // Retrieve the item by id const retrievedItem = await adapter.getById('testItems', createdItem.id); // Verify the retrieved item matches the created item expect(retrievedItem).toEqual(createdItem); }); test('should use provided id when creating items', async () => { // Create an item with a provided id const customId = 'custom-123'; const createdItem = await adapter.create('testItems', { ...testData[0], id: customId }); // Verify the item was created with the provided id expect(createdItem.id).toBe(customId); // Verify the file was created with the correct name const itemFilePath = path.join(testRootDir, 'testItems', `${customId}.json`); expect(await fs.pathExists(itemFilePath)).toBe(true); // Retrieve the item by the provided id const retrievedItem = await adapter.getById('testItems', customId); // Verify the retrieved item matches the created item expect(retrievedItem).toBeDefined(); expect(retrievedItem?.id).toBe(customId); }); test('should return null for non-existent items', async () => { // Try to get an item with a non-existent id const retrievedItem = await adapter.getById('testItems', 'non-existent-id'); // Verify null is returned expect(retrievedItem).toBeNull(); }); test('should handle special characters in names and values', async () => { // Create an item with special characters const specialItem = { name: 'Special@Item#123', value: 100, tags: ['special', 'test'], status: 'active' as const, description: 'Item with special characters: !@#$%^&*()_+-=[]{}|;:,.<>?' }; const createdItem = await adapter.create('testItems', specialItem); // Verify the item was created correctly expect(createdItem).toBeDefined(); expect(createdItem.name).toBe(specialItem.name); expect(createdItem.description).toBe(specialItem.description); // Retrieve the item const retrievedItem = await adapter.getById('testItems', createdItem.id); // Verify the retrieved item matches expect(retrievedItem).toEqual(createdItem); }); test('should handle empty values correctly', async () => { // Create an item with empty values const emptyItem = { name: '', value: 0, tags: [], status: 'active' as const, description: '' }; const createdItem = await adapter.create('testItems', emptyItem); // Verify the item was created correctly expect(createdItem).toBeDefined(); expect(createdItem.name).toBe(emptyItem.name); expect(createdItem.value).toBe(emptyItem.value); expect(createdItem.tags).toEqual(emptyItem.tags); expect(createdItem.description).toBe(emptyItem.description); // Retrieve the item const retrievedItem = await adapter.getById('testItems', createdItem.id); // Verify the retrieved item matches expect(retrievedItem).toEqual(createdItem); }); }); describe('Update and Delete Operations', () => { beforeEach(async () => { await adapter.initialize(testConfig); }); test('should update existing items', async () => { // Create an item const createdItem = await adapter.create('testItems', testData[0]); // Update the item const updateData = { name: 'Updated Item', value: 999, status: 'inactive' as const }; const updatedItem = await adapter.update('testItems', createdItem.id, updateData); // Verify the item was updated correctly expect(updatedItem).toBeDefined(); expect(updatedItem?.id).toBe(createdItem.id); expect(updatedItem?.name).toBe(updateData.name); expect(updatedItem?.value).toBe(updateData.value); expect(updatedItem?.status).toBe(updateData.status); expect(updatedItem?.updatedAt).toBeGreaterThanOrEqual(updatedItem?.createdAt || 0); // Verify the update is persisted on disk const itemFilePath = path.join(testRootDir, 'testItems', `${createdItem.id}.json`); const fileContent = await fs.readFile(itemFilePath, 'utf8'); const fileData = JSON.parse(fileContent); expect(fileData.name).toBe(updateData.name); expect(fileData.value).toBe(updateData.value); expect(fileData.status).toBe(updateData.status); // Verify the update is persisted in the adapter const retrievedItem = await adapter.getById('testItems', createdItem.id); expect(retrievedItem).toEqual(updatedItem); }); test('should return null when updating non-existent items', async () => { // Try to update a non-existent item const updatedItem = await adapter.update('testItems', 'non-existent-id', { name: 'Updated' }); // Verify null is returned expect(updatedItem).toBeNull(); }); test('should delete existing items', async () => { // Create an item const createdItem = await adapter.create('testItems', testData[0]); // Verify the item exists const beforeDelete = await adapter.getById('testItems', createdItem.id); expect(beforeDelete).toBeDefined(); // Verify the file exists on disk const itemFilePath = path.join(testRootDir, 'testItems', `${createdItem.id}.json`); expect(await fs.pathExists(itemFilePath)).toBe(true); // Delete the item const deleted = await adapter.delete('testItems', createdItem.id); // Verify the item was deleted expect(deleted).toBe(true); // Verify the file was removed from disk expect(await fs.pathExists(itemFilePath)).toBe(false); // Verify the item no longer exists in the adapter const afterDelete = await adapter.getById('testItems', createdItem.id); expect(afterDelete).toBeNull(); }); test('should return false when deleting non-existent items', async () => { // Try to delete a non-existent item const deleted = await adapter.delete('testItems', 'non-existent-id'); // Verify false is returned expect(deleted).toBe(false); }); }); describe('Bulk Operations', () => { beforeEach(async () => { await adapter.initialize(testConfig); }); test('should create multiple items in bulk', async () => { // Create multiple items in bulk const createdItems = await adapter.bulkCreate('testItems', testData); // Verify all items were created expect(createdItems.length).toBe(testData.length); for (let i = 0; i < createdItems.length; i++) { const item = createdItems[i]; const index = i; expect(item.id).toBeDefined(); expect(item.name).toBe(testData[index].name); expect(item.value).toBe(testData[index].value); expect(item.status).toBe(testData[index].status); // Verify each file was created on disk const itemFilePath = path.join(testRootDir, 'testItems', `${item.id}.json`); expect(await fs.pathExists(itemFilePath)).toBe(true); } // Verify all items can be retrieved const result = await adapter.find('testItems'); expect(result.total).toBe(testData.length); expect(result.data.length).toBe(testData.length); }); test('should handle empty bulk create', async () => { // Try to create an empty array of items const createdItems = await adapter.bulkCreate('testItems', []); // Verify an empty array is returned expect(createdItems).toEqual([]); // Verify no items were created const result = await adapter.find('testItems'); expect(result.total).toBe(0); }); }); describe('Query Operations', () => { beforeEach(async () => { await adapter.initialize(testConfig); // Create test data await adapter.bulkCreate('testItems', testData); }); test('should find all items with no query', async () => { // Find all items with no query const result = await adapter.find('testItems'); // Verify all items are returned expect(result.total).toBe(testData.length); expect(result.data.length).toBe(testData.length); }); test('should find items with exact match query', async () => { // Query for items with status 'active' const query: QueryCondition = { status: 'active' }; const result = await adapter.find('testItems', query); // Verify only active items are returned expect(result.data.length).toBeGreaterThan(0); result.data.forEach(item => { expect(item.status).toBe('active'); }); }); test('should find items with $eq operator', async () => { // Query for items with value equal to 10 const query: QueryCondition = { value: { $eq: 10 } }; const result = await adapter.find('testItems', query); // Verify only items with value 10 are returned expect(result.data.length).toBe(1); result.data.forEach(item => { expect(item.value).toBe(10); }); }); test('should find items with $neq operator', async () => { // Query for items with value not equal to 10 const query: QueryCondition = { value: { $neq: 10 } }; const result = await adapter.find('testItems', query); // Verify items with value 10 are excluded expect(result.data.length).toBe(testData.length - 1); result.data.forEach(item => { expect(item.value).not.toBe(10); }); }); test('should find items with $gt and $lt operators', async () => { // Query for items with value between 15 and 30 (exclusive) const query: QueryCondition = { value: { $gt: 15, $lt: 30 } }; const result = await adapter.find('testItems', query); // Verify only items with value between 15 and 30 are returned result.data.forEach(item => { expect(item.value).toBeGreaterThan(15); expect(item.value).toBeLessThan(30); }); }); test('should find items with $gte and $lte operators', async () => { // Query for items with value between 10 and 20 (inclusive) const query: QueryCondition = { value: { $gte: 10, $lte: 20 } }; const result = await adapter.find('testItems', query); // Verify only items with value between 10 and 20 are returned result.data.forEach(item => { expect(item.value).toBeGreaterThanOrEqual(10); expect(item.value).toBeLessThanOrEqual(20); }); }); test('should find items with $in operator', async () => { // Query for items with status 'active' or 'pending' const query: QueryCondition = { status: { $in: ['active', 'pending'] } }; const result = await adapter.find('testItems', query); // Verify only items with status 'active' or 'pending' are returned result.data.forEach(item => { expect(['active', 'pending']).toContain(item.status); }); }); test('should find items with $nin operator', async () => { // Query for items with status not 'inactive' const query: QueryCondition = { status: { $nin: ['inactive'] } }; const result = await adapter.find('testItems', query); // Verify items with status 'inactive' are excluded result.data.forEach(item => { expect(item.status).not.toBe('inactive'); }); }); test('should find items with $neq operator', async () => { // Query for items with value not equal to 20 const query: QueryCondition = { value: { $neq: 20 } }; const result = await adapter.find('testItems', query); // Verify items with value 20 are excluded result.data.forEach(item => { expect(item.value).not.toBe(20); }); }); test('should handle array comparison in queries', async () => { // Query for items with exact tag array match const query: QueryCondition = { tags: ['tag1', 'tag2'] }; const result = await adapter.find('testItems', query); // Verify only items with exact tag match are returned result.data.forEach(item => { expect(item.tags).toEqual(['tag1', 'tag2']); }); }); test('should find items with $like operator', async () => { // Query for items with name containing 'Item' const query: QueryCondition = { name: { $like: '%Item%' } }; const result = await adapter.find('testItems', query); // Verify only items with name containing 'Item' are returned result.data.forEach(item => { expect(item.name).toContain('Item'); }); }); test('should handle null values in query conditions', async () => { // Create an item with null description const nullItem = await adapter.create('testItems', { name: 'Null Description Item', value: 10, tags: ['null'], status: 'active', description: null }); // Query for items with null description const query: QueryCondition = { description: null }; const result = await adapter.find('testItems', query); // Should only find the item we just created with null description const nullResultItems = result.data.filter(item => item.id === nullItem.id); expect(nullResultItems.length).toBe(1); expect(nullResultItems[0].description).toBeNull(); }); test('should handle undefined values in query conditions', async () => { // Query for items with undefined description const queryUndefined: QueryCondition = { description: undefined }; const resultUndefined = await adapter.find('testItems', queryUndefined); // Verify the query executes without errors expect(resultUndefined).toBeDefined(); }); test('should handle $like operator with non-string values', async () => { // Query for items with value containing '1' using $like operator (should fail gracefully) const query: QueryCondition = { value: { $like: '%1%' } }; const result = await adapter.find('testItems', query); // Should return empty results since $like on non-string values returns false expect(result.data.length).toBe(0); }); test('should handle unknown operators gracefully', async () => { // Query with unknown operator const query: QueryCondition = { name: { $unknownOperator: 'test' } }; const result = await adapter.find('testItems', query); // Should not fail, should return all items since unknown operator is ignored expect(result.total).toBeGreaterThan(0); }); test('should sort items correctly', async () => { // Sort by value in ascending order const sortAsc: SortOption = { value: 'asc' }; const resultAsc = await adapter.find('testItems', {}, 0, 100, sortAsc); // Verify items are sorted in ascending order for (let i = 1; i < resultAsc.data.length; i++) { expect(resultAsc.data[i].value).toBeGreaterThanOrEqual(resultAsc.data[i - 1].value); } // Sort by value in descending order const sortDesc: SortOption = { value: 'desc' }; const resultDesc = await adapter.find('testItems', {}, 0, 100, sortDesc); // Verify items are sorted in descending order for (let i = 1; i < resultDesc.data.length; i++) { expect(resultDesc.data[i].value).toBeLessThanOrEqual(resultDesc.data[i - 1].value); } }); test('should support pagination', async () => { // Set page size to 3 const pageSize = 3; // Get first page const page1 = await adapter.find('testItems', {}, 0, pageSize); // Get second page const page2 = await adapter.find('testItems', {}, pageSize, pageSize); // Get third page const page3 = await adapter.find('testItems', {}, pageSize * 2, pageSize); // Verify page sizes expect(page1.data.length).toBe(pageSize); expect(page2.data.length).toBe(pageSize); expect(page3.data.length).toBe(testData.length - pageSize * 2); // Verify total count is consistent expect(page1.total).toBe(testData.length); expect(page2.total).toBe(testData.length); expect(page3.total).toBe(testData.length); // Verify no duplicate items across pages const allIds = [...page1.data, ...page2.data, ...page3.data].map(item => item.id); const uniqueIds = new Set(allIds); expect(uniqueIds.size).toBe(testData.length); }); test('should combine query, sort and pagination', async () => { // Query for active items, sort by value descending, get first 2 const query: QueryCondition = { status: 'active' }; const sort: SortOption = { value: 'desc' }; const result = await adapter.find('testItems', query, 0, 2, sort); // Verify results expect(result.data.length).toBe(2); expect(result.data[0].name).toBe('Special Item'); // value: 50 expect(result.data[1].name).toBe('Item D'); // value: 15 }); }); describe('Filesystem-Specific Tests', () => { beforeEach(async () => { await adapter.initialize(testConfig); }); test('should use custom file extension', async () => { // Initialize with custom file extension await adapter.close(); const customExtConfig = { ...testConfig, fileExtension: 'txt' }; await adapter.initialize(customExtConfig); // Create an item const createdItem = await adapter.create('testItems', testData[0]); // Verify the file was created with the custom extension const itemFilePath = path.join(testRootDir, 'testItems', `${createdItem.id}.txt`); expect(await fs.pathExists(itemFilePath)).toBe(true); }); test('should format JSON when prettyPrint is enabled', async () => { // Initialize with prettyPrint enabled await adapter.close(); const prettyConfig = { ...testConfig, prettyPrint: true }; await adapter.initialize(prettyConfig); // Create an item const createdItem = await adapter.create('testItems', testData[0]); // Verify the file content is pretty printed const itemFilePath = path.join(testRootDir, 'testItems', `${createdItem.id}.json`); const fileContent = await fs.readFile(itemFilePath, 'utf8'); expect(fileContent.includes('\n')).toBe(true); // Pretty printed JSON has newlines expect(fileContent.includes(' ')).toBe(true); // Pretty printed JSON has indentation }); test('should use transactional operations when enabled', async () => { // Initialize with enableTransactions enabled await adapter.close(); const transactionConfig = { ...testConfig, enableTransactions: true }; await adapter.initialize(transactionConfig); // Create an item const createdItem = await adapter.create('testItems', testData[0]); // Verify the file was created (transaction should have completed successfully) const itemFilePath = path.join(testRootDir, 'testItems', `${createdItem.id}.json`); expect(await fs.pathExists(itemFilePath)).toBe(true); // Verify no temporary files remain const tempFilePath = path.join(testRootDir, 'testItems', `${createdItem.id}.json.tmp`); expect(await fs.pathExists(tempFilePath)).toBe(false); }); test('should isolate namespaces correctly', async () => { // Create items in different namespaces await adapter.create('namespace1', testData[0]); await adapter.create('namespace2', testData[1]); await adapter.create('namespace1', testData[2]); // Verify directories were created for each namespace expect(await fs.pathExists(path.join(testRootDir, 'namespace1'))).toBe(true); expect(await fs.pathExists(path.join(testRootDir, 'namespace2'))).toBe(true); // Verify data is isolated const result1 = await adapter.find('namespace1'); const result2 = await adapter.find('namespace2'); expect(result1.total).toBe(2); expect(result2.total).toBe(1); // Verify items in namespace1 are correct const names1 = result1.data.map(item => item.name); expect(names1).toContain(testData[0].name); expect(names1).toContain(testData[2].name); // Verify items in namespace2 are correct const names2 = result2.data.map(item => item.name); expect(names2).toContain(testData[1].name); }); test('should sanitize namespace names', async () => { // Create item with special characters in namespace const specialNamespace = 'namespace/with/slashes\tand\nnewlines:and:colons*and*stars'; await adapter.create(specialNamespace, testData[0]); // Verify the namespace directory was created with sanitized name const sanitizedNamespace = 'namespace_with_slashes_and_newlines_and_colons_and_stars'; expect(await fs.pathExists(path.join(testRootDir, sanitizedNamespace))).toBe(true); }); test('should sanitize ids', async () => { // Create item with special characters in id const specialId = 'id/with/slashes\tand\nnewlines:and:colons*and*stars'; await adapter.create('testItems', { ...testData[0], id: specialId }); // Verify the file was created with sanitized id const sanitizedId = 'id_with_slashes_and_newlines_and_colons_and_stars'; const itemFilePath = path.join(testRootDir, 'testItems', `${sanitizedId}.json`); expect(await fs.pathExists(itemFilePath)).toBe(true); }); }); describe('Edge Cases', () => { beforeEach(async () => { await adapter.initialize(testConfig); }); test('should handle very large values', async () => { // Create an item with a very large value const largeItem = { name: 'Large Value Item', value: Number.MAX_SAFE_INTEGER, tags: ['large'], status: 'active' as const }; const createdItem = await adapter.create('testItems', largeItem); // Verify the item was created correctly expect(createdItem).toBeDefined(); expect(createdItem.value).toBe(Number.MAX_SAFE_INTEGER); // Retrieve the item const retrievedItem = await adapter.getById('testItems', createdItem.id); // Verify the retrieved item matches expect(retrievedItem).toEqual(createdItem); }); test('should handle null and undefined values', async () => { // Create an item with null and undefined values const nullUndefinedItem = { name: 'Null Undefined Item', value: 0, tags: ['test'], status: 'active' as const, description: null as any }; const createdItem = await adapter.create('testItems', nullUndefinedItem); // Verify the item was created correctly expect(createdItem).toBeDefined(); expect(createdItem.description).toBeNull(); // Update with undefined value const updatedItem = await adapter.update('testItems', createdItem.id, { description: undefined as any }); // Verify the update was applied expect(updatedItem?.description).toBeUndefined(); }); test('should handle empty namespace name', async () => { // Create item with empty namespace const createdItem = await adapter.create('', testData[0]); // Verify the item was created expect(createdItem).toBeDefined(); // Verify the directory was created (empty namespace is sanitized) const emptyNamespaceDir = path.join(testRootDir, ''); expect(await fs.pathExists(emptyNamespaceDir)).toBe(true); // Verify the item can be retrieved const retrievedItem = await adapter.getById('', createdItem.id); expect(retrievedItem).toEqual(createdItem); }); }); describe('Advanced Query Operations', () => { beforeEach(async () => { await adapter.initialize(testConfig); // Create test data with null and undefined values await adapter.bulkCreate('testItems', testData); await adapter.create('testItems', { name: 'Null Description', value: 100, tags: ['null'], status: 'active', description: null }); await adapter.create('testItems', { name: 'Undefined Description', value: 200, tags: ['undefined'], status: 'active' }); }); test('should handle null vs undefined in query conditions', async () => { // Query for items where description is specifically undefined const undefinedQuery: QueryCondition = { description: undefined }; const undefinedResult = await adapter.find('testItems', undefinedQuery); expect(undefinedResult).toBeDefined(); // Query for items where description is null const nullQuery: QueryCondition = { description: null }; const nullResult = await adapter.find('testItems', nullQuery); const nullItems = nullResult.data.filter(item => item.description === null); expect(nullItems.length).toBeGreaterThan(0); }); test('should handle multiple sort fields', async () => { // Test sorting by multiple fields: status first, then value const multiSort: SortOption = { status: 'asc', value: 'desc' }; const result = await adapter.find('testItems', {}, 0, 100, multiSort); expect(result.data.length).toBeGreaterThan(0); // Verify sorting is correct if (result.data.length > 1) { // Check that within the same status, value is sorted descending const statusGroups = new Map(); result.data.forEach(item => { if (!statusGroups.has(item.status)) { statusGroups.set(item.status, []); } statusGroups.get(item.status)!.push(item.value); }); // Verify each status group is sorted descending by value statusGroups.forEach(values => { for (let i = 1; i < values.length; i++) { expect(values[i]).toBeLessThanOrEqual(values[i - 1]); } }); } }); }); describe('Concurrency Control Tests', () => { beforeEach(async () => { // Initialize with low maxConcurrentOps to easily trigger the concurrency control await adapter.initialize({ ...testConfig, maxConcurrentOps: 2 }); }); test('should respect maxConcurrentOps limit', async () => { // Create multiple promises that will trigger the concurrency control const promises: Promise[] = []; // Create 5 concurrent write operations, which should trigger the concurrency control for (let i = 0; i < 5; i++) { promises.push( adapter.create('testItems', { name: `Concurrent Item ${i}`, value: i, tags: ['concurrent'], status: 'active' }) ); } // All promises should resolve successfully const results = await Promise.all(promises); expect(results.length).toBe(5); // Verify all items were created const allItems = await adapter.find('testItems', { tags: ['concurrent'] }); expect(allItems.total).toBe(5); }); }); });