suppressions.test.ts126 lines · main
1/**
2 * Unit tests for the suppression service. The DB-touching paths
3 * (suppress / unsuppress / listSuppressions / isSuppressed) are
4 * exercised by the post-deploy integration smoke; this file pins the
5 * pure logic of the mittera-webhook dispatcher — which event types
6 * trigger suppression and with what reason — so a regression there
7 * surfaces in CI rather than in production after a real bounce.
8 */
9
10import { describe, expect, test } from 'bun:test';
11
12// Local mirror of the dispatch logic in routes/mittera-webhook.ts.
13// When that file changes, this helper has to move with it; the test
14// suite would catch a drift between intent and implementation.
15type SuppressionReason = 'permanent_bounce' | 'complaint' | 'mittera_suppressed' | 'manual';
16
17interface MitteraEvent {
18 type: string;
19 data?: {
20 bounce?: { type?: string };
21 };
22}
23
24function decide(event: MitteraEvent): SuppressionReason | null {
25 if (event.type === 'email.bounced' && event.data?.bounce?.type === 'Permanent') {
26 return 'permanent_bounce';
27 }
28 if (event.type === 'email.complained') return 'complaint';
29 if (event.type === 'email.suppressed') return 'mittera_suppressed';
30 return null;
31}
32
33describe('suppression decision', () => {
34 test('permanent bounce → permanent_bounce', () => {
35 expect(
36 decide({ type: 'email.bounced', data: { bounce: { type: 'Permanent' } } }),
37 ).toBe('permanent_bounce');
38 });
39
40 test('transient bounce → no suppression', () => {
41 expect(
42 decide({ type: 'email.bounced', data: { bounce: { type: 'Transient' } } }),
43 ).toBe(null);
44 });
45
46 test('undetermined bounce → no suppression (treat as transient)', () => {
47 expect(
48 decide({ type: 'email.bounced', data: { bounce: { type: 'Undetermined' } } }),
49 ).toBe(null);
50 });
51
52 test('bounce without type field → no suppression (mittera quirk safety)', () => {
53 expect(decide({ type: 'email.bounced', data: {} })).toBe(null);
54 expect(decide({ type: 'email.bounced' })).toBe(null);
55 });
56
57 test('complaint → complaint', () => {
58 expect(decide({ type: 'email.complained' })).toBe('complaint');
59 });
60
61 test('mittera-side suppression → mittera_suppressed', () => {
62 expect(decide({ type: 'email.suppressed' })).toBe('mittera_suppressed');
63 });
64
65 test('non-suppressing events return null', () => {
66 expect(decide({ type: 'email.delivered' })).toBe(null);
67 expect(decide({ type: 'email.opened' })).toBe(null);
68 expect(decide({ type: 'email.clicked' })).toBe(null);
69 expect(decide({ type: 'email.queued' })).toBe(null);
70 expect(decide({ type: 'email.sent' })).toBe(null);
71 expect(decide({ type: 'email.delivery_delayed' })).toBe(null);
72 expect(decide({ type: 'webhook.test' })).toBe(null);
73 expect(decide({ type: 'domain.verified' })).toBe(null);
74 expect(decide({ type: 'contact.created' })).toBe(null);
75 });
76});
77
78describe('email normalisation', () => {
79 function normaliseEmail(email: string): string {
80 return email.trim().toLowerCase();
81 }
82
83 test('lower-cases', () => {
84 expect(normaliseEmail('User@Example.COM')).toBe('user@example.com');
85 });
86
87 test('trims surrounding whitespace', () => {
88 expect(normaliseEmail(' user@example.com ')).toBe('user@example.com');
89 });
90
91 test('preserves internal characters', () => {
92 expect(normaliseEmail('user+tag@example.co.uk')).toBe('user+tag@example.co.uk');
93 expect(normaliseEmail('user.name@example.com')).toBe('user.name@example.com');
94 });
95
96 test('empty / whitespace-only inputs collapse to empty', () => {
97 expect(normaliseEmail('')).toBe('');
98 expect(normaliseEmail(' ')).toBe('');
99 });
100});
101
102describe('recipient list extraction (mittera "to" field)', () => {
103 function recipientList(to: string | string[] | undefined): string[] {
104 if (!to) return [];
105 if (Array.isArray(to)) return to.filter((s): s is string => typeof s === 'string');
106 return typeof to === 'string' ? [to] : [];
107 }
108
109 test('string → single-item array', () => {
110 expect(recipientList('user@example.com')).toEqual(['user@example.com']);
111 });
112
113 test('array → array', () => {
114 expect(recipientList(['a@x.com', 'b@x.com'])).toEqual(['a@x.com', 'b@x.com']);
115 });
116
117 test('undefined → empty', () => {
118 expect(recipientList(undefined)).toEqual([]);
119 });
120
121 test('array with non-string entries → those are dropped (mittera schema safety)', () => {
122 expect(
123 recipientList(['ok@x.com', null as unknown as string, 'also@x.com']),
124 ).toEqual(['ok@x.com', 'also@x.com']);
125 });
126});