1package tui
2
3import (
4 "testing"
5 "time"
6
7 "charm.land/bubbles/v2/list"
8 tea "charm.land/bubbletea/v2"
9 "github.com/floatpane/matcha/backend"
10 "github.com/floatpane/matcha/config"
11 "github.com/floatpane/matcha/fetcher"
12)
13
14func collectMsgs(cmd tea.Cmd) []tea.Msg {
15 if cmd == nil {
16 return nil
17 }
18 msg := cmd()
19 if msg == nil {
20 return nil
21 }
22
23 // Try type assertion to see if it's a BatchMsg
24 if batch, ok := msg.(tea.BatchMsg); ok {
25 var msgs []tea.Msg
26 for _, m := range batch {
27 msgs = append(msgs, collectMsgs(m)...)
28 }
29 return msgs
30 }
31
32 // Otherwise it's a regular message
33 return []tea.Msg{msg}
34}
35
36// TestInboxUpdate verifies the state transitions in the inbox view.
37func TestInboxUpdate(t *testing.T) {
38 // Create sample accounts
39 accounts := []config.Account{
40 {ID: "account-1", Email: "test1@example.com", Name: "Test User 1"},
41 {ID: "account-2", Email: "test2@example.com", Name: "Test User 2"},
42 }
43
44 // Create a sample list of emails.
45 sampleEmails := []fetcher.Email{
46 {UID: 1, From: "a@example.com", Subject: "Email 1", Date: time.Now(), AccountID: "account-1"},
47 {UID: 2, From: "b@example.com", Subject: "Email 2", Date: time.Now().Add(-time.Hour), AccountID: "account-1"},
48 {UID: 3, From: "c@example.com", Subject: "Email 3", Date: time.Now().Add(-2 * time.Hour), AccountID: "account-2"},
49 }
50
51 inbox := NewInbox(sampleEmails, accounts)
52
53 t.Run("Select email to view", func(t *testing.T) {
54 // By default, the first item is selected (index 0).
55 // Move down to the second item (index 1).
56 inbox.list, _ = inbox.list.Update(tea.KeyPressMsg{Code: tea.KeyDown})
57
58 // Simulate pressing Enter to view the selected email.
59 _, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
60 if cmd == nil {
61 t.Fatal("Expected a command, but got nil.")
62 }
63
64 // Check the resulting message.
65 msg := cmd()
66 viewMsg, ok := msg.(ViewEmailMsg)
67 if !ok {
68 t.Fatalf("Expected a ViewEmailMsg, but got %T", msg)
69 }
70
71 // The index should match the selected item in the list.
72 expectedIndex := 1
73 if viewMsg.Index != expectedIndex {
74 t.Errorf("Expected index %d, but got %d", expectedIndex, viewMsg.Index)
75 }
76
77 // Verify UID and AccountID are passed correctly
78 expectedUID := uint32(2) // Second email has UID 2
79 if viewMsg.UID != expectedUID {
80 t.Errorf("Expected UID %d, but got %d", expectedUID, viewMsg.UID)
81 }
82
83 expectedAccountID := "account-1" // Second email belongs to account-1
84 if viewMsg.AccountID != expectedAccountID {
85 t.Errorf("Expected AccountID %q, but got %q", expectedAccountID, viewMsg.AccountID)
86 }
87 })
88}
89
90// TestInboxMultiAccountTabs verifies that tabs are created for multiple accounts.
91func TestInboxMultiAccountTabs(t *testing.T) {
92 accounts := []config.Account{
93 {ID: "account-1", Email: "mail.example.com", FetchEmail: "test1@example.com", Name: "User 1"},
94 {ID: "account-2", Email: "mail.example.com", FetchEmail: "test2@example.com", Name: "User 2"},
95 }
96
97 emails := []fetcher.Email{
98 {UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"},
99 }
100
101 inbox := NewInbox(emails, accounts)
102
103 // Should have 3 tabs: ALL + 2 accounts
104 if len(inbox.tabs) != 3 {
105 t.Errorf("Expected 3 tabs, got %d", len(inbox.tabs))
106 }
107
108 // First tab should be "ALL"
109 if inbox.tabs[0].ID != "" {
110 t.Errorf("Expected first tab ID to be empty (ALL), got %q", inbox.tabs[0].ID)
111 }
112 if inbox.tabs[0].Label != "ALL" {
113 t.Errorf("Expected first tab label to be 'ALL', got %q", inbox.tabs[0].Label)
114 }
115 if inbox.tabs[1].Label != "test1@example.com" {
116 t.Errorf("Expected first account tab to use FetchEmail, got %q", inbox.tabs[1].Label)
117 }
118
119 inbox.SetEmails(emails, accounts)
120 if inbox.tabs[1].Label != "test1@example.com" || inbox.tabs[1].Email != "test1@example.com" {
121 t.Errorf("Expected SetEmails to preserve FetchEmail tab display, got label=%q email=%q", inbox.tabs[1].Label, inbox.tabs[1].Email)
122 }
123}
124
125func TestInboxSearchResultsFilterByActiveAccountTab(t *testing.T) {
126 accounts := []config.Account{
127 {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"},
128 {ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"},
129 }
130
131 inbox := NewInbox(nil, accounts)
132 query := backend.ParseSearchQuery("quarterly")
133 results := []fetcher.Email{
134 {UID: 1, From: "a@example.com", To: []string{"first@example.com"}, Subject: "First", AccountID: "account-1"},
135 {UID: 2, From: "b@example.com", To: []string{"second@example.com"}, Subject: "Second", AccountID: "account-2"},
136 }
137
138 model, _ := inbox.Update(ApplySearchResultsMsg{Query: query, Emails: results})
139 inbox = model.(*Inbox)
140 if got := len(inbox.list.Items()); got != 2 {
141 t.Fatalf("expected all search results initially, got %d", got)
142 }
143
144 model, _ = inbox.Update(tea.KeyPressMsg{Code: tea.KeyRight, Text: "right"})
145 inbox = model.(*Inbox)
146 if got := len(inbox.list.Items()); got != 1 {
147 t.Fatalf("expected account-filtered search results after tab switch, got %d", got)
148 }
149 item, ok := inbox.list.Items()[0].(item)
150 if !ok {
151 t.Fatalf("expected inbox item, got %T", inbox.list.Items()[0])
152 }
153 if item.accountID != "account-1" {
154 t.Fatalf("expected account-1 result after first account tab, got %q", item.accountID)
155 }
156
157 email := inbox.GetEmailAtIndex(0)
158 if email == nil || email.UID != 1 {
159 t.Fatalf("GetEmailAtIndex should use filtered search results, got %#v", email)
160 }
161}
162
163func TestInboxAllAccountsDedupesSharedMailboxByMessageID(t *testing.T) {
164 accounts := []config.Account{
165 {ID: "account-1", Email: "mail.example.com", FetchEmail: "edu@andrinoff.com"},
166 {ID: "account-2", Email: "mail.example.com", FetchEmail: "me@andrinoff.com"},
167 {ID: "account-3", Email: "mail.example.com", FetchEmail: "business@andrinoff.com"},
168 }
169 emails := []fetcher.Email{
170 {UID: 81, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-1"},
171 {UID: 82, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-2"},
172 {UID: 83, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-3"},
173 }
174
175 inbox := NewInbox(emails, accounts)
176 if got := len(inbox.allEmails); got != 1 {
177 t.Fatalf("expected all accounts view to dedupe shared mailbox copies, got %d", got)
178 }
179 if got := len(inbox.emailsByAccount["account-1"]); got != 1 {
180 t.Fatalf("expected per-account bucket to remain unchanged, got %d", got)
181 }
182 row := inbox.list.Items()[0].(item)
183 if row.accountEmail != "business@andrinoff.com" {
184 t.Fatalf("expected deduped row label to match recipient account, got %q", row.accountEmail)
185 }
186 if row.accountID != "account-3" {
187 t.Fatalf("expected canonical row to use matching account copy, got %q", row.accountID)
188 }
189}
190
191func TestInboxSearchResultsDedupedAcrossAccounts(t *testing.T) {
192 accounts := []config.Account{
193 {ID: "account-1", Email: "mail.example.com", FetchEmail: "edu@andrinoff.com"},
194 {ID: "account-2", Email: "mail.example.com", FetchEmail: "business@andrinoff.com"},
195 }
196 inbox := NewInbox(nil, accounts)
197 query := backend.ParseSearchQuery("osc8")
198 results := []fetcher.Email{
199 {UID: 81, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-1"},
200 {UID: 82, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-2"},
201 }
202
203 model, _ := inbox.Update(ApplySearchResultsMsg{Query: query, Emails: results})
204 inbox = model.(*Inbox)
205 if got := len(inbox.searchResults); got != 1 {
206 t.Fatalf("expected search results to dedupe shared mailbox copies, got %d", got)
207 }
208 row := inbox.list.Items()[0].(item)
209 if row.accountEmail != "business@andrinoff.com" {
210 t.Fatalf("expected search result label to match recipient account, got %q", row.accountEmail)
211 }
212}
213
214func TestInboxAllAccountsDoesNotDedupeWhenMessageIDDiffers(t *testing.T) {
215 date := time.Now()
216 accounts := []config.Account{
217 {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"},
218 {ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"},
219 }
220 emails := []fetcher.Email{
221 {UID: 1, MessageID: "<one@example.com>", From: "sender@example.com", To: []string{"first@example.com"}, Subject: "Same", Date: date, AccountID: "account-1"},
222 {UID: 2, MessageID: "<two@example.com>", From: "sender@example.com", To: []string{"second@example.com"}, Subject: "Same", Date: date, AccountID: "account-2"},
223 }
224
225 inbox := NewInbox(emails, accounts)
226 if got := len(inbox.allEmails); got != 2 {
227 t.Fatalf("expected distinct Message-ID emails to remain visible, got %d", got)
228 }
229}
230
231func TestInboxAccountLabelUsesMatchingRecipient(t *testing.T) {
232 accounts := []config.Account{
233 {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"},
234 {ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"},
235 }
236 emails := []fetcher.Email{
237 {UID: 1, MessageID: "<first@example.com>", From: "a@example.com", To: []string{"Shared <shared@example.com>", "Second <second@example.com>"}, Subject: "First", AccountID: "account-1"},
238 {UID: 2, From: "b@example.com", To: []string{"shared@example.com"}, Subject: "Fallback", AccountID: "account-2"},
239 }
240
241 inbox := NewInbox(emails, accounts)
242 first := inbox.list.Items()[0].(item)
243 if first.accountEmail != "second@example.com" {
244 t.Fatalf("expected cross-account matching To recipient for account label, got %q", first.accountEmail)
245 }
246 second := inbox.list.Items()[1].(item)
247 if second.accountEmail != "second@example.com" {
248 t.Fatalf("expected FetchEmail fallback for unmatched recipient, got %q", second.accountEmail)
249 }
250}
251
252func TestInboxOpenSearchResultEmbedsEmailInViewMsg(t *testing.T) {
253 accounts := []config.Account{
254 {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"},
255 }
256 inbox := NewInbox(nil, accounts)
257 searchResult := fetcher.Email{UID: 42, MessageID: "<search@example.com>", From: "sender@example.com", To: []string{"first@example.com"}, Subject: "Search", AccountID: "account-1"}
258 model, _ := inbox.Update(ApplySearchResultsMsg{Query: backend.ParseSearchQuery("search"), Emails: []fetcher.Email{searchResult}})
259 inbox = model.(*Inbox)
260
261 _, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
262 if cmd == nil {
263 t.Fatal("expected open command")
264 }
265 msg := cmd()
266 viewMsg, ok := msg.(ViewEmailMsg)
267 if !ok {
268 t.Fatalf("expected ViewEmailMsg, got %T", msg)
269 }
270 if viewMsg.Email == nil {
271 t.Fatal("expected search result email to be embedded")
272 }
273 if viewMsg.Email.UID != searchResult.UID || viewMsg.Email.MessageID != searchResult.MessageID {
274 t.Fatalf("embedded email mismatch: %#v", viewMsg.Email)
275 }
276}
277
278func TestInboxClientSideFilterKeyStartsListFilter(t *testing.T) {
279 accounts := []config.Account{{ID: "account-1", Email: "test@example.com"}}
280 emails := []fetcher.Email{{UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"}}
281
282 inbox := NewInbox(emails, accounts)
283 model, _ := inbox.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
284 inbox = model.(*Inbox)
285
286 if inbox.list.FilterState() != list.Filtering {
287 t.Fatalf("expected client-side filter state %s, got %s", list.Filtering, inbox.list.FilterState())
288 }
289}
290
291// TestInboxSingleAccount verifies behavior with a single account.
292func TestInboxSingleAccount(t *testing.T) {
293 accounts := []config.Account{
294 {ID: "account-1", Email: "test@example.com"},
295 }
296
297 emails := []fetcher.Email{
298 {UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"},
299 }
300
301 inbox := NewInbox(emails, accounts)
302
303 // Should have 0 tabs (visually)
304 if len(inbox.tabs) != 1 {
305 t.Errorf("Expected 1 phantom tab, got %d", len(inbox.tabs))
306 }
307}
308
309// TestInboxNoAccounts verifies behavior with no accounts (legacy/edge case).
310func TestInboxNoAccounts(t *testing.T) {
311 emails := []fetcher.Email{
312 {UID: 1, From: "sender@example.com", Subject: "Test"},
313 }
314
315 inbox := NewInbox(emails, nil)
316
317 // Should have 1 tab: ALL only
318 if len(inbox.tabs) != 1 {
319 t.Errorf("Expected 1 tab, got %d", len(inbox.tabs))
320 }
321}
322
323// TestInboxDeleteEmailMsg verifies that delete messages include account ID.
324func TestInboxDeleteEmailMsg(t *testing.T) {
325 accounts := []config.Account{
326 {ID: "account-1", Email: "test@example.com"},
327 }
328
329 emails := []fetcher.Email{
330 {UID: 123, From: "sender@example.com", Subject: "Test", AccountID: "account-1"},
331 }
332
333 inbox := NewInbox(emails, accounts)
334
335 // Simulate pressing 'd' to delete
336 _, cmd := inbox.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
337 if cmd == nil {
338 t.Fatal("Expected a command, but got nil.")
339 }
340
341 msg := cmd()
342 deleteMsg, ok := msg.(DeleteEmailMsg)
343 if !ok {
344 t.Fatalf("Expected a DeleteEmailMsg, but got %T", msg)
345 }
346
347 if deleteMsg.UID != 123 {
348 t.Errorf("Expected UID 123, got %d", deleteMsg.UID)
349 }
350
351 if deleteMsg.AccountID != "account-1" {
352 t.Errorf("Expected AccountID 'account-1', got %q", deleteMsg.AccountID)
353 }
354}
355
356// TestInboxArchiveEmailMsg verifies that archive messages include account ID.
357func TestInboxArchiveEmailMsg(t *testing.T) {
358 accounts := []config.Account{
359 {ID: "account-1", Email: "test@example.com"},
360 }
361
362 emails := []fetcher.Email{
363 {UID: 456, From: "sender@example.com", Subject: "Test", AccountID: "account-1"},
364 }
365
366 inbox := NewInbox(emails, accounts)
367
368 // Simulate pressing 'a' to archive
369 _, cmd := inbox.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})
370 if cmd == nil {
371 t.Fatal("Expected a command, but got nil.")
372 }
373
374 msg := cmd()
375 archiveMsg, ok := msg.(ArchiveEmailMsg)
376 if !ok {
377 t.Fatalf("Expected an ArchiveEmailMsg, but got %T", msg)
378 }
379
380 if archiveMsg.UID != 456 {
381 t.Errorf("Expected UID 456, got %d", archiveMsg.UID)
382 }
383
384 if archiveMsg.AccountID != "account-1" {
385 t.Errorf("Expected AccountID 'account-1', got %q", archiveMsg.AccountID)
386 }
387}
388
389// TestInboxRemoveEmail verifies that emails can be removed from the inbox.
390func TestInboxRemoveEmail(t *testing.T) {
391 accounts := []config.Account{
392 {ID: "account-1", Email: "test@example.com"},
393 }
394
395 emails := []fetcher.Email{
396 {UID: 1, From: "a@example.com", Subject: "Email 1", AccountID: "account-1"},
397 {UID: 2, From: "b@example.com", Subject: "Email 2", AccountID: "account-1"},
398 }
399
400 inbox := NewInbox(emails, accounts)
401
402 // Remove the first email
403 inbox.RemoveEmail(1, "account-1")
404
405 // Check that only one email remains
406 if len(inbox.allEmails) != 1 {
407 t.Errorf("Expected 1 email after removal, got %d", len(inbox.allEmails))
408 }
409
410 if inbox.allEmails[0].UID != 2 {
411 t.Errorf("Expected remaining email UID to be 2, got %d", inbox.allEmails[0].UID)
412 }
413}
414
415// TestInboxGetEmailAtIndex verifies retrieving emails by index.
416func TestInboxGetEmailAtIndex(t *testing.T) {
417 accounts := []config.Account{
418 {ID: "account-1", Email: "test@example.com"},
419 }
420
421 emails := []fetcher.Email{
422 {UID: 1, From: "a@example.com", Subject: "Email 1", AccountID: "account-1"},
423 {UID: 2, From: "b@example.com", Subject: "Email 2", AccountID: "account-1"},
424 }
425
426 inbox := NewInbox(emails, accounts)
427
428 // Get email at index 0
429 email := inbox.GetEmailAtIndex(0)
430 if email == nil {
431 t.Fatal("Expected email at index 0, got nil")
432 }
433 if email.UID != 1 {
434 t.Errorf("Expected UID 1 at index 0, got %d", email.UID)
435 }
436
437 // Get email at invalid index
438 email = inbox.GetEmailAtIndex(999)
439 if email != nil {
440 t.Error("Expected nil for invalid index, got non-nil")
441 }
442
443 // Get email at negative index
444 email = inbox.GetEmailAtIndex(-1)
445 if email != nil {
446 t.Error("Expected nil for negative index, got non-nil")
447 }
448}
449
450func TestFetchMoreTriggeredAtListEnd(t *testing.T) {
451 accounts := []config.Account{
452 {ID: "account-1", Email: "test@example.com"},
453 }
454
455 emails := []fetcher.Email{
456 {UID: 1, From: "a@example.com", Subject: "Email 1", AccountID: "account-1", Date: time.Now()},
457 {UID: 2, From: "b@example.com", Subject: "Email 2", AccountID: "account-1", Date: time.Now().Add(-time.Minute)},
458 }
459
460 inbox := NewInbox(emails, accounts)
461
462 _, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyDown})
463 msgs := collectMsgs(cmd)
464
465 var fetchMsg FetchMoreEmailsMsg
466 for _, m := range msgs {
467 if msg, ok := m.(FetchMoreEmailsMsg); ok {
468 fetchMsg = msg
469 break
470 }
471 }
472
473 if fetchMsg.AccountID == "" {
474 t.Fatal("expected a FetchMoreEmailsMsg when reaching end of the list")
475 }
476
477 if fetchMsg.Offset != uint32(len(emails)) {
478 t.Fatalf("expected offset %d, got %d", len(emails), fetchMsg.Offset)
479 }
480 if fetchMsg.AccountID != "account-1" {
481 t.Fatalf("expected account ID 'account-1', got %q", fetchMsg.AccountID)
482 }
483 if fetchMsg.Mailbox != MailboxInbox {
484 t.Fatalf("expected MailboxInbox, got %s", fetchMsg.Mailbox)
485 }
486
487 // Default list height is 14, but our minimum limit is 20
488 expectedLimit := uint32(20)
489 if fetchMsg.Limit != expectedLimit {
490 t.Fatalf("expected Limit %d, got %d", expectedLimit, fetchMsg.Limit)
491 }
492}
493
494func TestTruncateEmailKeepsDomain(t *testing.T) {
495 tests := []struct {
496 name string
497 email string
498 want string
499 }{
500 {
501 name: "long local part keeps full domain",
502 email: "verylongemail@gmail.com",
503 want: "verylong...@gmail.com",
504 },
505 {
506 name: "short email unchanged",
507 email: "abc@gmail.com",
508 want: "abc@gmail.com",
509 },
510 }
511
512 for _, tt := range tests {
513 t.Run(tt.name, func(t *testing.T) {
514 got := truncateEmail(tt.email)
515 if got != tt.want {
516 t.Fatalf("truncateEmail(%q) = %q, want %q", tt.email, got, tt.want)
517 }
518 })
519 }
520}