@@ -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+
1627describe ( '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