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