mirror of
https://github.com/unraid/api.git
synced 2026-01-06 08:39:54 -06:00
<!-- 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 -->
759 lines
29 KiB
TypeScript
759 lines
29 KiB
TypeScript
import { Test } from '@nestjs/testing';
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
|
import {
|
|
containerToResource,
|
|
DockerOrganizerService,
|
|
} from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
|
import {
|
|
ContainerPortType,
|
|
ContainerState,
|
|
DockerContainer,
|
|
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
|
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
|
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
|
|
|
|
describe('containerToResource', () => {
|
|
it('should transform a DockerContainer to OrganizerResource', () => {
|
|
const container: DockerContainer = {
|
|
id: 'container-123',
|
|
names: ['/my-app', '/my-app-alias'],
|
|
image: 'nginx:latest',
|
|
imageId: 'sha256:abc123',
|
|
command: 'nginx -g "daemon off;"',
|
|
created: 1640995200,
|
|
ports: [
|
|
{
|
|
ip: '0.0.0.0',
|
|
privatePort: 80,
|
|
publicPort: 8080,
|
|
type: ContainerPortType.TCP,
|
|
},
|
|
],
|
|
state: ContainerState.RUNNING,
|
|
status: 'Up 2 hours',
|
|
autoStart: true,
|
|
labels: {
|
|
'com.docker.compose.service': 'web',
|
|
},
|
|
};
|
|
|
|
const result = containerToResource(container);
|
|
|
|
expect(result).toEqual({
|
|
id: '/my-app',
|
|
type: 'container',
|
|
name: '/my-app',
|
|
meta: container, // Now we store the entire container object
|
|
});
|
|
});
|
|
|
|
it('should use image as name when names array is empty', () => {
|
|
const container: DockerContainer = {
|
|
id: 'container-456',
|
|
names: [],
|
|
image: 'redis:alpine',
|
|
imageId: 'sha256:def456',
|
|
command: 'redis-server',
|
|
created: 1640995300,
|
|
ports: [],
|
|
state: ContainerState.EXITED,
|
|
status: 'Exited (0) 1 hour ago',
|
|
autoStart: false,
|
|
};
|
|
|
|
const result = containerToResource(container);
|
|
|
|
expect(result.name).toBe('redis:alpine');
|
|
expect(result.type).toBe('container');
|
|
expect(result.id).toBe('redis:alpine');
|
|
});
|
|
|
|
it('should handle containers with minimal data', () => {
|
|
const container: DockerContainer = {
|
|
id: 'container-789',
|
|
names: ['/minimal-container'],
|
|
image: 'alpine:latest',
|
|
imageId: 'sha256:ghi789',
|
|
command: 'sh',
|
|
created: 1640995400,
|
|
ports: [],
|
|
state: ContainerState.EXITED,
|
|
status: 'Exited (0) 5 minutes ago',
|
|
autoStart: false,
|
|
};
|
|
|
|
const result = containerToResource(container);
|
|
|
|
expect(result).toEqual({
|
|
id: '/minimal-container',
|
|
type: 'container',
|
|
name: '/minimal-container',
|
|
meta: container, // Now we store the entire container object
|
|
});
|
|
});
|
|
|
|
it('should handle containers with multiple ports', () => {
|
|
const container: DockerContainer = {
|
|
id: 'container-multiport',
|
|
names: ['/web-app'],
|
|
image: 'myapp:latest',
|
|
imageId: 'sha256:jkl012',
|
|
command: 'npm start',
|
|
created: 1640995500,
|
|
ports: [
|
|
{
|
|
ip: '0.0.0.0',
|
|
privatePort: 3000,
|
|
publicPort: 3000,
|
|
type: ContainerPortType.TCP,
|
|
},
|
|
{
|
|
ip: '0.0.0.0',
|
|
privatePort: 3001,
|
|
publicPort: 3001,
|
|
type: ContainerPortType.TCP,
|
|
},
|
|
],
|
|
state: ContainerState.RUNNING,
|
|
status: 'Up 30 minutes',
|
|
autoStart: true,
|
|
labels: {
|
|
maintainer: 'dev-team',
|
|
version: '1.0.0',
|
|
},
|
|
};
|
|
|
|
const result = containerToResource(container);
|
|
|
|
expect(result.meta?.ports).toHaveLength(2);
|
|
expect(result.meta?.labels).toEqual({
|
|
maintainer: 'dev-team',
|
|
version: '1.0.0',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('DockerOrganizerService', () => {
|
|
let service: DockerOrganizerService;
|
|
let configService: DockerConfigService;
|
|
let dockerService: DockerService;
|
|
|
|
const mockOrganizer: OrganizerV1 = {
|
|
version: 1,
|
|
resources: {
|
|
container1: {
|
|
id: 'container1',
|
|
type: 'container',
|
|
name: 'container1',
|
|
},
|
|
container2: {
|
|
id: 'container2',
|
|
type: 'container',
|
|
name: 'container2',
|
|
},
|
|
},
|
|
views: {
|
|
default: {
|
|
id: 'default',
|
|
name: 'Default',
|
|
root: 'root',
|
|
entries: {
|
|
root: { id: 'root', type: 'folder', name: 'Root', children: [] },
|
|
existingFolder: {
|
|
id: 'existingFolder',
|
|
type: 'folder',
|
|
name: 'Existing',
|
|
children: [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const moduleRef = await Test.createTestingModule({
|
|
providers: [
|
|
DockerOrganizerService,
|
|
{
|
|
provide: DockerConfigService,
|
|
useValue: {
|
|
getConfig: vi.fn().mockImplementation(() => structuredClone(mockOrganizer)),
|
|
validate: vi.fn().mockImplementation((config) => Promise.resolve(config)),
|
|
replaceConfig: vi.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: DockerService,
|
|
useValue: {
|
|
getContainers: vi.fn().mockResolvedValue([
|
|
{
|
|
id: 'container1',
|
|
names: ['container1'],
|
|
image: 'nginx:latest',
|
|
imageId: 'sha256:123',
|
|
command: 'nginx',
|
|
created: 1640995200,
|
|
ports: [],
|
|
state: 'running',
|
|
status: 'Up 1 hour',
|
|
autoStart: true,
|
|
},
|
|
{
|
|
id: 'container2',
|
|
names: ['container2'],
|
|
image: 'redis:latest',
|
|
imageId: 'sha256:456',
|
|
command: 'redis-server',
|
|
created: 1640995300,
|
|
ports: [],
|
|
state: 'running',
|
|
status: 'Up 2 hours',
|
|
autoStart: true,
|
|
},
|
|
]),
|
|
},
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = moduleRef.get<DockerOrganizerService>(DockerOrganizerService);
|
|
configService = moduleRef.get<DockerConfigService>(DockerConfigService);
|
|
dockerService = moduleRef.get<DockerService>(DockerService);
|
|
});
|
|
|
|
describe('createFolder', () => {
|
|
it('should create a folder in root by default', async () => {
|
|
const result = await service.createFolder({ name: 'New Folder' });
|
|
|
|
expect(result.version).toBe(1);
|
|
expect(configService.validate).toHaveBeenCalledWith(expect.any(Object));
|
|
expect(configService.replaceConfig).toHaveBeenCalledWith(result);
|
|
|
|
// Verify folder was created with correct properties
|
|
const newFolder = Object.values(result.views.default.entries).find(
|
|
(entry) => entry.type === 'folder' && entry.name === 'New Folder'
|
|
);
|
|
expect(newFolder).toBeDefined();
|
|
});
|
|
|
|
it('should create a folder with children', async () => {
|
|
const result = await service.createFolder({
|
|
name: 'Folder with Children',
|
|
parentId: 'root',
|
|
childrenIds: ['container1', 'container2'],
|
|
});
|
|
|
|
const newFolder = Object.values(result.views.default.entries).find(
|
|
(entry) => entry.type === 'folder' && entry.name === 'Folder with Children'
|
|
);
|
|
expect(newFolder).toBeDefined();
|
|
expect((newFolder as any).children).toEqual(['container1', 'container2']);
|
|
});
|
|
|
|
it('should throw error if parent does not exist', async () => {
|
|
await expect(
|
|
service.createFolder({ name: 'Test', parentId: 'nonexistent' })
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should throw error if parent is not a folder', async () => {
|
|
const organizerWithRef = structuredClone(mockOrganizer);
|
|
organizerWithRef.views.default.entries.refEntry = {
|
|
id: 'refEntry',
|
|
type: 'ref',
|
|
target: 'container1',
|
|
};
|
|
(configService.getConfig as any).mockReturnValue(organizerWithRef);
|
|
|
|
await expect(service.createFolder({ name: 'Test', parentId: 'refEntry' })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('setFolderChildren', () => {
|
|
it('should update folder children', async () => {
|
|
const result = await service.setFolderChildren({
|
|
folderId: 'existingFolder',
|
|
childrenIds: ['container1', 'container2'],
|
|
});
|
|
|
|
expect(result.version).toBe(1);
|
|
expect(configService.validate).toHaveBeenCalledWith(expect.any(Object));
|
|
expect(configService.replaceConfig).toHaveBeenCalledWith(result);
|
|
|
|
// Verify children were set
|
|
const folder = result.views.default.entries.existingFolder as any;
|
|
expect(folder.children).toEqual(['container1', 'container2']);
|
|
});
|
|
|
|
it('should create refs for resources not in entries', async () => {
|
|
const result = await service.setFolderChildren({
|
|
folderId: 'existingFolder',
|
|
childrenIds: ['container1'],
|
|
});
|
|
|
|
// Verify ref was created
|
|
expect(result.views.default.entries.container1).toEqual({
|
|
id: 'container1',
|
|
type: 'ref',
|
|
target: 'container1',
|
|
});
|
|
});
|
|
|
|
it('should handle empty children array', async () => {
|
|
const result = await service.setFolderChildren({
|
|
folderId: 'existingFolder',
|
|
childrenIds: [],
|
|
});
|
|
|
|
const folder = result.views.default.entries.existingFolder as any;
|
|
expect(folder.children).toEqual([]);
|
|
});
|
|
|
|
it('should use root as default folder', async () => {
|
|
const result = await service.setFolderChildren({
|
|
childrenIds: ['existingFolder'],
|
|
});
|
|
|
|
const rootFolder = result.views.default.entries.root as any;
|
|
expect(rootFolder.children).toContain('existingFolder');
|
|
});
|
|
|
|
it('should throw error if folder does not exist', async () => {
|
|
await expect(
|
|
service.setFolderChildren({ folderId: 'nonexistent', childrenIds: [] })
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should throw error if target is not a folder', async () => {
|
|
const organizerWithRef = structuredClone(mockOrganizer);
|
|
organizerWithRef.views.default.entries.refEntry = {
|
|
id: 'refEntry',
|
|
type: 'ref',
|
|
target: 'container1',
|
|
};
|
|
(configService.getConfig as any).mockReturnValue(organizerWithRef);
|
|
|
|
await expect(
|
|
service.setFolderChildren({ folderId: 'refEntry', childrenIds: [] })
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should throw error if child does not exist', async () => {
|
|
await expect(
|
|
service.setFolderChildren({
|
|
folderId: 'existingFolder',
|
|
childrenIds: ['nonexistentChild'],
|
|
})
|
|
).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);
|
|
});
|
|
});
|
|
});
|