Files
api/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.spec.ts
Pujit Mehrotra 78997a02c6 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 -->
2025-08-07 09:28:09 -04:00

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);
});
});
});