1package config
2
3import (
4 "reflect"
5 "testing"
6 "time"
7
8 "github.com/zalando/go-keyring"
9)
10
11// TestSaveAndLoadConfig verifies that the config can be saved to and loaded from a file correctly.
12func TestSaveAndLoadConfig(t *testing.T) {
13 // Use an in-memory mock keyring so tests do not interact with the host OS keyring
14 keyring.MockInit()
15
16 // Create a temporary directory for the test to avoid interfering with actual user config.
17 tempDir := t.TempDir()
18
19 // Temporarily override the user home directory to our temp directory.
20 // This ensures that our config file is written to a predictable, temporary location.
21 t.Setenv("HOME", tempDir)
22
23 // Define a sample configuration to save with multiple accounts.
24 expectedConfig := &Config{
25 Accounts: []Account{
26 {
27 ID: "test-id-1",
28 Name: "Test User",
29 Email: "test@example.com",
30 Password: "supersecret",
31 ServiceProvider: "gmail",
32 SendAsEmail: "alias@example.com",
33 },
34 {
35 ID: "test-id-2",
36 Name: "Custom User",
37 Email: "custom@example.com",
38 Password: "customsecret",
39 ServiceProvider: "custom",
40 IMAPServer: "imap.custom.com",
41 IMAPPort: 993,
42 SMTPServer: "smtp.custom.com",
43 SMTPPort: 587,
44 CatchAll: true,
45 },
46 },
47 }
48
49 // Attempt to save the configuration.
50 err := SaveConfig(expectedConfig)
51 if err != nil {
52 t.Fatalf("SaveConfig() failed: %v", err)
53 }
54
55 // Attempt to load the configuration back.
56 loadedConfig, err := LoadConfig()
57 if err != nil {
58 t.Fatalf("LoadConfig() failed: %v", err)
59 }
60
61 // Compare the loaded configuration with the original one.
62 // reflect.DeepEqual is used for a deep comparison of the structs.
63 if !reflect.DeepEqual(loadedConfig, expectedConfig) {
64 t.Errorf("Loaded config does not match expected config.\nGot: %+v\nWant: %+v", loadedConfig, expectedConfig)
65 }
66}
67
68// TestAccountGetIMAPServer tests the logic that determines the IMAP server address.
69func TestAccountGetIMAPServer(t *testing.T) {
70 testCases := []struct {
71 name string
72 account Account
73 want string
74 }{
75 {"Gmail", Account{ServiceProvider: "gmail"}, "imap.gmail.com"},
76 {"iCloud", Account{ServiceProvider: "icloud"}, "imap.mail.me.com"},
77 {"Custom", Account{ServiceProvider: "custom", IMAPServer: "imap.custom.com"}, "imap.custom.com"},
78 {"Unsupported", Account{ServiceProvider: "yahoo"}, ""},
79 {"Empty", Account{ServiceProvider: ""}, ""},
80 }
81
82 for _, tc := range testCases {
83 t.Run(tc.name, func(t *testing.T) {
84 got := tc.account.GetIMAPServer()
85 if got != tc.want {
86 t.Errorf("GetIMAPServer() = %q, want %q", got, tc.want)
87 }
88 })
89 }
90}
91
92// TestAccountGetSMTPServer tests the logic that determines the SMTP server address.
93func TestAccountGetSMTPServer(t *testing.T) {
94 testCases := []struct {
95 name string
96 account Account
97 want string
98 }{
99 {"Gmail", Account{ServiceProvider: "gmail"}, "smtp.gmail.com"},
100 {"iCloud", Account{ServiceProvider: "icloud"}, "smtp.mail.me.com"},
101 {"Custom", Account{ServiceProvider: "custom", SMTPServer: "smtp.custom.com"}, "smtp.custom.com"},
102 {"Unsupported", Account{ServiceProvider: "yahoo"}, ""},
103 {"Empty", Account{ServiceProvider: ""}, ""},
104 }
105
106 for _, tc := range testCases {
107 t.Run(tc.name, func(t *testing.T) {
108 got := tc.account.GetSMTPServer()
109 if got != tc.want {
110 t.Errorf("GetSMTPServer() = %q, want %q", got, tc.want)
111 }
112 })
113 }
114}
115
116// TestConfigAddRemoveAccount tests adding and removing accounts from config.
117func TestConfigAddRemoveAccount(t *testing.T) {
118 // Use an in-memory mock keyring to test the deletion step cleanly
119 keyring.MockInit()
120
121 cfg := &Config{}
122
123 // Add an account
124 account := Account{
125 Name: "Test",
126 Email: "test@example.com",
127 ServiceProvider: "gmail",
128 }
129 cfg.AddAccount(account)
130
131 if len(cfg.Accounts) != 1 {
132 t.Fatalf("Expected 1 account, got %d", len(cfg.Accounts))
133 }
134
135 // Check that ID was auto-generated
136 if cfg.Accounts[0].ID == "" {
137 t.Error("Expected account ID to be auto-generated")
138 }
139
140 // Remove the account
141 accountID := cfg.Accounts[0].ID
142 removed := cfg.RemoveAccount(accountID)
143 if !removed {
144 t.Error("RemoveAccount should return true when account exists")
145 }
146
147 if len(cfg.Accounts) != 0 {
148 t.Fatalf("Expected 0 accounts after removal, got %d", len(cfg.Accounts))
149 }
150
151 // Try to remove non-existent account
152 removed = cfg.RemoveAccount("non-existent")
153 if removed {
154 t.Error("RemoveAccount should return false for non-existent account")
155 }
156}
157
158// TestConfigGetAccountByID tests retrieving accounts by ID.
159func TestConfigGetAccountByID(t *testing.T) {
160 cfg := &Config{
161 Accounts: []Account{
162 {ID: "id-1", Email: "test1@example.com"},
163 {ID: "id-2", Email: "test2@example.com"},
164 },
165 }
166
167 account := cfg.GetAccountByID("id-1")
168 if account == nil {
169 t.Fatal("Expected to find account with id-1")
170 }
171 if account.Email != "test1@example.com" {
172 t.Errorf("Expected email test1@example.com, got %s", account.Email)
173 }
174
175 // Non-existent ID
176 account = cfg.GetAccountByID("non-existent")
177 if account != nil {
178 t.Error("Expected nil for non-existent account ID")
179 }
180}
181
182// TestConfigGetAccountByEmail tests retrieving accounts by email.
183func TestConfigGetAccountByEmail(t *testing.T) {
184 cfg := &Config{
185 Accounts: []Account{
186 {ID: "id-1", Email: "test1@example.com"},
187 {ID: "id-2", Email: "test2@example.com"},
188 },
189 }
190
191 account := cfg.GetAccountByEmail("test2@example.com")
192 if account == nil {
193 t.Fatal("Expected to find account with test2@example.com")
194 }
195 if account.ID != "id-2" {
196 t.Errorf("Expected ID id-2, got %s", account.ID)
197 }
198
199 // Non-existent email
200 account = cfg.GetAccountByEmail("nonexistent@example.com")
201 if account != nil {
202 t.Error("Expected nil for non-existent account email")
203 }
204}
205
206func TestAddContactNormalizesEmailAndDeduplicates(t *testing.T) {
207 t.Setenv("HOME", t.TempDir())
208
209 if err := AddContact("Alice", "Alice@Example.com"); err != nil {
210 t.Fatalf("AddContact() failed: %v", err)
211 }
212 if err := AddContact("", "alice@example.com"); err != nil {
213 t.Fatalf("AddContact() failed: %v", err)
214 }
215
216 cache, err := LoadContactsCache()
217 if err != nil {
218 t.Fatalf("LoadContactsCache() failed: %v", err)
219 }
220
221 if len(cache.Contacts) != 1 {
222 t.Fatalf("Expected 1 contact after deduplication, got %d", len(cache.Contacts))
223 }
224
225 contact := cache.Contacts[0]
226 if contact.Email != "alice@example.com" {
227 t.Errorf("Expected normalized email alice@example.com, got %s", contact.Email)
228 }
229 if contact.UseCount != 2 {
230 t.Errorf("Expected UseCount 2 after duplicate add, got %d", contact.UseCount)
231 }
232}
233
234// TestConfigHasAccounts tests the HasAccounts method.
235func TestConfigHasAccounts(t *testing.T) {
236 cfg := &Config{}
237 if cfg.HasAccounts() {
238 t.Error("Expected HasAccounts to return false for empty config")
239 }
240
241 cfg.AddAccount(Account{Email: "test@example.com"})
242 if !cfg.HasAccounts() {
243 t.Error("Expected HasAccounts to return true after adding account")
244 }
245}
246
247// TestAccountGetPorts tests the port retrieval methods.
248func TestAccountGetPorts(t *testing.T) {
249 // Gmail account should use default ports
250 gmailAccount := Account{ServiceProvider: "gmail"}
251 if gmailAccount.GetIMAPPort() != 993 {
252 t.Errorf("Expected Gmail IMAP port 993, got %d", gmailAccount.GetIMAPPort())
253 }
254 if gmailAccount.GetSMTPPort() != 587 {
255 t.Errorf("Expected Gmail SMTP port 587, got %d", gmailAccount.GetSMTPPort())
256 }
257
258 // Custom account with custom ports
259 customAccount := Account{
260 ServiceProvider: "custom",
261 IMAPPort: 1993,
262 SMTPPort: 1587,
263 }
264 if customAccount.GetIMAPPort() != 1993 {
265 t.Errorf("Expected custom IMAP port 1993, got %d", customAccount.GetIMAPPort())
266 }
267 if customAccount.GetSMTPPort() != 1587 {
268 t.Errorf("Expected custom SMTP port 1587, got %d", customAccount.GetSMTPPort())
269 }
270
271 // Custom account with default ports (0 means use default)
272 customDefaultAccount := Account{ServiceProvider: "custom"}
273 if customDefaultAccount.GetIMAPPort() != 993 {
274 t.Errorf("Expected default IMAP port 993 for custom with no port, got %d", customDefaultAccount.GetIMAPPort())
275 }
276 if customDefaultAccount.GetSMTPPort() != 587 {
277 t.Errorf("Expected default SMTP port 587 for custom with no port, got %d", customDefaultAccount.GetSMTPPort())
278 }
279}
280
281func TestAccountSendIdentityHelpers(t *testing.T) {
282 t.Run("send as takes precedence", func(t *testing.T) {
283 account := Account{
284 Name: "Alias User",
285 Email: "login@gmail.com",
286 FetchEmail: "inbox@gmail.com",
287 SendAsEmail: "alias@example.com",
288 }
289
290 if got := account.GetFetchEmail(); got != "inbox@gmail.com" {
291 t.Fatalf("GetFetchEmail() = %q, want %q", got, "inbox@gmail.com")
292 }
293 if got := account.GetSendAsEmail(); got != "alias@example.com" {
294 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "alias@example.com")
295 }
296 if got := account.FormatFromHeader(); got != "Alias User <alias@example.com>" {
297 t.Fatalf("FormatFromHeader() = %q, want %q", got, "Alias User <alias@example.com>")
298 }
299 })
300
301 t.Run("send as falls back to fetch then login", func(t *testing.T) {
302 account := Account{
303 Name: "Fallback User",
304 Email: "login@gmail.com",
305 FetchEmail: "inbox@gmail.com",
306 }
307 if got := account.GetSendAsEmail(); got != "inbox@gmail.com" {
308 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "inbox@gmail.com")
309 }
310
311 account.FetchEmail = ""
312 if got := account.GetSendAsEmail(); got != "login@gmail.com" {
313 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "login@gmail.com")
314 }
315 })
316
317 t.Run("format from header avoids double wrapping", func(t *testing.T) {
318 account := Account{
319 Name: "Account Name",
320 SendAsEmail: "Custom Name <custom@example.com>",
321 }
322 if got := account.FormatFromHeader(); got != "Custom Name <custom@example.com>" {
323 t.Fatalf("FormatFromHeader() = %q, want %q", got, "Custom Name <custom@example.com>")
324 }
325 })
326}
327
328func TestTranslateDateFormat(t *testing.T) {
329 testCases := []struct {
330 name string
331 input string
332 want string
333 sample string // expected output of time.Format for a fixed sample instant
334 }{
335 {"EU preset", DateFormatEU, "02/01/2006 15:04", "17/04/2026 09:05"},
336 {"US preset", DateFormatUS, "01/02/2006 03:04 PM", "04/17/2026 09:05 AM"},
337 {"ISO preset", DateFormatISO, "2006-01-02 15:04", "2026-04-17 09:05"},
338 {"seconds", "HH:MM:SS", "15:04:05", "09:05:00"},
339 {"explicit minutes", "YYYY-MM-DD HH:mm", "2006-01-02 15:04", "2026-04-17 09:05"},
340 {"2-digit year", "DD/MM/YY", "02/01/06", "17/04/26"},
341 {"literal passthrough", "some text", "some text", "some text"},
342 }
343
344 sample := time.Date(2026, 4, 17, 9, 5, 0, 0, time.UTC)
345 for _, tc := range testCases {
346 t.Run(tc.name, func(t *testing.T) {
347 got := translateDateFormat(tc.input)
348 if got != tc.want {
349 t.Fatalf("translateDateFormat(%q) = %q, want %q", tc.input, got, tc.want)
350 }
351 if rendered := sample.Format(got); rendered != tc.sample {
352 t.Fatalf("sample.Format(%q) = %q, want %q", got, rendered, tc.sample)
353 }
354 })
355 }
356}
357
358func TestConfigGetDateFormatDefault(t *testing.T) {
359 c := &Config{}
360 got := c.GetDateFormat()
361 want := translateDateFormat(DateFormatEU)
362 if got != want {
363 t.Fatalf("GetDateFormat() with empty DateFormat = %q, want %q", got, want)
364 }
365}
366
367func TestConfigGetDateFormatCustom(t *testing.T) {
368 c := &Config{DateFormat: "DD/MM/YYYY HH:MM"}
369 if got, want := c.GetDateFormat(), "02/01/2006 15:04"; got != want {
370 t.Fatalf("GetDateFormat() = %q, want %q", got, want)
371 }
372}