Files
accounted/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts
T
273af39994 fix(whatsapp): company question falls back to numbered text and ignores archived companies (#1992)
* fix(whatsapp): company question survives a Meta-rejected interactive send (#1589)

The linked multi-company sender in #1589 never heard back because Meta
rejected the reply-button payload synchronously (HTTP 400, #131009
"Duplicate button title"): the sender belongs to two companies with the
same name, one of them archived. askCompanyQuestion rolled the question
back and returned not_asked, the row stayed parked as
staged_awaiting_company, and the channel went silent.

- Exclude archived companies wherever the channel resolves memberships
  (isMember, resolveCompanyTarget, loadCompanyOptions, applyCompanyChoice,
  the M3 greeting count), same inner-join filter as the middleware.
- uniqueTitles: interactive button/row titles are made unique (position
  suffix) so two live same-named companies, or names that truncate to the
  same prefix, no longer trip #131009.
- Numbered-text fallback: when the interactive send is rejected at send
  time, ask the same M6 question as plain numbered text; roll back only
  when that fails too. A typed digit is recorded as via='numbered'.
- Drain: when the sender now resolves as 'single', rows parked behind the
  dead question are re-opened and kicked instead of expiring at Meta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(whatsapp): single-company drain also clears the dead company question (#1589)

Re-opening the parked rows left the conversation in state awaiting_company
with company_options and the company pending_question intact, so the sender
stayed behind a zombie question for up to 48h: every typed word became a
company_retry re-offering the archived company, 'byt' was swallowed, and
finalizeBurst could not ask about the drained receipts until the TTL sweep.

- After the drain, when a company question is open in any of its shapes
  (awaiting_company state, kept company_options, company pending_question),
  clear it through the guarded updateConversation: state -> idle, options and
  the company pending_question deleted, other question types untouched.
- Tests: the clear in its awaiting_company and post-TTL (idle + options)
  shapes, and its no-op for a representation question.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:26:38 +02:00

580 lines
21 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/whatsapp-inbox/lib/graph-api')
>('@/extensions/general/whatsapp-inbox/lib/graph-api')
return {
...actual,
sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }),
sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }),
sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }),
}
})
import {
sendText,
sendReplyButtons,
sendList,
truncateTitle,
uniqueTitles,
} from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import {
askCompanyQuestion,
applyCompanyChoice,
} from '@/extensions/general/whatsapp-inbox/lib/company-question'
import {
NO_COMPANY_OPTIONS,
STAGED_AWAITING_COMPANY,
} from '@/extensions/general/whatsapp-inbox/lib/conversation'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
const sendTextMock = vi.mocked(sendText)
const sendButtonsMock = vi.mocked(sendReplyButtons)
const sendListMock = vi.mocked(sendList)
function makeLink(overrides: Record<string, unknown> = {}) {
return {
id: 'link-1',
user_id: 'user-1',
phone_hash: 'hash-1',
phone_enc: 'enc',
phone_masked: '+46 70 *** ** 67',
wa_profile_name: null,
default_company_id: null,
last_company_id: null,
verified_at: '2026-08-01T09:00:00Z',
revoked_at: null,
muted_at: null,
last_message_at: null,
created_at: '2026-08-01T09:00:00Z',
updated_at: '2026-08-01T09:00:00Z',
...overrides,
} as never
}
function makeConversation(overrides: Record<string, unknown> = {}) {
return {
id: 'conv-1',
phone_link_id: 'link-1',
state: 'idle',
context: {},
company_id: null,
service_window_expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
debounce_until: null,
pending_ack: false,
last_inbound_at: null,
last_outbound_at: null,
created_at: '2026-08-01T09:00:00Z',
updated_at: '2026-08-01T09:00:00Z',
...overrides,
} as never
}
function memberships(n: number) {
return Array.from({ length: n }, (_, i) => ({ company_id: `company-${i + 1}` }))
}
function companies(n: number) {
return Array.from({ length: n }, (_, i) => ({ id: `company-${i + 1}`, name: `Bolag ${String.fromCharCode(65 + i)} AB` }))
}
const replyBase = {
senderPhoneHash: 'hash-1',
phoneLinkId: 'link-1',
conversationId: 'conv-1',
correlationId: 'corr-1',
}
describe('askCompanyQuestion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uses reply buttons for <=3 companies, ids = company ids', async () => {
const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase()
enqueue({ data: memberships(3) })
enqueue({ data: companies(3) })
enqueue({ data: [{ id: 'conv-1' }] }) // guarded transition won
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('asked')
expect(sendButtonsMock).toHaveBeenCalledTimes(1)
const args = sendButtonsMock.mock.calls[0][1]
expect(args.buttons).toHaveLength(3)
expect(args.buttons[0]).toEqual({ id: 'company-1', title: 'Bolag A AB' })
expect(args.body).toContain('Vilket företag')
// The interactive send succeeded: no numbered fallback, no duplicate question.
expect(sendTextMock).not.toHaveBeenCalled()
// Archived companies are never offered (#1589).
expect(
calls.some(
(c) =>
c.table === 'companies' &&
c.method === 'is' &&
c.args[0] === 'archived_at' &&
c.args[1] === null,
),
).toBe(true)
// State transition stored the options for digit replies too.
const patch = findCalls('whatsapp_conversations', 'update')[0][0] as {
state: string
context: { company_options: unknown[]; pending_question: { type: string } }
}
expect(patch.state).toBe('awaiting_company')
expect(patch.context.company_options).toHaveLength(3)
expect(patch.context.pending_question.type).toBe('company')
})
it('falls back to the numbered text question when the reply-button send is rejected', async () => {
sendButtonsMock.mockResolvedValueOnce({
ok: false,
wamid: null,
errorDetail:
'Send failed (HTTP 400): {"error":{"message":"(#131009) Parameter value is not valid","error_data":{"details":"Duplicate button title"}}}',
})
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: memberships(3) })
enqueue({ data: companies(3) })
enqueue({ data: [{ id: 'conv-1' }] })
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('asked')
expect(sendButtonsMock).toHaveBeenCalledTimes(1)
expect(sendTextMock).toHaveBeenCalledTimes(1)
const fallback = sendTextMock.mock.calls[0][1]
expect(fallback.template).toBe(TEMPLATE.m6CompanyQuestion)
expect(fallback.to).toBe('46701234567')
expect(fallback.body).toContain('1. Bolag A AB')
expect(fallback.body).toContain('3. Bolag C AB')
expect(fallback.body).toContain('Svara med en siffra')
// The question stays armed: exactly one conversation write, no rollback.
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(1)
})
it('falls back to the numbered text question when the list send is rejected', async () => {
sendListMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 400)' })
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: memberships(5) })
enqueue({ data: companies(5) })
enqueue({ data: [{ id: 'conv-1' }] })
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 2,
})
expect(asked).toBe('asked')
expect(sendListMock).toHaveBeenCalledTimes(1)
expect(sendTextMock).toHaveBeenCalledTimes(1)
const body = sendTextMock.mock.calls[0][1].body
expect(body).toContain('kvittona')
expect(body).toContain('5. Bolag E AB')
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(1)
})
it('rolls back only when the numbered fallback also fails', async () => {
sendButtonsMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 400)' })
sendTextMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' })
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: memberships(3) })
enqueue({ data: companies(3) })
enqueue({ data: [{ id: 'conv-1' }] }) // guarded transition won
enqueue({
data: [
{
...(makeConversation() as Record<string, unknown>),
state: 'awaiting_company',
context: { company_options: [{ id: 'company-1', name: 'Bolag A AB' }] },
},
],
}) // rollback echo
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('not_asked')
expect(sendButtonsMock).toHaveBeenCalledTimes(1)
expect(sendTextMock).toHaveBeenCalledTimes(1)
const updates = findCalls('whatsapp_conversations', 'update').map(
(args) => args[0] as { state?: string; context?: Record<string, unknown> },
)
expect(updates).toHaveLength(2)
expect(updates[0].state).toBe('awaiting_company')
expect(updates[1].state).toBe('idle')
expect(updates[1].context?.company_options).toBeUndefined()
expect(updates[1].context?.pending_question).toBeUndefined()
})
it('uses a list message for 4-10 companies', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: memberships(5) })
enqueue({ data: companies(5) })
enqueue({ data: [{ id: 'conv-1' }] })
await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 2,
})
expect(sendListMock).toHaveBeenCalledTimes(1)
const args = sendListMock.mock.calls[0][1]
expect(args.rows).toHaveLength(5)
expect(args.buttonLabel).toBe('Välj företag')
expect(args.body).toContain('kvittona')
})
it('falls back to a numbered text list for >10 companies', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: memberships(11) })
enqueue({ data: companies(11) })
enqueue({ data: [{ id: 'conv-1' }] })
await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(sendTextMock).toHaveBeenCalledTimes(1)
const body = sendTextMock.mock.calls[0][1].body
expect(body).toContain('1. ')
expect(body).toContain('11. ')
expect(body).toContain('Svara med en siffra')
})
it('asks EXACTLY once: a lost guarded transition sends nothing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: memberships(3) })
enqueue({ data: companies(3) })
enqueue({ data: [] }) // another worker already moved to awaiting_company
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('not_asked')
expect(sendButtonsMock).not.toHaveBeenCalled()
expect(sendListMock).not.toHaveBeenCalled()
expect(sendTextMock).not.toHaveBeenCalled()
})
it('treats a failed membership query as transient: nothing sent, nothing marked', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ error: { message: 'connection reset' } }) // memberships query fails
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('transient_error')
expect(sendButtonsMock).not.toHaveBeenCalled()
expect(sendListMock).not.toHaveBeenCalled()
expect(sendTextMock).not.toHaveBeenCalled()
// Neither the question state nor the staged rows were touched: the
// CALLER releases its row for retry.
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
expect(findCalls('whatsapp_messages', 'update')).toHaveLength(0)
})
it('genuinely zero options: re-marks the staged rows and sends ONE actionable M19', async () => {
const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase()
enqueue({ data: [] }) // memberships: really none
enqueue({ data: null }) // staged rows re-marked no_company_options
enqueue({ data: null }) // M19 burst dedupe: none sent yet
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('no_options')
const marker = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
expect(marker.error_message).toBe(NO_COMPANY_OPTIONS)
// The re-mark targets exactly the rows parked behind this episode.
expect(
calls.some(
(c) =>
c.table === 'whatsapp_messages' &&
c.method === 'eq' &&
c.args[0] === 'error_message' &&
c.args[1] === STAGED_AWAITING_COMPANY,
),
).toBe(true)
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m19NoCompany)
// No question is ever parked: there is nothing to answer.
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
})
it('suppresses a repeat M19 inside the burst window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // memberships: none
enqueue({ data: null }) // staged rows re-mark
enqueue({ data: { id: 'm19-earlier' } }) // an M19 already went out
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 2,
})
expect(asked).toBe('no_options')
expect(sendTextMock).not.toHaveBeenCalled()
})
})
describe('applyCompanyChoice', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const awaitingConversation = () =>
makeConversation({
state: 'awaiting_company',
context: {
company_options: [
{ id: 'company-1', name: 'Bolag A AB' },
{ id: 'company-2', name: 'Bolag B AB' },
],
pending_question: { type: 'company', inbox_item_id: null, asked_at: new Date().toISOString() },
},
})
it('digit answer pins the company for 8h, confirms, and re-opens the parked rows', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { company_id: 'company-2' } }) // membership check
enqueue({ data: null }) // conversation update
enqueue({ data: null }) // link last_company_id update
enqueue({ data: [{ id: 'stg-1' }, { id: 'stg-2' }] }) // staged reopen
const before = Date.now()
const applied = await applyCompanyChoice(supabase as unknown as SupabaseClient, {
conversation: awaitingConversation(),
link: makeLink(),
choice: { digit: 2 },
via: 'numbered',
to: '46701234567',
replyBase,
})
expect(applied).toEqual({
ok: true,
companyId: 'company-2',
companyName: 'Bolag B AB',
stagedMessageIds: ['stg-1', 'stg-2'],
})
// The membership check is a LIVE-membership check: a tap or digit for a
// company archived since the question was asked is rejected (#1589).
expect(findCalls('company_members', 'select')[0][0]).toContain('companies!inner(archived_at)')
expect(findCalls('company_members', 'is')).toContainEqual(['companies.archived_at', null])
const patch = findCalls('whatsapp_conversations', 'update')[0][0] as {
state: string
company_id: string
pending_ack: boolean
context: { pin_expires_at: string; pin_source: string; company_options?: unknown }
}
expect(patch.state).toBe('idle')
expect(patch.company_id).toBe('company-2')
expect(patch.pending_ack).toBe(true)
expect(patch.context.pin_source).toBe('numbered')
expect(patch.context.company_options).toBeUndefined()
const pinMs = new Date(patch.context.pin_expires_at).getTime() - before
expect(pinMs).toBeGreaterThan(7.9 * 60 * 60 * 1000)
expect(pinMs).toBeLessThan(8.1 * 60 * 60 * 1000)
expect(sendTextMock).toHaveBeenCalledTimes(1)
const confirm = sendTextMock.mock.calls[0][1]
expect(confirm.template).toBe(TEMPLATE.m6CompanyConfirm)
expect(confirm.body).toContain('Bolag B AB')
expect(confirm.body).toContain('byt')
// Reopen targeted exactly the staged marker.
const reopenPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
expect(reopenPatch.processing_status).toBe('received')
const { calls } = { calls: findCalls('whatsapp_messages', 'eq') }
expect(calls.some((args) => args[0] === 'error_message' && args[1] === STAGED_AWAITING_COMPANY)).toBe(true)
})
it('interactive answer maps the payload id directly', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { company_id: 'company-1' } })
enqueue({ data: null })
enqueue({ data: null })
enqueue({ data: [] })
const applied = await applyCompanyChoice(supabase as unknown as SupabaseClient, {
conversation: awaitingConversation(),
link: makeLink(),
choice: { companyId: 'company-1' },
via: 'button',
to: '46701234567',
replyBase,
})
expect(applied.ok).toBe(true)
if (!applied.ok) throw new Error('expected the choice to apply')
expect(applied.companyId).toBe('company-1')
expect(applied.stagedMessageIds).toEqual([])
})
it('rejects a company the sender is not a member of (forged/stale payload)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: null }) // membership check: none
const applied = await applyCompanyChoice(supabase as unknown as SupabaseClient, {
conversation: awaitingConversation(),
link: makeLink(),
choice: { companyId: 'company-elsewhere' },
via: 'button',
to: '46701234567',
replyBase,
})
expect(applied).toEqual({ ok: false, reason: 'not_member' })
expect(sendTextMock).not.toHaveBeenCalled()
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
})
it('rejects an out-of-range digit', async () => {
const { supabase } = createQueuedMockSupabase()
const applied = await applyCompanyChoice(supabase as unknown as SupabaseClient, {
conversation: awaitingConversation(),
link: makeLink(),
choice: { digit: 9 },
via: 'numbered',
to: '46701234567',
replyBase,
})
// Ordinary user input (a typo), so the CALLER re-prompts the options
// rather than the silence reserved for forged payloads.
expect(applied).toEqual({ ok: false, reason: 'invalid_option' })
expect(sendTextMock).not.toHaveBeenCalled()
})
it('treats a transient membership-query error as unresolved, not as "not a member"', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ error: { message: 'connection reset' } })
const applied = await applyCompanyChoice(supabase as unknown as SupabaseClient, {
conversation: awaitingConversation(),
link: makeLink(),
choice: { companyId: 'company-1' },
via: 'button',
to: '46701234567',
replyBase,
})
expect(applied).toEqual({ ok: false, reason: 'lookup_failed' })
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
})
it('a second tap after the choice was applied confirms nothing (options are gone)', async () => {
const { supabase, findCalls } = createQueuedMockSupabase()
const applied = await applyCompanyChoice(supabase as unknown as SupabaseClient, {
conversation: makeConversation({ state: 'idle', context: {} }),
link: makeLink(),
choice: { companyId: 'company-1' },
via: 'button',
to: '46701234567',
replyBase,
})
expect(applied).toEqual({ ok: false, reason: 'already_applied' })
expect(sendTextMock).not.toHaveBeenCalled()
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
})
})
describe('truncateTitle', () => {
it('keeps short titles untouched and cuts long ones cleanly at a word boundary', () => {
expect(truncateTitle('Bolag AB', 20)).toBe('Bolag AB')
const cut = truncateTitle('Wennberg Fastighetsförvaltning i Stockholm AB', 20)
expect(cut.length).toBeLessThanOrEqual(20)
expect(cut.endsWith('…')).toBe(true)
expect(cut).not.toMatch(/\s…$/) // no dangling space before the ellipsis
})
})
describe('uniqueTitles', () => {
it('passes distinct short names through untouched', () => {
expect(uniqueTitles(['Bolag A AB', 'Bolag B AB', 'Bolag C AB'], 20)).toEqual([
'Bolag A AB',
'Bolag B AB',
'Bolag C AB',
])
})
it('disambiguates identical names with their 1-based position (Meta #131009)', () => {
const titles = uniqueTitles(['Capelix AB', 'Capelix AB'], 20)
expect(new Set(titles).size).toBe(2)
expect(titles[0]).toBe('Capelix AB 1')
expect(titles[1]).toBe('Capelix AB 2')
for (const t of titles) expect(t.length).toBeLessThanOrEqual(20)
})
it('disambiguates names that only collide after truncation, within the limit', () => {
const titles = uniqueTitles(
['Wennberg Fastighetsförvaltning AB', 'Wennberg Fastighetsförvaltning Holding AB'],
20,
)
expect(new Set(titles.map((t) => t.toLowerCase())).size).toBe(2)
for (const t of titles) {
expect(t.length).toBeLessThanOrEqual(20)
expect(t.startsWith('Wennberg')).toBe(true)
}
expect(titles[0].endsWith(' 1')).toBe(true)
expect(titles[1].endsWith(' 2')).toBe(true)
})
it('treats a case-only difference as a collision', () => {
const titles = uniqueTitles(['Bolag AB', 'bolag ab', 'Annat AB'], 24)
expect(titles).toEqual(['Bolag AB 1', 'bolag ab 2', 'Annat AB'])
})
})