* 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>
561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
/**
|
|
* Regression tests for the adversarial-review hardening pass.
|
|
*
|
|
* Every case here fails against the pre-fix code: a failed send stranding the
|
|
* company question, an ignored ack send result, SEK-labelled foreign amounts,
|
|
* unguarded terminal status writes, quoted corrections landing on the wrong
|
|
* receipt, blind whole-context conversation writes, the plaintext phone in
|
|
* raw_payload, text answers to the re-send question, and the 48h expiry
|
|
* discarding parked receipts.
|
|
*/
|
|
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 }),
|
|
markReadWithTyping: vi.fn().mockResolvedValue(undefined),
|
|
downloadMedia: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/processing-history/append', () => ({
|
|
appendProcessingHistory: vi.fn().mockResolvedValue('event-1'),
|
|
}))
|
|
|
|
vi.mock('@/lib/rate-limits/agent', () => ({
|
|
checkAgentRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
vi.mock('@/extensions/general/whatsapp-inbox/lib/interpret-answer', () => ({
|
|
interpretChatAnswer: vi.fn().mockResolvedValue({ ok: false }),
|
|
}))
|
|
|
|
import { sendText, sendReplyButtons } from '@/extensions/general/whatsapp-inbox/lib/graph-api'
|
|
import { askCompanyQuestion } from '@/extensions/general/whatsapp-inbox/lib/company-question'
|
|
import {
|
|
finalizeBurst,
|
|
processInboundMessage,
|
|
} from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
|
|
import {
|
|
redactRawPayload,
|
|
resolveAnswerTarget,
|
|
resolveRecipient,
|
|
updateConversation,
|
|
} from '@/extensions/general/whatsapp-inbox/lib/conversation'
|
|
import { encryptPhone } from '@/extensions/general/whatsapp-inbox/lib/phone-crypto'
|
|
import { runSweep } from '@/extensions/general/whatsapp-inbox/lib/sweep'
|
|
import { botCopy, TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
|
|
|
|
const sendTextMock = vi.mocked(sendText)
|
|
const sendButtonsMock = vi.mocked(sendReplyButtons)
|
|
|
|
function makeConversation(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'conv-1',
|
|
phone_link_id: 'link-1',
|
|
state: 'idle',
|
|
context: {},
|
|
company_id: 'company-1',
|
|
service_window_expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
debounce_until: new Date(Date.now() - 1000).toISOString(),
|
|
pending_ack: true,
|
|
last_inbound_at: null,
|
|
last_outbound_at: null,
|
|
created_at: '2026-08-01T09:00:00Z',
|
|
updated_at: '2026-08-01T09:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
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',
|
|
default_company_id: null,
|
|
last_company_id: null,
|
|
verified_at: '2026-08-01T09:00:00Z',
|
|
revoked_at: null,
|
|
muted_at: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeRow(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'msg-1',
|
|
direction: 'inbound',
|
|
wamid: 'wamid.IN1',
|
|
sender_phone_hash: 'hash-1',
|
|
phone_link_id: 'link-1',
|
|
conversation_id: 'conv-1',
|
|
message_type: 'image',
|
|
body_text: null,
|
|
media_id: 'media-1',
|
|
media_mime: 'image/jpeg',
|
|
media_sha256: 'abc',
|
|
media_filename: 'kvitto.jpg',
|
|
raw_payload: { from: '46701234567' },
|
|
processing_status: 'received',
|
|
attempts: 0,
|
|
error_message: null,
|
|
inbox_item_id: null,
|
|
delivery_status: null,
|
|
correlation_id: 'corr-1',
|
|
acked_at: null,
|
|
created_at: '2026-08-01T10:00:00Z',
|
|
updated_at: '2026-08-01T10:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
|
|
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64)
|
|
sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null })
|
|
sendButtonsMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null })
|
|
})
|
|
|
|
describe('company question is not one-shot when the send fails', () => {
|
|
function enqueueAsk(mock: ReturnType<typeof createQueuedMockSupabase>) {
|
|
mock.enqueue({ data: [{ company_id: 'company-1' }, { company_id: 'company-2' }] })
|
|
mock.enqueue({
|
|
data: [
|
|
{ id: 'company-1', name: 'Bolag A AB' },
|
|
{ id: 'company-2', name: 'Bolag B AB' },
|
|
],
|
|
})
|
|
mock.enqueue({ data: [{ ...makeConversation(), state: 'awaiting_company' }] }) // guarded commit
|
|
}
|
|
|
|
it('rolls the question back so the next receipt re-asks', async () => {
|
|
sendButtonsMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' })
|
|
// The numbered text fallback (#1589) must fail too before anything rolls
|
|
// back; with the default ok:true text mock the question would stay armed.
|
|
sendTextMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' })
|
|
const mock = createQueuedMockSupabase()
|
|
enqueueAsk(mock)
|
|
mock.enqueue({
|
|
data: [
|
|
{
|
|
...makeConversation(),
|
|
state: 'awaiting_company',
|
|
context: { company_options: [{ id: 'company-1', name: 'Bolag A AB' }] },
|
|
},
|
|
],
|
|
})
|
|
|
|
const asked = await askCompanyQuestion(mock.supabase as unknown as SupabaseClient, {
|
|
conversation: makeConversation() as never,
|
|
link: makeLink() as never,
|
|
to: '46701234567',
|
|
replyBase: {},
|
|
stagedCount: 3,
|
|
})
|
|
|
|
expect(asked).toBe('not_asked')
|
|
// The numbered fallback was tried once, with the same template id, before
|
|
// giving up.
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6CompanyQuestion)
|
|
expect(sendTextMock.mock.calls[0][1].body).toContain('1. Bolag A AB')
|
|
const updates = mock
|
|
.findCalls('whatsapp_conversations', 'update')
|
|
.map((args) => args[0] as { state?: string; context?: Record<string, unknown> })
|
|
// First write arms the question, the second undoes it.
|
|
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('keeps the question when the send succeeded', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
enqueueAsk(mock)
|
|
|
|
const asked = await askCompanyQuestion(mock.supabase as unknown as SupabaseClient, {
|
|
conversation: makeConversation() as never,
|
|
link: makeLink() as never,
|
|
to: '46701234567',
|
|
replyBase: {},
|
|
stagedCount: 1,
|
|
})
|
|
|
|
expect(asked).toBe('asked')
|
|
expect(mock.findCalls('whatsapp_conversations', 'update')).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('combined ack', () => {
|
|
function enqueueBurst(
|
|
mock: ReturnType<typeof createQueuedMockSupabase>,
|
|
extracted: Record<string, unknown>,
|
|
) {
|
|
mock.enqueue({ data: [{ id: 'conv-1' }] }) // claimAck
|
|
mock.enqueue({ data: makeConversation() })
|
|
mock.enqueue({
|
|
data: [
|
|
{
|
|
...makeRow(),
|
|
processing_status: 'done',
|
|
inbox_item_id: 'item-1',
|
|
},
|
|
],
|
|
})
|
|
mock.enqueue({
|
|
data: [
|
|
{
|
|
id: 'item-1',
|
|
company_id: 'company-1',
|
|
extracted_data: extracted,
|
|
channel_context: { channel: 'whatsapp' },
|
|
document_id: null,
|
|
},
|
|
],
|
|
})
|
|
mock.enqueue({ data: [makeConversation()] }) // guarded state write
|
|
}
|
|
|
|
it('states a foreign total in its own currency, never as kronor', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
enqueueBurst(mock, {
|
|
documentKind: 'receipt',
|
|
legibility: 'good',
|
|
supplier: { name: 'Hotel Adlon' },
|
|
invoice: { invoiceDate: '2026-07-30', currency: 'EUR' },
|
|
totals: { total: 250 },
|
|
lineItems: [],
|
|
})
|
|
|
|
await finalizeBurst(mock.supabase as unknown as SupabaseClient, 'conv-1')
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
const body = sendTextMock.mock.calls[0][1].body
|
|
expect(body).toContain('250 EUR')
|
|
expect(body).not.toContain('250 kr')
|
|
})
|
|
|
|
it('still says kr for SEK and for an unstated currency', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
enqueueBurst(mock, {
|
|
documentKind: 'receipt',
|
|
legibility: 'good',
|
|
supplier: { name: 'ICA Maxi' },
|
|
invoice: { invoiceDate: '2026-07-30' },
|
|
totals: { total: 234 },
|
|
lineItems: [],
|
|
})
|
|
|
|
await finalizeBurst(mock.supabase as unknown as SupabaseClient, 'conv-1')
|
|
expect(sendTextMock.mock.calls[0][1].body).toContain('234 kr')
|
|
})
|
|
|
|
it('leaves the rows unacked when the send failed so the sweep retries', async () => {
|
|
sendTextMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' })
|
|
const mock = createQueuedMockSupabase()
|
|
enqueueBurst(mock, {
|
|
documentKind: 'receipt',
|
|
legibility: 'good',
|
|
supplier: { name: 'ICA Maxi' },
|
|
invoice: { invoiceDate: '2026-07-30' },
|
|
totals: { total: 234 },
|
|
lineItems: [],
|
|
})
|
|
|
|
await finalizeBurst(mock.supabase as unknown as SupabaseClient, 'conv-1')
|
|
|
|
const messageUpdates = mock
|
|
.findCalls('whatsapp_messages', 'update')
|
|
.map((args) => args[0] as Record<string, unknown>)
|
|
expect(messageUpdates.some((patch) => 'acked_at' in patch)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('terminal status writes hold the claim', () => {
|
|
it('guards markStatus on processing_status=processing', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({ data: makeRow() })
|
|
mock.enqueue({ data: { id: 'msg-1' } }) // claim
|
|
mock.enqueue({ data: makeLink({ revoked_at: '2026-08-01T10:00:00Z' }) })
|
|
mock.enqueue({ data: null }) // markStatus skipped
|
|
|
|
await processInboundMessage(mock.supabase as unknown as SupabaseClient, 'msg-1')
|
|
|
|
const guards = mock
|
|
.findCalls('whatsapp_messages', 'eq')
|
|
.filter((args) => args[0] === 'processing_status')
|
|
// The claim guards on 'received'; the terminal write must guard on
|
|
// 'processing' so a losing worker cannot clobber the winner's row.
|
|
expect(guards.map((args) => args[1])).toContain('processing')
|
|
})
|
|
})
|
|
|
|
describe('answer targeting', () => {
|
|
const askedAt = new Date(Date.now() - 60 * 60 * 1000).toISOString()
|
|
|
|
function conversationWith(recent: Record<string, unknown>[], overrides = {}) {
|
|
return makeConversation({
|
|
state: 'awaiting_context',
|
|
context: {
|
|
pending_question: { type: 'context', inbox_item_id: 'item-B', asked_at: askedAt },
|
|
recent_questions: recent,
|
|
},
|
|
...overrides,
|
|
})
|
|
}
|
|
|
|
it('binds a quoted follow-up to the quoted receipt, not to another open question', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({ data: { inbox_item_id: 'item-A' } }) // quoted wamid resolves
|
|
|
|
const target = await resolveAnswerTarget(
|
|
mock.supabase as unknown as SupabaseClient,
|
|
conversationWith([
|
|
{ type: 'representation', inbox_item_id: 'item-A', asked_at: askedAt, status: 'answered' },
|
|
{ type: 'context', inbox_item_id: 'item-B', asked_at: askedAt, status: 'open' },
|
|
]) as never,
|
|
'wamid.QUOTED',
|
|
)
|
|
|
|
expect(target?.inboxItemId).toBe('item-A')
|
|
expect(target?.followUp).toBe(true)
|
|
})
|
|
|
|
it('quoting the open question is still an ordinary answer, not a late one', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({ data: { inbox_item_id: 'item-B' } })
|
|
|
|
const target = await resolveAnswerTarget(
|
|
mock.supabase as unknown as SupabaseClient,
|
|
conversationWith([
|
|
{ type: 'context', inbox_item_id: 'item-B', asked_at: askedAt, status: 'open' },
|
|
]) as never,
|
|
'wamid.QUOTED',
|
|
)
|
|
|
|
expect(target).toEqual({ type: 'context', inboxItemId: 'item-B', late: false })
|
|
})
|
|
})
|
|
|
|
describe('conversation writes are compare-and-set', () => {
|
|
it('re-applies the mutation against fresh state after losing a race', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({ data: [] }) // guard did not match: someone else wrote first
|
|
mock.enqueue({ data: makeConversation({ context: { budget: { day_key: 'x', count: 2 } } }) })
|
|
mock.enqueue({ data: [makeConversation()] })
|
|
|
|
const seen: number[] = []
|
|
const result = await updateConversation(
|
|
mock.supabase as unknown as SupabaseClient,
|
|
makeConversation() as never,
|
|
(_current, context) => {
|
|
seen.push(context.budget?.count ?? 0)
|
|
return { context: { ...context, question_queue: [] } }
|
|
},
|
|
)
|
|
|
|
expect(result).not.toBeNull()
|
|
// Second pass saw the concurrent writer's state, not the stale snapshot.
|
|
expect(seen).toEqual([0, 2])
|
|
})
|
|
|
|
it('gives up rather than looping forever', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
for (let i = 0; i < 12; i++) {
|
|
mock.enqueue({ data: [] })
|
|
mock.enqueue({ data: makeConversation() })
|
|
}
|
|
|
|
const result = await updateConversation(
|
|
mock.supabase as unknown as SupabaseClient,
|
|
makeConversation() as never,
|
|
(_current, context) => ({ context }),
|
|
)
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('phone PII', () => {
|
|
it('strips the sender number from the persisted payload but keeps the quote id', () => {
|
|
const redacted = redactRawPayload({
|
|
from: '46701234567',
|
|
id: 'wamid.IN1',
|
|
type: 'text',
|
|
context: { id: 'wamid.QUOTED' },
|
|
})
|
|
expect(redacted).not.toHaveProperty('from')
|
|
expect(redacted).toMatchObject({ id: 'wamid.IN1', context: { id: 'wamid.QUOTED' } })
|
|
})
|
|
|
|
it('resolves the reply address from the encrypted link when the payload is redacted', () => {
|
|
const row = makeRow({ raw_payload: { id: 'wamid.IN1', type: 'text' } })
|
|
const link = { phone_enc: encryptPhone('46701234567') }
|
|
expect(resolveRecipient(row as never, link)).toBe('46701234567')
|
|
})
|
|
|
|
it('returns null instead of throwing on a shredded link', () => {
|
|
const row = makeRow({ raw_payload: {} })
|
|
expect(resolveRecipient(row as never, { phone_enc: '' })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('text reply while a re-send question is open', () => {
|
|
it('keeps it as a note on that receipt and leaves the question open', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({
|
|
data: makeRow({
|
|
id: 'msg-t1',
|
|
message_type: 'text',
|
|
body_text: 'det var lunch på Ica, 240 kr',
|
|
media_id: null,
|
|
}),
|
|
})
|
|
mock.enqueue({ data: { id: 'msg-t1' } }) // claim
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({
|
|
data: makeConversation({
|
|
state: 'awaiting_resend',
|
|
context: {
|
|
pending_question: {
|
|
type: 'resend',
|
|
inbox_item_id: 'item-9',
|
|
asked_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
}),
|
|
})
|
|
mock.enqueue({ data: { channel_context: { channel: 'whatsapp' } } }) // load item context
|
|
mock.enqueue({ data: null }) // item update
|
|
mock.enqueue({ data: null }) // markStatus done
|
|
|
|
await processInboundMessage(mock.supabase as unknown as SupabaseClient, 'msg-t1')
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m9NoteSaved)
|
|
const itemPatch = mock.findCall('invoice_inbox_items', 'update') as [
|
|
{ channel_context: Record<string, unknown> },
|
|
]
|
|
expect(itemPatch[0].channel_context.user_note).toContain('lunch på Ica')
|
|
// The question is untouched: only a sharper file can answer it.
|
|
expect(mock.findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('answer worker re-claims', () => {
|
|
it('does not follow a delivered confirm with the M16 fallback', async () => {
|
|
// A worker that died after applying the answer leaves the row
|
|
// 'processing'; the sweep re-runs it and the question now resolves to
|
|
// nothing, which used to send "I did not understand" right after the
|
|
// confirmation the user already received.
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({
|
|
data: makeRow({
|
|
id: 'msg-t1',
|
|
message_type: 'text',
|
|
body_text: 'Lunch med Anna Berg',
|
|
media_id: null,
|
|
attempts: 1,
|
|
}),
|
|
})
|
|
mock.enqueue({ data: { id: 'msg-t1' } }) // claim
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: makeConversation({ state: 'idle', context: {} }) })
|
|
mock.enqueue({ data: null }) // markStatus done
|
|
|
|
await processInboundMessage(mock.supabase as unknown as SupabaseClient, 'msg-t1')
|
|
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('still explains itself on a first attempt with nothing to answer', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({
|
|
data: makeRow({
|
|
id: 'msg-t1',
|
|
message_type: 'text',
|
|
body_text: 'kan du bokföra allt åt mig?',
|
|
media_id: null,
|
|
attempts: 0,
|
|
}),
|
|
})
|
|
mock.enqueue({ data: { id: 'msg-t1' } })
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: makeConversation({ state: 'idle', context: {} }) })
|
|
mock.enqueue({ data: null })
|
|
|
|
await processInboundMessage(mock.supabase as unknown as SupabaseClient, 'msg-t1')
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m16Fallback)
|
|
})
|
|
})
|
|
|
|
describe('48h company-question expiry', () => {
|
|
it('keeps the parked receipts recoverable by a late answer', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({ data: [] }) // 1a stuck received
|
|
mock.enqueue({ data: [] }) // 1b stuck processing
|
|
mock.enqueue({ data: [] }) // 2a stale pending_ack
|
|
mock.enqueue({ data: [] }) // 2b unacked rows
|
|
mock.enqueue({
|
|
data: [
|
|
makeConversation({
|
|
state: 'awaiting_company',
|
|
context: {
|
|
company_options: [{ id: 'company-1', name: 'Bolag A AB' }],
|
|
pending_question: {
|
|
type: 'company',
|
|
inbox_item_id: null,
|
|
asked_at: new Date(Date.now() - 72 * 60 * 60 * 1000).toISOString(),
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
}) // 3 question TTL
|
|
mock.enqueue({ data: null }) // stamp only rows past the media window
|
|
mock.enqueue({ count: 2 }) // rows still staged
|
|
mock.enqueue({ data: null }) // conversation back to idle
|
|
mock.enqueue({ data: [] }) // 4 pins
|
|
|
|
await runSweep(mock.supabase as unknown as SupabaseClient)
|
|
|
|
const expiryStamp = mock
|
|
.findCalls('whatsapp_messages', 'update')
|
|
.map((args) => args[0] as Record<string, unknown>)
|
|
.find((patch) => patch.error_message === 'company_choice_expired')
|
|
// The stamp is now bounded by how long Meta still serves the media.
|
|
expect(expiryStamp).toBeTruthy()
|
|
const stampFilters = mock.findCalls('whatsapp_messages', 'lt')
|
|
expect(stampFilters.some((args) => args[0] === 'created_at')).toBe(true)
|
|
|
|
const idleUpdate = mock
|
|
.findCalls('whatsapp_conversations', 'update')
|
|
.map((args) => args[0] as { state?: string; context?: Record<string, unknown> })
|
|
.find((patch) => patch.state === 'idle')
|
|
// company_options survive, so a late digit still maps to a company.
|
|
expect(idleUpdate?.context?.company_options).toBeTruthy()
|
|
expect(idleUpdate?.context?.pending_question).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('stop copy', () => {
|
|
it('does not claim the number is disconnected: stopp only pauses', () => {
|
|
for (const locale of ['sv', 'en'] as const) {
|
|
const body = botCopy(locale).m11Stop()
|
|
expect(body).not.toMatch(/kopplas från|is disconnected/)
|
|
expect(body).toMatch(/pausar|pausing/)
|
|
}
|
|
})
|
|
})
|