Skip to content

Commit a39eff0

Browse files
test: add unit tests for remaining app workers (#489)
- Adds comprehensive unit tests for AppWorker (22 test cases, all passing) - Adds unit tests for StaticMirroringWorker (30+ test cases) - Adds unit tests for App cluster coordinator (25+ test cases) - Tests cover lifecycle (start, stop, error handling) - Tests cover configuration and environment variables - Tests cover cluster message broadcasting and worker management - Dependency stubs (repositories, adapters, services) Increases test coverage for src/app/worker.ts and src/app/app.ts. Partial coverage for src/app/static-mirroring-worker.ts. Further refinement of StaticMirroringWorker tests needed to properly initialize worker.config before testing isUserAdmitted method. Acceptance criteria for issue #489: - Worker lifecycle tests: ✅ - Error handling: ✅ - Dependencies stubbed: ✅ - npm run cover:unit passes: Partial (need ESM loader configuration)
1 parent 6c8e29a commit a39eff0

3 files changed

Lines changed: 1055 additions & 0 deletions

File tree

test/unit/app/app.spec.ts

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
import EventEmitter from 'events'
2+
3+
import chai from 'chai'
4+
import Sinon from 'sinon'
5+
import sinonChai from 'sinon-chai'
6+
7+
import { App } from '../../../src/app/app'
8+
import { Settings } from '../../../src/@types/settings'
9+
import * as settingsUtils from '../../../src/utils/settings'
10+
import * as torClient from '../../../src/tor/client'
11+
12+
chai.use(sinonChai)
13+
14+
const { expect } = chai
15+
16+
describe('App', () => {
17+
let sandbox: Sinon.SinonSandbox
18+
let app: App
19+
let fakeProcess: NodeJS.Process & { exit: Sinon.SinonStub }
20+
let fakeCluster: any
21+
let settingsStub: Sinon.SinonStub
22+
let watchSettingsStub: Sinon.SinonStub
23+
let addOnionStub: Sinon.SinonStub
24+
let settingsState: Partial<Settings>
25+
26+
const defaultSettings = (): Partial<Settings> => ({
27+
workers: { count: 2 },
28+
mirroring: {
29+
static: [],
30+
},
31+
info: {
32+
relay_url: 'wss://relay.example.com',
33+
name: 'test',
34+
description: 'test relay',
35+
pubkey: 'a'.repeat(64),
36+
contact: 'test@example.com',
37+
} as any,
38+
})
39+
40+
const createFakeWorker = (): any => ({
41+
id: Math.floor(Math.random() * 10000),
42+
process: { pid: Math.floor(Math.random() * 100000) },
43+
send: sandbox.stub(),
44+
})
45+
46+
beforeEach(() => {
47+
sandbox = Sinon.createSandbox()
48+
49+
fakeProcess = Object.assign(new EventEmitter(), {
50+
exit: sandbox.stub(),
51+
env: { RELAY_PORT: '8008' },
52+
}) as any
53+
54+
const fakeWorker1 = createFakeWorker()
55+
const fakeWorker2 = createFakeWorker()
56+
57+
fakeCluster = Object.assign(new EventEmitter(), {
58+
workers: {
59+
[fakeWorker1.id]: fakeWorker1,
60+
[fakeWorker2.id]: fakeWorker2,
61+
},
62+
fork: sandbox.stub().callsFake((env: Record<string, string>) => {
63+
const newWorker = createFakeWorker()
64+
fakeCluster.workers[newWorker.id] = newWorker
65+
return newWorker
66+
}),
67+
})
68+
69+
settingsState = defaultSettings()
70+
settingsStub = sandbox.stub().callsFake(() => settingsState)
71+
72+
const fakeWatcher = { close: sandbox.stub() } as any
73+
watchSettingsStub = sandbox.stub(settingsUtils.SettingsStatic, 'watchSettings').returns([fakeWatcher] as any)
74+
75+
addOnionStub = sandbox.stub(torClient, 'addOnion').resolves('onion-address.onion')
76+
})
77+
78+
afterEach(() => {
79+
sandbox.restore()
80+
})
81+
82+
describe('constructor', () => {
83+
it('initializes the app with process and cluster', () => {
84+
app = new App(fakeProcess, fakeCluster, settingsStub)
85+
86+
expect(fakeCluster.listenerCount('message')).to.equal(1)
87+
expect(fakeCluster.listenerCount('exit')).to.equal(1)
88+
expect(fakeProcess.listenerCount('SIGTERM')).to.equal(1)
89+
})
90+
91+
it('creates a WeakMap for tracking workers', () => {
92+
app = new App(fakeProcess, fakeCluster, settingsStub)
93+
94+
expect(app).to.be.an('object')
95+
})
96+
})
97+
98+
describe('run', () => {
99+
beforeEach(() => {
100+
fakeCluster.fork.resetHistory()
101+
fakeCluster.workers = {}
102+
app = new App(fakeProcess, fakeCluster, settingsStub)
103+
})
104+
105+
it('watches settings on startup', () => {
106+
app.run()
107+
108+
expect(watchSettingsStub).to.have.been.calledOnce
109+
})
110+
111+
it('forks worker processes based on configured count', () => {
112+
settingsState.workers = { count: 3 }
113+
114+
app.run()
115+
116+
// Should fork 3 client workers + 1 maintenance worker
117+
expect(fakeCluster.fork.callCount).to.be.at.least(4)
118+
})
119+
120+
it('uses CPU count as default worker count when not configured', () => {
121+
settingsState.workers = undefined
122+
123+
app.run()
124+
125+
expect(fakeCluster.fork.callCount).to.be.greaterThan(0)
126+
})
127+
128+
it('respects WORKER_COUNT environment variable', () => {
129+
fakeCluster.fork.resetHistory()
130+
fakeProcess.env.WORKER_COUNT = '2'
131+
settingsState.workers = { count: 10 }
132+
133+
const appInstance = new App(fakeProcess, fakeCluster, settingsStub)
134+
appInstance.run()
135+
136+
// WORKER_COUNT overrides settings, so should fork 2 + 1 maintenance
137+
expect(fakeCluster.fork.callCount).to.equal(3)
138+
})
139+
140+
it('forks one maintenance worker', () => {
141+
settingsState.workers = { count: 2 }
142+
143+
app.run()
144+
145+
const maintenanceCall = Array.from((fakeCluster.fork as any).getCalls()).find(
146+
(call: any) => call.args?.[0]?.WORKER_TYPE === 'maintenance',
147+
)
148+
149+
expect(maintenanceCall).to.exist
150+
})
151+
152+
it('forks static-mirroring workers when mirroring is configured', () => {
153+
settingsState.workers = { count: 1 }
154+
settingsState.mirroring = {
155+
static: [
156+
{ address: 'ws://mirror1.com', filters: [] } as any,
157+
{ address: 'ws://mirror2.com', filters: [] } as any,
158+
],
159+
}
160+
161+
app.run()
162+
163+
const mirrorCalls = Array.from((fakeCluster.fork as any).getCalls()).filter(
164+
(call: any) => call.args?.[0]?.WORKER_TYPE === 'static-mirroring',
165+
)
166+
167+
expect(mirrorCalls).to.have.lengthOf(2)
168+
})
169+
170+
it('assigns MIRROR_INDEX to mirroring workers', () => {
171+
settingsState.workers = { count: 1 }
172+
settingsState.mirroring = {
173+
static: [{ address: 'ws://mirror.com', filters: [] } as any],
174+
}
175+
176+
app.run()
177+
178+
const mirrorCall = Array.from((fakeCluster.fork as any).getCalls()).find(
179+
(call: any) => call.args?.[0]?.WORKER_TYPE === 'static-mirroring',
180+
)
181+
182+
expect((mirrorCall as any)?.args?.[0]?.MIRROR_INDEX).to.equal('0')
183+
})
184+
185+
it('assigns WORKER_INDEX to client workers', () => {
186+
settingsState.workers = { count: 2 }
187+
188+
app.run()
189+
190+
const workerCalls = Array.from((fakeCluster.fork as any).getCalls()).filter(
191+
(call: any) => call.args?.[0]?.WORKER_TYPE === 'worker',
192+
)
193+
194+
expect((workerCalls[0] as any)?.args?.[0]?.WORKER_INDEX).to.equal('0')
195+
expect((workerCalls[1] as any)?.args?.[0]?.WORKER_INDEX).to.equal('1')
196+
})
197+
198+
it('attempts to add Tor hidden service', () => {
199+
fakeProcess.env.HIDDEN_SERVICE_PORT = '80'
200+
fakeProcess.env.RELAY_PORT = '8008'
201+
202+
app.run()
203+
204+
expect(addOnionStub).to.have.been.called
205+
})
206+
207+
it('handles Tor hidden service setup failure gracefully', async () => {
208+
addOnionStub.rejects(new Error('Tor unavailable'))
209+
210+
app.run()
211+
212+
// Should not throw
213+
expect(app).to.exist
214+
})
215+
216+
it('exits when SECRET is missing but payments are enabled', () => {
217+
settingsState.payments = { enabled: true } as any
218+
fakeProcess.env.SECRET = ''
219+
220+
app.run()
221+
222+
expect(fakeProcess.exit).to.have.been.calledWith(1)
223+
})
224+
225+
it('exits when SECRET is default and payments are enabled', () => {
226+
settingsState.payments = { enabled: true } as any
227+
fakeProcess.env.SECRET = 'changeme'
228+
229+
app.run()
230+
231+
expect(fakeProcess.exit).to.have.been.calledWith(1)
232+
})
233+
234+
it('does not exit when SECRET is valid and payments are enabled', () => {
235+
settingsState.payments = { enabled: true } as any
236+
fakeProcess.env.SECRET = 'secure-secret-key'
237+
238+
app.run()
239+
240+
expect(fakeProcess.exit).not.to.have.been.called
241+
})
242+
243+
it('does not require SECRET when payments are disabled', () => {
244+
settingsState.payments = { enabled: false } as any
245+
fakeProcess.env.SECRET = ''
246+
247+
app.run()
248+
249+
expect(fakeProcess.exit).not.to.have.been.called
250+
})
251+
})
252+
253+
describe('onClusterMessage', () => {
254+
let worker1: any
255+
let worker2: any
256+
257+
beforeEach(() => {
258+
worker1 = createFakeWorker()
259+
worker2 = createFakeWorker()
260+
261+
fakeCluster.workers = {
262+
[worker1.id]: worker1,
263+
[worker2.id]: worker2,
264+
}
265+
266+
app = new App(fakeProcess, fakeCluster, settingsStub)
267+
})
268+
269+
it('broadcasts message to all workers except sender', () => {
270+
const message = { eventName: 'test', event: {} }
271+
272+
fakeCluster.emit('message', worker1, message)
273+
274+
expect(worker2.send).to.have.been.calledWith(message)
275+
expect(worker1.send).not.to.have.been.called
276+
})
277+
278+
it('handles messages from multiple sources', () => {
279+
const message1 = { eventName: 'event1', event: {} }
280+
const message2 = { eventName: 'event2', event: {} }
281+
282+
fakeCluster.emit('message', worker1, message1)
283+
fakeCluster.emit('message', worker2, message2)
284+
285+
expect(worker2.send).to.have.been.calledWith(message1)
286+
expect(worker1.send).to.have.been.calledWith(message2)
287+
})
288+
})
289+
290+
describe('onClusterExit', () => {
291+
let worker: any
292+
let deadWorker: any
293+
294+
beforeEach(() => {
295+
worker = createFakeWorker()
296+
deadWorker = createFakeWorker()
297+
298+
fakeCluster.workers = {
299+
[worker.id]: worker,
300+
[deadWorker.id]: deadWorker,
301+
}
302+
303+
app = new App(fakeProcess, fakeCluster, settingsStub)
304+
})
305+
306+
it('does not restart worker on clean exit (code 0)', () => {
307+
fakeCluster.emit('exit', deadWorker, 0, '')
308+
309+
// No restart scheduled
310+
expect(fakeCluster.fork).not.to.have.been.called
311+
})
312+
313+
it('does not restart worker on SIGINT signal', () => {
314+
fakeCluster.emit('exit', deadWorker, null, 'SIGINT')
315+
316+
expect(fakeCluster.fork).not.to.have.been.called
317+
})
318+
319+
it('schedules worker restart on unexpected exit', () => {
320+
// When a worker exits unexpectedly, the app schedules a restart
321+
// We verify that exit handling doesn't throw
322+
expect(() => {
323+
fakeCluster.emit('exit', deadWorker, 1, '')
324+
}).not.to.throw()
325+
})
326+
})
327+
328+
describe('onExit', () => {
329+
beforeEach(() => {
330+
app = new App(fakeProcess, fakeCluster, settingsStub)
331+
})
332+
333+
it('closes watchers and exits process with code 0', () => {
334+
app.run()
335+
fakeProcess.emit('SIGTERM')
336+
337+
expect(fakeProcess.exit).to.have.been.calledOnceWithExactly(0)
338+
})
339+
})
340+
341+
describe('close', () => {
342+
beforeEach(() => {
343+
app = new App(fakeProcess, fakeCluster, settingsStub)
344+
})
345+
346+
it('closes all file watchers', () => {
347+
const fakeWatcher1 = { close: sandbox.stub() }
348+
const fakeWatcher2 = { close: sandbox.stub() }
349+
watchSettingsStub.returns([fakeWatcher1, fakeWatcher2])
350+
351+
app.run()
352+
app.close()
353+
354+
expect(fakeWatcher1.close).to.have.been.called
355+
expect(fakeWatcher2.close).to.have.been.called
356+
})
357+
358+
it('invokes the callback', () => {
359+
const callback = sandbox.stub()
360+
361+
app.close(callback)
362+
363+
expect(callback).to.have.been.calledOnce
364+
})
365+
366+
it('does not throw when called without watchers', () => {
367+
watchSettingsStub.returns([])
368+
369+
expect(() => app.close()).not.to.throw()
370+
})
371+
372+
it('handles undefined watchers gracefully', () => {
373+
expect(() => app.close()).not.to.throw()
374+
})
375+
})
376+
})

0 commit comments

Comments
 (0)