From 78997a02c6d96ec0ed75352dfc9849524147428c Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 7 Aug 2025 09:28:09 -0400 Subject: [PATCH] feat: `deleteDockerEntries` mutation (#1564) ## Summary by CodeRabbit * **New Features** * Added the ability to delete multiple Docker entries (including folders and their descendants) via a new mutation in the interface. * **Bug Fixes** * Ensured that deleting entries handles complex folder hierarchies, circular references, and missing references robustly. * **Tests** * Introduced comprehensive tests for deleting entries and handling organizer structures, ensuring correct behavior in various scenarios and edge cases. --- api/generated-schema.graphql | 1 + .../docker/docker-organizer.service.spec.ts | 405 +++++++ .../docker/docker-organizer.service.ts | 14 + .../graph/resolvers/docker/docker.resolver.ts | 13 + .../organizer/organizer-view.test.ts | 991 ++++++++++++++++++ api/src/unraid-api/organizer/organizer.ts | 147 +++ 6 files changed, 1571 insertions(+) create mode 100644 api/src/unraid-api/organizer/organizer-view.test.ts diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index bbc439239..1264a6cdc 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1874,6 +1874,7 @@ type Mutation { rclone: RCloneMutations! createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1! setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1! + deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1! """Initiates a flash drive backup using a configured remote.""" initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus! diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.spec.ts index 45a7eb439..b7159438c 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.spec.ts @@ -350,4 +350,409 @@ describe('DockerOrganizerService', () => { ).rejects.toThrow(); }); }); + + describe('deleteEntries', () => { + // Test constants to avoid magic values + const TEST_FOLDER_ID = 'testFolder'; + const TEST_ENTRY_ID = 'testEntry'; + const PERFORMANCE_TEST_SIZE = 50; // Reduced for faster tests + + // Helper function to create test organizer with specific entries + const createTestOrganizer = (entries: Record = {}) => { + const organizer = structuredClone(mockOrganizer); + Object.assign(organizer.views.default.entries, entries); + return organizer; + }; + + // Helper to get typed root folder + const getRootFolder = (result: any) => result.views.default.entries.root; + + it('should delete entries and maintain proper orchestration', async () => { + const testOrganizer = createTestOrganizer({ + [TEST_FOLDER_ID]: { + id: TEST_FOLDER_ID, + type: 'folder', + name: 'Test Folder', + children: [], + }, + }); + (configService.getConfig as any).mockReturnValue(testOrganizer); + + const result = await service.deleteEntries({ + entryIds: new Set([TEST_FOLDER_ID]), + }); + + // Verify service contract fulfillment + expect(result).toBeDefined(); + expect(result.version).toBe(1); + expect(result.views.default).toBeDefined(); + + // Verify service orchestration without being overly specific + expect(configService.getConfig).toHaveBeenCalled(); + expect(configService.validate).toHaveBeenCalled(); + expect(configService.replaceConfig).toHaveBeenCalled(); + + // Verify the deletion outcome + expect(result.views.default.entries[TEST_FOLDER_ID]).toBeUndefined(); + }); + + it('should handle empty entryIds set gracefully', async () => { + const originalEntryCount = Object.keys(mockOrganizer.views.default.entries).length; + + const result = await service.deleteEntries({ + entryIds: new Set(), + }); + + // Verify basic service contract + expect(result).toBeDefined(); + expect(result.version).toBe(1); + expect(configService.validate).toHaveBeenCalled(); + expect(configService.replaceConfig).toHaveBeenCalled(); + + // Verify no unintended deletions occurred + expect(Object.keys(result.views.default.entries).length).toBeGreaterThanOrEqual( + originalEntryCount + ); + expect(result.views.default.entries.existingFolder).toBeDefined(); + }); + + it('should synchronize resources during operation', async () => { + const result = await service.deleteEntries({ + entryIds: new Set(), + }); + + // Verify resources structure is maintained and updated + expect(result.resources).toBeDefined(); + expect(typeof result.resources).toBe('object'); + + // Verify container resources are properly structured + const containerResources = Object.values(result.resources).filter( + (resource: any) => resource.type === 'container' + ); + expect(containerResources.length).toBeGreaterThan(0); + + // Each container resource should have required properties + containerResources.forEach((resource: any) => { + expect(resource).toHaveProperty('id'); + expect(resource).toHaveProperty('type', 'container'); + expect(resource).toHaveProperty('name'); + expect(resource).toHaveProperty('meta'); + }); + }); + + it('should handle deletion of non-existent entries gracefully', async () => { + const NON_EXISTENT_ID = 'definitivelyDoesNotExist'; + const originalEntries = Object.keys(mockOrganizer.views.default.entries); + + const result = await service.deleteEntries({ + entryIds: new Set([NON_EXISTENT_ID]), + }); + + // Verify service completed successfully + expect(result).toBeDefined(); + expect(result.version).toBe(1); + + // Verify no existing entries were accidentally deleted + originalEntries.forEach((entryId) => { + expect(result.views.default.entries[entryId]).toBeDefined(); + }); + }); + + it('should handle mixed valid and invalid entry deletion', async () => { + const VALID_ENTRY = 'existingFolder'; + const INVALID_ENTRY = 'nonExistentEntry'; + + const result = await service.deleteEntries({ + entryIds: new Set([VALID_ENTRY, INVALID_ENTRY]), + }); + + // Verify operation completed successfully despite invalid entry + expect(result).toBeDefined(); + expect(result.version).toBe(1); + + // Valid entry should be deleted, invalid entry should be ignored + expect(result.views.default.entries[VALID_ENTRY]).toBeUndefined(); + expect(result.views.default.entries[INVALID_ENTRY]).toBeUndefined(); // Never existed + }); + + it('should perform synchronization as part of operation', async () => { + const syncSpy = vi.spyOn(service, 'syncAndGetOrganizer'); + + const result = await service.deleteEntries({ + entryIds: new Set(), + }); + + // Verify sync occurred and result reflects synchronized state + expect(syncSpy).toHaveBeenCalled(); + expect(result.resources).toBeDefined(); + expect(Object.keys(result.resources).length).toBeGreaterThan(0); + }); + + it('should handle cascading deletions correctly', async () => { + const PARENT_FOLDER = 'parentFolder'; + const CHILD_FOLDER = 'childFolder'; + + const hierarchicalOrganizer = createTestOrganizer({ + [PARENT_FOLDER]: { + id: PARENT_FOLDER, + type: 'folder', + name: 'Parent Folder', + children: [CHILD_FOLDER], + }, + [CHILD_FOLDER]: { + id: CHILD_FOLDER, + type: 'folder', + name: 'Child Folder', + children: [], + }, + }); + + const rootFolder = getRootFolder(hierarchicalOrganizer); + rootFolder.children = [PARENT_FOLDER]; + (configService.getConfig as any).mockReturnValue(hierarchicalOrganizer); + + const result = await service.deleteEntries({ + entryIds: new Set([PARENT_FOLDER]), + }); + + // Both parent and child should be deleted due to cascading + expect(result.views.default.entries[PARENT_FOLDER]).toBeUndefined(); + expect(result.views.default.entries[CHILD_FOLDER]).toBeUndefined(); + + // Root should no longer reference deleted parent + const resultRoot = getRootFolder(result); + expect(resultRoot.children).not.toContain(PARENT_FOLDER); + }); + + it('should handle validation failure appropriately', async () => { + const validationError = new Error('Configuration validation failed'); + (configService.validate as any).mockRejectedValue(validationError); + + await expect( + service.deleteEntries({ + entryIds: new Set([TEST_FOLDER_ID]), + }) + ).rejects.toThrow(); + + // Should not save invalid configuration + expect(configService.replaceConfig).not.toHaveBeenCalled(); + }); + + it('should handle docker service failure gracefully', async () => { + const dockerError = new Error('Docker service unavailable'); + (dockerService.getContainers as any).mockRejectedValue(dockerError); + + await expect( + service.deleteEntries({ + entryIds: new Set([TEST_FOLDER_ID]), + }) + ).rejects.toThrow(); + + // Should fail early before attempting validation/save + expect(configService.replaceConfig).not.toHaveBeenCalled(); + }); + + it('should handle complex folder hierarchies correctly', async () => { + const PARENT_FOLDER = 'parentFolder'; + const CHILD_FOLDER = 'childFolder'; + const SIBLING_FOLDER = 'siblingFolder'; + + const complexOrganizer = createTestOrganizer({ + [PARENT_FOLDER]: { + id: PARENT_FOLDER, + type: 'folder', + name: 'Parent Folder', + children: ['existingFolder'], // References existing mock entry + }, + [SIBLING_FOLDER]: { + id: SIBLING_FOLDER, + type: 'folder', + name: 'Sibling Folder', + children: [], + }, + }); + + const rootFolder = getRootFolder(complexOrganizer); + rootFolder.children = [PARENT_FOLDER, SIBLING_FOLDER]; + (configService.getConfig as any).mockReturnValue(complexOrganizer); + + const result = await service.deleteEntries({ + entryIds: new Set([PARENT_FOLDER]), + }); + + // Verify targeted deletion occurred + expect(result.views.default.entries[PARENT_FOLDER]).toBeUndefined(); + expect(result.views.default.entries.existingFolder).toBeUndefined(); // Cascaded deletion + + // Verify unrelated entries are preserved + expect(result.views.default.entries[SIBLING_FOLDER]).toBeDefined(); + + // Verify view structure integrity + const resultRoot = getRootFolder(result); + expect(resultRoot.children).not.toContain(PARENT_FOLDER); + expect(resultRoot.children).toContain(SIBLING_FOLDER); + }); + + it('should maintain resource integrity after operations', async () => { + const result = await service.deleteEntries({ + entryIds: new Set(['existingFolder']), + }); + + // Verify resources maintain expected structure and content + expect(result.resources).toBeDefined(); + expect(typeof result.resources).toBe('object'); + + // Verify each resource has consistent structure + Object.entries(result.resources).forEach(([resourceId, resource]: [string, any]) => { + expect(resource).toHaveProperty('id', resourceId); + expect(resource).toHaveProperty('type'); + expect(resource).toHaveProperty('name'); + + // Container resources should have metadata + if (resource.type === 'container') { + expect(resource).toHaveProperty('meta'); + expect(resource.meta).toBeDefined(); + } + }); + }); + + it('should maintain data consistency throughout operation', async () => { + // Test that the service maintains data integrity without testing specific call sequences + let configGetCount = 0; + let validateCount = 0; + let replaceCount = 0; + + (configService.getConfig as any).mockImplementation(() => { + configGetCount++; + return structuredClone(mockOrganizer); + }); + + (configService.validate as any).mockImplementation((config: any) => { + validateCount++; + // Validate that we received a proper config object + expect(config).toHaveProperty('version'); + expect(config).toHaveProperty('resources'); + expect(config).toHaveProperty('views'); + return Promise.resolve(config); + }); + + (configService.replaceConfig as any).mockImplementation((config: any) => { + replaceCount++; + // Validate that we're saving a consistent config + expect(config).toHaveProperty('version'); + expect(config.views.default).toBeDefined(); + }); + + const result = await service.deleteEntries({ + entryIds: new Set(['existingFolder']), + }); + + // Verify essential operations occurred without being overly specific about sequence + expect(configGetCount).toBeGreaterThan(0); + expect(validateCount).toBeGreaterThan(0); + expect(replaceCount).toBeGreaterThan(0); + expect(result).toBeDefined(); + }); + + it('should handle deletion when default view is missing', async () => { + const organizerWithoutDefaultView = structuredClone(mockOrganizer); + delete organizerWithoutDefaultView.views.default; + (configService.getConfig as any).mockReturnValue(organizerWithoutDefaultView); + + const result = await service.deleteEntries({ + entryIds: new Set(['someEntry']), + }); + + // Should still work and create/maintain proper structure + expect(result.views.default).toBeDefined(); + expect(configService.validate).toHaveBeenCalled(); + expect(configService.replaceConfig).toHaveBeenCalled(); + }); + + it('should maintain relative order of remaining entries', async () => { + const ENTRIES = ['entryA', 'entryB', 'entryC', 'entryD']; + const TO_DELETE = ['entryB', 'entryD']; + const EXPECTED_REMAINING = ['entryA', 'entryC']; + + const organizerWithOrdering = createTestOrganizer(); + const rootFolder = getRootFolder(organizerWithOrdering); + rootFolder.children = [...ENTRIES]; + + // Create the test entries + ENTRIES.forEach((entryId) => { + organizerWithOrdering.views.default.entries[entryId] = { + id: entryId, + type: 'ref', + target: `target_${entryId}`, + }; + }); + + (configService.getConfig as any).mockReturnValue(organizerWithOrdering); + + const result = await service.deleteEntries({ + entryIds: new Set(TO_DELETE), + }); + + const resultRoot = getRootFolder(result); + + // Verify deleted entries are gone + TO_DELETE.forEach((entryId) => { + expect(result.views.default.entries[entryId]).toBeUndefined(); + expect(resultRoot.children).not.toContain(entryId); + }); + + // Verify remaining entries are present and in relative order + EXPECTED_REMAINING.forEach((entryId) => { + expect(result.views.default.entries[entryId]).toBeDefined(); + expect(resultRoot.children).toContain(entryId); + }); + + // Check that relative order is preserved among remaining entries + const remainingPositions = EXPECTED_REMAINING.map((id) => resultRoot.children.indexOf(id)); + expect(remainingPositions[0]).toBeLessThan(remainingPositions[1]); // entryA before entryC + }); + + it('should handle bulk operations efficiently', async () => { + const bulkOrganizer = createTestOrganizer(); + const entriesToDelete = new Set(); + + // Create test entries for bulk deletion + for (let i = 0; i < PERFORMANCE_TEST_SIZE; i++) { + const entryId = `bulkEntry${i}`; + entriesToDelete.add(entryId); + bulkOrganizer.views.default.entries[entryId] = { + id: entryId, + type: 'ref', + target: `bulkTarget${i}`, + }; + } + + const rootFolder = getRootFolder(bulkOrganizer); + rootFolder.children.push(...Array.from(entriesToDelete)); + (configService.getConfig as any).mockReturnValue(bulkOrganizer); + + const startTime = Date.now(); + const result = await service.deleteEntries({ + entryIds: entriesToDelete, + }); + const endTime = Date.now(); + + // Verify all bulk entries were deleted + entriesToDelete.forEach((entryId) => { + expect(result.views.default.entries[entryId]).toBeUndefined(); + }); + + const resultRoot = getRootFolder(result); + entriesToDelete.forEach((entryId) => { + expect(resultRoot.children).not.toContain(entryId); + }); + + // Verify operation completed in reasonable time (not a strict performance test) + expect(endTime - startTime).toBeLessThan(5000); // 5 seconds should be more than enough + + // Verify service contract still fulfilled + expect(result).toBeDefined(); + expect(result.version).toBe(1); + }); + }); }); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.ts index 141267b81..5b0ccd9be 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.ts @@ -11,6 +11,7 @@ import { createFolderInView, DEFAULT_ORGANIZER_ROOT_ID, DEFAULT_ORGANIZER_VIEW_ID, + deleteOrganizerEntries, resolveOrganizer, setFolderChildrenInView, } from '@app/unraid-api/organizer/organizer.js'; @@ -183,4 +184,17 @@ export class DockerOrganizerService { this.dockerConfigService.replaceConfig(validated); return validated; } + + async deleteEntries(params: { entryIds: Set }): Promise { + const { entryIds } = params; + const organizer = await this.syncAndGetOrganizer(); + const newOrganizer = structuredClone(organizer); + + deleteOrganizerEntries(newOrganizer.views.default, entryIds, { mutate: true }); + addMissingResourcesToView(newOrganizer.resources, newOrganizer.views.default); + + const validated = await this.dockerConfigService.validate(newOrganizer); + this.dockerConfigService.replaceConfig(validated); + return validated; + } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts index 186fd2fb0..b93ed2c0f 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -105,4 +105,17 @@ export class DockerResolver { }); return this.dockerOrganizerService.resolveOrganizer(organizer); } + + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.DOCKER, + possession: AuthPossession.ANY, + }) + @Mutation(() => ResolvedOrganizerV1) + public async deleteDockerEntries(@Args('entryIds', { type: () => [String] }) entryIds: string[]) { + const organizer = await this.dockerOrganizerService.deleteEntries({ + entryIds: new Set(entryIds), + }); + return this.dockerOrganizerService.resolveOrganizer(organizer); + } } diff --git a/api/src/unraid-api/organizer/organizer-view.test.ts b/api/src/unraid-api/organizer/organizer-view.test.ts new file mode 100644 index 000000000..5514d4c46 --- /dev/null +++ b/api/src/unraid-api/organizer/organizer-view.test.ts @@ -0,0 +1,991 @@ +import { describe, expect, it } from 'vitest'; + +import { collectDescendants, deleteOrganizerEntries } from '@app/unraid-api/organizer/organizer.js'; +import { OrganizerView } from '@app/unraid-api/organizer/organizer.model.js'; + +describe('collectDescendants', () => { + const createMockView = (entries: Record): OrganizerView => ({ + id: 'test-view', + name: 'Test View', + root: 'root', + entries, + }); + + it('should collect single entry (resource ref)', () => { + const view = createMockView({ + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = collectDescendants(view, 'resource1'); + + expect(result).toEqual(new Set(['resource1'])); + }); + + it('should collect folder with children', () => { + const view = createMockView({ + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['resource1', 'resource2'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + }); + + const result = collectDescendants(view, 'folder1'); + + expect(result).toEqual(new Set(['folder1', 'resource1', 'resource2'])); + }); + + it('should collect nested folders recursively', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1', 'resource1'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['folder2', 'resource2'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['resource3'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + resource3: { + id: 'resource3', + type: 'ref', + target: 'target3', + }, + }); + + const result = collectDescendants(view, 'root'); + + expect(result).toEqual( + new Set(['root', 'folder1', 'resource1', 'folder2', 'resource2', 'resource3']) + ); + }); + + it('should handle empty folder', () => { + const view = createMockView({ + emptyFolder: { + id: 'emptyFolder', + type: 'folder', + name: 'Empty Folder', + children: [], + }, + }); + + const result = collectDescendants(view, 'emptyFolder'); + + expect(result).toEqual(new Set(['emptyFolder'])); + }); + + it('should return empty set for non-existent entry', () => { + const view = createMockView({}); + + const result = collectDescendants(view, 'non-existent'); + + expect(result).toEqual(new Set()); + }); + + it('should handle circular references without infinite loop', () => { + const view = createMockView({ + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['folder2'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['folder1', 'resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = collectDescendants(view, 'folder1'); + + expect(result).toEqual(new Set(['folder1', 'folder2', 'resource1'])); + }); + + it('should use provided collection when passed', () => { + const view = createMockView({ + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const existingCollection = new Set(['existing-item']); + const result = collectDescendants(view, 'resource1', existingCollection); + + expect(result).toEqual(new Set(['existing-item', 'resource1'])); + expect(result).toBe(existingCollection); // Should modify the same set + }); + + it('should handle deep nesting', () => { + const entries: Record = {}; + const expectedSet = new Set(); + + // Create a deeply nested structure: folder0 -> folder1 -> ... -> folder9 -> resource + for (let i = 0; i < 10; i++) { + const folderId = `folder${i}`; + const nextId = i === 9 ? 'deepResource' : `folder${i + 1}`; + + entries[folderId] = { + id: folderId, + type: 'folder', + name: `Folder ${i}`, + children: [nextId], + }; + expectedSet.add(folderId); + } + + entries.deepResource = { + id: 'deepResource', + type: 'ref', + target: 'deepTarget', + }; + expectedSet.add('deepResource'); + + const view = createMockView(entries); + const result = collectDescendants(view, 'folder0'); + + expect(result).toEqual(expectedSet); + }); + + it('should handle mixed content (folders and refs)', () => { + const view = createMockView({ + mixedFolder: { + id: 'mixedFolder', + type: 'folder', + name: 'Mixed Content', + children: ['subFolder', 'resource1', 'resource2'], + }, + subFolder: { + id: 'subFolder', + type: 'folder', + name: 'Sub Folder', + children: ['resource3'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + resource3: { + id: 'resource3', + type: 'ref', + target: 'target3', + }, + }); + + const result = collectDescendants(view, 'mixedFolder'); + + expect(result).toEqual( + new Set(['mixedFolder', 'subFolder', 'resource1', 'resource2', 'resource3']) + ); + }); + + it('should handle entries with children that do not exist in view', () => { + const view = createMockView({ + brokenFolder: { + id: 'brokenFolder', + type: 'folder', + name: 'Broken Folder', + children: ['existingResource', 'nonExistentResource'], + }, + existingResource: { + id: 'existingResource', + type: 'ref', + target: 'target1', + }, + }); + + const result = collectDescendants(view, 'brokenFolder'); + + expect(result).toEqual(new Set(['brokenFolder', 'existingResource'])); + }); + + it('should handle self-referencing entry', () => { + const view = createMockView({ + selfRef: { + id: 'selfRef', + type: 'folder', + name: 'Self Reference', + children: ['selfRef', 'resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = collectDescendants(view, 'selfRef'); + + expect(result).toEqual(new Set(['selfRef', 'resource1'])); + }); + + it('should preserve insertion order in the set', () => { + const view = createMockView({ + folder: { + id: 'folder', + type: 'folder', + name: 'Ordered Folder', + children: ['c', 'a', 'b'], + }, + a: { id: 'a', type: 'ref', target: 'targetA' }, + b: { id: 'b', type: 'ref', target: 'targetB' }, + c: { id: 'c', type: 'ref', target: 'targetC' }, + }); + + const result = collectDescendants(view, 'folder'); + const resultArray = Array.from(result); + + expect(resultArray).toEqual(['folder', 'c', 'a', 'b']); + }); +}); + +describe('deleteOrganizerEntries', () => { + const createMockView = (entries: Record): OrganizerView => ({ + id: 'test-view', + name: 'Test View', + root: 'root', + entries, + }); + + it('should delete a single resource ref', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1', 'resource2'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['resource1'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource2'], + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + }); + expect(result).not.toBe(view); // Should return new object + }); + + it('should delete a folder and all its descendants', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1', 'resource1'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['resource2', 'resource3'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + resource3: { + id: 'resource3', + type: 'ref', + target: 'target3', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['folder1'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + }); + + it('should delete multiple entries at once', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1', 'resource2', 'resource3'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + resource3: { + id: 'resource3', + type: 'ref', + target: 'target3', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['resource1', 'resource3'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource2'], + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + }); + }); + + it('should delete nested folders recursively', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['folder2'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['folder1'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: [], + }, + }); + }); + + it('should handle deletion of non-existent entries gracefully', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['non-existent'])); + + expect(result.entries).toEqual(view.entries); + }); + + it('should mutate original view when mutate=true', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['resource1']), { mutate: true }); + + expect(result).toBe(view); // Should return same object + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: [], + }, + }); + }); + + it('should not mutate original view when mutate=false (default)', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const originalEntries = structuredClone(view.entries); + const result = deleteOrganizerEntries(view, new Set(['resource1'])); + + expect(result).not.toBe(view); // Should return new object + expect(view.entries).toEqual(originalEntries); // Original should be unchanged + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: [], + }, + }); + }); + + it('should handle entries referenced by multiple parents', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1', 'folder2'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['resource1'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['resource1', 'resource2'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['resource1'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1', 'folder2'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: [], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['resource2'], + }, + resource2: { + id: 'resource2', + type: 'ref', + target: 'target2', + }, + }); + }); + + it('should handle circular references during deletion', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['folder2'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['folder1', 'resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['folder1'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: [], + }, + }); + }); + + it('should handle deletion of root folder', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['root'])); + + expect(result.entries).toEqual({}); + }); + + it('should handle empty deletion set', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set()); + + expect(result.entries).toEqual(view.entries); + expect(result).not.toBe(view); // Should still return new object + }); + + it('should handle deletion when parent folders have broken references', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1', 'non-existent'], + }, + brokenFolder: { + id: 'brokenFolder', + type: 'folder', + name: 'Broken Folder', + children: ['resource1', 'another-non-existent'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['resource1'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['non-existent'], + }, + brokenFolder: { + id: 'brokenFolder', + type: 'folder', + name: 'Broken Folder', + children: ['another-non-existent'], + }, + }); + }); + + it('should handle deletion of all entries', () => { + const view = createMockView({ + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['folder1', 'resource1'])); + + expect(result.entries).toEqual({}); + }); + + it('should handle self-referencing entries', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['selfRef'], + }, + selfRef: { + id: 'selfRef', + type: 'folder', + name: 'Self Reference', + children: ['selfRef', 'resource1'], + }, + resource1: { + id: 'resource1', + type: 'ref', + target: 'target1', + }, + }); + + const result = deleteOrganizerEntries(view, new Set(['selfRef'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: [], + }, + }); + }); + + it('should preserve order of remaining children after deletion', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['a', 'b', 'c', 'd', 'e'], + }, + a: { id: 'a', type: 'ref', target: 'targetA' }, + b: { id: 'b', type: 'ref', target: 'targetB' }, + c: { id: 'c', type: 'ref', target: 'targetC' }, + d: { id: 'd', type: 'ref', target: 'targetD' }, + e: { id: 'e', type: 'ref', target: 'targetE' }, + }); + + const result = deleteOrganizerEntries(view, new Set(['b', 'd'])); + + expect((result.entries.root as any).children).toEqual(['a', 'c', 'e']); + }); + + it('should handle complex nested structure with mixed deletion', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1', 'folder2', 'resource1'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['folder3', 'resource2'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['resource3'], + }, + folder3: { + id: 'folder3', + type: 'folder', + name: 'Folder 3', + children: ['resource4'], + }, + resource1: { id: 'resource1', type: 'ref', target: 'target1' }, + resource2: { id: 'resource2', type: 'ref', target: 'target2' }, + resource3: { id: 'resource3', type: 'ref', target: 'target3' }, + resource4: { id: 'resource4', type: 'ref', target: 'target4' }, + }); + + const result = deleteOrganizerEntries(view, new Set(['folder1', 'resource1'])); + + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder2'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['resource3'], + }, + resource3: { id: 'resource3', type: 'ref', target: 'target3' }, + }); + }); + + it('should handle pre-collected descendants (dry run behavior)', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1', 'resource1'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['folder2', 'resource2'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['resource3'], + }, + resource1: { id: 'resource1', type: 'ref', target: 'target1' }, + resource2: { id: 'resource2', type: 'ref', target: 'target2' }, + resource3: { id: 'resource3', type: 'ref', target: 'target3' }, + }); + + // Method 1: Direct deletion + const directResult = deleteOrganizerEntries(view, new Set(['folder1'])); + + // Method 2: Pre-collect descendants (dry run), then delete + const descendants = collectDescendants(view, 'folder1'); + const preCollectedResult = deleteOrganizerEntries(view, descendants); + + // Results should be identical + expect(preCollectedResult.entries).toEqual(directResult.entries); + expect(descendants).toEqual(new Set(['folder1', 'folder2', 'resource2', 'resource3'])); + + // Both should produce the same final state + const expectedEntries = { + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['resource1'], + }, + resource1: { id: 'resource1', type: 'ref', target: 'target1' }, + }; + + expect(directResult.entries).toEqual(expectedEntries); + expect(preCollectedResult.entries).toEqual(expectedEntries); + }); + + it('should optimize when descendant collector is called with already-collected entries', () => { + const view = createMockView({ + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['resource1', 'resource2'], + }, + resource1: { id: 'resource1', type: 'ref', target: 'target1' }, + resource2: { id: 'resource2', type: 'ref', target: 'target2' }, + }); + + // Track how many times the collector is meaningfully called + let meaningfulCalls = 0; + const trackingCollector = (view: OrganizerView, entryId: string, collection?: Set) => { + const initialSize = collection?.size ?? 0; + const result = collectDescendants(view, entryId, collection); + if (result.size > initialSize) { + meaningfulCalls++; + } + return result; + }; + + // Pre-collect all descendants, then pass them to delete + const allDescendants = collectDescendants(view, 'folder1'); + expect(allDescendants).toEqual(new Set(['folder1', 'resource1', 'resource2'])); + + // Reset counter and delete with pre-collected descendants + meaningfulCalls = 0; + const result = deleteOrganizerEntries(view, allDescendants, { + descendantCollector: trackingCollector, + }); + + // Only the first call should be meaningful (folder1), + // subsequent calls for resource1 and resource2 should early-return + expect(meaningfulCalls).toBe(1); + expect(result.entries).toEqual({}); + }); + + it('should demonstrate early return behavior with mixed pre-collected entries', () => { + const view = createMockView({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: ['folder1', 'folder2'], + }, + folder1: { + id: 'folder1', + type: 'folder', + name: 'Folder 1', + children: ['resource1'], + }, + folder2: { + id: 'folder2', + type: 'folder', + name: 'Folder 2', + children: ['resource2'], + }, + resource1: { id: 'resource1', type: 'ref', target: 'target1' }, + resource2: { id: 'resource2', type: 'ref', target: 'target2' }, + }); + + // Collect descendants of folder1, then add folder2 manually + const descendants = collectDescendants(view, 'folder1'); + descendants.add('folder2'); // Add folder2 but not its descendants + + // Track collection calls + const collectionCalls: string[] = []; + const trackingCollector = (view: OrganizerView, entryId: string, collection?: Set) => { + collectionCalls.push(entryId); + return collectDescendants(view, entryId, collection); + }; + + const result = deleteOrganizerEntries(view, descendants, { + descendantCollector: trackingCollector, + }); + + // Should have been called for each entry in the set + expect(collectionCalls).toEqual(['folder1', 'resource1', 'folder2']); + + // folder1 and resource1 calls should early-return since they're already collected + // folder2 call should collect its descendants (resource2) + expect(result.entries).toEqual({ + root: { + id: 'root', + type: 'folder', + name: 'Root', + children: [], + }, + }); + }); +}); diff --git a/api/src/unraid-api/organizer/organizer.ts b/api/src/unraid-api/organizer/organizer.ts index a00f4ef17..0fea32d56 100644 --- a/api/src/unraid-api/organizer/organizer.ts +++ b/api/src/unraid-api/organizer/organizer.ts @@ -231,3 +231,150 @@ export function setFolderChildrenInView(params: SetFolderChildrenInViewParams): return newView; } + +/** + * Recursively collects all descendants of an entry in an organizer view. + * + * **IMPORTANT: The returned set includes the starting `entryId` as well, not just its descendants.** + * + * This function performs a depth-first traversal of the organizer hierarchy, collecting + * all reachable entry IDs starting from the given entry. It handles various edge cases + * gracefully and prevents infinite loops in circular structures. + * + * @param view - The organizer view containing the entry definitions + * @param entryId - The ID of the entry to start collection from + * @param collection - Optional existing Set to add results to. If provided, this Set + * will be modified in-place and returned. + * @returns A Set containing the entryId and all its descendants + * + * @example + * ```typescript + * // Basic usage - collects entry and all descendants + * const descendants = collectDescendants(view, 'folder1'); + * // descendants contains 'folder1' plus all nested children + * + * // Using existing collection + * const existing = new Set(['other-item']); + * const result = collectDescendants(view, 'folder1', existing); + * // result === existing (same Set object, modified in-place) + * ``` + * + * @remarks + * **Behavior and Edge Cases:** + * + * - **Self-inclusion**: The starting `entryId` is always included in the result set + * - **Cycle detection**: Automatically prevents infinite loops when entries reference themselves + * or create circular dependencies. Each entry ID is only processed once. + * - **Missing entries**: If `entryId` doesn't exist in the view, returns an empty set (or + * the provided collection unchanged) + * - **Broken references**: If a folder's children array contains IDs that don't exist in the + * view, those missing children are silently skipped without errors + * - **Resource refs**: Entries with `type: 'ref'` are treated as leaf nodes - they are + * collected but don't traverse further (as they have no children) + * - **Empty folders**: Folders with no children are still collected and returned + * - **Collection mutation**: If a collection parameter is provided, it is modified in-place + * rather than creating a new Set + * - **Traversal order**: Uses depth-first traversal, processing folders before their children. + * The Set maintains insertion order, so parent entries appear before their descendants. + * + * **Performance:** O(n) where n is the number of reachable entries. Each entry is visited + * at most once due to the cycle detection mechanism. + */ +export function collectDescendants( + view: OrganizerView, + entryId: string, + collection?: Set +): Set { + collection ??= new Set(); + const entry = view.entries[entryId]; + if (!entry || collection.has(entryId)) return collection; + + collection.add(entryId); + if (entry.type === 'folder') { + for (const childId of entry.children) { + collectDescendants(view, childId, collection); + } + } + return collection; +} + +/** + * Deletes entries from an organizer view along with all their descendants. + * + * This function performs a cascading deletion - when you delete a folder, all of its + * nested children (folders and resource references) are also deleted. The function + * automatically handles cleanup by removing deleted entry references from all parent + * folders throughout the view. + * + * @param view - The organizer view to delete entries from + * @param entryIds - Set of entry IDs to delete (folders and/or resource refs) + * @param opts - Options object + * @param opts.mutate - If true, modifies the original view in-place. If false (default), + * returns a new cloned view with deletions applied + * @returns The modified organizer view (original if mutate=true, clone if mutate=false) + * + * @example + * ```typescript + * // Delete multiple entries (returns new view) + * const updatedView = deleteOrganizerEntries(view, new Set(['folder1', 'resource2'])); + * + * // Delete in-place (mutates original view) + * const sameView = deleteOrganizerEntries(view, new Set(['folder1']), { mutate: true }); + * ``` + * + * @remarks + * **Important Behaviors:** + * + * - **Cascading deletion**: Deleting a folder automatically deletes all its descendants + * (nested folders and resource references). Use `collectDescendants()` first if you + * need to know what will be deleted. + * + * - **Reference cleanup**: Removes deleted entry IDs from ALL folder children arrays + * throughout the view, not just direct parents. This handles cases where entries + * are referenced by multiple folders. + * + * - **Circular reference safe**: Handles circular folder structures without infinite + * loops thanks to the underlying `collectDescendants()` function. + * + * - **Graceful handling**: Non-existent entry IDs are silently ignored. Empty deletion + * sets are handled without errors. + * + * - **Immutability control**: By default creates a new view object (`mutate=false`). + * Set `mutate=true` only when you want to modify the original view in-place. + * + * **Performance**: O(n*m) where n is the number of entries in the view and m is the + * average number of children per folder, due to the reference cleanup step. + */ +export function deleteOrganizerEntries( + view: OrganizerView, + entryIds: Set, + opts: { + mutate?: boolean; + descendantCollector?: ( + view: OrganizerView, + entryId: string, + collection?: Set + ) => Set; + } = {} +): OrganizerView { + const { descendantCollector = collectDescendants } = opts; + // Stage entries for deletion + const toDelete = new Set(); + for (const entryId of entryIds) { + descendantCollector(view, entryId, toDelete); + } + + const newView = opts.mutate ? view : structuredClone(view); + // Remove references from parent folders + Object.values(newView.entries).forEach((entry) => { + if (entry.type === 'folder') { + entry.children = entry.children.filter((childId) => !toDelete.has(childId)); + } + }); + + // Delete entries + for (const entryId of toDelete) { + delete newView.entries[entryId]; + } + return newView; +}