config_test.go

  1package config
  2
  3import (
  4	"os"
  5	"path/filepath"
  6	"reflect"
  7	"testing"
  8	"time"
  9
 10	"github.com/zalando/go-keyring"
 11)
 12
 13// TestSaveAndLoadConfig verifies that the config can be saved to and loaded from a file correctly.
 14func TestSaveAndLoadConfig(t *testing.T) {
 15	// Use an in-memory mock keyring so tests do not interact with the host OS keyring
 16	keyring.MockInit()
 17
 18	// Create a temporary directory for the test to avoid interfering with actual user config.
 19	tempDir := t.TempDir()
 20
 21	// Temporarily override the user home directory to our temp directory.
 22	// This ensures that our config file is written to a predictable, temporary location.
 23	t.Setenv("HOME", tempDir)
 24
 25	// Define a sample configuration to save with multiple accounts.
 26	expectedConfig := &Config{
 27		Accounts: []Account{
 28			{
 29				ID:              "test-id-1",
 30				Name:            "Test User",
 31				Email:           "test@example.com",
 32				Password:        "supersecret",
 33				ServiceProvider: "gmail",
 34				SendAsEmail:     "alias@example.com",
 35			},
 36			{
 37				ID:              "test-id-2",
 38				Name:            "Custom User",
 39				Email:           "custom@example.com",
 40				Password:        "customsecret",
 41				ServiceProvider: "custom",
 42				IMAPServer:      "imap.custom.com",
 43				IMAPPort:        993,
 44				SMTPServer:      "smtp.custom.com",
 45				SMTPPort:        587,
 46				CatchAll:        true,
 47			},
 48		},
 49	}
 50
 51	// Attempt to save the configuration.
 52	err := SaveConfig(expectedConfig)
 53	if err != nil {
 54		t.Fatalf("SaveConfig() failed: %v", err)
 55	}
 56
 57	// Attempt to load the configuration back.
 58	loadedConfig, err := LoadConfig()
 59	if err != nil {
 60		t.Fatalf("LoadConfig() failed: %v", err)
 61	}
 62
 63	// Compare the loaded configuration with the original one.
 64	// reflect.DeepEqual is used for a deep comparison of the structs.
 65	if !reflect.DeepEqual(loadedConfig, expectedConfig) {
 66		t.Errorf("Loaded config does not match expected config.\nGot:  %+v\nWant: %+v", loadedConfig, expectedConfig)
 67	}
 68}
 69
 70// TestAccountGetIMAPServer tests the logic that determines the IMAP server address.
 71func TestAccountGetIMAPServer(t *testing.T) {
 72	testCases := []struct {
 73		name    string
 74		account Account
 75		want    string
 76	}{
 77		{"Gmail", Account{ServiceProvider: "gmail"}, "imap.gmail.com"},
 78		{"iCloud", Account{ServiceProvider: "icloud"}, "imap.mail.me.com"},
 79		{"Custom", Account{ServiceProvider: "custom", IMAPServer: "imap.custom.com"}, "imap.custom.com"},
 80		{"Unsupported", Account{ServiceProvider: "yahoo"}, ""},
 81		{"Empty", Account{ServiceProvider: ""}, ""},
 82	}
 83
 84	for _, tc := range testCases {
 85		t.Run(tc.name, func(t *testing.T) {
 86			got := tc.account.GetIMAPServer()
 87			if got != tc.want {
 88				t.Errorf("GetIMAPServer() = %q, want %q", got, tc.want)
 89			}
 90		})
 91	}
 92}
 93
 94// TestAccountGetSMTPServer tests the logic that determines the SMTP server address.
 95func TestAccountGetSMTPServer(t *testing.T) {
 96	testCases := []struct {
 97		name    string
 98		account Account
 99		want    string
100	}{
101		{"Gmail", Account{ServiceProvider: "gmail"}, "smtp.gmail.com"},
102		{"iCloud", Account{ServiceProvider: "icloud"}, "smtp.mail.me.com"},
103		{"Custom", Account{ServiceProvider: "custom", SMTPServer: "smtp.custom.com"}, "smtp.custom.com"},
104		{"Unsupported", Account{ServiceProvider: "yahoo"}, ""},
105		{"Empty", Account{ServiceProvider: ""}, ""},
106	}
107
108	for _, tc := range testCases {
109		t.Run(tc.name, func(t *testing.T) {
110			got := tc.account.GetSMTPServer()
111			if got != tc.want {
112				t.Errorf("GetSMTPServer() = %q, want %q", got, tc.want)
113			}
114		})
115	}
116}
117
118// TestConfigAddRemoveAccount tests adding and removing accounts from config.
119func TestConfigAddRemoveAccount(t *testing.T) {
120	// Use an in-memory mock keyring to test the deletion step cleanly
121	keyring.MockInit()
122
123	cfg := &Config{}
124
125	// Add an account
126	account := Account{
127		Name:            "Test",
128		Email:           "test@example.com",
129		ServiceProvider: "gmail",
130	}
131	cfg.AddAccount(account)
132
133	if len(cfg.Accounts) != 1 {
134		t.Fatalf("Expected 1 account, got %d", len(cfg.Accounts))
135	}
136
137	// Check that ID was auto-generated
138	if cfg.Accounts[0].ID == "" {
139		t.Error("Expected account ID to be auto-generated")
140	}
141
142	// Remove the account
143	accountID := cfg.Accounts[0].ID
144	removed := cfg.RemoveAccount(accountID)
145	if !removed {
146		t.Error("RemoveAccount should return true when account exists")
147	}
148
149	if len(cfg.Accounts) != 0 {
150		t.Fatalf("Expected 0 accounts after removal, got %d", len(cfg.Accounts))
151	}
152
153	// Try to remove non-existent account
154	removed = cfg.RemoveAccount("non-existent")
155	if removed {
156		t.Error("RemoveAccount should return false for non-existent account")
157	}
158}
159
160// TestConfigGetAccountByID tests retrieving accounts by ID.
161func TestConfigGetAccountByID(t *testing.T) {
162	cfg := &Config{
163		Accounts: []Account{
164			{ID: "id-1", Email: "test1@example.com"},
165			{ID: "id-2", Email: "test2@example.com"},
166		},
167	}
168
169	account := cfg.GetAccountByID("id-1")
170	if account == nil {
171		t.Fatal("Expected to find account with id-1")
172	}
173	if account.Email != "test1@example.com" {
174		t.Errorf("Expected email test1@example.com, got %s", account.Email)
175	}
176
177	// Non-existent ID
178	account = cfg.GetAccountByID("non-existent")
179	if account != nil {
180		t.Error("Expected nil for non-existent account ID")
181	}
182}
183
184// TestConfigGetAccountByEmail tests retrieving accounts by email.
185func TestConfigGetAccountByEmail(t *testing.T) {
186	cfg := &Config{
187		Accounts: []Account{
188			{ID: "id-1", Email: "test1@example.com"},
189			{ID: "id-2", Email: "test2@example.com"},
190		},
191	}
192
193	account := cfg.GetAccountByEmail("test2@example.com")
194	if account == nil {
195		t.Fatal("Expected to find account with test2@example.com")
196	}
197	if account.ID != "id-2" {
198		t.Errorf("Expected ID id-2, got %s", account.ID)
199	}
200
201	// Non-existent email
202	account = cfg.GetAccountByEmail("nonexistent@example.com")
203	if account != nil {
204		t.Error("Expected nil for non-existent account email")
205	}
206}
207
208func TestAddContactNormalizesEmailAndDeduplicates(t *testing.T) {
209	t.Setenv("HOME", t.TempDir())
210
211	if err := AddContactForAccount("Alice", "Alice@Example.com", "account-1"); err != nil {
212		t.Fatalf("AddContactForAccount() failed: %v", err)
213	}
214	if err := AddContactForAccount("", "alice@example.com", "account-1"); err != nil {
215		t.Fatalf("AddContactForAccount() failed: %v", err)
216	}
217
218	cache, err := LoadContactsCache()
219	if err != nil {
220		t.Fatalf("LoadContactsCache() failed: %v", err)
221	}
222
223	if len(cache.Contacts) != 1 {
224		t.Fatalf("Expected 1 contact after deduplication, got %d", len(cache.Contacts))
225	}
226
227	contact := cache.Contacts[0]
228	if contact.Email != "alice@example.com" {
229		t.Errorf("Expected normalized email alice@example.com, got %s", contact.Email)
230	}
231	usage := contact.Usage["account-1"]
232	if usage.UseCount != 2 {
233		t.Errorf("Expected UseCount 2 after duplicate add, got %d", usage.UseCount)
234	}
235}
236
237func TestMigrateContactsCacheUsageExpandsLegacyUsage(t *testing.T) {
238	t.Setenv("HOME", t.TempDir())
239
240	lastUsed := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC)
241	path, err := GetContactsCachePath()
242	if err != nil {
243		t.Fatalf("GetContactsCachePath() failed: %v", err)
244	}
245	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
246		t.Fatalf("MkdirAll() failed: %v", err)
247	}
248	legacyJSON := `{"contacts":[{"name":"Alice","email":"alice@example.com","last_used":"` + lastUsed.Format(time.RFC3339) + `","use_count":7}]}`
249	if err := os.WriteFile(path, []byte(legacyJSON), 0600); err != nil {
250		t.Fatalf("WriteFile() failed: %v", err)
251	}
252
253	if err := MigrateContactsCacheUsage([]string{"account-1", "account-2"}); err != nil {
254		t.Fatalf("MigrateContactsCacheUsage() failed: %v", err)
255	}
256
257	cache, err := LoadContactsCache()
258	if err != nil {
259		t.Fatalf("LoadContactsCache() failed: %v", err)
260	}
261	if len(cache.Contacts) != 1 {
262		t.Fatalf("Expected 1 contact, got %d", len(cache.Contacts))
263	}
264	for _, accountID := range []string{"account-1", "account-2"} {
265		usage, ok := cache.Contacts[0].Usage[accountID]
266		if !ok {
267			t.Fatalf("Expected usage for %s", accountID)
268		}
269		if usage.UseCount != 7 || !usage.LastUsed.Equal(lastUsed) {
270			t.Fatalf("Unexpected usage for %s: %+v", accountID, usage)
271		}
272	}
273	if _, ok := cache.Contacts[0].Usage[legacyContactUsageKey]; ok {
274		t.Fatal("Legacy usage key should be removed after migration")
275	}
276}
277
278func TestSearchContactsForAccountFiltersAndSortsByUsage(t *testing.T) {
279	t.Setenv("HOME", t.TempDir())
280
281	now := time.Now()
282	cache := &ContactsCache{Contacts: []Contact{
283		{
284			Name:  "Alice",
285			Email: "alice@example.com",
286			Usage: map[string]ContactUsage{
287				"account-1": {UseCount: 1, LastUsed: now},
288			},
289		},
290		{
291			Name:  "Alicia",
292			Email: "alicia@example.com",
293			Usage: map[string]ContactUsage{
294				"account-2": {UseCount: 9, LastUsed: now.Add(time.Hour)},
295			},
296		},
297		{
298			Name:  "Alina",
299			Email: "alina@example.com",
300			Usage: map[string]ContactUsage{
301				"account-1": {UseCount: 3, LastUsed: now.Add(-time.Hour)},
302			},
303		},
304	}}
305	if err := SaveContactsCache(cache); err != nil {
306		t.Fatalf("SaveContactsCache() failed: %v", err)
307	}
308
309	matches := SearchContactsForAccount("ali", "account-1")
310	if len(matches) != 2 {
311		t.Fatalf("Expected 2 account-1 matches, got %d", len(matches))
312	}
313	if matches[0].Email != "alina@example.com" {
314		t.Fatalf("Expected highest account-1 usage first, got %s", matches[0].Email)
315	}
316}
317
318func TestCleanupAccountCacheRemovesOnlyTargetAccountData(t *testing.T) {
319	t.Setenv("HOME", t.TempDir())
320
321	now := time.Now()
322	emailFor := func(accountID string, uid uint32) CachedEmail {
323		return CachedEmail{
324			UID:       uid,
325			From:      accountID + "@example.com",
326			Subject:   "subject",
327			Date:      now,
328			AccountID: accountID,
329		}
330	}
331
332	if err := SaveEmailCache(&EmailCache{Emails: []CachedEmail{
333		emailFor("account-1", 1),
334		emailFor("account-2", 2),
335	}}); err != nil {
336		t.Fatalf("SaveEmailCache() failed: %v", err)
337	}
338	if err := SaveFolderCache(&FolderCache{Accounts: []CachedFolders{
339		{AccountID: "account-1", Folders: []string{"INBOX"}},
340		{AccountID: "account-2", Folders: []string{"INBOX", "Sent"}},
341	}}); err != nil {
342		t.Fatalf("SaveFolderCache() failed: %v", err)
343	}
344	if err := SaveFolderEmailCache("INBOX", []CachedEmail{
345		emailFor("account-1", 1),
346		emailFor("account-2", 2),
347	}); err != nil {
348		t.Fatalf("SaveFolderEmailCache(INBOX) failed: %v", err)
349	}
350	if err := SaveFolderEmailCache("OnlyDeleted", []CachedEmail{
351		emailFor("account-1", 3),
352	}); err != nil {
353		t.Fatalf("SaveFolderEmailCache(OnlyDeleted) failed: %v", err)
354	}
355	if err := SaveDraftsCache(&DraftsCache{Drafts: []Draft{
356		{ID: "draft-1", AccountID: "account-1", Subject: "delete"},
357		{ID: "draft-2", AccountID: "account-2", Subject: "keep"},
358	}}); err != nil {
359		t.Fatalf("SaveDraftsCache() failed: %v", err)
360	}
361	if err := SaveContactsCache(&ContactsCache{Contacts: []Contact{
362		{
363			Name:  "Shared",
364			Email: "shared@example.com",
365			Usage: map[string]ContactUsage{
366				"account-1": {UseCount: 1, LastUsed: now},
367				"account-2": {UseCount: 2, LastUsed: now},
368			},
369		},
370		{
371			Name:  "Only Deleted",
372			Email: "deleted@example.com",
373			Usage: map[string]ContactUsage{
374				"account-1": {UseCount: 1, LastUsed: now},
375			},
376		},
377	}}); err != nil {
378		t.Fatalf("SaveContactsCache() failed: %v", err)
379	}
380	if err := SaveEmailBody("INBOX", CachedEmailBody{
381		UID:       1,
382		AccountID: "account-1",
383		Body:      "delete",
384	}, 1<<20); err != nil {
385		t.Fatalf("SaveEmailBody(account-1) failed: %v", err)
386	}
387	if err := SaveEmailBody("INBOX", CachedEmailBody{
388		UID:       2,
389		AccountID: "account-2",
390		Body:      "keep",
391	}, 1<<20); err != nil {
392		t.Fatalf("SaveEmailBody(account-2) failed: %v", err)
393	}
394	if err := SaveEmailBody("OnlyDeleted", CachedEmailBody{
395		UID:       3,
396		AccountID: "account-1",
397		Body:      "delete",
398	}, 1<<20); err != nil {
399		t.Fatalf("SaveEmailBody(OnlyDeleted) failed: %v", err)
400	}
401
402	if err := CleanupAccountCache("account-1"); err != nil {
403		t.Fatalf("CleanupAccountCache() failed: %v", err)
404	}
405
406	emailCache, err := LoadEmailCache()
407	if err != nil {
408		t.Fatalf("LoadEmailCache() failed: %v", err)
409	}
410	if len(emailCache.Emails) != 1 || emailCache.Emails[0].AccountID != "account-2" {
411		t.Fatalf("Unexpected email cache after cleanup: %+v", emailCache.Emails)
412	}
413
414	folderCache, err := LoadFolderCache()
415	if err != nil {
416		t.Fatalf("LoadFolderCache() failed: %v", err)
417	}
418	if len(folderCache.Accounts) != 1 || folderCache.Accounts[0].AccountID != "account-2" {
419		t.Fatalf("Unexpected folder cache after cleanup: %+v", folderCache.Accounts)
420	}
421
422	folderEmails, err := LoadFolderEmailCache("INBOX")
423	if err != nil {
424		t.Fatalf("LoadFolderEmailCache(INBOX) failed: %v", err)
425	}
426	if len(folderEmails) != 1 || folderEmails[0].AccountID != "account-2" {
427		t.Fatalf("Unexpected folder emails after cleanup: %+v", folderEmails)
428	}
429	onlyDeletedFolderPath, err := folderEmailCacheFile("OnlyDeleted")
430	if err != nil {
431		t.Fatalf("folderEmailCacheFile() failed: %v", err)
432	}
433	if _, err := os.Stat(onlyDeletedFolderPath); !os.IsNotExist(err) {
434		t.Fatalf("Expected folder email cache with only deleted account to be removed, stat err=%v", err)
435	}
436
437	draftsCache, err := LoadDraftsCache()
438	if err != nil {
439		t.Fatalf("LoadDraftsCache() failed: %v", err)
440	}
441	if len(draftsCache.Drafts) != 1 || draftsCache.Drafts[0].AccountID != "account-2" {
442		t.Fatalf("Unexpected drafts after cleanup: %+v", draftsCache.Drafts)
443	}
444
445	contactsCache, err := LoadContactsCache()
446	if err != nil {
447		t.Fatalf("LoadContactsCache() failed: %v", err)
448	}
449	if len(contactsCache.Contacts) != 1 || contactsCache.Contacts[0].Email != "shared@example.com" {
450		t.Fatalf("Unexpected contacts after cleanup: %+v", contactsCache.Contacts)
451	}
452	if _, ok := contactsCache.Contacts[0].Usage["account-1"]; ok {
453		t.Fatal("Deleted account usage should be removed from shared contact")
454	}
455	if _, ok := contactsCache.Contacts[0].Usage["account-2"]; !ok {
456		t.Fatal("Remaining account usage should stay on shared contact")
457	}
458
459	bodyCache, err := LoadEmailBodyCache("INBOX")
460	if err != nil {
461		t.Fatalf("LoadEmailBodyCache(INBOX) failed: %v", err)
462	}
463	if len(bodyCache.Bodies) != 1 || bodyCache.Bodies[0].AccountID != "account-2" {
464		t.Fatalf("Unexpected body cache after cleanup: %+v", bodyCache.Bodies)
465	}
466	onlyDeletedBodyPath, err := bodyCacheFile("OnlyDeleted")
467	if err != nil {
468		t.Fatalf("bodyCacheFile() failed: %v", err)
469	}
470	if _, err := os.Stat(onlyDeletedBodyPath); !os.IsNotExist(err) {
471		t.Fatalf("Expected body cache with only deleted account to be removed, stat err=%v", err)
472	}
473}
474
475// TestConfigHasAccounts tests the HasAccounts method.
476func TestConfigHasAccounts(t *testing.T) {
477	cfg := &Config{}
478	if cfg.HasAccounts() {
479		t.Error("Expected HasAccounts to return false for empty config")
480	}
481
482	cfg.AddAccount(Account{Email: "test@example.com"})
483	if !cfg.HasAccounts() {
484		t.Error("Expected HasAccounts to return true after adding account")
485	}
486}
487
488// TestAccountGetPorts tests the port retrieval methods.
489func TestAccountGetPorts(t *testing.T) {
490	// Gmail account should use default ports
491	gmailAccount := Account{ServiceProvider: "gmail"}
492	if gmailAccount.GetIMAPPort() != 993 {
493		t.Errorf("Expected Gmail IMAP port 993, got %d", gmailAccount.GetIMAPPort())
494	}
495	if gmailAccount.GetSMTPPort() != 587 {
496		t.Errorf("Expected Gmail SMTP port 587, got %d", gmailAccount.GetSMTPPort())
497	}
498
499	// Custom account with custom ports
500	customAccount := Account{
501		ServiceProvider: "custom",
502		IMAPPort:        1993,
503		SMTPPort:        1587,
504	}
505	if customAccount.GetIMAPPort() != 1993 {
506		t.Errorf("Expected custom IMAP port 1993, got %d", customAccount.GetIMAPPort())
507	}
508	if customAccount.GetSMTPPort() != 1587 {
509		t.Errorf("Expected custom SMTP port 1587, got %d", customAccount.GetSMTPPort())
510	}
511
512	// Custom account with default ports (0 means use default)
513	customDefaultAccount := Account{ServiceProvider: "custom"}
514	if customDefaultAccount.GetIMAPPort() != 993 {
515		t.Errorf("Expected default IMAP port 993 for custom with no port, got %d", customDefaultAccount.GetIMAPPort())
516	}
517	if customDefaultAccount.GetSMTPPort() != 587 {
518		t.Errorf("Expected default SMTP port 587 for custom with no port, got %d", customDefaultAccount.GetSMTPPort())
519	}
520}
521
522func TestAccountSendIdentityHelpers(t *testing.T) {
523	t.Run("send as takes precedence", func(t *testing.T) {
524		account := Account{
525			Name:        "Alias User",
526			Email:       "login@gmail.com",
527			FetchEmail:  "inbox@gmail.com",
528			SendAsEmail: "alias@example.com",
529		}
530
531		if got := account.GetFetchEmail(); got != "inbox@gmail.com" {
532			t.Fatalf("GetFetchEmail() = %q, want %q", got, "inbox@gmail.com")
533		}
534		if got := account.GetSendAsEmail(); got != "alias@example.com" {
535			t.Fatalf("GetSendAsEmail() = %q, want %q", got, "alias@example.com")
536		}
537		if got := account.FormatFromHeader(); got != "Alias User <alias@example.com>" {
538			t.Fatalf("FormatFromHeader() = %q, want %q", got, "Alias User <alias@example.com>")
539		}
540	})
541
542	t.Run("send as falls back to fetch then login", func(t *testing.T) {
543		account := Account{
544			Name:       "Fallback User",
545			Email:      "login@gmail.com",
546			FetchEmail: "inbox@gmail.com",
547		}
548		if got := account.GetSendAsEmail(); got != "inbox@gmail.com" {
549			t.Fatalf("GetSendAsEmail() = %q, want %q", got, "inbox@gmail.com")
550		}
551
552		account.FetchEmail = ""
553		if got := account.GetSendAsEmail(); got != "login@gmail.com" {
554			t.Fatalf("GetSendAsEmail() = %q, want %q", got, "login@gmail.com")
555		}
556	})
557
558	t.Run("format from header avoids double wrapping", func(t *testing.T) {
559		account := Account{
560			Name:        "Account Name",
561			SendAsEmail: "Custom Name <custom@example.com>",
562		}
563		if got := account.FormatFromHeader(); got != "Custom Name <custom@example.com>" {
564			t.Fatalf("FormatFromHeader() = %q, want %q", got, "Custom Name <custom@example.com>")
565		}
566	})
567}
568
569func TestTranslateDateFormat(t *testing.T) {
570	testCases := []struct {
571		name   string
572		input  string
573		want   string
574		sample string // expected output of time.Format for a fixed sample instant
575	}{
576		{"EU preset", DateFormatEU, "02/01/2006 15:04", "17/04/2026 09:05"},
577		{"US preset", DateFormatUS, "01/02/2006 03:04 PM", "04/17/2026 09:05 AM"},
578		{"ISO preset", DateFormatISO, "2006-01-02 15:04", "2026-04-17 09:05"},
579		{"seconds", "HH:MM:SS", "15:04:05", "09:05:00"},
580		{"explicit minutes", "YYYY-MM-DD HH:mm", "2006-01-02 15:04", "2026-04-17 09:05"},
581		{"2-digit year", "DD/MM/YY", "02/01/06", "17/04/26"},
582		{"literal passthrough", "some text", "some text", "some text"},
583	}
584
585	sample := time.Date(2026, 4, 17, 9, 5, 0, 0, time.UTC)
586	for _, tc := range testCases {
587		t.Run(tc.name, func(t *testing.T) {
588			got := translateDateFormat(tc.input)
589			if got != tc.want {
590				t.Fatalf("translateDateFormat(%q) = %q, want %q", tc.input, got, tc.want)
591			}
592			if rendered := sample.Format(got); rendered != tc.sample {
593				t.Fatalf("sample.Format(%q) = %q, want %q", got, rendered, tc.sample)
594			}
595		})
596	}
597}
598
599func TestConfigGetDateFormatDefault(t *testing.T) {
600	c := &Config{}
601	got := c.GetDateFormat()
602	want := translateDateFormat(DateFormatEU)
603	if got != want {
604		t.Fatalf("GetDateFormat() with empty DateFormat = %q, want %q", got, want)
605	}
606}
607
608func TestConfigGetDateFormatCustom(t *testing.T) {
609	c := &Config{DateFormat: "DD/MM/YYYY HH:MM"}
610	if got, want := c.GetDateFormat(), "02/01/2006 15:04"; got != want {
611		t.Fatalf("GetDateFormat() = %q, want %q", got, want)
612	}
613}