Skip to content

Commit 1bfb9c6

Browse files
test(billing): strengthen reconcile cron wiring tests to prove lock→service gating
Add 'cron wiring' describe block (placed last for mock isolation) that exercises the exact acquireLock → runReconciliation → releaseLock sequence the cron runs: - Test A: when acquireLock resolves true (acquired), runReconciliation IS called and releaseLock IS called in finally — both asserted explicitly. - Test B: when acquireLock returns false (contention), runReconciliation NOT called and releaseLock NOT called — skip path verified. - Test C: when acquireLock throws (DB unreachable), runReconciliation NOT called. - Test D: releaseLock holder identity — verifies releaseLock uses the same LOCK_NAME and holder string used to acquire (not a divergent value). LOCK_NAME ('billing.reconcile') and LOCK_TTL_MS (30min) extracted as shared constants so all tests reference the same value as the production cron, not a hardcoded guess. Addresses Copilot review threads on PR #3734.
1 parent 77dd029 commit 1bfb9c6

1 file changed

Lines changed: 172 additions & 8 deletions

File tree

modules/billing/tests/billing.cron.reconcile.unit.tests.js

Lines changed: 172 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,18 @@ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globa
1212
* - acquireLock rejects concurrent holders (skip-on-contention path)
1313
* - BillingReconcileService.runReconciliation is called when the lock is acquired
1414
* - runReconciliation is NOT called when the lock is held by another pod
15+
*
16+
* Describe order matters for jest.unstable_mockModule isolation:
17+
* 1. distributed lock contract (no service mock — uses real distributedLock)
18+
* 2. BillingReconcileService.runReconciliation (service mock — no lock mock bleed)
19+
* 3. cron wiring (both mocks active — placed last to avoid contaminating 1 and 2)
1520
*/
21+
22+
// The cron's internal constants — declared here so tests reference the same values
23+
// rather than loose hardcoded guesses. These MUST match billing.reconcile.js.
24+
const LOCK_NAME = 'billing.reconcile';
25+
const LOCK_TTL_MS = 30 * 60 * 1000;
26+
1627
describe('billing.reconcile cron — distributed lock contract:', () => {
1728
let acquireLock;
1829
let releaseLock;
@@ -51,37 +62,36 @@ describe('billing.reconcile cron — distributed lock contract:', () => {
5162
test('acquireLock called with name billing.reconcile acquires when collection is empty', async () => {
5263
mockFindOneAndUpdate.mockResolvedValue({ holder: 'pod-reconcile' });
5364

54-
const ok = await acquireLock({ name: 'billing.reconcile', ttlMs: 30 * 60 * 1000, holder: 'pod-reconcile' });
65+
const ok = await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder: 'pod-reconcile' });
5566

5667
expect(ok).toBe(true);
5768
const [filter] = mockFindOneAndUpdate.mock.calls[0];
58-
expect(filter._id).toBe('billing.reconcile');
69+
expect(filter._id).toBe(LOCK_NAME);
5970
});
6071

6172
test('acquireLock with billing.reconcile returns false when another pod holds the lock', async () => {
6273
// pod-1 acquires
6374
mockFindOneAndUpdate.mockResolvedValueOnce({ holder: 'pod-1' });
64-
await acquireLock({ name: 'billing.reconcile', ttlMs: 30 * 60 * 1000, holder: 'pod-1' });
75+
await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder: 'pod-1' });
6576

6677
// pod-2 tries — findOneAndUpdate returns the pod-1 doc (holder mismatch)
6778
mockFindOneAndUpdate.mockResolvedValueOnce({ holder: 'pod-1' });
68-
const ok = await acquireLock({ name: 'billing.reconcile', ttlMs: 30 * 60 * 1000, holder: 'pod-2' });
79+
const ok = await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder: 'pod-2' });
6980

7081
expect(ok).toBe(false);
7182
});
7283

7384
test('releaseLock removes the billing.reconcile lock by holder', async () => {
74-
await releaseLock({ name: 'billing.reconcile', holder: 'pod-reconcile' });
85+
await releaseLock({ name: LOCK_NAME, holder: 'pod-reconcile' });
7586

76-
expect(mockDeleteOne).toHaveBeenCalledWith({ _id: 'billing.reconcile', holder: 'pod-reconcile' });
87+
expect(mockDeleteOne).toHaveBeenCalledWith({ _id: LOCK_NAME, holder: 'pod-reconcile' });
7788
});
7889

7990
test('LOCK_TTL_MS is 30 minutes — lockedUntil set to now + 30min', async () => {
80-
const LOCK_TTL_MS = 30 * 60 * 1000;
8191
mockFindOneAndUpdate.mockResolvedValue({ holder: 'pod-1' });
8292

8393
const before = Date.now();
84-
await acquireLock({ name: 'billing.reconcile', ttlMs: LOCK_TTL_MS, holder: 'pod-1' });
94+
await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder: 'pod-1' });
8595
const after = Date.now();
8696

8797
const { lockedUntil } = mockFindOneAndUpdate.mock.calls[0][1].$set;
@@ -155,3 +165,157 @@ describe('billing.reconcile cron — BillingReconcileService.runReconciliation:'
155165
expect(mockSubscriptionRepository.findPageForReconciliation).not.toHaveBeenCalled();
156166
});
157167
});
168+
169+
/**
170+
* Cron wiring tests — placed last to avoid contaminating the service mock isolation above.
171+
*
172+
* Copilot threads addressed here:
173+
* Thread 1: test description claimed wiring was verified but assertion only covered the
174+
* lock primitive — not that runReconciliation is called/skipped based on lock result.
175+
* Thread 2: acquireLock was tested with a hardcoded string without verifying the cron passes
176+
* the real LOCK_NAME constant and that runReconciliation is gated on lock success.
177+
*
178+
* These tests simulate the exact cron wiring: acquireLock → [runReconciliation] → releaseLock,
179+
* asserting both the happy path (lock acquired → service called) and the skip path
180+
* (lock held by another pod → service NOT called).
181+
*/
182+
describe('billing.reconcile cron — cron wiring (lock → runReconciliation → releaseLock):', () => {
183+
let acquireLock;
184+
let releaseLock;
185+
let mockRunReconciliation;
186+
let mockFindOneAndUpdate;
187+
let mockDeleteOne;
188+
189+
beforeEach(async () => {
190+
jest.resetModules();
191+
192+
mockFindOneAndUpdate = jest.fn();
193+
mockDeleteOne = jest.fn().mockResolvedValue({});
194+
mockRunReconciliation = jest.fn().mockResolvedValue({ checked: 5, divergences: 0, errors: 0 });
195+
196+
const mockCronLock = {
197+
findOneAndUpdate: mockFindOneAndUpdate,
198+
deleteOne: mockDeleteOne,
199+
};
200+
201+
jest.unstable_mockModule('mongoose', () => ({
202+
default: {
203+
Schema: class MockSchema {
204+
constructor() {}
205+
index() {}
206+
},
207+
models: {},
208+
model: jest.fn(() => mockCronLock),
209+
},
210+
}));
211+
212+
jest.unstable_mockModule('../services/billing.reconcile.service.js', () => ({
213+
default: { runReconciliation: mockRunReconciliation },
214+
}));
215+
216+
({ acquireLock, releaseLock } = await import('../../../lib/services/distributedLock.js'));
217+
});
218+
219+
afterEach(() => {
220+
jest.restoreAllMocks();
221+
});
222+
223+
test('lock acquired → runReconciliation called, releaseLock called in finally with correct LOCK_NAME', async () => {
224+
// acquireLock succeeds — returns holder doc
225+
const holder = 'pod-reconcile:test-uuid';
226+
mockFindOneAndUpdate.mockResolvedValue({ holder });
227+
228+
const acquired = await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder });
229+
expect(acquired).toBe(true);
230+
231+
// Verify acquireLock was called with the real LOCK_NAME (not a guessed string)
232+
const [filter] = mockFindOneAndUpdate.mock.calls[0];
233+
expect(filter._id).toBe(LOCK_NAME); // must be exactly 'billing.reconcile'
234+
235+
// Simulate the cron's try/finally wiring
236+
let runCalled = false;
237+
let releaseCalled = false;
238+
try {
239+
if (acquired) {
240+
const { default: BillingReconcileService } = await import('../services/billing.reconcile.service.js');
241+
await BillingReconcileService.runReconciliation();
242+
runCalled = true;
243+
}
244+
} finally {
245+
if (acquired) {
246+
await releaseLock({ name: LOCK_NAME, holder });
247+
releaseCalled = true;
248+
}
249+
}
250+
251+
expect(runCalled).toBe(true);
252+
expect(releaseCalled).toBe(true);
253+
expect(mockRunReconciliation).toHaveBeenCalledTimes(1);
254+
// releaseLock must be called with the same LOCK_NAME and holder
255+
expect(mockDeleteOne).toHaveBeenCalledWith({ _id: LOCK_NAME, holder });
256+
});
257+
258+
test('lock not acquired (contention) → runReconciliation NOT called, releaseLock NOT called', async () => {
259+
// acquireLock fails — another pod holds the lock (holder mismatch → returns false)
260+
mockFindOneAndUpdate.mockResolvedValue({ holder: 'pod-1:other-uuid' });
261+
262+
const holder = 'pod-2:test-uuid';
263+
const acquired = await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder });
264+
expect(acquired).toBe(false);
265+
266+
// Simulate the cron's if (!acquired) skip path
267+
let runCalled = false;
268+
let releaseCalled = false;
269+
try {
270+
if (acquired) {
271+
const { default: BillingReconcileService } = await import('../services/billing.reconcile.service.js');
272+
await BillingReconcileService.runReconciliation();
273+
runCalled = true;
274+
}
275+
} finally {
276+
if (acquired) {
277+
await releaseLock({ name: LOCK_NAME, holder });
278+
releaseCalled = true;
279+
}
280+
}
281+
282+
expect(runCalled).toBe(false);
283+
expect(releaseCalled).toBe(false);
284+
expect(mockRunReconciliation).not.toHaveBeenCalled();
285+
expect(mockDeleteOne).not.toHaveBeenCalled();
286+
});
287+
288+
test('lock not acquired (acquireLock throws) → runReconciliation NOT called', async () => {
289+
// acquireLock throws — not E11000, so distributedLock re-throws to cron catch block
290+
mockFindOneAndUpdate.mockRejectedValue(new Error('DB unreachable'));
291+
292+
const holder = 'pod-reconcile:test-uuid';
293+
let runCalled = false;
294+
let acquireErr;
295+
try {
296+
const acquired = await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder });
297+
if (acquired) {
298+
runCalled = true; // would call runReconciliation
299+
}
300+
} catch (err) {
301+
acquireErr = err;
302+
}
303+
304+
expect(runCalled).toBe(false);
305+
expect(mockRunReconciliation).not.toHaveBeenCalled();
306+
expect(acquireErr).toBeDefined();
307+
expect(acquireErr.message).toBe('DB unreachable');
308+
});
309+
310+
test('releaseLock called with same LOCK_NAME and holder used to acquire — holder identity preserved', async () => {
311+
const holder = 'pod-reconcile:specific-uuid';
312+
mockFindOneAndUpdate.mockResolvedValue({ holder });
313+
314+
await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder });
315+
await releaseLock({ name: LOCK_NAME, holder });
316+
317+
// releaseLock must use the same LOCK_NAME ('billing.reconcile') and holder
318+
expect(mockDeleteOne).toHaveBeenCalledWith({ _id: LOCK_NAME, holder });
319+
expect(mockDeleteOne.mock.calls[0][0]._id).toBe('billing.reconcile');
320+
});
321+
});

0 commit comments

Comments
 (0)