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
3 changes: 1 addition & 2 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,7 @@
"cron": "3.5.0",
"cross-fetch": "^4.0.0",
"diff": "^7.0.0",
"docker-event-emitter": "^0.3.0",
"dockerode": "^3.3.5",
"dockerode": "^4.0.5",
"dotenv": "^16.4.5",
"execa": "^9.5.1",
"exit-hook": "^4.0.0",
Expand Down
1 change: 0 additions & 1 deletion api/src/unraid-api/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { LoggerModule } from 'nestjs-pino';

import { apiLogger } from '@app/core/log.js';
import { LOG_LEVEL } from '@app/environment.js';
import { AuthInterceptor } from '@app/unraid-api/auth/auth.interceptor.js';
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
import { CronModule } from '@app/unraid-api/cron/cron.module.js';
Expand Down
8 changes: 3 additions & 5 deletions api/src/unraid-api/graph/graph.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo';
import { Module, UnauthorizedException } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';

import { ApolloServerPlugin } from '@apollo/server';
import { NoUnusedVariablesRule, print } from 'graphql';
import {
DateTimeResolver,
Expand All @@ -12,7 +11,6 @@ import {
URLResolver,
UUIDResolver,
} from 'graphql-scalars';
import { AuthZService } from 'nest-authz';

import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long.js';
import { loadTypeDefs } from '@app/graphql/schema/loadTypesDefs.js';
Expand All @@ -31,8 +29,8 @@ import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [PluginModule, AuthModule],
inject: [PluginService, AuthZService],
useFactory: async (pluginService: PluginService, authZService: AuthZService) => {
inject: [PluginService],
useFactory: async (pluginService: PluginService) => {
const plugins = await pluginService.getGraphQLConfiguration();
const authEnumTypeDefs = getAuthEnumTypeDefs();
const typeDefs = print(await loadTypeDefs([plugins.typeDefs, authEnumTypeDefs]));
Expand Down
254 changes: 254 additions & 0 deletions api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { Logger } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PassThrough, Readable } from 'stream';

import Docker from 'dockerode';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';

// Mock chokidar
vi.mock('chokidar', () => ({
watch: vi.fn().mockReturnValue({
on: vi.fn().mockReturnThis(),
}),
}));

// Mock @nestjs/common
vi.mock('@nestjs/common', async () => {
const actual = await vi.importActual('@nestjs/common');
return {
...actual,
Injectable: () => vi.fn(),
Logger: vi.fn().mockImplementation(() => ({
debug: vi.fn(),
error: vi.fn(),
log: vi.fn(),
})),
};
});

// Mock store getters
vi.mock('@app/store/index.js', () => ({
getters: {
paths: vi.fn().mockReturnValue({
'var-run': '/var/run',
'docker-socket': '/var/run/docker.sock',
}),
},
}));

// Mock DockerService
vi.mock('./docker.service.js', () => ({
DockerService: vi.fn().mockImplementation(() => ({
getDockerClient: vi.fn(),
debouncedContainerCacheUpdate: vi.fn(),
})),
}));

describe('DockerEventService', () => {
let service: DockerEventService;
let dockerService: DockerService;
let mockDockerClient: Docker;
let mockEventStream: PassThrough;
let mockLogger: Logger;
let module: TestingModule;

beforeEach(async () => {
// Create a mock Docker client
mockDockerClient = {
getEvents: vi.fn(),
} as unknown as Docker;

// Create a mock Docker service *instance*
const mockDockerServiceImpl = {
getDockerClient: vi.fn().mockReturnValue(mockDockerClient),
debouncedContainerCacheUpdate: vi.fn(),
};

// Create a mock event stream
mockEventStream = new PassThrough();

// Set up the mock Docker client to return our mock event stream
vi.spyOn(mockDockerClient, 'getEvents').mockResolvedValue(
mockEventStream as unknown as Readable
);

// Create a mock logger
mockLogger = new Logger(DockerEventService.name) as Logger;

// Use the mock implementation in the testing module
module = await Test.createTestingModule({
providers: [
DockerEventService,
{
provide: DockerService,
useValue: mockDockerServiceImpl,
},
],
}).compile();

service = module.get<DockerEventService>(DockerEventService);
dockerService = module.get<DockerService>(DockerService);
});

afterEach(() => {
vi.clearAllMocks();
if (service['dockerEventStream']) {
service.stopEventStream();
}
module.close();
});

it('should be defined', () => {
expect(service).toBeDefined();
});

const waitForEventProcessing = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms));

it('should process Docker events correctly', async () => {
await service['setupDockerWatch']();
expect(service.isActive()).toBe(true);

const event = {
Type: 'container',
Action: 'start',
id: '123',
from: 'test-image',
time: Date.now(),
timeNano: Date.now() * 1000000,
};

mockEventStream.write(JSON.stringify(event) + '\n');

await waitForEventProcessing();

expect(dockerService.debouncedContainerCacheUpdate).toHaveBeenCalled();
});

it('should ignore non-watched actions', async () => {
await service['setupDockerWatch']();
expect(service.isActive()).toBe(true);

const event = {
Type: 'container',
Action: 'unknown',
id: '123',
from: 'test-image',
time: Date.now(),
timeNano: Date.now() * 1000000,
};

mockEventStream.write(JSON.stringify(event) + '\n');

await waitForEventProcessing();

expect(dockerService.debouncedContainerCacheUpdate).not.toHaveBeenCalled();
});

it('should handle malformed JSON gracefully', async () => {
await service['setupDockerWatch']();
expect(service.isActive()).toBe(true);

const malformedJson = '{malformed json}\n';
mockEventStream.write(malformedJson);

const validEvent = { Type: 'container', Action: 'start', id: '456' };
mockEventStream.write(JSON.stringify(validEvent) + '\n');

await waitForEventProcessing();

expect(service.isActive()).toBe(true);
expect(dockerService.debouncedContainerCacheUpdate).toHaveBeenCalledTimes(1);
});

it('should handle multiple JSON bodies in a single chunk', async () => {
await service['setupDockerWatch']();
expect(service.isActive()).toBe(true);

const events = [
{ Type: 'container', Action: 'start', id: '123', from: 'test-image-1' },
{ Type: 'container', Action: 'stop', id: '456', from: 'test-image-2' },
];

mockEventStream.write(events.map((event) => JSON.stringify(event)).join('\n') + '\n');

await waitForEventProcessing();

expect(dockerService.debouncedContainerCacheUpdate).toHaveBeenCalledTimes(2);
});

it('should handle mixed valid and invalid JSON in a single chunk', async () => {
await service['setupDockerWatch']();
expect(service.isActive()).toBe(true);

const validEvent = { Type: 'container', Action: 'start', id: '123', from: 'test-image' };
const invalidJson = '{malformed json}';

mockEventStream.write(JSON.stringify(validEvent) + '\n' + invalidJson + '\n');

await waitForEventProcessing();

expect(dockerService.debouncedContainerCacheUpdate).toHaveBeenCalledTimes(1);

expect(service.isActive()).toBe(true);
});

it('should handle empty lines in a chunk', async () => {
await service['setupDockerWatch']();
expect(service.isActive()).toBe(true);

const event = { Type: 'container', Action: 'start', id: '123', from: 'test-image' };

mockEventStream.write('\n\n' + JSON.stringify(event) + '\n\n');

await waitForEventProcessing();

expect(dockerService.debouncedContainerCacheUpdate).toHaveBeenCalledTimes(1);

expect(service.isActive()).toBe(true);
});

it('should handle stream errors gracefully', async () => {
const stopSpy = vi.spyOn(service, 'stopEventStream');

await service['setupDockerWatch']();
expect(service.isActive()).toBe(true);

const testError = new Error('Stream error');
mockEventStream.emit('error', testError);

await waitForEventProcessing();

expect(service.isActive()).toBe(false);
expect(stopSpy).toHaveBeenCalled();
});

it('should clean up resources when stopped', async () => {
// Start the event stream
await service['setupDockerWatch']();
expect(service.isActive()).toBe(true); // Ensure it started

// Check if the stream exists before spying
const stream = service['dockerEventStream'];
let removeListenersSpy: any, destroySpy: any;
if (stream) {
removeListenersSpy = vi.spyOn(stream, 'removeAllListeners');
destroySpy = vi.spyOn(stream, 'destroy');
}

// Stop the event stream
service.stopEventStream();

// Verify that the service has stopped
expect(service.isActive()).toBe(false);
// Verify stream methods were called if the stream existed
if (removeListenersSpy) {
expect(removeListenersSpy).toHaveBeenCalled();
}
if (destroySpy) {
expect(destroySpy).toHaveBeenCalled();
}
});
});
Loading
Loading