|
| 1 | +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 2 | +import pg from 'pg'; |
| 3 | + |
| 4 | +vi.mock('./db.js', () => ({ query: vi.fn() })); |
| 5 | +import { query } from './db.js'; |
| 6 | +import { listMessages } from './messageService.js'; |
| 7 | + |
| 8 | +// #576: the threaded Inbox badge counts the whole thread, Sent replies included, so it matches |
| 9 | +// what the thread route loads. Uses only connection-local temporary tables in an explicitly |
| 10 | +// supplied database. |
| 11 | +describe.skipIf(!process.env.MAILFLOW_TEST_DATABASE_URL)('threaded Inbox message_count PostgreSQL', () => { |
| 12 | + let database; |
| 13 | + const account = '22222222-2222-4222-8222-222222222222'; |
| 14 | + let nextId = 1; |
| 15 | + const id = () => `00000000-0000-4000-8000-${String(nextId++).padStart(12, '0')}`; |
| 16 | + |
| 17 | + beforeAll(async () => { |
| 18 | + database = new pg.Client({ connectionString: process.env.MAILFLOW_TEST_DATABASE_URL }); |
| 19 | + await database.connect(); |
| 20 | + await database.query(` |
| 21 | + CREATE TEMP TABLE email_accounts (id uuid, user_id text, enabled boolean, |
| 22 | + include_in_unified_inbox boolean, name text, email_address text, color text); |
| 23 | + CREATE TEMP TABLE folders (account_id uuid, path text, total_count integer DEFAULT 0, |
| 24 | + unread_count integer DEFAULT 0); |
| 25 | + CREATE TEMP TABLE messages (id uuid PRIMARY KEY, account_id uuid, folder text, |
| 26 | + uid bigint, message_id text, in_reply_to text, thread_references text, |
| 27 | + thread_id text, thread_key text, date timestamptz, is_deleted boolean DEFAULT false, |
| 28 | + subject text, from_name text, from_email text, to_addresses jsonb, cc_addresses jsonb, |
| 29 | + reply_to jsonb, snippet text, is_read boolean DEFAULT false, is_starred boolean DEFAULT false, |
| 30 | + has_attachments boolean DEFAULT false, category text, list_unsubscribe text, |
| 31 | + list_unsubscribe_post text, delivery_addresses jsonb, spam_verdict text, |
| 32 | + spam_user_override text, spam_score_ml real); |
| 33 | + CREATE TEMP TABLE contacts (id uuid, user_id text, primary_email text, photo_data text)`); |
| 34 | + query.mockImplementation((sql, values) => database.query(sql, values)); |
| 35 | + }); |
| 36 | + afterAll(async () => { |
| 37 | + await database?.end(); |
| 38 | + }); |
| 39 | + beforeEach(async () => { |
| 40 | + await database.query('TRUNCATE messages, folders, email_accounts, contacts'); |
| 41 | + await database.query( |
| 42 | + "INSERT INTO email_accounts VALUES ($1,'owner',true,true,'Work','me@example.com','#123456')", |
| 43 | + [account], |
| 44 | + ); |
| 45 | + }); |
| 46 | + |
| 47 | + const insert = (folder, messageId, threadKey, date, subject) => database.query( |
| 48 | + `INSERT INTO messages(id,account_id,folder,message_id,thread_id,thread_key,date,subject) |
| 49 | + VALUES ($1,$2,$3,$4,$5,$5,$6,$7)`, |
| 50 | + [id(), account, folder, messageId, threadKey, date, subject], |
| 51 | + ); |
| 52 | + |
| 53 | + const inboxRows = async (accountId) => { |
| 54 | + const result = await listMessages({ userId: 'owner', accountId, folder: 'INBOX', threaded: true }); |
| 55 | + return Object.fromEntries(result.messages.map(row => [row.thread_id, row])); |
| 56 | + }; |
| 57 | + |
| 58 | + for (const scope of ['account', 'unified']) { |
| 59 | + const accountId = scope === 'account' ? account : undefined; |
| 60 | + |
| 61 | + it(`counts your Sent reply in a received thread (${scope} Inbox)`, async () => { |
| 62 | + await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question'); |
| 63 | + await insert('Sent', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question'); |
| 64 | + |
| 65 | + const row = (await inboxRows(accountId))['<a@ext>']; |
| 66 | + expect(row.message_count).toBe(2); |
| 67 | + // The listed row is still the Inbox message; only the count widens. |
| 68 | + expect(row.folder).toBe('INBOX'); |
| 69 | + expect(row.subject).toBe('Question'); |
| 70 | + }); |
| 71 | + |
| 72 | + it(`counts a conversation started from MailFlow after the first reply (${scope} Inbox)`, async () => { |
| 73 | + await insert('Sent', '<s@me>', '<s@me>', '2026-10-01T09:00Z', 'Proposal'); |
| 74 | + await insert('INBOX', '<r1@ext>', '<s@me>', '2026-10-01T12:00Z', 'Re: Proposal'); |
| 75 | + |
| 76 | + expect((await inboxRows(accountId))['<s@me>'].message_count).toBe(2); |
| 77 | + }); |
| 78 | + |
| 79 | + it(`counts one email once when it sits in several folders (${scope} Inbox)`, async () => { |
| 80 | + await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question'); |
| 81 | + await insert('Archive', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question'); |
| 82 | + await insert('Sent', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question'); |
| 83 | + await insert('Sent Items', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question'); |
| 84 | + |
| 85 | + expect((await inboxRows(accountId))['<a@ext>'].message_count).toBe(2); |
| 86 | + }); |
| 87 | + |
| 88 | + it(`leaves a lone received message at 1 and lists no Sent-only thread (${scope} Inbox)`, async () => { |
| 89 | + await insert('INBOX', '<solo@ext>', '<solo@ext>', '2026-10-01T10:00Z', 'Hello'); |
| 90 | + await insert('Sent', '<out@me>', '<out@me>', '2026-10-01T11:00Z', 'Unanswered'); |
| 91 | + |
| 92 | + const rows = await inboxRows(accountId); |
| 93 | + expect(rows['<solo@ext>'].message_count).toBe(1); |
| 94 | + expect(rows['<out@me>']).toBeUndefined(); |
| 95 | + }); |
| 96 | + } |
| 97 | + |
| 98 | + it('does not count deleted messages', async () => { |
| 99 | + await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question'); |
| 100 | + await insert('Sent', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question'); |
| 101 | + await database.query("UPDATE messages SET is_deleted = true WHERE message_id = '<b@me>'"); |
| 102 | + |
| 103 | + expect((await inboxRows(account))['<a@ext>'].message_count).toBe(1); |
| 104 | + }); |
| 105 | +}); |
0 commit comments