Skip to content

Commit a2c5d24

Browse files
authored
fix: refactor API client to support Unix socket connections (#1575)
1 parent b321687 commit a2c5d24

31 files changed

+2175
-210
lines changed

api/dev/configs/api.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"version": "4.12.0",
3-
"extraOrigins": [],
4-
"sandbox": true,
5-
"ssoSubIds": [],
6-
"plugins": ["unraid-api-plugin-connect"]
7-
}
2+
"version": "4.12.0",
3+
"extraOrigins": [],
4+
"sandbox": true,
5+
"ssoSubIds": [],
6+
"plugins": ["unraid-api-plugin-connect"]
7+
}

api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
"semver": "7.7.2",
139139
"strftime": "0.10.3",
140140
"systeminformation": "5.27.7",
141+
"undici": "^7.13.0",
141142
"uuid": "11.1.0",
142143
"ws": "8.18.3",
143144
"zen-observable-ts": "1.1.0",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ConfigModule } from '@nestjs/config';
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
4+
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
7+
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
8+
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
9+
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
10+
import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js';
11+
12+
describe('CliServicesModule', () => {
13+
let module: TestingModule;
14+
15+
beforeEach(async () => {
16+
module = await Test.createTestingModule({
17+
imports: [CliServicesModule],
18+
}).compile();
19+
});
20+
21+
afterEach(async () => {
22+
await module?.close();
23+
});
24+
25+
it('should compile the module', () => {
26+
expect(module).toBeDefined();
27+
});
28+
29+
it('should provide CliInternalClientService', () => {
30+
const service = module.get(CliInternalClientService);
31+
expect(service).toBeDefined();
32+
expect(service).toBeInstanceOf(CliInternalClientService);
33+
});
34+
35+
it('should provide AdminKeyService', () => {
36+
const service = module.get(AdminKeyService);
37+
expect(service).toBeDefined();
38+
expect(service).toBeInstanceOf(AdminKeyService);
39+
});
40+
41+
it('should provide InternalGraphQLClientFactory via token', () => {
42+
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
43+
expect(factory).toBeDefined();
44+
expect(factory).toBeInstanceOf(InternalGraphQLClientFactory);
45+
});
46+
47+
describe('CliInternalClientService dependencies', () => {
48+
it('should have all required dependencies available', () => {
49+
// This test ensures that CliInternalClientService can be instantiated
50+
// with all its dependencies properly resolved
51+
const service = module.get(CliInternalClientService);
52+
expect(service).toBeDefined();
53+
54+
// Verify the service has its dependencies injected
55+
// The service should be able to create a client without errors
56+
expect(service.getClient).toBeDefined();
57+
expect(service.clearClient).toBeDefined();
58+
});
59+
60+
it('should resolve InternalGraphQLClientFactory dependency via token', () => {
61+
// Explicitly test that the factory is available in the module context via token
62+
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
63+
expect(factory).toBeDefined();
64+
expect(factory.createClient).toBeDefined();
65+
});
66+
67+
it('should resolve AdminKeyService dependency', () => {
68+
// Explicitly test that AdminKeyService is available in the module context
69+
const adminKeyService = module.get(AdminKeyService);
70+
expect(adminKeyService).toBeDefined();
71+
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
72+
});
73+
});
74+
});
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { ConfigModule, ConfigService } from '@nestjs/config';
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
4+
import type { InternalGraphQLClientFactory } from '@unraid/shared';
5+
import { ApolloClient } from '@apollo/client/core/index.js';
6+
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8+
9+
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
10+
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
11+
12+
describe('CliInternalClientService', () => {
13+
let service: CliInternalClientService;
14+
let clientFactory: InternalGraphQLClientFactory;
15+
let adminKeyService: AdminKeyService;
16+
let module: TestingModule;
17+
18+
const mockApolloClient = {
19+
query: vi.fn(),
20+
mutate: vi.fn(),
21+
stop: vi.fn(),
22+
};
23+
24+
beforeEach(async () => {
25+
module = await Test.createTestingModule({
26+
imports: [ConfigModule.forRoot()],
27+
providers: [
28+
CliInternalClientService,
29+
{
30+
provide: INTERNAL_CLIENT_SERVICE_TOKEN,
31+
useValue: {
32+
createClient: vi.fn().mockResolvedValue(mockApolloClient),
33+
},
34+
},
35+
{
36+
provide: AdminKeyService,
37+
useValue: {
38+
getOrCreateLocalAdminKey: vi.fn().mockResolvedValue('test-admin-key'),
39+
},
40+
},
41+
],
42+
}).compile();
43+
44+
service = module.get<CliInternalClientService>(CliInternalClientService);
45+
clientFactory = module.get<InternalGraphQLClientFactory>(INTERNAL_CLIENT_SERVICE_TOKEN);
46+
adminKeyService = module.get<AdminKeyService>(AdminKeyService);
47+
});
48+
49+
afterEach(async () => {
50+
await module?.close();
51+
});
52+
53+
it('should be defined', () => {
54+
expect(service).toBeDefined();
55+
});
56+
57+
describe('dependency injection', () => {
58+
it('should have InternalGraphQLClientFactory injected', () => {
59+
expect(clientFactory).toBeDefined();
60+
expect(clientFactory.createClient).toBeDefined();
61+
});
62+
63+
it('should have AdminKeyService injected', () => {
64+
expect(adminKeyService).toBeDefined();
65+
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
66+
});
67+
});
68+
69+
describe('getClient', () => {
70+
it('should create a client with getApiKey function', async () => {
71+
const client = await service.getClient();
72+
73+
// The API key is now fetched lazily, not immediately
74+
expect(clientFactory.createClient).toHaveBeenCalledWith({
75+
getApiKey: expect.any(Function),
76+
enableSubscriptions: false,
77+
});
78+
79+
// Verify the getApiKey function works correctly when called
80+
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
81+
const apiKey = await callArgs.getApiKey();
82+
expect(apiKey).toBe('test-admin-key');
83+
expect(adminKeyService.getOrCreateLocalAdminKey).toHaveBeenCalled();
84+
85+
expect(client).toBe(mockApolloClient);
86+
});
87+
88+
it('should return cached client on subsequent calls', async () => {
89+
const client1 = await service.getClient();
90+
const client2 = await service.getClient();
91+
92+
expect(client1).toBe(client2);
93+
expect(clientFactory.createClient).toHaveBeenCalledTimes(1);
94+
});
95+
96+
it('should handle errors when getting admin key', async () => {
97+
const error = new Error('Failed to get admin key');
98+
vi.mocked(adminKeyService.getOrCreateLocalAdminKey).mockRejectedValueOnce(error);
99+
100+
// The client creation will succeed, but the API key error happens later
101+
const client = await service.getClient();
102+
expect(client).toBe(mockApolloClient);
103+
104+
// Now test that the getApiKey function throws the expected error
105+
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
106+
await expect(callArgs.getApiKey()).rejects.toThrow();
107+
});
108+
});
109+
110+
describe('clearClient', () => {
111+
it('should stop and clear the client', async () => {
112+
// First create a client
113+
await service.getClient();
114+
115+
// Clear the client
116+
service.clearClient();
117+
118+
expect(mockApolloClient.stop).toHaveBeenCalled();
119+
});
120+
121+
it('should handle clearing when no client exists', () => {
122+
// Should not throw when clearing a non-existent client
123+
expect(() => service.clearClient()).not.toThrow();
124+
});
125+
126+
it('should create a new client after clearing', async () => {
127+
// Create initial client
128+
await service.getClient();
129+
130+
// Clear it
131+
service.clearClient();
132+
133+
// Create new client
134+
await service.getClient();
135+
136+
// Should have created client twice
137+
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
138+
});
139+
});
140+
141+
describe('race condition protection', () => {
142+
it('should prevent stale client resurrection when clearClient() is called during creation', async () => {
143+
let resolveClientCreation!: (client: any) => void;
144+
145+
// Mock createClient to return a controllable promise
146+
const clientCreationPromise = new Promise<any>((resolve) => {
147+
resolveClientCreation = resolve;
148+
});
149+
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
150+
151+
// Start client creation (but don't await yet)
152+
const getClientPromise = service.getClient();
153+
154+
// Clear the client while creation is in progress
155+
service.clearClient();
156+
157+
// Now complete the client creation
158+
resolveClientCreation(mockApolloClient);
159+
160+
// Wait for getClient to complete
161+
const client = await getClientPromise;
162+
163+
// The client should be returned from getClient
164+
expect(client).toBe(mockApolloClient);
165+
166+
// But subsequent getClient calls should create a new client
167+
// because the race condition protection prevented assignment
168+
await service.getClient();
169+
170+
// Should have created a second client, proving the first wasn't assigned
171+
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
172+
});
173+
174+
it('should handle concurrent getClient calls during race condition', async () => {
175+
let resolveClientCreation!: (client: any) => void;
176+
177+
// Mock createClient to return a controllable promise
178+
const clientCreationPromise = new Promise<any>((resolve) => {
179+
resolveClientCreation = resolve;
180+
});
181+
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
182+
183+
// Start multiple concurrent client creation calls
184+
const getClientPromise1 = service.getClient();
185+
const getClientPromise2 = service.getClient(); // Should wait for first one
186+
187+
// Clear the client while creation is in progress
188+
service.clearClient();
189+
190+
// Complete the client creation
191+
resolveClientCreation(mockApolloClient);
192+
193+
// Both calls should resolve with the same client
194+
const [client1, client2] = await Promise.all([getClientPromise1, getClientPromise2]);
195+
expect(client1).toBe(mockApolloClient);
196+
expect(client2).toBe(mockApolloClient);
197+
198+
// But the client should not be cached due to race condition protection
199+
await service.getClient();
200+
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
201+
});
202+
});
203+
});

0 commit comments

Comments
 (0)