mirror of
https://github.com/unraid/api.git
synced 2026-01-04 07:29:48 -06:00
feat: deleteDockerEntries mutation (#1564)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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!
|
||||
|
||||
@@ -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<string, any> = {}) => {
|
||||
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<string>();
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> }): Promise<OrganizerV1> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
991
api/src/unraid-api/organizer/organizer-view.test.ts
Normal file
991
api/src/unraid-api/organizer/organizer-view.test.ts
Normal file
@@ -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<string, any>): 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<string, any> = {};
|
||||
const expectedSet = new Set<string>();
|
||||
|
||||
// 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<string, any>): 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<string>) => {
|
||||
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<string>) => {
|
||||
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: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>
|
||||
): Set<string> {
|
||||
collection ??= new Set<string>();
|
||||
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<string>,
|
||||
opts: {
|
||||
mutate?: boolean;
|
||||
descendantCollector?: (
|
||||
view: OrganizerView,
|
||||
entryId: string,
|
||||
collection?: Set<string>
|
||||
) => Set<string>;
|
||||
} = {}
|
||||
): OrganizerView {
|
||||
const { descendantCollector = collectDescendants } = opts;
|
||||
// Stage entries for deletion
|
||||
const toDelete = new Set<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user