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 });