frontend-analytics-model.test.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import assert from 'node:assert/strict';
  2. import { test } from 'node:test';
  3. import {
  4. buildDashboardSummary,
  5. buildDeliveryFunnel,
  6. buildEngagementSummary,
  7. buildEngagementTrend,
  8. buildEventTimeline,
  9. buildDomainRanking,
  10. buildHourlyHeatmap,
  11. buildStatusDistribution,
  12. buildTopLinks,
  13. buildTrendSeries
  14. } from '../src/frontend/analytics-model.js';
  15. test('builds dashboard summary from analytics, DNS health, and SMTP state', () => {
  16. const summary = buildDashboardSummary({
  17. analytics: {
  18. summary: {
  19. total: 20,
  20. queued: 18,
  21. failed: 2,
  22. recipients: 25,
  23. today: 8,
  24. successRate: 90,
  25. verifiedDomains: 1
  26. }
  27. },
  28. domains: [
  29. domain(true, [
  30. record('verification', 'ok'),
  31. record('dkim', 'ok'),
  32. record('spf', 'ok'),
  33. record('dmarc', 'ok'),
  34. record('sender-a', 'ok')
  35. ]),
  36. domain(false, [
  37. record('verification', 'ok'),
  38. record('dkim', 'warn'),
  39. record('spf', 'missing')
  40. ])
  41. ],
  42. events: [{ createdAt: '2026-07-08T12:30:00.000Z' }],
  43. config: { submission: { enabled: true } },
  44. smtpCredential: { passwordSet: true }
  45. });
  46. assert.equal(summary.verifiedDomains, 1);
  47. assert.equal(summary.today, 8);
  48. assert.equal(summary.successRate, 90);
  49. assert.equal(summary.bounceRate, 10);
  50. assert.equal(summary.complaintRate, 0);
  51. assert.equal(summary.dnsIssues, 4);
  52. assert.equal(summary.smtpReady, true);
  53. assert.equal(summary.lastSentAt, '2026-07-08T12:30:00.000Z');
  54. });
  55. test('normalizes chart datasets for trend, status, ranking, and hourly views', () => {
  56. const analytics = {
  57. byDay: [
  58. { day: '2026-07-07', total: 6, queued: 5, failed: 1, recipients: 8 },
  59. { day: '2026-07-08', total: 9, queued: 9, failed: 0, recipients: 10 }
  60. ],
  61. byStatus: [
  62. { status: 'queued', total: 14 },
  63. { status: 'failed', total: 1 }
  64. ],
  65. byDomain: [
  66. { domain: 'b.example.com', total: 2, queued: 2, failed: 0, recipients: 2 },
  67. { domain: 'a.example.com', total: 8, queued: 7, failed: 1, recipients: 12 }
  68. ],
  69. hourly: [
  70. { hour: 0, total: 0, queued: 0, failed: 0 },
  71. { hour: 9, total: 4, queued: 3, failed: 1 }
  72. ]
  73. };
  74. assert.deepEqual(buildTrendSeries(analytics), [
  75. { date: '2026-07-07', total: 6, accepted: 5, failed: 1, recipients: 8 },
  76. { date: '2026-07-08', total: 9, accepted: 9, failed: 0, recipients: 10 }
  77. ]);
  78. assert.deepEqual(buildStatusDistribution(analytics), [
  79. { status: 'queued', label: 'queued', value: 14 },
  80. { status: 'failed', label: 'failed', value: 1 }
  81. ]);
  82. assert.deepEqual(buildDomainRanking(analytics).map((item) => item.domain), ['a.example.com', 'b.example.com']);
  83. assert.deepEqual(buildHourlyHeatmap(analytics).find((item) => item.hour === '09:00'), {
  84. hour: '09:00',
  85. total: 4,
  86. accepted: 3,
  87. failed: 1
  88. });
  89. });
  90. test('builds delivery funnel and event timeline models', () => {
  91. const analytics = {
  92. deliveryFunnel: [
  93. { stage: 'submitted', total: 5, rate: 100 },
  94. { stage: 'accepted', total: 4, rate: 80 },
  95. { stage: 'delivered', total: 2, rate: 40 },
  96. { stage: 'pending', total: 1, rate: 20 },
  97. { stage: 'failed', total: 2, rate: 40 }
  98. ]
  99. };
  100. assert.deepEqual(buildDeliveryFunnel(analytics), [
  101. { stage: 'submitted', total: 5, rate: 100, tone: 'info' },
  102. { stage: 'accepted', total: 4, rate: 80, tone: 'info' },
  103. { stage: 'delivered', total: 2, rate: 40, tone: 'success' },
  104. { stage: 'pending', total: 1, rate: 20, tone: 'warning' },
  105. { stage: 'failed', total: 2, rate: 40, tone: 'error' }
  106. ]);
  107. const timeline = buildEventTimeline({
  108. id: 7,
  109. status: 'sent',
  110. queueId: 'Q7',
  111. createdAt: '2026-07-09T11:59:00.000Z',
  112. deliveredAt: '2026-07-09T12:00:00.000Z',
  113. deliveryAttempts: [{
  114. at: '2026-07-09T12:00:00.000Z',
  115. queueId: 'Q7',
  116. status: 'sent',
  117. recipient: 'user@example.com',
  118. relay: 'mx.example.net',
  119. response: '250 ok'
  120. }],
  121. webhookDeliveries: [{
  122. id: 11,
  123. webhookId: 3,
  124. userId: 1,
  125. sendEventId: 7,
  126. eventType: 'sent',
  127. status: 'success',
  128. attemptCount: 1,
  129. createdAt: '2026-07-09T12:00:01.000Z',
  130. lastAttemptAt: '2026-07-09T12:00:02.000Z',
  131. responseStatus: 200
  132. }]
  133. });
  134. assert.deepEqual(timeline.map((item) => item.stage), ['submitted', 'accepted', 'delivered', 'webhook']);
  135. assert.equal(timeline[1].queueId, 'Q7');
  136. assert.equal(timeline[2].tone, 'success');
  137. assert.equal(timeline[3].status, 'success');
  138. });
  139. test('normalizes engagement KPIs trends links and timeline events', () => {
  140. const analytics = {
  141. engagement: {
  142. trackedDelivered: 10,
  143. totalOpens: 8,
  144. uniqueOpens: 6,
  145. proxyOpens: 2,
  146. totalClicks: 5,
  147. uniqueClicks: 4,
  148. scannerEvents: 3,
  149. openRate: 60,
  150. clickRate: 40,
  151. clickToOpenRate: 66.7
  152. },
  153. engagementByDay: [
  154. { day: '2026-07-08', opens: 3, uniqueOpens: 2, clicks: 1, uniqueClicks: 1, scannerEvents: 1 },
  155. { day: '2026-07-09', opens: 5, uniqueOpens: 4, clicks: 4, uniqueClicks: 3, scannerEvents: 2 }
  156. ],
  157. topLinks: [{
  158. fingerprint: 'abc',
  159. target: 'https://example.com/path',
  160. targetOrigin: 'https://example.com',
  161. clicks: 5,
  162. uniqueClicks: 4,
  163. lastClickedAt: '2026-07-09T12:00:00.000Z'
  164. }]
  165. };
  166. assert.deepEqual(buildEngagementSummary(analytics), analytics.engagement);
  167. assert.deepEqual(buildEngagementTrend(analytics)[0], {
  168. date: '2026-07-08',
  169. opens: 3,
  170. uniqueOpens: 2,
  171. clicks: 1,
  172. uniqueClicks: 1,
  173. scannerEvents: 1
  174. });
  175. assert.equal(buildTopLinks(analytics)[0].target, 'https://example.com/path');
  176. const timeline = buildEventTimeline({
  177. id: 9,
  178. status: 'sent',
  179. createdAt: '2026-07-09T11:00:00.000Z',
  180. deliveryAttempts: [{
  181. at: '2026-07-09T12:02:00.000Z',
  182. status: 'sent',
  183. recipient: 'user@example.com',
  184. response: '250 ok'
  185. }],
  186. tracking: {
  187. events: [
  188. { id: 1, eventType: 'open', source: 'proxy', occurredAt: '2026-07-09T12:00:00.000Z' },
  189. {
  190. id: 2,
  191. eventType: 'click',
  192. source: 'direct',
  193. targetOrigin: 'https://example.com',
  194. occurredAt: '2026-07-09T12:01:00.000Z'
  195. }
  196. ]
  197. }
  198. });
  199. assert.deepEqual(timeline.map((item) => item.stage), ['submitted', 'opened', 'clicked', 'delivered']);
  200. assert.equal(timeline[1].source, 'proxy');
  201. assert.equal(timeline[2].targetOrigin, 'https://example.com');
  202. });
  203. function domain(verified, records) {
  204. return {
  205. status: {
  206. verified,
  207. records
  208. }
  209. };
  210. }
  211. function record(key, status) {
  212. return { key, status };
  213. }