Skip to content

Commit 73b8234

Browse files
authored
fix(whatsapp): share one business-account credential across its phone numbers (#1120)
1 parent 457a9c5 commit 73b8234

51 files changed

Lines changed: 45411 additions & 263 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/builder/__tests__/ads-account-switcher.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,16 @@ const integrations = [
7373
name: "Primary",
7474
displayPhoneNumber: "+12025550101",
7575
hasCapiScope: true,
76+
isCoexist: false,
7677
},
7778
{
7879
id: "iw-2",
7980
name: "Secondary",
8081
displayPhoneNumber: "+12025550102",
8182
hasCapiScope: true,
83+
// A WhatsApp Business app number, so its reconnect must ask Meta for the
84+
// coexistence screen — the only place such an account is listed.
85+
isCoexist: true,
8286
},
8387
]
8488

apps/builder/__tests__/disconnect-whatsapp-action.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => {
2525
inboxDisconnect: vi.fn().mockResolvedValue(undefined),
2626
isRevokedTokenError: vi.fn(() => false),
2727
metaCapiDeleteByIntegration: vi.fn().mockResolvedValue(undefined),
28+
deleteWabaIfOrphaned: vi.fn().mockResolvedValue(false),
2829
tx,
2930
txChain,
3031
whatsappDisconnect: vi.fn().mockResolvedValue(undefined),
@@ -38,7 +39,12 @@ const mocks = vi.hoisted(() => {
3839
// require booting the real business/database module graph).
3940
const integrationWhatsappServiceDisconnect = vi.fn(
4041
async (props: {
41-
integrationWhatsapp: { id: string; inboxId: string; phoneNumberId: string }
42+
integrationWhatsapp: {
43+
id: string
44+
inboxId: string
45+
phoneNumberId: string
46+
wabaId: string
47+
}
4248
ownerId: string
4349
workspaceId: string
4450
tx: typeof mocks.tx
@@ -68,6 +74,11 @@ const integrationWhatsappServiceDisconnect = vi.fn(
6874
props.tx,
6975
)
7076
tx.delete({ id: "whatsappId" })
77+
await mocks.deleteWabaIfOrphaned({
78+
workspaceId: props.workspaceId,
79+
wabaId: props.integrationWhatsapp.wabaId,
80+
tx: props.tx,
81+
})
7182
await mocks.inboxDisconnect({
7283
inboxId: props.integrationWhatsapp.inboxId,
7384
ownerId: props.ownerId,
@@ -83,6 +94,9 @@ vi.mock("@chatbotx.io/business", () => ({
8394
disconnect: integrationWhatsappServiceDisconnect,
8495
},
8596
workspaceService: { findById: mocks.workspaceFindById },
97+
whatsappBusinessAccountService: {
98+
deleteIfOrphaned: mocks.deleteWabaIfOrphaned,
99+
},
86100
}))
87101

88102
vi.mock("@chatbotx.io/database/client", () => ({
@@ -126,6 +140,7 @@ const integrationWhatsappRow = {
126140
auth: { clientId: "client-1" },
127141
inboxId: "inbox-1",
128142
phoneNumberId: "phone-1",
143+
wabaId: "waba-1",
129144
}
130145

131146
describe("disconnectWhatsappAction", () => {
@@ -164,6 +179,11 @@ describe("disconnectWhatsappAction", () => {
164179
reason: "manual",
165180
tx: mocks.tx,
166181
})
182+
expect(mocks.deleteWabaIfOrphaned).toHaveBeenCalledWith({
183+
workspaceId: "workspace-1",
184+
wabaId: "waba-1",
185+
tx: mocks.tx,
186+
})
167187
})
168188

169189
// A WhatsApp coexist run parked in `waiting` survived disconnect,

apps/builder/__tests__/whatsapp-automatic-events-card.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const baseIntegration = {
3232
displayPhoneNumber: "84339426550",
3333
wabaId: "1303031825154214",
3434
hasCapiScope: true,
35+
isCoexist: false,
3536
}
3637

3738
const whatsappCredentialPublic: WhatsappCredentialPublic = {

apps/builder/__tests__/whatsapp-connect-action.direct.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ vi.mock("@chatbotx.io/business", () => ({
7575
platformCredentialService: {
7676
resolveForOwner: mocks.platformCredentialResolveMock,
7777
},
78+
whatsappBusinessAccountService: {
79+
findByWaba: mocks.findWabaRecordMock,
80+
markProvisioned: mocks.markWabaProvisionedMock,
81+
upsertCurrentCredential: mocks.upsertWabaCredentialMock,
82+
},
7883
workspaceMemberService: {
7984
isMember: mocks.isMemberMock,
8085
},
@@ -94,6 +99,10 @@ vi.mock("@chatbotx.io/integration-whatsapp/api/auth", () => ({
9499
appAccessToken: (settings: { clientId: string; clientSecret: string }) =>
95100
`${settings.clientId}|${settings.clientSecret}`,
96101
debugToken: mocks.debugTokenMock,
102+
// `getWhatsappGrantedScopes` reads the grant through this one, and
103+
// `persistConnectedWaba` swallows its own failures — leaving it off the mock
104+
// silently skipped the WABA record write instead of failing the test.
105+
debugTokenOrThrow: mocks.debugTokenMock,
97106
exchangeAccessToken: mocks.exchangeAccessTokenMock,
98107
}))
99108

@@ -125,6 +134,7 @@ vi.mock("@chatbotx.io/integration-whatsapp/api/webhook", () => ({
125134
}))
126135

127136
vi.mock("@chatbotx.io/redis", () => ({
137+
distributedLock: { runExclusive: mocks.distributedLockRunExclusiveMock },
128138
invalidateCacheByTags: mocks.invalidateCacheByTagsMock,
129139
}))
130140

apps/builder/__tests__/whatsapp-connect-action.follow-ups.test.ts

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

120129
vi.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 () => {

apps/builder/__tests__/whatsapp-connect-action.mocks.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const mocks = {
4646
findActiveSignupSessionForUserMock: vi.fn(),
4747
createIdMock: vi.fn(),
4848
debugTokenMock: vi.fn(),
49+
distributedLockRunExclusiveMock: vi.fn(),
4950
exchangeAccessTokenMock: vi.fn(),
5051
findConnectedPhoneNumberIdsMock: vi.fn(),
5152
findWabaMock: vi.fn(),
@@ -65,6 +66,9 @@ export const mocks = {
6566
resolveProviderOriginMock: vi.fn(),
6667
shareCreditLineMock: vi.fn(),
6768
subscribeWebhookMock: vi.fn(),
69+
findWabaRecordMock: vi.fn(),
70+
markWabaProvisionedMock: vi.fn(),
71+
upsertWabaCredentialMock: vi.fn(),
6872
updateAuthMock: vi.fn(),
6973
updateWorkspaceLogoMock: vi.fn(),
7074
workspaceFindMock: vi.fn(),
@@ -204,9 +208,21 @@ export function resetWhatsappConnectActionMocks() {
204208
mocks.buildContextMock.mockResolvedValue({})
205209
mocks.updateWorkspaceLogoMock.mockResolvedValue(undefined)
206210
mocks.subscribeWebhookMock.mockResolvedValue(undefined)
211+
mocks.findWabaRecordMock.mockResolvedValue(null)
212+
mocks.upsertWabaCredentialMock.mockResolvedValue({
213+
id: "waba-row",
214+
revision: 1,
215+
})
216+
mocks.markWabaProvisionedMock.mockResolvedValue({
217+
id: "waba-row",
218+
revision: 2,
219+
})
207220
mocks.updateAuthMock.mockResolvedValue(undefined)
208221
mocks.invalidateCacheByTagsMock.mockResolvedValue(undefined)
209222
mocks.debugTokenMock.mockResolvedValue({ app_id: "app-123", is_valid: true })
223+
mocks.distributedLockRunExclusiveMock.mockImplementation(
224+
async ({ fn }: { fn: () => Promise<unknown> }) => await fn(),
225+
)
210226

211227
mocks.connectPhoneNumberMock.mockResolvedValue({
212228
workspaceId: "ws-1",

apps/builder/__tests__/whatsapp-connect-action.session.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ vi.mock("@chatbotx.io/business", () => ({
7878
platformCredentialService: {
7979
resolveForOwner: mocks.platformCredentialResolveMock,
8080
},
81+
whatsappBusinessAccountService: {
82+
findByWaba: mocks.findWabaRecordMock,
83+
markProvisioned: mocks.markWabaProvisionedMock,
84+
upsertCurrentCredential: mocks.upsertWabaCredentialMock,
85+
},
8186
workspaceMemberService: {
8287
isMember: mocks.isMemberMock,
8388
},
@@ -97,6 +102,10 @@ vi.mock("@chatbotx.io/integration-whatsapp/api/auth", () => ({
97102
appAccessToken: (settings: { clientId: string; clientSecret: string }) =>
98103
`${settings.clientId}|${settings.clientSecret}`,
99104
debugToken: mocks.debugTokenMock,
105+
// `getWhatsappGrantedScopes` reads the grant through this one, and
106+
// `persistConnectedWaba` swallows its own failures — leaving it off the mock
107+
// silently skipped the WABA record write instead of failing the test.
108+
debugTokenOrThrow: mocks.debugTokenMock,
100109
exchangeAccessToken: mocks.exchangeAccessTokenMock,
101110
}))
102111

@@ -124,6 +133,7 @@ vi.mock("@chatbotx.io/integration-whatsapp/api/webhook", () => ({
124133
}))
125134

126135
vi.mock("@chatbotx.io/redis", () => ({
136+
distributedLock: { runExclusive: mocks.distributedLockRunExclusiveMock },
127137
invalidateCacheByTags: mocks.invalidateCacheByTagsMock,
128138
}))
129139

0 commit comments

Comments
 (0)