* feat(invoice-inbox): store HTML mails as underlag, expandable field editor Body-only mails and .html attachments (including forwarded .eml bodies) no longer dead-end as "Fel vid bearbetning": the mail body is wrapped into a self-contained text/html document, stored through the normal upload/extract pipeline, and extracted via a new HTML-to-text Bedrock path, so the mail itself can serve as bookable underlag. Empty mails keep the error row, unsupported types are still rejected, and webhook retries dedupe on resend_email_id. Mail HTML is attacker-controlled, so rendering is fully sandboxed: iframe sandbox in the workspace preview and a CSP sandbox header on /api/documents/:id/inline for text/html. The type is accepted only from the email pipeline (EMAIL_ALLOWED_MIME_TYPES), never from manual upload. The "Extraherade falt" rail gains an expand button opening a centered dialog with the same autosaving field editor at a readable size (two columns), which also gives every failed or skipped extraction a manual fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): sortable verifikat list headers with amount sort - clickable sort toggles on the verifikat list headers (asc -> desc -> default) - total_amount computed column + sort_by total/description on the list route - failed list loads render an error card with retry, never the empty-ledger state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): decode bank CSV as Windows-1252 fallback in column mapping The client read the uploaded file with file.text(), which is UTF-8-only, so Windows-1252 exports (e.g. Handelsbanken) rendered and re-parsed with U+FFFD in place of Swedish characters. Decode from bytes with the shared decodeFileContent() helper, matching what the server parse route does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): stackable sort keys on verifikat list headers - shift-click adds a column as secondary/tertiary sort key (max 3), plain click keeps the single-key tri-state cycle - sort_by accepts a comma-separated priority list; single tokens stay valid - voucher tiebreak follows the last key's direction (#972 parity) - priority numbers on stacked headers; hint text in the filter dialog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): keep ROT/RUT deduction fields positive on credit notes Crediting an invoice with a ROT/RUT deduction failed 100% of the time: the credit-note path negated deduction_total (and per-item deduction_amount) like the other amounts, but both columns carry CHECK (>= 0), so Postgres rejected the insert and the user only saw 'Kunde inte skapa kreditfaktura'. Store the deduction fields as positive magnitudes, matching the convention everywhere else. The stored sign is inert on credit notes: the reversing verifikat recomputes the ROT/RUT split from the items, and the PDF and amount-to-pay logic skip deductions on credit notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(transactions): share the source chip across inbox and history modes Move SourceFilter to transaction-types.ts (widened with 'bank:other' and 'acct:<id>'), render the one toolbar ContextPicker in both view modes, and drop the narrower duplicate chip inside TransactionHistoryList. The history list now applies the acct:/bank:other narrowing itself and hides skattekonto rows under any bank-side selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump js-yaml to 4.3.1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): recognize PostgREST computed columns in the migration parser The verifikat amount sort orders by total_amount, a PostgREST computed column (a function on the journal_entries row type, migration 20260811100000). The schema guard only modeled real columns, so no-phantom-columns flagged the order as a phantom. Teach the parser that a function whose only argument is a table's row type joins that table's column set, with DROP FUNCTION retraction when the signature names the row type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: resolve PR #1523 review findings - journal-entries route: append the globally unique id tiebreak to every direct-query sort; voucher series+number repeat across fiscal years, so the all-years scope could duplicate or drop rows at page boundaries. Existing order assertions updated, new all-years tiebreak test. - documents inline route: CSP source policy on HTML previews; sandbox alone still loads remote resources, letting a tracking pixel notify the sender on open. New route test asserts the full header. - JournalEntryList: catch rejected list requests so loading cannot stick forever, and gate every post-await state write behind a request generation so a slow earlier request cannot overwrite the current sort. - TransactionHistoryList: pagination follows the selected source scope (reachable with zero matches on the current page, hidden for the skattekonto scope it cannot affect). - transactions page: bank:other picker availability derives from history rows too, not only the pending inbox dataset. - DECISIONS.md: mark the superseded single-sort decision; record the credit-note deduction positive-magnitude invariant and its verified reader inventory (Swedish review flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: guard metadata refetches behind the list request generation fetchAttachmentCounts and fetchRattelseFlags write state after their own awaits; a stale list request's late completion could overwrite attachment counts and rattelse flags for rows a newer request just rendered, showing false missing-underlag warnings. Both helpers now take the caller's generation guard and discard stale completions, including the attachment-counts loaded flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
584 lines
23 KiB
TypeScript
584 lines
23 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
|
import { ResendSignatureError } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
|
import { createQueuedMockSupabase, createMockRequest } from '@/tests/helpers'
|
|
|
|
vi.mock('@/extensions/general/invoice-inbox/lib/resend-inbound', async () => {
|
|
const actual = await vi.importActual<typeof import('@/extensions/general/invoice-inbox/lib/resend-inbound')>(
|
|
'@/extensions/general/invoice-inbox/lib/resend-inbound'
|
|
)
|
|
return {
|
|
...actual,
|
|
verifyInboundWebhook: vi.fn(),
|
|
fetchReceivingEmail: vi.fn(),
|
|
fetchInboundAttachment: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('@supabase/supabase-js', () => ({
|
|
createClient: vi.fn(),
|
|
}))
|
|
|
|
// uploadAndExtract does real storage + Bedrock work; these tests only assert
|
|
// what the webhook hands it. Constants and the pure HTML helpers stay real so
|
|
// MIME gating and body-document building run genuine code.
|
|
vi.mock('@/extensions/general/invoice-inbox/lib/upload-and-extract', async () => {
|
|
const actual = await vi.importActual<typeof import('@/extensions/general/invoice-inbox/lib/upload-and-extract')>(
|
|
'@/extensions/general/invoice-inbox/lib/upload-and-extract'
|
|
)
|
|
return { ...actual, uploadAndExtract: vi.fn() }
|
|
})
|
|
|
|
// applyDomainStatusFromWebhook confirms the receiving capability with Resend
|
|
// before flipping a row to verified: keep that lookup off the network.
|
|
const { domainsMock } = vi.hoisted(() => ({
|
|
domainsMock: {
|
|
get: vi.fn(),
|
|
},
|
|
}))
|
|
vi.mock('resend', () => ({
|
|
Resend: class {
|
|
domains = domainsMock
|
|
},
|
|
}))
|
|
|
|
// Rate limiter is a thin RPC wrapper; bypass it so the queued-mock sequence
|
|
// in each test doesn't have to account for the extra Supabase call.
|
|
vi.mock('@/lib/rate-limits/inbox', () => ({
|
|
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
import { verifyInboundWebhook, fetchReceivingEmail, fetchInboundAttachment } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
|
import { uploadAndExtract } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
function findRoute(method: string, path: string) {
|
|
return invoiceInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
|
|
}
|
|
|
|
const webhookRoute = findRoute('POST', '/inbound')
|
|
|
|
function mockReceivedEvent(overrides?: Record<string, unknown>) {
|
|
return {
|
|
type: 'email.received' as const,
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
data: {
|
|
email_id: 'em_123',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
from: 'billing@supplier.com',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
cc: [],
|
|
bcc: [],
|
|
subject: 'Invoice #5678',
|
|
message_id: '<msg-id@supplier.com>',
|
|
attachments: [
|
|
{
|
|
id: 'att_1',
|
|
filename: 'invoice.pdf',
|
|
size: 12345,
|
|
content_type: 'application/pdf',
|
|
content_id: 'cid1',
|
|
content_disposition: 'attachment',
|
|
},
|
|
],
|
|
...overrides,
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('POST /inbound', () => {
|
|
const originalEnv = { ...process.env }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.RESEND_INBOUND_DOMAIN = 'arcim.io'
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost'
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv }
|
|
})
|
|
|
|
it('returns 503 when RESEND_INBOUND_DOMAIN is not set', async () => {
|
|
delete process.env.RESEND_INBOUND_DOMAIN
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: { type: 'email.received' } })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(503)
|
|
})
|
|
|
|
it('returns 401 when signature verification fails', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockImplementation(() => {
|
|
throw new ResendSignatureError('bad sig')
|
|
})
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: { type: 'email.received' } })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('ignores non-received events with 200', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue({
|
|
type: 'email.sent',
|
|
created_at: '',
|
|
data: {},
|
|
} as never)
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('returns 404 when no recipient matches our domain or a verified custom domain', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['random@contoso.com'] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [] }) // company_inbound_domains lookup finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('routes mail on a verified custom domain to its company (any local part)', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['fakturor@hansbolag.example'], attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ company_id: 'company-9', domain: 'hansbolag.example' }] }) // verified domain
|
|
enqueue({ data: { created_by: 'user-owner-9' } }) // company owner
|
|
enqueue({ data: null }) // body-document dedupe check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-9' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['fakturor@hansbolag.example'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Invoice #5678',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
// A body-only mail now becomes a text/html document for company-9:
|
|
// proves the custom-domain routing reached the processing stage.
|
|
expect(body.data.reason).toBe('email_body')
|
|
expect(vi.mocked(uploadAndExtract).mock.calls[0][2]).toBe('company-9')
|
|
})
|
|
|
|
it('does not route mail for an unverified custom domain', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ to: ['faktura@pending-bolag.example'] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
// status='verified' filter means a pending claim never matches
|
|
enqueue({ data: [] })
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('prefers the shared-domain address when both shared and custom recipients are present', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({
|
|
to: ['acme-ab-x7f2@arcim.io', 'faktura@hansbolag.example'],
|
|
attachments: [],
|
|
}) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
// Only the three shared-path queries are enqueued: if the handler also
|
|
// ran the custom-domain lookup, the queue would shift and created_by
|
|
// would resolve to null (500). A 200 proves the shared path won.
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // body-document dedupe check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-1' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io', 'faktura@hansbolag.example'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Invoice #5678',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('email_body')
|
|
})
|
|
|
|
it('applies domain.updated events to custom-domain rows', async () => {
|
|
process.env.RESEND_API_KEY = 'test-key'
|
|
domainsMock.get.mockResolvedValue({
|
|
data: {
|
|
id: 'rd_123',
|
|
status: 'verified',
|
|
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
|
records: [],
|
|
},
|
|
error: null,
|
|
})
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue({
|
|
type: 'domain.updated',
|
|
created_at: '2026-07-01T10:00:00Z',
|
|
data: {
|
|
id: 'rd_123',
|
|
name: 'hansbolag.example',
|
|
status: 'verified',
|
|
created_at: '2026-07-01T09:00:00Z',
|
|
region: 'eu-west-1',
|
|
records: [],
|
|
},
|
|
} as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'row-1', verified_at: null } }) // row by resend_domain_id
|
|
enqueue({ data: null }) // update
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.domain_updated).toBe(true)
|
|
})
|
|
|
|
it('returns 404 when the address is not in company_inboxes', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null }) // company_inboxes lookup returns nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('returns 410 when the address is deprecated', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'deprecated' } })
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(410)
|
|
})
|
|
|
|
it('skips already-processed attachments (per-attachment idempotency)', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } }) // inbox lookup
|
|
enqueue({ data: { created_by: 'user-owner-1' } }) // company owner
|
|
enqueue({ data: { id: 'existing-item-1' } }) // per-attachment dup check finds existing row
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Invoice #5678',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [
|
|
{ id: 'att_1', filename: 'invoice.pdf', size: 100, content_type: 'application/pdf', content_id: 'cid', content_disposition: 'attachment' },
|
|
],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.results[0].duplicate).toBe(true)
|
|
expect(body.data.results[0].inbox_item_id).toBe('existing-item-1')
|
|
expect(fetchInboundAttachment).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 500 when the company has no created_by owner', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: null } }) // company with no owner
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
expect(res.status).toBe(500)
|
|
})
|
|
|
|
it('stores the mail body as a text/html document when the mail has no attachments', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // body-document dedupe check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-body-1' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Kvitto på ditt köp',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: '<div>Att betala: <b>1 234,56 kr</b></div>',
|
|
text: 'Att betala: 1 234,56 kr',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('email_body')
|
|
expect(body.data.processed).toBe(1)
|
|
expect(body.data.inbox_item_id).toBe('item-body-1')
|
|
expect(fetchInboundAttachment).not.toHaveBeenCalled()
|
|
|
|
const [, , companyId, file, source] = vi.mocked(uploadAndExtract).mock.calls[0]
|
|
expect(companyId).toBe('company-1')
|
|
expect(source).toBe('email')
|
|
expect(file.type).toBe('text/html')
|
|
expect(file.name).toMatch(/^mail-.*\.html$/)
|
|
const stored = new TextDecoder().decode(new Uint8Array(file.buffer))
|
|
expect(stored.toLowerCase().startsWith('<!doctype html')).toBe(true)
|
|
expect(stored).toContain('<div>Att betala: <b>1 234,56 kr</b></div>')
|
|
})
|
|
|
|
it('keeps the error row for a no-attachment mail with an empty body', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // error-row insert
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Tomt mejl',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: ' ',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('no_attachments')
|
|
expect(uploadAndExtract).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not duplicate the body document when Resend retries the webhook', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: { id: 'existing-body-item' } }) // dedupe check finds the first delivery's row
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Kvitto',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: '<div>Kvitto</div>',
|
|
text: null,
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('email_body_duplicate')
|
|
expect(body.data.inbox_item_id).toBe('existing-body-item')
|
|
expect(uploadAndExtract).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to the error row when the body document upload fails', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
|
mockReceivedEvent({ attachments: [] }) as never
|
|
)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // dedupe check finds nothing
|
|
enqueue({ data: null }) // fallback error-row insert
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockRejectedValue(new Error('storage down'))
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Kvitto',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: '<div>Kvitto</div>',
|
|
text: null,
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [],
|
|
} as never)
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.reason).toBe('no_attachments')
|
|
})
|
|
|
|
it('accepts a text/html attachment and wraps it into a full document', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // per-attachment dup check finds nothing
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-html-1' } as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Faktura',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Se bifogad faktura',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [
|
|
{ id: 'att_html', filename: 'faktura.html', size: 100, content_type: 'text/html', content_id: 'cid', content_disposition: 'attachment' },
|
|
],
|
|
} as never)
|
|
vi.mocked(fetchInboundAttachment).mockResolvedValue({
|
|
id: 'att_html',
|
|
filename: 'faktura.html',
|
|
contentType: 'text/html',
|
|
buffer: new TextEncoder().encode('<div>Faktura 123: 500 kr</div>').buffer as ArrayBuffer,
|
|
})
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.results[0].inbox_item_id).toBe('item-html-1')
|
|
|
|
const [, , , file] = vi.mocked(uploadAndExtract).mock.calls[0]
|
|
expect(file.type).toBe('text/html')
|
|
const stored = new TextDecoder().decode(new Uint8Array(file.buffer))
|
|
expect(stored.toLowerCase().startsWith('<!doctype html')).toBe(true)
|
|
expect(stored).toContain('<div>Faktura 123: 500 kr</div>')
|
|
})
|
|
|
|
it('still rejects attachment types outside the email allowlist', async () => {
|
|
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
|
enqueue({ data: { created_by: 'user-owner-1' } })
|
|
enqueue({ data: null }) // per-attachment dup check finds nothing
|
|
enqueue({ data: null }) // rejection-row insert
|
|
vi.mocked(createClient).mockReturnValue(supabase as never)
|
|
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
|
object: 'email',
|
|
id: 'em_123',
|
|
to: ['acme-ab-x7f2@arcim.io'],
|
|
from: 'billing@supplier.com',
|
|
created_at: '2026-04-20T10:00:00Z',
|
|
subject: 'Zip',
|
|
bcc: null,
|
|
cc: null,
|
|
reply_to: null,
|
|
html: null,
|
|
text: 'Body',
|
|
headers: {},
|
|
message_id: '<msg@x>',
|
|
raw: null,
|
|
attachments: [
|
|
{ id: 'att_zip', filename: 'faktura.zip', size: 100, content_type: 'application/zip', content_id: 'cid', content_disposition: 'attachment' },
|
|
],
|
|
} as never)
|
|
vi.mocked(fetchInboundAttachment).mockResolvedValue({
|
|
id: 'att_zip',
|
|
filename: 'faktura.zip',
|
|
contentType: 'application/zip',
|
|
buffer: new ArrayBuffer(8),
|
|
})
|
|
|
|
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
|
const res = await webhookRoute.handler(request)
|
|
const body = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.results[0].error).toBe('Unsupported type application/zip')
|
|
expect(uploadAndExtract).not.toHaveBeenCalled()
|
|
})
|
|
})
|