cache_test.go

  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}