Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cursor/rules/api-rules.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ alwaysApply: false
* Test suite is VITEST, do not use jest
pnpm --filter ./api test
* Prefer to not mock simple dependencies
* For error testing, use `.rejects.toThrow()` without arguments - don't test exact error message strings unless the message format is specifically what you're testing

4 changes: 4 additions & 0 deletions .cursor/rules/web-testing-rules.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ globs: **/*.test.ts,**/__test__/components/**/*.ts,**/__test__/store/**/*.ts,**/
alwaysApply: false
---

## General Testing Best Practices
- **Error Testing:** Use `.rejects.toThrow()` without arguments to test that functions throw errors. Don't test exact error message strings unless the message format is specifically what you're testing
- **Focus on Behavior:** Test what the code does, not implementation details like exact error message wording

## Vue Component Testing Best Practices
- This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment
- Nuxt is currently set to auto import so some vue files may need compute or ref imported
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,6 @@ api/dev/Unraid.net/myservers.cfg

# Claude local settings
.claude/settings.local.json

# local Mise settings
.mise.toml
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ Enables GraphQL playground at `http://tower.local/graphql`

### Testing Guidelines

#### General Testing Best Practices

- **Error Testing:** Use `.rejects.toThrow()` without arguments to test that functions throw errors. Don't test exact error message strings unless the message format is specifically what you're testing
- **Focus on Behavior:** Test what the code does, not implementation details like exact error message wording
- **Avoid Brittleness:** Don't write tests that break when minor changes are made to error messages, log formats, or other non-essential details
- **Use Mocks Correctly**: Mocks should be used as nouns, not verbs.

#### Vue Component Testing

- This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment
Expand Down
5 changes: 3 additions & 2 deletions api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,6 @@ deploy/*

!**/*.login.*

# local status - doesn't need to be tracked
dev/connectStatus.json
# local api configs - don't need project-wide tracking
dev/connectStatus.json
dev/configs/*
19 changes: 0 additions & 19 deletions api/dev/configs/docker.organizer.json

This file was deleted.

2 changes: 2 additions & 0 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1872,6 +1872,8 @@ type Mutation {
parityCheck: ParityCheckMutations!
apiKey: ApiKeyMutations!
rclone: RCloneMutations!
createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1!
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!

"""Initiates a flash drive backup using a configured remote."""
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!
Expand Down
24 changes: 15 additions & 9 deletions api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { ValidationError } from 'class-validator';

import { AppError } from '@app/core/errors/app-error.js';
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.dto.js';
import {
DEFAULT_ORGANIZER_ROOT_ID,
DEFAULT_ORGANIZER_VIEW_ID,
} from '@app/unraid-api/organizer/organizer.js';
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
import { validateOrganizerIntegrity } from '@app/unraid-api/organizer/organizer.validation.js';

@Injectable()
Expand All @@ -28,11 +32,16 @@ export class DockerConfigService extends ConfigFilePersister<OrganizerV1> {
resources: {},
views: {
default: {
id: 'default',
id: DEFAULT_ORGANIZER_VIEW_ID,
name: 'Default',
root: 'root',
root: DEFAULT_ORGANIZER_ROOT_ID,
entries: {
root: { type: 'folder', id: 'root', name: 'Root', children: [] },
root: {
type: 'folder',
id: DEFAULT_ORGANIZER_ROOT_ID,
name: 'Root',
children: [],
},
},
},
},
Expand All @@ -43,10 +52,7 @@ export class DockerConfigService extends ConfigFilePersister<OrganizerV1> {
const organizer = await validateObject(OrganizerV1, config);
const { isValid, errors } = await validateOrganizerIntegrity(organizer);
if (!isValid) {
const error = new ValidationError();
error.target = organizer;
error.contexts = errors;
throw error;
throw new AppError(`Docker organizer validation failed: ${JSON.stringify(errors, null, 2)}`);
}
return organizer;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { describe, expect, it } from 'vitest';
import { Test } from '@nestjs/testing';

import { containerToResource } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
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', () => {
Expand Down Expand Up @@ -127,3 +135,219 @@ describe('containerToResource', () => {
});
});
});

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