email-admin.test.ts112 lines · main
1/**
2 * Unit tests for the per-template stats join (Phase 8 §3). The DB-touching
3 * wrapper (getEmailAdminSummary) is left to the post-deploy integration
4 * smoke; this file pins the pure aggregation — how send rows and webhook
5 * outcome rows correlate into per-template counts — so a regression there
6 * surfaces in CI rather than as silently-wrong dashboard numbers.
7 */
8
9import { describe, expect, test } from 'bun:test';
10
11import { aggregateTemplateStats } from './email-admin.js';
12
13interface Row {
14 action: string;
15 metadata: Record<string, unknown> | null;
16}
17
18const send = (template: string, messageId: string | null, transport: 'mittera' | 'smtp' = 'mittera'): Row => ({
19 action: `${transport}.${template}.sent`,
20 metadata: messageId ? { messageId } : {},
21});
22
23const outcome = (type: 'delivered' | 'bounced' | 'complained', messageId: string | null): Row => ({
24 action: `mittera.email.${type}`,
25 metadata: messageId ? { messageId } : {},
26});
27
28describe('aggregateTemplateStats', () => {
29 test('counts sends grouped by template', () => {
30 const stats = aggregateTemplateStats(
31 [send('magic_link', 'm1'), send('magic_link', 'm2'), send('invitation', 'm3')],
32 [],
33 );
34 const byTemplate = Object.fromEntries(stats.map((s) => [s.template, s.sends]));
35 expect(byTemplate.magic_link).toBe(2);
36 expect(byTemplate.invitation).toBe(1);
37 });
38
39 test('correlates delivery outcomes back to the template via messageId', () => {
40 const stats = aggregateTemplateStats(
41 [send('magic_link', 'm1'), send('magic_link', 'm2'), send('invitation', 'm3')],
42 [outcome('delivered', 'm1'), outcome('bounced', 'm2'), outcome('complained', 'm3')],
43 );
44 const magic = stats.find((s) => s.template === 'magic_link')!;
45 expect(magic).toEqual({ template: 'magic_link', sends: 2, delivered: 1, bounced: 1, complained: 0 });
46 const invite = stats.find((s) => s.template === 'invitation')!;
47 expect(invite).toEqual({ template: 'invitation', sends: 1, delivered: 0, bounced: 0, complained: 1 });
48 });
49
50 test('handles the old double-prefixed mittera.email.* outcome action shape', () => {
51 const stats = aggregateTemplateStats(
52 [send('verify_email', 'm9')],
53 [{ action: 'mittera.email.delivered', metadata: { messageId: 'm9' } }],
54 );
55 expect(stats[0]!.delivered).toBe(1);
56 });
57
58 test('counts smtp-fallback sends under the same template', () => {
59 const stats = aggregateTemplateStats(
60 [send('reset_password', 'm1', 'mittera'), send('reset_password', 'm2', 'smtp')],
61 [],
62 );
63 expect(stats[0]).toEqual({
64 template: 'reset_password',
65 sends: 2,
66 delivered: 0,
67 bounced: 0,
68 complained: 0,
69 });
70 });
71
72 test('skips an outcome whose send is outside the window (no template to attribute)', () => {
73 const stats = aggregateTemplateStats(
74 [send('magic_link', 'm1')],
75 [outcome('delivered', 'm1'), outcome('bounced', 'unknown-mid')],
76 );
77 expect(stats).toHaveLength(1);
78 expect(stats[0]).toEqual({
79 template: 'magic_link',
80 sends: 1,
81 delivered: 1,
82 bounced: 0,
83 complained: 0,
84 });
85 });
86
87 test('ignores non-send / non-outcome rows and sends with no messageId still count', () => {
88 const stats = aggregateTemplateStats(
89 [
90 send('magic_link', null), // accepted by mittera but no id echoed — still a send
91 { action: 'mittera.email.opened', metadata: { messageId: 'm1' } }, // not a .sent row
92 ],
93 [{ action: 'mittera.email.opened', metadata: { messageId: 'm1' } }], // not a tracked outcome
94 );
95 expect(stats).toHaveLength(1);
96 expect(stats[0]).toEqual({
97 template: 'magic_link',
98 sends: 1,
99 delivered: 0,
100 bounced: 0,
101 complained: 0,
102 });
103 });
104
105 test('sorts templates by send volume descending', () => {
106 const stats = aggregateTemplateStats(
107 [send('a', 'a1'), send('b', 'b1'), send('b', 'b2'), send('b', 'b3'), send('c', 'c1'), send('c', 'c2')],
108 [],
109 );
110 expect(stats.map((s) => s.template)).toEqual(['b', 'c', 'a']);
111 });
112});