@@ -72,6 +72,11 @@ vi.mock("@chatbotx.io/business", () => ({
7272 platformCredentialService : {
7373 resolveForOwner : mocks . platformCredentialResolveMock ,
7474 } ,
75+ whatsappBusinessAccountService : {
76+ findByWaba : mocks . findWabaRecordMock ,
77+ markProvisioned : mocks . markWabaProvisionedMock ,
78+ upsertCurrentCredential : mocks . upsertWabaCredentialMock ,
79+ } ,
7580 workspaceMemberService : {
7681 isMember : mocks . isMemberMock ,
7782 } ,
@@ -91,6 +96,10 @@ vi.mock("@chatbotx.io/integration-whatsapp/api/auth", () => ({
9196 appAccessToken : ( settings : { clientId : string ; clientSecret : string } ) =>
9297 `${ settings . clientId } |${ settings . clientSecret } ` ,
9398 debugToken : mocks . debugTokenMock ,
99+ // `getWhatsappGrantedScopes` reads the grant through this one, and
100+ // `persistConnectedWaba` swallows its own failures — leaving it off the mock
101+ // silently skipped the WABA record write instead of failing the test.
102+ debugTokenOrThrow : mocks . debugTokenMock ,
94103 exchangeAccessToken : mocks . exchangeAccessTokenMock ,
95104} ) )
96105
@@ -118,6 +127,7 @@ vi.mock("@chatbotx.io/integration-whatsapp/api/webhook", () => ({
118127} ) )
119128
120129vi . mock ( "@chatbotx.io/redis" , ( ) => ( {
130+ distributedLock : { runExclusive : mocks . distributedLockRunExclusiveMock } ,
121131 invalidateCacheByTags : mocks . invalidateCacheByTagsMock ,
122132} ) )
123133
@@ -140,7 +150,89 @@ describe("connectWhatsappAction — follow-ups and unhandled failures", () => {
140150 } )
141151
142152 describe ( "WABA pre-work" , ( ) => {
143- test ( "runs before persist and is idempotent across two requests" , async ( ) => {
153+ test ( "serializes concurrent first connects so WABA provisioning runs once" , async ( ) => {
154+ const secondPhoneNumber = { ...selectedPhoneNumber , id : "phone-2" }
155+ let waba : { provisionedAt : Date | null ; revision : number } | null = null
156+ let sessionWorkspaceId : string | null = null
157+ let tail = Promise . resolve ( )
158+ mocks . platformCredentialResolveMock . mockResolvedValue ( {
159+ config : {
160+ clientId : "client-1" ,
161+ clientSecret : "secret-1" ,
162+ configId : "config-1" ,
163+ systemUserId : "system-user-1" ,
164+ systemUserToken : "system-token-1" ,
165+ businessName : "Business" ,
166+ verifyToken : "verify-token" ,
167+ version : "v23.0" ,
168+ businessId : "credit-line-owner-1" ,
169+ } ,
170+ } )
171+ mocks . findActiveSignupSessionForUserMock . mockImplementation ( async ( ) => ( {
172+ ...defaultSession ,
173+ workspaceId : sessionWorkspaceId ,
174+ candidatePhoneNumberIds : [ selectedPhoneNumber . id , secondPhoneNumber . id ] ,
175+ } ) )
176+ mocks . connectPhoneNumberMock . mockImplementation ( ( ) => {
177+ sessionWorkspaceId = "ws-1"
178+ return {
179+ workspaceId : "ws-1" ,
180+ createdWorkspace : false ,
181+ integrationRow,
182+ wasCreated : true ,
183+ }
184+ } )
185+ mocks . findWabaRecordMock . mockImplementation ( async ( ) => waba )
186+ mocks . upsertWabaCredentialMock . mockImplementation ( ( ) => {
187+ waba ??= { provisionedAt : null , revision : 1 }
188+ return waba
189+ } )
190+ mocks . markWabaProvisionedMock . mockImplementation ( ( ) => {
191+ waba = { provisionedAt : new Date ( ) , revision : 2 }
192+ return waba
193+ } )
194+ mocks . distributedLockRunExclusiveMock . mockImplementation (
195+ async ( { fn } : { fn : ( ) => Promise < unknown > } ) => {
196+ const previous = tail
197+ let release : ( ( ) => void ) | undefined
198+ tail = new Promise < void > ( ( resolve ) => {
199+ release = resolve
200+ } )
201+ await previous
202+ try {
203+ return await fn ( )
204+ } finally {
205+ release ?.( )
206+ }
207+ } ,
208+ )
209+
210+ await Promise . all ( [
211+ callConnectWhatsappAction ( {
212+ ctx : { user : { id : "user-1" } } ,
213+ parsedInput : {
214+ ...BASE_INPUT ,
215+ phoneNumberId : selectedPhoneNumber . id ,
216+ signupSessionId : "signup-session-1" ,
217+ } ,
218+ } ) ,
219+ callConnectWhatsappAction ( {
220+ ctx : { user : { id : "user-1" } } ,
221+ parsedInput : {
222+ ...BASE_INPUT ,
223+ phoneNumberId : secondPhoneNumber . id ,
224+ signupSessionId : "signup-session-1" ,
225+ } ,
226+ } ) ,
227+ ] )
228+
229+ expect ( mocks . addSystemUserMock ) . toHaveBeenCalledTimes ( 1 )
230+ expect ( mocks . shareCreditLineMock ) . toHaveBeenCalledTimes ( 1 )
231+ expect ( mocks . subscribeWebhookMock ) . toHaveBeenCalledTimes ( 1 )
232+ expect ( mocks . markWabaProvisionedMock ) . toHaveBeenCalledTimes ( 1 )
233+ } )
234+
235+ test ( "skips provisioning for a second number when the WABA is provisioned" , async ( ) => {
144236 const secondPhoneNumber = { ...selectedPhoneNumber , id : "phone-2" }
145237 mocks . findActiveSignupSessionForUserMock . mockResolvedValue ( {
146238 ...defaultSession ,
@@ -187,6 +279,15 @@ describe("connectWhatsappAction — follow-ups and unhandled failures", () => {
187279
188280 mocks . addSystemUserMock . mockClear ( )
189281 mocks . subscribeWebhookMock . mockClear ( )
282+ mocks . findActiveSignupSessionForUserMock . mockResolvedValue ( {
283+ ...defaultSession ,
284+ workspaceId : "ws-1" ,
285+ candidatePhoneNumberIds : [ selectedPhoneNumber . id , secondPhoneNumber . id ] ,
286+ } )
287+ mocks . findWabaRecordMock . mockResolvedValue ( {
288+ id : "waba-row" ,
289+ provisionedAt : new Date ( "2026-09-08T00:00:00.000Z" ) ,
290+ } )
190291
191292 await callConnectWhatsappAction ( {
192293 ctx : { user : { id : "user-1" } } ,
@@ -197,13 +298,12 @@ describe("connectWhatsappAction — follow-ups and unhandled failures", () => {
197298 } ,
198299 } )
199300
200- // Same request shape runs the same idempotent calls again — no
201- // state carried between requests that would skip them.
202- expect ( mocks . addSystemUserMock ) . toHaveBeenCalledTimes ( 1 )
203- // Plain subscribeWebhook now also runs once from the manual follow-up
204- // path on OTHER requests, but this is the non-manual pre-work call —
205- // exactly one per request here too.
206- expect ( mocks . subscribeWebhookMock ) . toHaveBeenCalledTimes ( 1 )
301+ expect ( mocks . addSystemUserMock ) . not . toHaveBeenCalled ( )
302+ expect ( mocks . subscribeWebhookMock ) . not . toHaveBeenCalled ( )
303+ expect ( mocks . findWabaRecordMock ) . toHaveBeenLastCalledWith ( {
304+ workspaceId : "ws-1" ,
305+ wabaId : "waba-1" ,
306+ } )
207307 } )
208308
209309 test ( "manual connect never runs WABA-level pre-work" , async ( ) => {
0 commit comments