/ tests / inbound / inbound-processor.unit.test.js
inbound-processor.unit.test.js
  1  /**
  2   * Unit Tests for Inbound Processor Module
  3   *
  4   * Tests all exported functions:
  5   * - pollAllChannels() - dynamic import of SMS/Email polling
  6   * - processAllReplies() - dynamic import of SMS/Email reply processing
  7   * - getUnreadConversations(limit) - DB query for unread inbound messages
  8   * - markConversationRead(conversationId) - sets read_at timestamp
  9   * - getConversationThread(outreachId) - gets thread by site_id
 10   * - markThreadRead(outreachId) - marks all inbound messages in thread as read
 11   * - getInboundStats() - aggregated stats on inbound conversations
 12   *
 13   * Uses mock.module for pollAllChannels/processAllReplies to avoid real SMS/Email imports.
 14   * Uses shared in-memory SQLite database with production schema for DB function tests.
 15   */
 16  
 17  import { test, describe, before, after, beforeEach, mock } from 'node:test';
 18  import assert from 'node:assert';
 19  import Database from 'better-sqlite3';
 20  import { readFileSync } from 'fs';
 21  import { join, dirname } from 'path';
 22  import { fileURLToPath } from 'url';
 23  import { createPgMock } from '../helpers/pg-mock.js';
 24  
 25  const __filename = fileURLToPath(import.meta.url);
 26  const __dirname = dirname(__filename);
 27  const projectRoot = join(__dirname, '../..');
 28  const schemaPath = join(projectRoot, 'db/schema.sql');
 29  
 30  // --- Mock Setup for dynamic imports (pollAllChannels / processAllReplies) ---
 31  
 32  const mockSMS = {
 33    pollInboundSMS: mock.fn(async () => ({ processed: 2, stored: 1, unmatched: 1 })),
 34    processPendingReplies: mock.fn(async () => ({ sent: 1, failed: 0 })),
 35  };
 36  
 37  const mockEmail = {
 38    pollInboundEmails: mock.fn(async () => ({ processed: 3, stored: 2, unmatched: 1 })),
 39    processPendingReplies: mock.fn(async () => ({ sent: 2, failed: 1 })),
 40  };
 41  
 42  await mock.module('../../src/inbound/sms.js', { namedExports: mockSMS });
 43  await mock.module('../../src/inbound/email.js', { namedExports: mockEmail });
 44  
 45  // Shared in-memory database — full production schema
 46  const db = new Database(':memory:');
 47  const schema = readFileSync(schemaPath, 'utf-8');
 48  db.exec(schema);
 49  db.pragma('foreign_keys = ON');
 50  
 51  // Add conversion_score column (processor.js references s.conversion_score,
 52  // which exists in production via migration but not in base schema.sql)
 53  try {
 54    db.exec('ALTER TABLE sites ADD COLUMN conversion_score REAL');
 55  } catch {
 56    // Column may already exist if schema was updated
 57  }
 58  
 59  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 60  
 61  // Import processor AFTER all mock.module() calls so dynamic imports resolve to mocks
 62  const {
 63    pollAllChannels,
 64    processAllReplies,
 65    getUnreadConversations,
 66    getConversationThread,
 67    markConversationRead,
 68    markThreadRead,
 69    getInboundStats,
 70  } = await import('../../src/inbound/processor.js');
 71  
 72  // --- Helper functions ---
 73  
 74  /**
 75   * Insert a site into the test database
 76   */
 77  function insertSite({ id, domain, keyword, landingPageUrl, conversionScore }) {
 78    db.prepare(
 79      `INSERT INTO sites (id, domain, landing_page_url, keyword, conversion_score)
 80       VALUES (?, ?, ?, ?, ?)`
 81    ).run(id, domain, landingPageUrl || `https://${domain}`, keyword, conversionScore || null);
 82  }
 83  
 84  /**
 85   * Insert an outreach into the test database
 86   */
 87  function insertOutreach({ id, siteId, contactMethod, contactUri, proposalText, subjectLine, status }) {
 88    db.prepare(
 89      `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status)
 90       VALUES (?, ?, ?, ?, ?, ?, 'outbound', ?)`
 91    ).run(
 92      id,
 93      siteId,
 94      contactMethod,
 95      contactUri,
 96      proposalText || 'Test proposal',
 97      subjectLine || 'Test subject',
 98      status || 'sent'
 99    );
100  }
101  
102  /**
103   * Insert a conversation into the test database
104   */
105  function insertConversation({ id, outreachId, direction, channel, senderIdentifier, messageBody, subjectLine, sentiment, readAt }) {
106    db.prepare(
107      `INSERT INTO messages (id, site_id, direction, contact_method, contact_uri, message_body, subject_line, sentiment, read_at)
108       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
109    ).run(
110      id || null,
111      outreachId,
112      direction || 'inbound',
113      channel || 'email',
114      senderIdentifier || 'test@example.com',
115      messageBody,
116      subjectLine || null,
117      sentiment || null,
118      readAt || null
119    );
120  }
121  
122  // Insert base test data once
123  before(() => {
124    insertSite({ id: 1, domain: 'acme-plumbing.com', keyword: 'plumber near me', landingPageUrl: 'https://acme-plumbing.com', conversionScore: 72 });
125    insertSite({ id: 2, domain: 'best-roofing.com', keyword: 'roof repair', landingPageUrl: 'https://best-roofing.com', conversionScore: 58 });
126    insertSite({ id: 3, domain: 'elite-hvac.com', keyword: 'ac installation', landingPageUrl: 'https://elite-hvac.com', conversionScore: 85 });
127  });
128  
129  // =============================================================================
130  // pollAllChannels Tests
131  // =============================================================================
132  
133  describe('pollAllChannels', () => {
134    beforeEach(() => {
135      mockSMS.pollInboundSMS.mock.resetCalls();
136      mockEmail.pollInboundEmails.mock.resetCalls();
137      // Reset to default successful implementations
138      mockSMS.pollInboundSMS.mock.mockImplementation(async () => ({ processed: 2, stored: 1, unmatched: 1 }));
139      mockEmail.pollInboundEmails.mock.mockImplementation(async () => ({ processed: 3, stored: 2, unmatched: 1 }));
140    });
141  
142    test('returns combined results from SMS and Email', async () => {
143      const results = await pollAllChannels();
144  
145      assert.deepStrictEqual(results.sms, { processed: 2, stored: 1, unmatched: 1 });
146      assert.deepStrictEqual(results.email, { processed: 3, stored: 2, unmatched: 1 });
147      assert.strictEqual(mockSMS.pollInboundSMS.mock.callCount(), 1);
148      assert.strictEqual(mockEmail.pollInboundEmails.mock.callCount(), 1);
149    });
150  
151    test('handles SMS poll failure gracefully', async () => {
152      mockSMS.pollInboundSMS.mock.mockImplementation(async () => {
153        throw new Error('Twilio connection failed');
154      });
155  
156      const results = await pollAllChannels();
157  
158      // SMS should remain at default zeros when it fails
159      assert.deepStrictEqual(results.sms, { processed: 0, stored: 0, unmatched: 0 });
160      // Email should still succeed
161      assert.deepStrictEqual(results.email, { processed: 3, stored: 2, unmatched: 1 });
162    });
163  
164    test('handles Email poll failure gracefully', async () => {
165      mockEmail.pollInboundEmails.mock.mockImplementation(async () => {
166        throw new Error('Resend API error');
167      });
168  
169      const results = await pollAllChannels();
170  
171      // SMS should still succeed
172      assert.deepStrictEqual(results.sms, { processed: 2, stored: 1, unmatched: 1 });
173      // Email should remain at default zeros when it fails
174      assert.deepStrictEqual(results.email, { processed: 0, stored: 0, unmatched: 0 });
175    });
176  
177    test('handles both SMS and Email poll failure gracefully', async () => {
178      mockSMS.pollInboundSMS.mock.mockImplementation(async () => { throw new Error('Twilio down'); });
179      mockEmail.pollInboundEmails.mock.mockImplementation(async () => { throw new Error('Resend down'); });
180  
181      const results = await pollAllChannels();
182  
183      assert.deepStrictEqual(results.sms, { processed: 0, stored: 0, unmatched: 0 });
184      assert.deepStrictEqual(results.email, { processed: 0, stored: 0, unmatched: 0 });
185    });
186  });
187  
188  // =============================================================================
189  // processAllReplies Tests
190  // =============================================================================
191  
192  describe('processAllReplies', () => {
193    beforeEach(() => {
194      mockSMS.processPendingReplies.mock.resetCalls();
195      mockEmail.processPendingReplies.mock.resetCalls();
196      // Reset to default successful implementations
197      mockSMS.processPendingReplies.mock.mockImplementation(async () => ({ sent: 1, failed: 0 }));
198      mockEmail.processPendingReplies.mock.mockImplementation(async () => ({ sent: 2, failed: 1 }));
199    });
200  
201    test('returns combined results from SMS and Email', async () => {
202      const results = await processAllReplies();
203  
204      assert.deepStrictEqual(results.sms, { sent: 1, failed: 0 });
205      assert.deepStrictEqual(results.email, { sent: 2, failed: 1 });
206      assert.strictEqual(mockSMS.processPendingReplies.mock.callCount(), 1);
207      assert.strictEqual(mockEmail.processPendingReplies.mock.callCount(), 1);
208    });
209  
210    test('handles SMS processing failure gracefully', async () => {
211      mockSMS.processPendingReplies.mock.mockImplementation(async () => { throw new Error('SMS send failed'); });
212  
213      const results = await processAllReplies();
214  
215      assert.deepStrictEqual(results.sms, { sent: 0, failed: 0 });
216      assert.deepStrictEqual(results.email, { sent: 2, failed: 1 });
217    });
218  
219    test('handles Email processing failure gracefully', async () => {
220      mockEmail.processPendingReplies.mock.mockImplementation(async () => { throw new Error('Email send failed'); });
221  
222      const results = await processAllReplies();
223  
224      assert.deepStrictEqual(results.sms, { sent: 1, failed: 0 });
225      assert.deepStrictEqual(results.email, { sent: 0, failed: 0 });
226    });
227  
228    test('handles both SMS and Email processing failure gracefully', async () => {
229      mockSMS.processPendingReplies.mock.mockImplementation(async () => { throw new Error('SMS down'); });
230      mockEmail.processPendingReplies.mock.mockImplementation(async () => { throw new Error('Email down'); });
231  
232      const results = await processAllReplies();
233  
234      assert.deepStrictEqual(results.sms, { sent: 0, failed: 0 });
235      assert.deepStrictEqual(results.email, { sent: 0, failed: 0 });
236    });
237  });
238  
239  // =============================================================================
240  // Database Function Tests
241  // =============================================================================
242  
243  describe('Database functions', () => {
244  
245    // =========================================================================
246    // getUnreadConversations
247    // =========================================================================
248  
249    describe('getUnreadConversations', () => {
250      before(() => {
251        // Clean messages for predictable state, then re-insert outbound messages.
252        db.exec('DELETE FROM messages');
253        insertOutreach({ id: 101, siteId: 1, contactMethod: 'email', contactUri: 'info@acme-plumbing.com', proposalText: 'Hi, we noticed your website...', subjectLine: 'Website Improvement Proposal', status: 'sent' });
254        insertOutreach({ id: 102, siteId: 2, contactMethod: 'sms', contactUri: '+14155551234', proposalText: 'Quick website fix offer', subjectLine: null, status: 'delivered' });
255        insertOutreach({ id: 103, siteId: 3, contactMethod: 'email', contactUri: 'owner@elite-hvac.com', proposalText: 'Premium website redesign', subjectLine: 'Premium Redesign', status: 'sent' });
256      });
257  
258      test('returns empty array when no conversations exist', async () => {
259        const conversations = await getUnreadConversations();
260        assert.ok(Array.isArray(conversations), 'Should return an array');
261        assert.strictEqual(conversations.length, 0, 'Should be empty with no conversations');
262      });
263  
264      test('returns unread inbound conversations with joined data', async () => {
265        insertConversation({ id: 1, outreachId: 1, direction: 'inbound', channel: 'email', senderIdentifier: 'info@acme-plumbing.com', messageBody: 'Yes, I am interested in your proposal!', subjectLine: 'Re: Website Improvement Proposal', sentiment: 'positive' });
266        insertConversation({ id: 2, outreachId: 2, direction: 'inbound', channel: 'sms', senderIdentifier: '+14155551234', messageBody: 'Tell me more about pricing', sentiment: 'neutral' });
267        insertConversation({ id: 3, outreachId: 1, direction: 'inbound', channel: 'email', senderIdentifier: 'info@acme-plumbing.com', messageBody: 'When can we schedule a call?', subjectLine: 'Re: Website Improvement Proposal', sentiment: 'positive' });
268  
269        const conversations = await getUnreadConversations();
270  
271        assert.strictEqual(conversations.length, 3, 'Should return all 3 unread inbound conversations');
272  
273        const first = conversations[0];
274        assert.ok(first.id, 'Should have conversation id');
275        assert.ok(first.site_id, 'Should have site_id');
276        assert.ok(first.domain, 'Should have domain from sites table');
277        assert.ok(first.keyword, 'Should have keyword from sites table');
278        assert.ok(first.contact_uri, 'Should have contact_uri from messages table');
279        assert.ok(first.contact_method, 'Should have contact_method from messages table');
280        assert.ok(first.message_body, 'Should have message_body');
281        assert.ok(first.direction === 'inbound', 'Should only return inbound messages');
282      });
283  
284      test('does not return outbound conversations', async () => {
285        insertConversation({ id: 10, outreachId: 3, direction: 'outbound', channel: 'email', senderIdentifier: 'operator@333method.com', messageBody: 'Thanks for your interest! Here are the details...' });
286  
287        const conversations = await getUnreadConversations();
288  
289        // Should still be 3 (only inbound, not the outbound one)
290        assert.strictEqual(conversations.length, 3, 'Should not include outbound conversations');
291        for (const conv of conversations) {
292          assert.strictEqual(conv.direction, 'inbound', 'All results should be inbound');
293        }
294      });
295  
296      test('does not return already-read conversations', async () => {
297        insertConversation({ id: 11, outreachId: 3, direction: 'inbound', channel: 'email', senderIdentifier: 'owner@elite-hvac.com', messageBody: 'Not interested, thanks', sentiment: 'negative', readAt: new Date().toISOString() });
298  
299        const conversations = await getUnreadConversations();
300  
301        // Should still be 3 (read conversation excluded)
302        assert.strictEqual(conversations.length, 3, 'Should not include read conversations');
303        const ids = conversations.map(c => c.id);
304        assert.ok(!ids.includes(11), 'Read conversation should not appear in unread results');
305      });
306  
307      test('respects limit parameter', async () => {
308        const conversations = await getUnreadConversations(2);
309        assert.strictEqual(conversations.length, 2, 'Should respect limit of 2');
310      });
311  
312      test('returns results ordered by created_at DESC (newest first)', async () => {
313        const conversations = await getUnreadConversations();
314  
315        for (let i = 1; i < conversations.length; i++) {
316          const prev = new Date(conversations[i - 1].received_at).getTime();
317          const curr = new Date(conversations[i].received_at).getTime();
318          assert.ok(prev >= curr, 'Should be ordered newest first');
319        }
320      });
321  
322      test('defaults limit to 50', async () => {
323        const conversations = await getUnreadConversations();
324        assert.ok(conversations.length <= 50, 'Default limit should be 50');
325        assert.strictEqual(conversations.length, 3);
326      });
327    });
328  
329    // =========================================================================
330    // markConversationRead
331    // =========================================================================
332  
333    describe('markConversationRead', () => {
334      test('sets read_at timestamp on conversation', async () => {
335        const before = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(1);
336        assert.strictEqual(before.read_at, null, 'Should start unread');
337  
338        const result = await markConversationRead(1);
339        assert.strictEqual(result, true, 'Should return true');
340  
341        const after = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(1);
342        assert.ok(after.read_at, 'Should have a read_at timestamp after marking read');
343      });
344  
345      test('returns true even for non-existent conversation (no-op)', async () => {
346        const result = await markConversationRead(99999);
347        assert.strictEqual(result, true, 'Should return true even for non-existent ID');
348      });
349  
350      test('does not affect other conversations', async () => {
351        const conv2 = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(2);
352        assert.strictEqual(conv2.read_at, null, 'Other conversations should remain unread');
353      });
354    });
355  
356    // =========================================================================
357    // getConversationThread
358    // =========================================================================
359  
360    describe('getConversationThread', () => {
361      before(() => {
362        // Reset messages for predictable state
363        db.exec('DELETE FROM messages');
364        insertOutreach({ id: 201, siteId: 1, contactMethod: 'email', contactUri: 'info@acme-plumbing.com', proposalText: 'Hi, we noticed your website...', subjectLine: 'Website Improvement Proposal', status: 'sent' });
365        insertOutreach({ id: 202, siteId: 2, contactMethod: 'sms', contactUri: '+14155551234', proposalText: 'Quick website fix offer', subjectLine: null, status: 'delivered' });
366        insertConversation({ id: 211, outreachId: 1, direction: 'inbound', channel: 'email', senderIdentifier: 'info@acme-plumbing.com', messageBody: 'Yes, I am interested!', sentiment: 'positive' });
367      });
368  
369      test('returns outreach details and messages for a thread', async () => {
370        const thread = await getConversationThread(1);
371  
372        assert.ok(thread.outreach, 'Should have outreach details');
373        assert.ok(thread.outreach.id, 'Should have an outreach id');
374        assert.strictEqual(thread.outreach.domain, 'acme-plumbing.com');
375        assert.strictEqual(thread.outreach.keyword, 'plumber near me');
376        assert.strictEqual(thread.outreach.contact_method, 'email');
377        assert.strictEqual(thread.outreach.contact_uri, 'info@acme-plumbing.com');
378        assert.ok(thread.outreach.message_body, 'Should include message_body');
379        assert.ok(thread.outreach.landing_page_url, 'Should include landing_page_url');
380  
381        assert.ok(Array.isArray(thread.messages), 'Should have messages array');
382        assert.ok(thread.messages.length >= 2, 'Should have at least 2 messages in thread');
383  
384        const msg = thread.messages[0];
385        assert.ok(msg.id, 'Message should have id');
386        assert.ok(msg.direction, 'Message should have direction');
387        assert.ok(msg.channel, 'Message should have channel');
388        assert.ok(msg.message_body, 'Message should have message_body');
389        assert.ok(msg.received_at, 'Message should have received_at (created_at alias)');
390      });
391  
392      test('returns messages ordered by created_at ASC (oldest first)', async () => {
393        const thread = await getConversationThread(1);
394  
395        for (let i = 1; i < thread.messages.length; i++) {
396          const prev = new Date(thread.messages[i - 1].received_at).getTime();
397          const curr = new Date(thread.messages[i].received_at).getTime();
398          assert.ok(prev <= curr, 'Messages should be ordered oldest first (ASC)');
399        }
400      });
401  
402      test('includes both inbound and outbound messages', async () => {
403        const thread = await getConversationThread(1);
404  
405        const directions = thread.messages.map(m => m.direction);
406        assert.ok(directions.includes('inbound'), 'Should include inbound messages');
407        assert.ok(directions.includes('outbound'), 'Should include outbound messages');
408      });
409  
410      test('returns undefined outreach and empty messages for non-existent outreach', async () => {
411        const thread = await getConversationThread(99999);
412  
413        assert.ok(thread.outreach == null, `Should have null/undefined outreach, got ${thread.outreach}`);
414        assert.ok(Array.isArray(thread.messages), 'Should still return messages array');
415        assert.strictEqual(thread.messages.length, 0, 'Messages should be empty');
416      });
417  
418      test('returns thread for outreach with single message', async () => {
419        const thread = await getConversationThread(2);
420  
421        assert.ok(thread.outreach, 'Should have outreach details');
422        assert.strictEqual(thread.outreach.domain, 'best-roofing.com');
423        assert.strictEqual(thread.messages.length, 1, 'Should have exactly 1 message');
424        assert.strictEqual(thread.messages[0].channel, 'sms');
425      });
426    });
427  
428    // =========================================================================
429    // markThreadRead
430    // =========================================================================
431  
432    describe('markThreadRead', () => {
433      before(() => {
434        // Reset read_at for conversations in site 1 for a clean test
435        db.prepare("UPDATE messages SET read_at = NULL WHERE site_id = 1 AND direction = 'inbound'").run();
436      });
437  
438      test('marks all inbound conversations in thread as read', async () => {
439        const unreadBefore = db
440          .prepare("SELECT COUNT(*) as count FROM messages WHERE site_id = 1 AND direction = 'inbound' AND read_at IS NULL")
441          .get();
442        assert.ok(unreadBefore.count > 0, 'Should have unread inbound messages to start');
443  
444        const changedCount = await markThreadRead(1);
445        assert.ok(changedCount >= 1, 'Should mark at least 1 conversation as read');
446  
447        const unreadAfter = db
448          .prepare("SELECT COUNT(*) as count FROM messages WHERE site_id = 1 AND direction = 'inbound' AND read_at IS NULL")
449          .get();
450        assert.strictEqual(unreadAfter.count, 0, 'Should have no unread inbound messages after');
451      });
452  
453      test('does not mark outbound messages as read', async () => {
454        const outbound = db
455          .prepare("SELECT COUNT(*) as count FROM messages WHERE site_id = 1 AND direction = 'outbound'")
456          .get();
457        assert.ok(outbound.count > 0, 'Should have outbound messages');
458      });
459  
460      test('returns 0 for non-existent outreach', async () => {
461        const changedCount = await markThreadRead(99999);
462        assert.strictEqual(changedCount, 0, 'Should return 0 for non-existent outreach');
463      });
464  
465      test('returns 0 when all messages already read', async () => {
466        const changedCount = await markThreadRead(1);
467        assert.strictEqual(changedCount, 0, 'Should return 0 when all already read');
468      });
469    });
470  
471    // =========================================================================
472    // getInboundStats
473    // =========================================================================
474  
475    describe('getInboundStats', () => {
476      before(() => {
477        // Ensure at least 2 unread inbound messages exist for stats testing.
478        const existing = db
479          .prepare("SELECT COUNT(*) as c FROM messages WHERE direction = 'inbound' AND read_at IS NULL")
480          .get().c;
481        if (existing < 2) {
482          try {
483            insertConversation({ id: 301, outreachId: 2, direction: 'inbound', channel: 'sms', senderIdentifier: '+14155551234', messageBody: 'Tell me more about pricing', sentiment: 'neutral' });
484          } catch {
485            // Already exists
486          }
487        }
488        // Reset all inbound messages to unread for consistent stats
489        db.prepare("UPDATE messages SET read_at = NULL WHERE direction = 'inbound'").run();
490      });
491  
492      test('returns unreadCount, byChannel, and bySentiment', async () => {
493        const stats = await getInboundStats();
494  
495        assert.ok(typeof stats.unreadCount === 'number', 'Should have unreadCount as number');
496        assert.ok(Array.isArray(stats.byChannel), 'Should have byChannel array');
497        assert.ok(Array.isArray(stats.bySentiment), 'Should have bySentiment array');
498      });
499  
500      test('unreadCount reflects actual unread inbound messages', async () => {
501        const stats = await getInboundStats();
502        assert.ok(stats.unreadCount >= 1, 'Should have at least 1 unread message');
503      });
504  
505      test('byChannel includes correct channel breakdown', async () => {
506        const stats = await getInboundStats();
507  
508        assert.ok(stats.byChannel.length > 0, 'Should have at least one channel');
509  
510        for (const ch of stats.byChannel) {
511          assert.ok(typeof ch.total === 'number', 'Channel should have total');
512          assert.ok(typeof ch.unread === 'number', 'Channel should have unread count');
513          assert.ok(ch.total >= ch.unread, 'Total should be >= unread');
514        }
515      });
516  
517      test('bySentiment groups unread messages by sentiment', async () => {
518        const stats = await getInboundStats();
519  
520        for (const s of stats.bySentiment) {
521          assert.ok(typeof s.count === 'number', 'Sentiment should have count');
522          assert.ok(s.count > 0, 'Sentiment count should be positive');
523        }
524      });
525  
526      test('returns zero unreadCount when all conversations are read', async () => {
527        db.prepare("UPDATE messages SET read_at = CURRENT_TIMESTAMP WHERE direction = 'inbound'").run();
528  
529        const stats = await getInboundStats();
530        assert.strictEqual(stats.unreadCount, 0, 'Should be 0 when all read');
531        assert.strictEqual(stats.bySentiment.length, 0, 'No unread sentiment groups');
532  
533        for (const ch of stats.byChannel) {
534          assert.strictEqual(ch.unread, 0, 'Unread should be 0 for each channel');
535        }
536  
537        // Reset
538        db.prepare("UPDATE messages SET read_at = NULL WHERE direction = 'inbound'").run();
539      });
540    });
541  });