Skip to content

Commit 02e6a2b

Browse files
committed
feat(dashboard): Added New test cases for collector services and updated polling services
1 parent 65f23a0 commit 02e6a2b

5 files changed

Lines changed: 208 additions & 16 deletions

File tree

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
"pretest:unit": "mkdir -p .test-reports/unit",
4141
"test:unit": "mocha 'test/**/*.spec.ts'",
4242
"test:unit:dashboard": "mocha 'test/unit/dashboard-service/**/*.spec.ts'",
43-
"test:e2e:dashboard:ws": "node scripts/check_dashboard_ws_updates.js",
4443
"test:unit:watch": "npm run test:unit -- --min --watch --watch-files src/**/*,test/**/*",
4544
"cover:unit": "nyc --report-dir .coverage/unit npm run test:unit",
4645
"docker:build": "docker build -t nostream .",

src/dashboard-service/polling/polling-scheduler.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,66 @@ type Tick = () => Promise<void> | void
44

55
const debug = createLogger('dashboard-service:polling')
66

7+
/**
8+
* Runs a tick callback on a fixed cadence, but — unlike setInterval — never
9+
* overlaps: the next tick is only scheduled *after* the current one resolves
10+
* or rejects. This prevents DB query storms when a poll takes longer than the
11+
* configured interval.
12+
*/
713
export class PollingScheduler {
8-
private timer: NodeJS.Timer | undefined
14+
private timer: NodeJS.Timeout | undefined
15+
private running = false
916

1017
public constructor(
1118
private readonly intervalMs: number,
1219
private readonly tick: Tick,
1320
) { }
1421

1522
public start(): void {
16-
if (this.timer) {
23+
if (this.running) {
1724
return
1825
}
1926

27+
this.running = true
2028
debug('starting scheduler with interval %d ms', this.intervalMs)
21-
22-
this.timer = setInterval(() => {
23-
Promise.resolve(this.tick())
24-
.catch((error) => {
25-
console.error('dashboard-service: polling tick failed', error)
26-
})
27-
}, this.intervalMs)
29+
this.scheduleNext()
2830
}
2931

3032
public stop(): void {
31-
if (!this.timer) {
33+
if (!this.running) {
3234
return
3335
}
3436

3537
debug('stopping scheduler')
36-
clearInterval(this.timer)
37-
this.timer = undefined
38+
this.running = false
39+
40+
if (this.timer) {
41+
clearTimeout(this.timer)
42+
this.timer = undefined
43+
}
3844
}
3945

4046
public isRunning(): boolean {
41-
return Boolean(this.timer)
47+
return this.running
48+
}
49+
50+
private scheduleNext(): void {
51+
if (!this.running) {
52+
return
53+
}
54+
55+
this.timer = setTimeout(() => {
56+
this.timer = undefined
57+
58+
Promise.resolve(this.tick())
59+
.catch((error) => {
60+
console.error('dashboard-service: polling tick failed', error)
61+
})
62+
.finally(() => {
63+
// Schedule the next tick only after the current one completes,
64+
// regardless of success or failure.
65+
this.scheduleNext()
66+
})
67+
}, this.intervalMs)
4268
}
4369
}

test/unit/dashboard-service/app.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ describe('dashboard-service app', () => {
1010
port: 0,
1111
wsPath: '/api/v1/kpis/stream',
1212
pollIntervalMs: 1000,
13+
useDummyData: true,
14+
collectorMode: 'full',
1315
})
1416

1517
await service.start()

test/unit/dashboard-service/polling-scheduler.spec.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ describe('PollingScheduler', () => {
1414
clock.restore()
1515
})
1616

17+
/**
18+
* The scheduler uses recursive setTimeout (not setInterval), so each tick
19+
* is only enqueued after the previous one resolves. With instant-resolving
20+
* stubs the sequence is:
21+
* T=0 start() → schedules tick at T=1000
22+
* T=1000 tick #1 resolves → schedules tick at T=2000
23+
* T=2000 tick #2 resolves → schedules tick at T=3000
24+
* T=3000 tick #3 resolves → schedules tick at T=4000
25+
* tickAsync drives the microtask queue between timer firings, so all three
26+
* ticks complete inside tickAsync(3000).
27+
*/
1728
it('runs tick callback on interval while running', async () => {
1829
const tick = Sinon.stub().resolves(undefined)
1930
const scheduler = new PollingScheduler(1000, tick)
@@ -30,10 +41,57 @@ describe('PollingScheduler', () => {
3041
const scheduler = new PollingScheduler(500, tick)
3142

3243
scheduler.start()
33-
await clock.tickAsync(1000)
44+
await clock.tickAsync(1000) // ticks at 500ms, 1000ms → 2 calls
3445
scheduler.stop()
35-
await clock.tickAsync(1000)
46+
await clock.tickAsync(1000) // no more ticks after stop
3647

3748
expect(tick.callCount).to.equal(2)
3849
})
50+
51+
it('does not overlap ticks when callback is slow', async () => {
52+
// Tick takes 1500ms — longer than the 1000ms interval.
53+
// With setInterval this would cause overlap; with recursive setTimeout it must not.
54+
let running = 0
55+
let maxConcurrent = 0
56+
57+
const tick = Sinon.stub().callsFake(async () => {
58+
running++
59+
maxConcurrent = Math.max(maxConcurrent, running)
60+
await clock.tickAsync(1500)
61+
running--
62+
})
63+
64+
const scheduler = new PollingScheduler(1000, tick)
65+
scheduler.start()
66+
// Drive enough time for two potential overlapping cycles
67+
await clock.tickAsync(4000)
68+
scheduler.stop()
69+
70+
expect(maxConcurrent).to.equal(1, 'ticks must not run concurrently')
71+
})
72+
73+
it('continues scheduling after a failed tick', async () => {
74+
const tick = Sinon.stub()
75+
.onFirstCall().rejects(new Error('transient error'))
76+
.resolves(undefined)
77+
78+
const scheduler = new PollingScheduler(1000, tick)
79+
scheduler.start()
80+
await clock.tickAsync(3000)
81+
scheduler.stop()
82+
83+
// First tick rejects, but the scheduler must recover and run two more.
84+
expect(tick.callCount).to.be.greaterThanOrEqual(2)
85+
})
86+
87+
it('isRunning reflects scheduler state', () => {
88+
const tick = Sinon.stub().resolves(undefined)
89+
const scheduler = new PollingScheduler(1000, tick)
90+
91+
expect(scheduler.isRunning()).to.equal(false)
92+
scheduler.start()
93+
expect(scheduler.isRunning()).to.equal(true)
94+
scheduler.stop()
95+
expect(scheduler.isRunning()).to.equal(false)
96+
})
3997
})
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import chai, { expect } from 'chai'
2+
import chaiAsPromised from 'chai-as-promised'
3+
import Sinon from 'sinon'
4+
5+
import { IKPICollector, SnapshotService } from '../../../src/dashboard-service/services/snapshot-service'
6+
import { DashboardMetrics } from '../../../src/dashboard-service/types'
7+
8+
chai.use(chaiAsPromised)
9+
10+
const createMetrics = (overrides: Partial<DashboardMetrics> = {}): DashboardMetrics => ({
11+
eventsByKind: [],
12+
admittedUsers: 0,
13+
satsPaid: 0,
14+
topTalkers: {
15+
allTime: [],
16+
recent: [],
17+
},
18+
...overrides,
19+
})
20+
21+
const makeCollector = (stub: Sinon.SinonStub): IKPICollector => ({
22+
collectMetrics: stub,
23+
})
24+
25+
describe('SnapshotService', () => {
26+
let sandbox: Sinon.SinonSandbox
27+
28+
beforeEach(() => {
29+
sandbox = Sinon.createSandbox()
30+
})
31+
32+
afterEach(() => {
33+
sandbox.restore()
34+
})
35+
36+
it('updates snapshot when collected metrics change', async () => {
37+
const firstMetrics = createMetrics({ admittedUsers: 1 })
38+
const nextMetrics = createMetrics({ admittedUsers: 2 })
39+
40+
const stub = sandbox.stub()
41+
.onFirstCall().resolves(firstMetrics)
42+
.onSecondCall().resolves(firstMetrics)
43+
.onThirdCall().resolves(nextMetrics)
44+
45+
const service = new SnapshotService(makeCollector(stub))
46+
47+
const first = await service.refresh()
48+
expect(first.changed).to.equal(true, 'first refresh should report changed')
49+
expect(first.snapshot.sequence).to.equal(1)
50+
expect(first.snapshot.status).to.equal('live')
51+
52+
const second = await service.refresh()
53+
expect(second.changed).to.equal(false, 'second refresh with same metrics should not change')
54+
expect(second.snapshot.sequence).to.equal(1, 'sequence must not advance when metrics are unchanged')
55+
56+
const third = await service.refresh()
57+
expect(third.changed).to.equal(true, 'third refresh with new metrics should report changed')
58+
expect(third.snapshot.sequence).to.equal(2)
59+
expect(third.snapshot.metrics.admittedUsers).to.equal(2)
60+
})
61+
62+
it('does not advance sequence when metrics are identical across refreshes', async () => {
63+
const metrics = createMetrics({ satsPaid: 100 })
64+
const stub = sandbox.stub().resolves(metrics)
65+
66+
const service = new SnapshotService(makeCollector(stub))
67+
68+
const first = await service.refresh()
69+
expect(first.changed).to.equal(true)
70+
expect(first.snapshot.sequence).to.equal(1)
71+
72+
const second = await service.refresh()
73+
expect(second.changed).to.equal(false)
74+
expect(second.snapshot.sequence).to.equal(1)
75+
})
76+
77+
it('propagates collector errors to the caller', async () => {
78+
const stub = sandbox.stub().rejects(new Error('db down'))
79+
80+
const service = new SnapshotService(makeCollector(stub))
81+
82+
await expect(service.refresh()).to.be.rejectedWith('db down')
83+
})
84+
85+
it('returns the last known snapshot via getSnapshot()', async () => {
86+
const metrics = createMetrics({ admittedUsers: 5 })
87+
const stub = sandbox.stub().resolves(metrics)
88+
89+
const service = new SnapshotService(makeCollector(stub))
90+
91+
await service.refresh()
92+
93+
const snap = service.getSnapshot()
94+
expect(snap.sequence).to.equal(1)
95+
expect(snap.status).to.equal('live')
96+
expect(snap.metrics.admittedUsers).to.equal(5)
97+
})
98+
99+
it('sets status to live after a successful refresh', async () => {
100+
const stub = sandbox.stub().resolves(createMetrics())
101+
102+
const service = new SnapshotService(makeCollector(stub))
103+
104+
const { snapshot } = await service.refresh()
105+
expect(snapshot.status).to.equal('live')
106+
})
107+
})

0 commit comments

Comments
 (0)