1package config
2
3import (
4 "os"
5 "path/filepath"
6 "slices"
7 "strings"
8 "sync"
9 "testing"
10)
11
12func setup(t *testing.T) {
13 t.Helper()
14 dir := t.TempDir()
15 t.Setenv("HOME", dir)
16 t.Setenv("USERPROFILE", dir)
17 resetLRU()
18}
19
20func TestEmailCache_SaveLoadRoundTrip(t *testing.T) {
21 setup(t)
22
23 e1 := CachedEmail{
24 UID: 1,
25 From: "t1@e.com",
26 To: []string{"t2@e.com"},
27 Subject: "Hello",
28 }
29
30 e2 := CachedEmail{
31 UID: 2,
32 From: "t2@e.com",
33 To: []string{"t1@e.com"},
34 Subject: "Hello",
35 }
36
37 input := &EmailCache{Emails: []CachedEmail{e1, e2}}
38
39 if err := SaveEmailCache(input); err != nil {
40 t.Fatalf("SaveEmailCache: %v", err)
41 }
42
43 output, err := LoadEmailCache()
44 if err != nil {
45 t.Fatalf("LoadEmailCache: %v", err)
46 }
47
48 if len(output.Emails) != len(input.Emails) {
49 t.Fatalf("email count: got %d, want %d", len(output.Emails), len(input.Emails))
50 }
51
52 for i := range output.Emails {
53 IN := input.Emails[i]
54 OU := output.Emails[i]
55 if IN.UID != OU.UID || IN.From != OU.From || !slices.Equal(IN.To, OU.To) || IN.Subject != OU.Subject {
56 t.Errorf("email[%d] mismatch: got %+v, want %+v", i, OU, IN)
57 }
58 }
59}
60
61func TestEmailCache_HasEmailCache_FalseWhenMissing(t *testing.T) {
62 setup(t)
63 if HasEmailCache() {
64 t.Error("HasEmailCache should be false before any save")
65 }
66}
67
68func TestEmailCache_HasEmailCache_TrueAfterSave(t *testing.T) {
69 setup(t)
70
71 if err := SaveEmailCache(&EmailCache{}); err != nil {
72 t.Fatalf("SaveEmailCache: %v", err)
73 }
74
75 if !HasEmailCache() {
76 t.Error("HasEmailCache should be true after save")
77 }
78}
79
80func TestEmailCache_ClearEmailCache(t *testing.T) {
81 setup(t)
82
83 e := CachedEmail{
84 UID: 1,
85 AccountID: "account",
86 From: "t1@e.com",
87 To: []string{"t2@e.com"},
88 Subject: "Hello",
89 }
90
91 if err := SaveEmailCache(&EmailCache{Emails: []CachedEmail{e}}); err != nil {
92 t.Fatalf("SaveEmailCache: %v", err)
93 }
94
95 if err := ClearEmailCache(); err != nil {
96 t.Fatalf("ClearEmailCache: %v", err)
97 }
98
99 if HasEmailCache() {
100 t.Error("HasEmailCache should be false after clear")
101 }
102}
103
104func TestEmailCache_RemoveAccount(t *testing.T) {
105 setup(t)
106
107 e1 := CachedEmail{UID: 1, AccountID: "a1"}
108 e2 := CachedEmail{UID: 2, AccountID: "a2"}
109 e3 := CachedEmail{UID: 3, AccountID: "a3"}
110
111 if err := SaveEmailCache(&EmailCache{Emails: []CachedEmail{e1, e2, e3}}); err != nil {
112 t.Fatalf("SaveEmailCache: %v", err)
113 }
114
115 if err := removeAccountFromEmailCache("a2"); err != nil {
116 t.Fatalf("removeAccountFromEmailCache: %v", err)
117 }
118
119 output, err := LoadEmailCache()
120 if err != nil {
121 t.Fatalf("LoadEmailCache: %v", err)
122 }
123
124 for _, e := range output.Emails {
125 if e.AccountID == "a2" {
126 t.Errorf("found email belonging to removed account AC2: %+v", e)
127 }
128 }
129}
130
131func TestEmailCache_LoadCorruptFile(t *testing.T) {
132 setup(t)
133
134 if err := SaveEmailCache(&EmailCache{}); err != nil {
135 t.Fatalf("SaveEmailCache: %v", err)
136 }
137
138 path, err := cacheFile()
139 if err != nil {
140 t.Fatalf("cacheFile: %v", err)
141 }
142
143 if err := os.WriteFile(path, []byte("{corrupted json}"), 0600); err != nil {
144 t.Fatalf("WriteFile: %v", err)
145 }
146
147 if _, err = LoadEmailCache(); err == nil {
148 t.Error("LoadEmailCache should return an error for corrupt JSON")
149 }
150}
151
152func TestContacts_SaveLoadRoundTrip(t *testing.T) {
153 setup(t)
154
155 c := Contact{
156 Name: "t",
157 Email: "t@e.com",
158 Addresses: []string{"address 1, address 2"},
159 }
160
161 input := &ContactsCache{Contacts: []Contact{c}}
162
163 if err := SaveContactsCache(input); err != nil {
164 t.Fatalf("SaveContactsCache: %v", err)
165 }
166
167 output, err := LoadContactsCache()
168 if err != nil {
169 t.Fatalf("LoadContactsCache: %v", err)
170 }
171
172 if len(output.Contacts) != len(input.Contacts) {
173 t.Fatalf("contacts count mismatch:\n got: %d\n want: %d", len(output.Contacts), len(input.Contacts))
174 }
175
176 for i := range output.Contacts {
177 IN := input.Contacts[i]
178 OU := output.Contacts[i]
179 if IN.Name != OU.Name || IN.Email != OU.Email || !slices.Equal(IN.Addresses, OU.Addresses) {
180 t.Errorf("contact[%d] mismatch: got %+v, want %+v", i, OU, IN)
181 }
182 }
183}
184
185func TestContacts_SearchEmpty(t *testing.T) {
186 setup(t)
187 if results := SearchContacts(""); len(results) != 0 {
188 t.Errorf("SearchContacts(\"\") should return nil, got %d results", len(results))
189 }
190}
191
192func TestContacts_LoadCorruptFile(t *testing.T) {
193 setup(t)
194
195 path, err := GetContactsCachePath()
196 if err != nil {
197 t.Fatalf("GetContactsCachePath: %v", err)
198 }
199
200 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
201 t.Fatalf("MkdirAll: %v", err)
202 }
203
204 if err := os.WriteFile(path, []byte("{corrupted json}"), 0600); err != nil {
205 t.Fatalf("WriteFile: %v", err)
206 }
207
208 if _, err = LoadContactsCache(); err == nil {
209 t.Error("LoadContactsCache should error on corrupt JSON")
210 }
211}
212
213func TestDrafts_SaveLoadRoundTrip(t *testing.T) {
214 setup(t)
215
216 d := Draft{
217 ID: "draft 1",
218 To: "d@e.com",
219 Subject: "Hello World",
220 AccountID: "a1",
221 }
222
223 if err := SaveDraft(d); err != nil {
224 t.Fatalf("SaveDraft: %v", err)
225 }
226
227 output := GetDraft("draft 1")
228 if output == nil {
229 t.Fatal("GetDraft returned nil")
230 }
231
232 if output.ID != d.ID || output.To != d.To || output.Subject != d.Subject || output.AccountID != d.AccountID {
233 t.Errorf("draft mismatch: got %+v, want %+v", output, d)
234 }
235}
236
237func TestDrafts_UpdateExisting(t *testing.T) {
238 setup(t)
239
240 d := Draft{
241 ID: "draft 1",
242 To: "d@e.com",
243 Subject: "Hello World",
244 AccountID: "a1",
245 }
246
247 if err := SaveDraft(d); err != nil {
248 t.Fatalf("SaveDraft: %v", err)
249 }
250
251 d.Subject = "Hello"
252 if err := SaveDraft(d); err != nil {
253 t.Fatalf("SaveDraft (update): %v", err)
254 }
255
256 output := GetAllDrafts()
257 if len(output) != 1 {
258 t.Fatalf("expected 1 draft after update, got %d", len(output))
259 }
260
261 if output[0].Subject != "Hello" {
262 t.Errorf("subject: got %q, want %q", output[0].Subject, "Hello")
263 }
264}
265
266func TestDrafts_Delete(t *testing.T) {
267 setup(t)
268
269 d := Draft{
270 ID: "draft 1",
271 To: "d@e.com",
272 Subject: "Hello World",
273 AccountID: "a1",
274 }
275
276 if err := SaveDraft(d); err != nil {
277 t.Fatalf("SaveDraft: %v", err)
278 }
279
280 if err := DeleteDraft("draft 1"); err != nil {
281 t.Fatalf("DeleteDraft: %v", err)
282 }
283
284 if GetDraft("draft 1") != nil {
285 t.Error("deleted draft should return nil")
286 }
287
288 if HasDrafts() {
289 t.Error("HasDrafts should be false after all drafts deleted")
290 }
291}
292
293func TestDrafts_LoadCorruptFile(t *testing.T) {
294 setup(t)
295
296 path, err := draftsFile()
297 if err != nil {
298 t.Fatalf("draftsFile: %v", err)
299 }
300
301 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
302 t.Fatalf("MkdirAll: %v", err)
303 }
304
305 if err := os.WriteFile(path, []byte("{corrupted json}"), 0600); err != nil {
306 t.Fatalf("WriteFile: %v", err)
307 }
308
309 if _, err = LoadDraftsCache(); err == nil {
310 t.Error("LoadDraftsCache should error on corrupt JSON")
311 }
312}
313
314func TestEmailBody_SaveLoadRoundTrip(t *testing.T) {
315 setup(t)
316
317 body := CachedEmailBody{
318 UID: 1,
319 AccountID: "account",
320 Body: "Hello World",
321 }
322
323 threshold := 100 * 1024 * 1024
324
325 if err := SaveEmailBody("INBOX", body, threshold); err != nil {
326 t.Fatalf("SaveEmailBody: %v", err)
327 }
328
329 output := GetCachedEmailBody("INBOX", 1, "account", threshold)
330 if output == nil {
331 t.Fatal("GetCachedEmailBody returned nil")
332 }
333
334 if output.Body != body.Body {
335 t.Errorf("body text: got %q, want %q", output.Body, body.Body)
336 }
337}
338
339func TestEmailBody_FolderIsolation(t *testing.T) {
340 setup(t)
341
342 b1 := CachedEmailBody{
343 UID: 1,
344 AccountID: "account 1",
345 Body: "Hello INBOX",
346 }
347
348 b2 := CachedEmailBody{
349 UID: 2,
350 AccountID: "account 2",
351 Body: "Hello Sent",
352 }
353
354 threshold := 100 * 1024 * 1024
355
356 _ = SaveEmailBody("INBOX", b1, threshold)
357 _ = SaveEmailBody("Sent", b2, threshold)
358
359 outputInbox := GetCachedEmailBody("INBOX", 1, "account 1", threshold)
360 outputSent := GetCachedEmailBody("Sent", 2, "account 2", threshold)
361
362 if outputInbox == nil || outputInbox.Body != "Hello INBOX" {
363 t.Errorf("INBOX body: got %v", outputInbox)
364 }
365 if outputSent == nil || outputSent.Body != "Hello Sent" {
366 t.Errorf("Sent body: got %v", outputSent)
367 }
368}
369
370func TestEmailBody_PruneRemovesStaleUIDs(t *testing.T) {
371 setup(t)
372
373 b1 := CachedEmailBody{UID: 1, AccountID: "account 1", Body: "body 1"}
374 b2 := CachedEmailBody{UID: 2, AccountID: "account 1", Body: "body 2"}
375 b3 := CachedEmailBody{UID: 3, AccountID: "account 1", Body: "body 3"}
376
377 threshold := 100 * 1024 * 1024
378
379 _ = SaveEmailBody("INBOX", b1, threshold)
380 _ = SaveEmailBody("INBOX", b2, threshold)
381 _ = SaveEmailBody("INBOX", b3, threshold)
382
383 if err := PruneEmailBodyCache("INBOX", map[uint32]string{2: "account 1"}, threshold); err != nil {
384 t.Fatalf("PruneEmailBodyCache: %v", err)
385 }
386
387 if GetCachedEmailBody("INBOX", 1, "account 1", threshold) != nil {
388 t.Error("UID 1 should have been pruned")
389 }
390
391 if GetCachedEmailBody("INBOX", 3, "account 1", threshold) != nil {
392 t.Error("UID 3 should have been pruned")
393 }
394
395 if GetCachedEmailBody("INBOX", 2, "account 1", threshold) == nil {
396 t.Error("UID 2 should still be cached")
397 }
398}
399
400func TestEmailBody_CorruptBodyCacheFile(t *testing.T) {
401 setup(t)
402
403 b := CachedEmailBody{UID: 1, AccountID: "account", Body: "Hello World"}
404
405 _ = SaveEmailBody("INBOX", b, 100*1024*1024)
406
407 path, err := bodyCacheFile("INBOX")
408 if err != nil {
409 t.Fatalf("bodyCacheFile: %v", err)
410 }
411
412 if err := os.WriteFile(path, []byte("{corrupted json}"), 0600); err != nil {
413 t.Fatalf("WriteFile: %v", err)
414 }
415
416 if _, err = LoadEmailBodyCache("INBOX"); err == nil {
417 t.Error("LoadEmailBodyCache should error on corrupt JSON")
418 }
419}
420
421func TestEmailBodyCache_AttachmentsPreserved(t *testing.T) {
422 setup(t)
423
424 a1 := CachedAttachment{
425 Filename: "invoice.pdf",
426 PartID: "2",
427 MIMEType: "application/pdf",
428 }
429
430 a2 := CachedAttachment{
431 Filename: "meeting.ics",
432 PartID: "3",
433 MIMEType: "text/calendar",
434 }
435
436 body := CachedEmailBody{
437 UID: 1,
438 AccountID: "account",
439 Body: "attachment",
440 Attachments: []CachedAttachment{a1, a2},
441 }
442
443 threshold := 100 * 1024 * 1024
444
445 _ = SaveEmailBody("INBOX", body, threshold)
446
447 output := GetCachedEmailBody("INBOX", 1, "account", threshold)
448 if output == nil {
449 t.Fatal("GetCachedEmailBody returned nil")
450 }
451
452 if len(output.Attachments) != 2 {
453 t.Fatalf("expected 2 attachments, got %d", len(output.Attachments))
454 }
455
456 if output.Attachments[0].Filename != "invoice.pdf" {
457 t.Errorf("attachment[0].Filename: got %q", output.Attachments[0].Filename)
458 }
459
460 if output.Attachments[1].Filename != "meeting.ics" {
461 t.Errorf("attachment[1].Filename: got %q", output.Attachments[1].Filename)
462 }
463}
464
465func TestLRU_EvictsLeastRecentlyUsed(t *testing.T) {
466 setup(t)
467
468 body := strings.Repeat("a", 100)
469
470 threshold := 250
471
472 b1 := CachedEmailBody{UID: 1, AccountID: "account", Body: body, SizeBytes: len(body)}
473 b2 := CachedEmailBody{UID: 2, AccountID: "account", Body: body, SizeBytes: len(body)}
474 b3 := CachedEmailBody{UID: 3, AccountID: "account", Body: body, SizeBytes: len(body)}
475
476 _ = SaveEmailBody("INBOX", b1, threshold)
477 _ = SaveEmailBody("INBOX", b2, threshold)
478 _ = SaveEmailBody("INBOX", b3, threshold)
479
480 if GetCachedEmailBody("INBOX", 1, "account", threshold) != nil {
481 t.Error("UID 1 should have been evicted (LRU)")
482 }
483
484 if GetCachedEmailBody("INBOX", 2, "account", threshold) == nil {
485 t.Error("UID 2 should still be cached")
486 }
487
488 if GetCachedEmailBody("INBOX", 3, "account", threshold) == nil {
489 t.Error("UID 3 should still be cached")
490 }
491}
492
493func TestLRU_OversizedBodyRejected(t *testing.T) {
494 setup(t)
495
496 body := CachedEmailBody{
497 UID: 1,
498 AccountID: "account",
499 Body: strings.Repeat("a", 100),
500 }
501
502 _ = SaveEmailBody("INBOX", body, 50)
503
504 if GetCachedEmailBody("INBOX", 1, "account", 50) != nil {
505 t.Error("oversized body should not be stored in LRU")
506 }
507}
508
509func TestLRU_GetPromotesToFront(t *testing.T) {
510 setup(t)
511
512 b1 := CachedEmailBody{UID: 1, AccountID: "account", Body: strings.Repeat("a", 50)}
513 b2 := CachedEmailBody{UID: 2, AccountID: "account", Body: strings.Repeat("a", 50)}
514
515 threshold := 100
516
517 _ = SaveEmailBody("INBOX", b1, threshold)
518 _ = SaveEmailBody("INBOX", b2, threshold)
519
520 GetCachedEmailBody("INBOX", 1, "account", threshold)
521
522 b3 := CachedEmailBody{UID: 3, AccountID: "account", Body: strings.Repeat("a", 50)}
523 _ = SaveEmailBody("INBOX", b3, threshold)
524
525 if GetCachedEmailBody("INBOX", 2, "account", threshold) != nil {
526 t.Error("UID 2 should have been evicted (LRU after promotion of UID 1)")
527 }
528
529 if GetCachedEmailBody("INBOX", 1, "account", threshold) == nil {
530 t.Error("UID 1 should still be cached (was promoted)")
531 }
532}
533
534func TestLRU_DeleteRemovesEntry(t *testing.T) {
535 setup(t)
536
537 b := CachedEmailBody{UID: 1, AccountID: "account", Body: strings.Repeat("a", 50)}
538
539 threshold := 100
540
541 _ = SaveEmailBody("INBOX", b, threshold)
542
543 GetLRUInstance(threshold).Delete("INBOX", 1, "account")
544
545 if GetCachedEmailBody("INBOX", 1, "account", threshold) != nil {
546 t.Error("deleted entry should not be retrievable")
547 }
548}
549
550func TestLRU_ThresholdUpdate(t *testing.T) {
551 setup(t)
552
553 lru1 := GetLRUInstance(100)
554 if lru1.threshold != 100 {
555 t.Errorf("threshold: got %d, want %d", lru1.threshold, 100)
556 }
557
558 lru2 := GetLRUInstance(50)
559 if lru2.threshold != 50 {
560 t.Errorf("updated threshold: got %d, want %d", lru2.threshold, 50)
561 }
562
563 if lru1 != lru2 {
564 t.Error("GetLRUInstance should always return the same pointer")
565 }
566}
567
568func TestEmailBody_EvictsLeastRecentlyAccessedAcrossFolders(t *testing.T) {
569 setup(t)
570
571 b1 := CachedEmailBody{UID: 1, AccountID: "account", Body: strings.Repeat("a", 50)}
572 b2 := CachedEmailBody{UID: 2, AccountID: "account", Body: strings.Repeat("a", 50)}
573 b3 := CachedEmailBody{UID: 3, AccountID: "account", Body: strings.Repeat("a", 50)}
574
575 _ = SaveEmailBody("INBOX", b1, 100)
576 _ = SaveEmailBody("Sent", b2, 100)
577 _ = SaveEmailBody("Trash", b3, 100)
578
579 if got := GetCachedEmailBody("INBOX", 1, "account", 100); got != nil {
580 t.Error("oldest INBOX body should be evicted from LRU")
581 }
582
583 if got := GetCachedEmailBody("Sent", 2, "account", 100); got == nil {
584 t.Error("recent Archive body should still be cached")
585 }
586
587 if got := GetCachedEmailBody("Trash", 3, "account", 100); got == nil {
588 t.Error("new Sent body should be cached")
589 }
590}
591
592func TestEmailBody_EvictsMultipleEntriesUntilUnderLimit(t *testing.T) {
593 setup(t)
594
595 b1 := CachedEmailBody{UID: 1, AccountID: "account", Body: strings.Repeat("a", 50)}
596 b2 := CachedEmailBody{UID: 2, AccountID: "account", Body: strings.Repeat("a", 50)}
597 b3 := CachedEmailBody{UID: 3, AccountID: "account", Body: strings.Repeat("a", 50)}
598 b4 := CachedEmailBody{UID: 4, AccountID: "account", Body: strings.Repeat("a", 150)}
599
600 _ = SaveEmailBody("INBOX", b1, 150)
601 _ = SaveEmailBody("INBOX", b2, 150)
602 _ = SaveEmailBody("INBOX", b3, 150)
603 _ = SaveEmailBody("INBOX", b4, 150)
604
605 if got := GetCachedEmailBody("INBOX", 1, "account", 150); got != nil {
606 t.Error("UID 1 should have been evicted")
607 }
608
609 if got := GetCachedEmailBody("INBOX", 2, "account", 150); got != nil {
610 t.Error("UID 2 should have been evicted")
611 }
612
613 if got := GetCachedEmailBody("INBOX", 3, "account", 150); got != nil {
614 t.Error("UID 3 should have been evicted")
615 }
616
617 if got := GetCachedEmailBody("INBOX", 4, "account", 150); got == nil {
618 t.Error("new Archive body should be cached")
619 }
620}
621
622func TestLRU_ConcurrentReadWrite(t *testing.T) {
623 setup(t)
624
625 var wg sync.WaitGroup
626 wg.Add(20)
627
628 for i := range 20 {
629 go func(i int) {
630 defer wg.Done()
631 uid := uint32(i % 5)
632 b := CachedEmailBody{
633 UID: uid,
634 AccountID: "account",
635 Body: "Hello World",
636 }
637
638 _ = SaveEmailBody("INBOX", b, 1000000)
639 _ = GetCachedEmailBody("INBOX", uid, "account", 1000000)
640 }(i)
641 }
642 wg.Wait()
643}