Pressing Leta produced mails=25, documents=0 on a real two-mailbox run. Nothing was found because nothing was searched: every request came back 429 "Too many concurrent requests for user". Two bugs, and the second is the one that matters. The search fanned out with Promise.all over every message id at once, one Gmail request per message, per connection. Gmail enforces a per-user concurrency ceiling as well as a daily quota, and this sailed past it long before any volume worth worrying about. It now runs through a pool of five per connection, which is comfortably under and still finishes a page of results in a couple of round trips. The catch turned each refusal into an empty array, with a comment saying one mailbox's failure must not become the company's. Right instinct, wrong consequence: an empty array is also what an empty mailbox returns, and the manual hunt loop stops on fetched === 0 because that is its signal for "the mailboxes hold nothing more for what is open". So a rate-limited search told the user their receipts do not exist, and stopped looking. searchFailureCount() now separates "could not look" from "nothing there". The run route reports it, and the loop treats a pass with failures as failed rather than finished, so pressing again is the obvious next move instead of a pointless one. This is the failure this feature exists to catch, happening inside the feature: silence that reads as an answer. Restoring the unbounded fan-out fails one test; removing the failure counter fails three. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
119 lines
4.7 KiB
TypeScript
119 lines
4.7 KiB
TypeScript
/**
|
|
* Gmail refuses before any quota is near.
|
|
*
|
|
* The search fanned out one request per message id at once. Gmail answers 429
|
|
* "Too many concurrent requests for user" to that, and the catch turned the
|
|
* refusal into an empty result — which is indistinguishable from a mailbox that
|
|
* genuinely holds nothing. A real run against two connections produced
|
|
* `mails=25 documents=0`, and the client loop read the zero as "nothing left to
|
|
* find" and stopped. The user was told there were no receipts by a search that
|
|
* never happened.
|
|
*
|
|
* Two things must hold: the fan-out stays under the ceiling, and a refusal is
|
|
* distinguishable from an empty mailbox.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const searchMessageIds = vi.fn()
|
|
const getMessageSummary = vi.fn()
|
|
const getAccessToken = vi.fn()
|
|
const listActiveConnections = vi.fn()
|
|
const touchSearched = vi.fn()
|
|
|
|
vi.mock('@/lib/auth/api-keys', () => ({ createServiceClientNoCookies: () => ({}) }))
|
|
vi.mock('../gmail-client', () => ({
|
|
searchMessageIds: (...a: unknown[]) => searchMessageIds(...a),
|
|
getMessageSummary: (...a: unknown[]) => getMessageSummary(...a),
|
|
fetchAttachmentBytes: vi.fn(),
|
|
clearMessageCache: vi.fn(),
|
|
describeAttachment: vi.fn(),
|
|
}))
|
|
vi.mock('../google-oauth', () => ({ isGoogleMailConfigured: () => true }))
|
|
vi.mock('../connections', () => ({
|
|
getAccessToken: (...a: unknown[]) => getAccessToken(...a),
|
|
listActiveConnections: (...a: unknown[]) => listActiveConnections(...a),
|
|
touchSearched: (...a: unknown[]) => touchSearched(...a),
|
|
}))
|
|
|
|
const { GmailSearchService } = await import('../search-service')
|
|
|
|
function connection(id: string) {
|
|
return { id, email_address: `${id}@example.test`, provider: 'gmail', status: 'active' }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
getAccessToken.mockResolvedValue('token')
|
|
listActiveConnections.mockResolvedValue([connection('c1')])
|
|
touchSearched.mockResolvedValue(undefined)
|
|
})
|
|
|
|
describe('GmailSearchService.search', () => {
|
|
it('never has more than a handful of summary requests in flight', async () => {
|
|
// The ceiling is Gmail's, not ours: exceeding it fails the whole search.
|
|
let inFlight = 0
|
|
let peak = 0
|
|
searchMessageIds.mockResolvedValue(Array.from({ length: 40 }, (_, i) => `m${i}`))
|
|
getMessageSummary.mockImplementation(async () => {
|
|
inFlight++
|
|
peak = Math.max(peak, inFlight)
|
|
await new Promise((r) => setTimeout(r, 1))
|
|
inFlight--
|
|
return { messageId: 'm', subject: 'Kvitto', from: 'a@b.c' }
|
|
})
|
|
|
|
const svc = new GmailSearchService()
|
|
await svc.search('company-1', { merchant: 'x', amount: 1, currency: 'SEK', date: '2026-08-01' })
|
|
|
|
expect(getMessageSummary).toHaveBeenCalledTimes(40)
|
|
expect(peak).toBeLessThanOrEqual(5)
|
|
})
|
|
|
|
it('reports a refused search instead of passing it off as an empty mailbox', async () => {
|
|
searchMessageIds.mockRejectedValue(new Error('Gmail 429: Too many concurrent requests for user.'))
|
|
|
|
const svc = new GmailSearchService()
|
|
const out = await svc.search('company-1', { merchant: 'x', amount: 1, currency: 'SEK', date: '2026-08-01' })
|
|
|
|
// No candidates either way; the count is the only thing that separates
|
|
// "could not look" from "nothing there".
|
|
expect(out).toEqual([])
|
|
expect(svc.searchFailureCount()).toBe(1)
|
|
})
|
|
|
|
it('counts a genuinely empty mailbox as no failure', async () => {
|
|
searchMessageIds.mockResolvedValue([])
|
|
const svc = new GmailSearchService()
|
|
const out = await svc.search('company-1', { merchant: 'x', amount: 1, currency: 'SEK', date: '2026-08-01' })
|
|
expect(out).toEqual([])
|
|
expect(svc.searchFailureCount()).toBe(0)
|
|
})
|
|
|
|
it('lets one refused mailbox shrink the search without hiding the others', async () => {
|
|
listActiveConnections.mockResolvedValue([connection('c1'), connection('c2')])
|
|
let call = 0
|
|
searchMessageIds.mockImplementation(async () => {
|
|
call++
|
|
if (call === 1) throw new Error('Gmail 429')
|
|
return ['m1']
|
|
})
|
|
getMessageSummary.mockResolvedValue({ messageId: 'm1', subject: 'Kvitto', from: 'a@b.c' })
|
|
|
|
const svc = new GmailSearchService()
|
|
const out = await svc.search('company-1', { merchant: 'x', amount: 1, currency: 'SEK', date: '2026-08-01' })
|
|
|
|
expect(out.length).toBe(1)
|
|
expect(svc.searchFailureCount()).toBe(1)
|
|
})
|
|
|
|
it('starts each search from zero failures', async () => {
|
|
searchMessageIds.mockRejectedValueOnce(new Error('Gmail 429')).mockResolvedValue([])
|
|
const svc = new GmailSearchService()
|
|
const q = { merchant: 'x', amount: 1, currency: 'SEK', date: '2026-08-01' }
|
|
await svc.search('company-1', q)
|
|
expect(svc.searchFailureCount()).toBe(1)
|
|
await svc.search('company-1', q)
|
|
expect(svc.searchFailureCount()).toBe(0)
|
|
})
|
|
})
|