Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5fd94cc
fix: add undici and pify dependencies, refactor API client to support…
elibosley Aug 8, 2025
014a1a8
chore: update TypeScript configuration and dependencies
elibosley Aug 8, 2025
a5c9458
chore: update undici dependency to version 7.13.0
elibosley Aug 8, 2025
a497bb2
fix: update fetch function type in CliInternalClientService
elibosley Aug 8, 2025
4cf1403
chore: utilize a shared local client to facilitate plugins (#1576)
elibosley Aug 8, 2025
9e65d62
chore: update dependencies in pnpm-lock.yaml
elibosley Aug 8, 2025
51170fa
refactor: enhance WebSocket handling and client cleanup
elibosley Aug 8, 2025
e19c558
test: add unit tests for CliServicesModule and CliInternalClientService
elibosley Aug 8, 2025
2d629c7
refactor: introduce SocketConfigService for improved socket handling
elibosley Aug 8, 2025
b0ab015
refactor: replace InternalGraphQLClientFactory with token-based injec…
elibosley Aug 8, 2025
39cd8f2
feat: integrate WebSocket implementation in GraphQL client services
elibosley Aug 8, 2025
e32c3e5
fix: concurrency checks
elibosley Aug 8, 2025
fa2b4a5
chore: update WebSocket dependencies in package.json and pnpm-lock.yaml
elibosley Aug 8, 2025
ab83233
fix: update default origin for GraphQL client services
elibosley Aug 8, 2025
c9e147b
chore: update WebSocket dependency in package.json and pnpm-lock.yaml
elibosley Aug 8, 2025
bb6b776
chore: migrate test imports from bun to vitest
elibosley Aug 8, 2025
83c9938
refactor: integrate SocketConfigService into BaseInternalClientService
elibosley Aug 9, 2025
16cb1ec
test: add WebSocket Unix Socket connection tests
elibosley Aug 9, 2025
64054f8
test: enhance SocketConfigService tests with cleanup procedures
elibosley Aug 9, 2025
62f365c
test: improve cleanup procedures in WebSocket Unix Socket tests
elibosley Aug 9, 2025
e94178e
test: refine WebSocket Unix Socket tests and enhance error handling
elibosley Aug 11, 2025
0bc9ee5
chore: update @apollo/client version to 3.13.9 in package.json and pn…
elibosley Aug 11, 2025
caf54cd
chore: add graphql-ws dependency to devDependencies
elibosley Aug 11, 2025
e0c3211
refactor: remove BaseInternalClientService and related tests, update …
elibosley Aug 11, 2025
1c31fd7
refactor: update client creation to use getApiKey function
elibosley Aug 11, 2025
2e7c771
refactor: update form component exports to include file extensions
elibosley Aug 11, 2025
88421a5
test: update InternalClientService tests to verify lazy-loaded API ke…
elibosley Aug 11, 2025
ee4394e
refactor: streamline internal client imports and introduce InternalGr…
elibosley Aug 12, 2025
ef5cec6
refactor: make apollo type imports in unraid-shared explicit
pujitm Aug 13, 2025
cae44ea
fix: race condition in internal client creation
pujitm Aug 13, 2025
cb075a1
fix: brittle expect.toThrow expectation
pujitm Aug 13, 2025
2c94572
test: race condition protection
pujitm Aug 13, 2025
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
12 changes: 6 additions & 6 deletions api/dev/configs/api.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": "4.12.0",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],
"plugins": ["unraid-api-plugin-connect"]
}
"version": "4.12.0",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],
"plugins": ["unraid-api-plugin-connect"]
}
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"semver": "7.7.2",
"strftime": "0.10.3",
"systeminformation": "5.27.7",
"undici": "^7.13.0",
"uuid": "11.1.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0",
Expand Down
74 changes: 74 additions & 0 deletions api/src/unraid-api/cli/cli.module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ConfigModule } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';

import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js';

describe('CliServicesModule', () => {
let module: TestingModule;

beforeEach(async () => {
module = await Test.createTestingModule({
imports: [CliServicesModule],
}).compile();
});

afterEach(async () => {
await module?.close();
});

it('should compile the module', () => {
expect(module).toBeDefined();
});

it('should provide CliInternalClientService', () => {
const service = module.get(CliInternalClientService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(CliInternalClientService);
});

it('should provide AdminKeyService', () => {
const service = module.get(AdminKeyService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(AdminKeyService);
});

it('should provide InternalGraphQLClientFactory via token', () => {
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
expect(factory).toBeDefined();
expect(factory).toBeInstanceOf(InternalGraphQLClientFactory);
});

describe('CliInternalClientService dependencies', () => {
it('should have all required dependencies available', () => {
// This test ensures that CliInternalClientService can be instantiated
// with all its dependencies properly resolved
const service = module.get(CliInternalClientService);
expect(service).toBeDefined();

// Verify the service has its dependencies injected
// The service should be able to create a client without errors
expect(service.getClient).toBeDefined();
expect(service.clearClient).toBeDefined();
});

it('should resolve InternalGraphQLClientFactory dependency via token', () => {
// Explicitly test that the factory is available in the module context via token
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
expect(factory).toBeDefined();
expect(factory.createClient).toBeDefined();
});

it('should resolve AdminKeyService dependency', () => {
// Explicitly test that AdminKeyService is available in the module context
const adminKeyService = module.get(AdminKeyService);
expect(adminKeyService).toBeDefined();
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
});
});
});
203 changes: 203 additions & 0 deletions api/src/unraid-api/cli/internal-client.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';

import type { InternalGraphQLClientFactory } from '@unraid/shared';
import { ApolloClient } from '@apollo/client/core/index.js';
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';

describe('CliInternalClientService', () => {
let service: CliInternalClientService;
let clientFactory: InternalGraphQLClientFactory;
let adminKeyService: AdminKeyService;
let module: TestingModule;

const mockApolloClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
};

beforeEach(async () => {
module = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
providers: [
CliInternalClientService,
{
provide: INTERNAL_CLIENT_SERVICE_TOKEN,
useValue: {
createClient: vi.fn().mockResolvedValue(mockApolloClient),
},
},
{
provide: AdminKeyService,
useValue: {
getOrCreateLocalAdminKey: vi.fn().mockResolvedValue('test-admin-key'),
},
},
],
}).compile();

service = module.get<CliInternalClientService>(CliInternalClientService);
clientFactory = module.get<InternalGraphQLClientFactory>(INTERNAL_CLIENT_SERVICE_TOKEN);
adminKeyService = module.get<AdminKeyService>(AdminKeyService);
});

afterEach(async () => {
await module?.close();
});

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

describe('dependency injection', () => {
it('should have InternalGraphQLClientFactory injected', () => {
expect(clientFactory).toBeDefined();
expect(clientFactory.createClient).toBeDefined();
});

it('should have AdminKeyService injected', () => {
expect(adminKeyService).toBeDefined();
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
});
});

describe('getClient', () => {
it('should create a client with getApiKey function', async () => {
const client = await service.getClient();

// The API key is now fetched lazily, not immediately
expect(clientFactory.createClient).toHaveBeenCalledWith({
getApiKey: expect.any(Function),
enableSubscriptions: false,
});

// Verify the getApiKey function works correctly when called
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
const apiKey = await callArgs.getApiKey();
expect(apiKey).toBe('test-admin-key');
expect(adminKeyService.getOrCreateLocalAdminKey).toHaveBeenCalled();

expect(client).toBe(mockApolloClient);
});

it('should return cached client on subsequent calls', async () => {
const client1 = await service.getClient();
const client2 = await service.getClient();

expect(client1).toBe(client2);
expect(clientFactory.createClient).toHaveBeenCalledTimes(1);
});

it('should handle errors when getting admin key', async () => {
const error = new Error('Failed to get admin key');
vi.mocked(adminKeyService.getOrCreateLocalAdminKey).mockRejectedValueOnce(error);

// The client creation will succeed, but the API key error happens later
const client = await service.getClient();
expect(client).toBe(mockApolloClient);

// Now test that the getApiKey function throws the expected error
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
await expect(callArgs.getApiKey()).rejects.toThrow();
});
});

describe('clearClient', () => {
it('should stop and clear the client', async () => {
// First create a client
await service.getClient();

// Clear the client
service.clearClient();

expect(mockApolloClient.stop).toHaveBeenCalled();
});

it('should handle clearing when no client exists', () => {
// Should not throw when clearing a non-existent client
expect(() => service.clearClient()).not.toThrow();
});

it('should create a new client after clearing', async () => {
// Create initial client
await service.getClient();

// Clear it
service.clearClient();

// Create new client
await service.getClient();

// Should have created client twice
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});
});

describe('race condition protection', () => {
it('should prevent stale client resurrection when clearClient() is called during creation', async () => {
let resolveClientCreation!: (client: any) => void;

// Mock createClient to return a controllable promise
const clientCreationPromise = new Promise<any>((resolve) => {
resolveClientCreation = resolve;
});
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);

// Start client creation (but don't await yet)
const getClientPromise = service.getClient();

// Clear the client while creation is in progress
service.clearClient();

// Now complete the client creation
resolveClientCreation(mockApolloClient);

// Wait for getClient to complete
const client = await getClientPromise;

// The client should be returned from getClient
expect(client).toBe(mockApolloClient);

// But subsequent getClient calls should create a new client
// because the race condition protection prevented assignment
await service.getClient();

// Should have created a second client, proving the first wasn't assigned
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});

it('should handle concurrent getClient calls during race condition', async () => {
let resolveClientCreation!: (client: any) => void;

// Mock createClient to return a controllable promise
const clientCreationPromise = new Promise<any>((resolve) => {
resolveClientCreation = resolve;
});
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);

// Start multiple concurrent client creation calls
const getClientPromise1 = service.getClient();
const getClientPromise2 = service.getClient(); // Should wait for first one

// Clear the client while creation is in progress
service.clearClient();

// Complete the client creation
resolveClientCreation(mockApolloClient);

// Both calls should resolve with the same client
const [client1, client2] = await Promise.all([getClientPromise1, getClientPromise2]);
expect(client1).toBe(mockApolloClient);
expect(client2).toBe(mockApolloClient);

// But the client should not be cached due to race condition protection
await service.getClient();
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});
});
});
Loading
Loading