test: refactor body cache tests (#1359)

Mohamed Mahmoud created

## What?

Refactors the test suite for email body cache.

## Why?

The previous implementation only covered a few narrow scenarios and
failed to test the system under most practical edge cases.

Closes #887

Change summary

config/cache.go      |   7 
config/cache_test.go | 703 ++++++++++++++++++++++++++++++++++++++-------
config/lru.go        |   6 
3 files changed, 597 insertions(+), 119 deletions(-)

Detailed changes

config/cache.go 🔗

@@ -707,12 +707,7 @@ func saveEmailBodyCache(cache *EmailBodyCache) error {
 // mutate cache state.
 func GetCachedEmailBody(folderName string, uid uint32, accountID string, threshold int) *CachedEmailBody {
 	lru := GetLRUInstance(threshold)
-
-	if node := lru.Get(folderName, uid, accountID); node != nil {
-		return node.Body
-	}
-
-	return nil
+	return lru.Get(folderName, uid, accountID)
 }
 
 func calculateEmailBodySize(body *CachedEmailBody) int {

config/cache_test.go 🔗

@@ -1,162 +1,643 @@
 package config
 
 import (
-	"reflect"
+	"os"
+	"path/filepath"
+	"slices"
 	"strings"
+	"sync"
 	"testing"
-	"time"
 )
 
-func TestSaveEmailBodyEvictsLeastRecentlyAccessedAcrossFolders(t *testing.T) {
-	folderCacheTestSetup(t)
-
-	oldTime := time.Now().Add(-2 * time.Hour)
-	recentTime := time.Now().Add(-1 * time.Hour)
-
-	if err := saveEmailBodyCache(&EmailBodyCache{
-		FolderName: "INBOX",
-		Bodies: []CachedEmailBody{
-			{
-				UID:            1,
-				AccountID:      "acct",
-				Body:           strings.Repeat("a", 10),
-				SizeBytes:      10,
-				CachedAt:       oldTime,
-				LastAccessedAt: oldTime,
-			},
-		},
-	}); err != nil {
-		t.Fatalf("save old cache: %v", err)
-	}
-
-	if err := saveEmailBodyCache(&EmailBodyCache{
-		FolderName: "Archive",
-		Bodies: []CachedEmailBody{
-			{
-				UID:            2,
-				AccountID:      "acct",
-				Body:           strings.Repeat("b", 10),
-				SizeBytes:      10,
-				CachedAt:       recentTime,
-				LastAccessedAt: recentTime,
-			},
-		},
-	}); err != nil {
-		t.Fatalf("save recent cache: %v", err)
-	}
-
-	if err := SaveEmailBody("Sent", CachedEmailBody{
-		UID:       3,
-		AccountID: "acct",
-		Body:      strings.Repeat("c", 10),
-	}, 20); err != nil {
-		t.Fatalf("SaveEmailBody: %v", err)
+func setup(t *testing.T) {
+	t.Helper()
+	dir := t.TempDir()
+	t.Setenv("HOME", dir)
+	t.Setenv("USERPROFILE", dir)
+	resetLRU()
+}
+
+func TestEmailCache_SaveLoadRoundTrip(t *testing.T) {
+	setup(t)
+
+	e1 := CachedEmail{
+		UID:     1,
+		From:    "t1@e.com",
+		To:      []string{"t2@e.com"},
+		Subject: "Hello",
 	}
 
-	inbox, err := LoadEmailBodyCache("INBOX")
+	e2 := CachedEmail{
+		UID:     2,
+		From:    "t2@e.com",
+		To:      []string{"t1@e.com"},
+		Subject: "Hello",
+	}
+
+	input := &EmailCache{Emails: []CachedEmail{e1, e2}}
+
+	if err := SaveEmailCache(input); err != nil {
+		t.Fatalf("SaveEmailCache: %v", err)
+	}
+
+	output, err := LoadEmailCache()
 	if err != nil {
-		t.Fatalf("LoadEmailBodyCache(INBOX): %v", err)
+		t.Fatalf("LoadEmailCache: %v", err)
+	}
+
+	if len(output.Emails) != len(input.Emails) {
+		t.Fatalf("email count: got %d, want %d", len(output.Emails), len(input.Emails))
+	}
+
+	for i := range output.Emails {
+		IN := input.Emails[i]
+		OU := output.Emails[i]
+		if IN.UID != OU.UID || IN.From != OU.From || !slices.Equal(IN.To, OU.To) || IN.Subject != OU.Subject {
+			t.Errorf("email[%d] mismatch: got %+v, want %+v", i, OU, IN)
+		}
+	}
+}
+
+func TestEmailCache_HasEmailCache_FalseWhenMissing(t *testing.T) {
+	setup(t)
+	if HasEmailCache() {
+		t.Error("HasEmailCache should be false before any save")
+	}
+}
+
+func TestEmailCache_HasEmailCache_TrueAfterSave(t *testing.T) {
+	setup(t)
+
+	if err := SaveEmailCache(&EmailCache{}); err != nil {
+		t.Fatalf("SaveEmailCache: %v", err)
+	}
+
+	if !HasEmailCache() {
+		t.Error("HasEmailCache should be true after save")
 	}
-	if len(inbox.Bodies) != 0 {
-		t.Fatalf("oldest INBOX body should be evicted, got %d bodies", len(inbox.Bodies))
+}
+
+func TestEmailCache_ClearEmailCache(t *testing.T) {
+	setup(t)
+
+	e := CachedEmail{
+		UID:       1,
+		AccountID: "account",
+		From:      "t1@e.com",
+		To:        []string{"t2@e.com"},
+		Subject:   "Hello",
+	}
+
+	if err := SaveEmailCache(&EmailCache{Emails: []CachedEmail{e}}); err != nil {
+		t.Fatalf("SaveEmailCache: %v", err)
+	}
+
+	if err := ClearEmailCache(); err != nil {
+		t.Fatalf("ClearEmailCache: %v", err)
 	}
 
-	archive, err := LoadEmailBodyCache("Archive")
+	if HasEmailCache() {
+		t.Error("HasEmailCache should be false after clear")
+	}
+}
+
+func TestEmailCache_RemoveAccount(t *testing.T) {
+	setup(t)
+
+	e1 := CachedEmail{UID: 1, AccountID: "a1"}
+	e2 := CachedEmail{UID: 2, AccountID: "a2"}
+	e3 := CachedEmail{UID: 3, AccountID: "a3"}
+
+	if err := SaveEmailCache(&EmailCache{Emails: []CachedEmail{e1, e2, e3}}); err != nil {
+		t.Fatalf("SaveEmailCache: %v", err)
+	}
+
+	if err := removeAccountFromEmailCache("a2"); err != nil {
+		t.Fatalf("removeAccountFromEmailCache: %v", err)
+	}
+
+	output, err := LoadEmailCache()
 	if err != nil {
-		t.Fatalf("LoadEmailBodyCache(Archive): %v", err)
+		t.Fatalf("LoadEmailCache: %v", err)
 	}
-	if len(archive.Bodies) != 1 || archive.Bodies[0].UID != 2 {
-		t.Fatalf("recent Archive body should remain, got %+v", archive.Bodies)
+
+	for _, e := range output.Emails {
+		if e.AccountID == "a2" {
+			t.Errorf("found email belonging to removed account AC2: %+v", e)
+		}
 	}
+}
+
+func TestEmailCache_LoadCorruptFile(t *testing.T) {
+	setup(t)
 
-	sent, err := LoadEmailBodyCache("Sent")
+	if err := SaveEmailCache(&EmailCache{}); err != nil {
+		t.Fatalf("SaveEmailCache: %v", err)
+	}
+
+	path, err := cacheFile()
 	if err != nil {
-		t.Fatalf("LoadEmailBodyCache(Sent): %v", err)
+		t.Fatalf("cacheFile: %v", err)
 	}
-	if len(sent.Bodies) != 1 || sent.Bodies[0].UID != 3 {
-		t.Fatalf("new Sent body should remain, got %+v", sent.Bodies)
+
+	if err := os.WriteFile(path, []byte("{corrupted json}"), 0600); err != nil {
+		t.Fatalf("WriteFile: %v", err)
+	}
+
+	if _, err = LoadEmailCache(); err == nil {
+		t.Error("LoadEmailCache should return an error for corrupt JSON")
 	}
 }
 
-func TestSaveEmailBodyEvictsMultipleEntriesUntilUnderLimit(t *testing.T) {
-	folderCacheTestSetup(t)
+func TestContacts_SaveLoadRoundTrip(t *testing.T) {
+	setup(t)
 
-	now := time.Now()
-	bodies := make([]CachedEmailBody, 0, 4)
-	for i := 1; i <= 4; i++ {
-		accessedAt := now.Add(-time.Duration(5-i) * time.Minute)
-		bodies = append(bodies, CachedEmailBody{
-			UID:            uint32(i),
-			AccountID:      "acct",
-			Body:           strings.Repeat(string(rune('a'+i-1)), 10),
-			SizeBytes:      10,
-			CachedAt:       accessedAt,
-			LastAccessedAt: accessedAt,
-		})
+	c := Contact{
+		Name:      "t",
+		Email:     "t@e.com",
+		Addresses: []string{"address 1, address 2"},
 	}
 
-	if err := saveEmailBodyCache(&EmailBodyCache{
-		FolderName: "INBOX",
-		Bodies:     bodies,
-	}); err != nil {
-		t.Fatalf("save cache: %v", err)
+	input := &ContactsCache{Contacts: []Contact{c}}
+
+	if err := SaveContactsCache(input); err != nil {
+		t.Fatalf("SaveContactsCache: %v", err)
 	}
 
-	if err := SaveEmailBody("Archive", CachedEmailBody{
-		UID:       5,
-		AccountID: "acct",
-		Body:      strings.Repeat("e", 30),
-	}, 50); err != nil {
-		t.Fatalf("SaveEmailBody: %v", err)
+	output, err := LoadContactsCache()
+	if err != nil {
+		t.Fatalf("LoadContactsCache: %v", err)
+	}
+
+	if len(output.Contacts) != len(input.Contacts) {
+		t.Fatalf("contacts count mismatch:\n  got:  %d\n  want: %d", len(output.Contacts), len(input.Contacts))
 	}
 
-	inbox, err := LoadEmailBodyCache("INBOX")
+	for i := range output.Contacts {
+		IN := input.Contacts[i]
+		OU := output.Contacts[i]
+		if IN.Name != OU.Name || IN.Email != OU.Email || !slices.Equal(IN.Addresses, OU.Addresses) {
+			t.Errorf("contact[%d] mismatch: got %+v, want %+v", i, OU, IN)
+		}
+	}
+}
+
+func TestContacts_SearchEmpty(t *testing.T) {
+	setup(t)
+	if results := SearchContacts(""); len(results) != 0 {
+		t.Errorf("SearchContacts(\"\") should return nil, got %d results", len(results))
+	}
+}
+
+func TestContacts_LoadCorruptFile(t *testing.T) {
+	setup(t)
+
+	path, err := GetContactsCachePath()
 	if err != nil {
-		t.Fatalf("LoadEmailBodyCache(INBOX): %v", err)
+		t.Fatalf("GetContactsCachePath: %v", err)
 	}
 
-	gotUIDs := make([]uint32, 0, len(inbox.Bodies))
-	for _, body := range inbox.Bodies {
-		gotUIDs = append(gotUIDs, body.UID)
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		t.Fatalf("MkdirAll: %v", err)
 	}
-	wantUIDs := []uint32{3, 4}
-	if !reflect.DeepEqual(gotUIDs, wantUIDs) {
-		t.Fatalf("remaining INBOX UIDs = %v, want %v", gotUIDs, wantUIDs)
+
+	if err := os.WriteFile(path, []byte("{corrupted json}"), 0600); err != nil {
+		t.Fatalf("WriteFile: %v", err)
+	}
+
+	if _, err = LoadContactsCache(); err == nil {
+		t.Error("LoadContactsCache should error on corrupt JSON")
+	}
+}
+
+func TestDrafts_SaveLoadRoundTrip(t *testing.T) {
+	setup(t)
+
+	d := Draft{
+		ID:        "draft 1",
+		To:        "d@e.com",
+		Subject:   "Hello World",
+		AccountID: "a1",
+	}
+
+	if err := SaveDraft(d); err != nil {
+		t.Fatalf("SaveDraft: %v", err)
+	}
+
+	output := GetDraft("draft 1")
+	if output == nil {
+		t.Fatal("GetDraft returned nil")
+	}
+
+	if output.ID != d.ID || output.To != d.To || output.Subject != d.Subject || output.AccountID != d.AccountID {
+		t.Errorf("draft mismatch: got %+v, want %+v", output, d)
+	}
+}
+
+func TestDrafts_UpdateExisting(t *testing.T) {
+	setup(t)
+
+	d := Draft{
+		ID:        "draft 1",
+		To:        "d@e.com",
+		Subject:   "Hello World",
+		AccountID: "a1",
+	}
+
+	if err := SaveDraft(d); err != nil {
+		t.Fatalf("SaveDraft: %v", err)
+	}
+
+	d.Subject = "Hello"
+	if err := SaveDraft(d); err != nil {
+		t.Fatalf("SaveDraft (update): %v", err)
+	}
+
+	output := GetAllDrafts()
+	if len(output) != 1 {
+		t.Fatalf("expected 1 draft after update, got %d", len(output))
+	}
+
+	if output[0].Subject != "Hello" {
+		t.Errorf("subject: got %q, want %q", output[0].Subject, "Hello")
+	}
+}
+
+func TestDrafts_Delete(t *testing.T) {
+	setup(t)
+
+	d := Draft{
+		ID:        "draft 1",
+		To:        "d@e.com",
+		Subject:   "Hello World",
+		AccountID: "a1",
 	}
 
-	archive, err := LoadEmailBodyCache("Archive")
+	if err := SaveDraft(d); err != nil {
+		t.Fatalf("SaveDraft: %v", err)
+	}
+
+	if err := DeleteDraft("draft 1"); err != nil {
+		t.Fatalf("DeleteDraft: %v", err)
+	}
+
+	if GetDraft("draft 1") != nil {
+		t.Error("deleted draft should return nil")
+	}
+
+	if HasDrafts() {
+		t.Error("HasDrafts should be false after all drafts deleted")
+	}
+}
+
+func TestDrafts_LoadCorruptFile(t *testing.T) {
+	setup(t)
+
+	path, err := draftsFile()
 	if err != nil {
-		t.Fatalf("LoadEmailBodyCache(Archive): %v", err)
+		t.Fatalf("draftsFile: %v", err)
+	}
+
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		t.Fatalf("MkdirAll: %v", err)
 	}
-	if len(archive.Bodies) != 1 || archive.Bodies[0].UID != 5 {
-		t.Fatalf("new Archive body should remain, got %+v", archive.Bodies)
+
+	if err := os.WriteFile(path, []byte("{corrupted json}"), 0600); err != nil {
+		t.Fatalf("WriteFile: %v", err)
+	}
+
+	if _, err = LoadDraftsCache(); err == nil {
+		t.Error("LoadDraftsCache should error on corrupt JSON")
 	}
 }
 
-func TestSaveEmailBodyDropsOversizedReplacement(t *testing.T) {
-	folderCacheTestSetup(t)
+func TestEmailBody_SaveLoadRoundTrip(t *testing.T) {
+	setup(t)
 
-	if err := SaveEmailBody("INBOX", CachedEmailBody{
+	body := CachedEmailBody{
 		UID:       1,
-		AccountID: "acct",
-		Body:      strings.Repeat("a", 10),
-	}, 20); err != nil {
-		t.Fatalf("initial SaveEmailBody: %v", err)
+		AccountID: "account",
+		Body:      "Hello World",
 	}
 
-	if err := SaveEmailBody("INBOX", CachedEmailBody{
+	threshold := 100 * 1024 * 1024
+
+	if err := SaveEmailBody("INBOX", body, threshold); err != nil {
+		t.Fatalf("SaveEmailBody: %v", err)
+	}
+
+	output := GetCachedEmailBody("INBOX", 1, "account", threshold)
+	if output == nil {
+		t.Fatal("GetCachedEmailBody returned nil")
+	}
+
+	if output.Body != body.Body {
+		t.Errorf("body text: got %q, want %q", output.Body, body.Body)
+	}
+}
+
+func TestEmailBody_FolderIsolation(t *testing.T) {
+	setup(t)
+
+	b1 := CachedEmailBody{
 		UID:       1,
-		AccountID: "acct",
-		Body:      strings.Repeat("b", 25),
-	}, 20); err != nil {
-		t.Fatalf("oversized SaveEmailBody: %v", err)
+		AccountID: "account 1",
+		Body:      "Hello INBOX",
+	}
+
+	b2 := CachedEmailBody{
+		UID:       2,
+		AccountID: "account 2",
+		Body:      "Hello Sent",
+	}
+
+	threshold := 100 * 1024 * 1024
+
+	_ = SaveEmailBody("INBOX", b1, threshold)
+	_ = SaveEmailBody("Sent", b2, threshold)
+
+	outputInbox := GetCachedEmailBody("INBOX", 1, "account 1", threshold)
+	outputSent := GetCachedEmailBody("Sent", 2, "account 2", threshold)
+
+	if outputInbox == nil || outputInbox.Body != "Hello INBOX" {
+		t.Errorf("INBOX body: got %v", outputInbox)
+	}
+	if outputSent == nil || outputSent.Body != "Hello Sent" {
+		t.Errorf("Sent body: got %v", outputSent)
+	}
+}
+
+func TestEmailBody_PruneRemovesStaleUIDs(t *testing.T) {
+	setup(t)
+
+	b1 := CachedEmailBody{UID: 1, AccountID: "account 1", Body: "body 1"}
+	b2 := CachedEmailBody{UID: 2, AccountID: "account 1", Body: "body 2"}
+	b3 := CachedEmailBody{UID: 3, AccountID: "account 1", Body: "body 3"}
+
+	threshold := 100 * 1024 * 1024
+
+	_ = SaveEmailBody("INBOX", b1, threshold)
+	_ = SaveEmailBody("INBOX", b2, threshold)
+	_ = SaveEmailBody("INBOX", b3, threshold)
+
+	if err := PruneEmailBodyCache("INBOX", map[uint32]string{2: "account 1"}, threshold); err != nil {
+		t.Fatalf("PruneEmailBodyCache: %v", err)
+	}
+
+	if GetCachedEmailBody("INBOX", 1, "account 1", threshold) != nil {
+		t.Error("UID 1 should have been pruned")
+	}
+
+	if GetCachedEmailBody("INBOX", 3, "account 1", threshold) != nil {
+		t.Error("UID 3 should have been pruned")
 	}
 
-	cache, err := LoadEmailBodyCache("INBOX")
+	if GetCachedEmailBody("INBOX", 2, "account 1", threshold) == nil {
+		t.Error("UID 2 should still be cached")
+	}
+}
+
+func TestEmailBody_CorruptBodyCacheFile(t *testing.T) {
+	setup(t)
+
+	b := CachedEmailBody{UID: 1, AccountID: "account", Body: "Hello World"}
+
+	_ = SaveEmailBody("INBOX", b, 100*1024*1024)
+
+	path, err := bodyCacheFile("INBOX")
 	if err != nil {
-		t.Fatalf("LoadEmailBodyCache: %v", err)
+		t.Fatalf("bodyCacheFile: %v", err)
+	}
+
+	if err := os.WriteFile(path, []byte("{corrupted json}"), 0600); err != nil {
+		t.Fatalf("WriteFile: %v", err)
 	}
-	if len(cache.Bodies) != 0 {
-		t.Fatalf("oversized replacement should not remain cached, got %+v", cache.Bodies)
+
+	if _, err = LoadEmailBodyCache("INBOX"); err == nil {
+		t.Error("LoadEmailBodyCache should error on corrupt JSON")
+	}
+}
+
+func TestEmailBodyCache_AttachmentsPreserved(t *testing.T) {
+	setup(t)
+
+	a1 := CachedAttachment{
+		Filename: "invoice.pdf",
+		PartID:   "2",
+		MIMEType: "application/pdf",
+	}
+
+	a2 := CachedAttachment{
+		Filename: "meeting.ics",
+		PartID:   "3",
+		MIMEType: "text/calendar",
+	}
+
+	body := CachedEmailBody{
+		UID:         1,
+		AccountID:   "account",
+		Body:        "attachment",
+		Attachments: []CachedAttachment{a1, a2},
+	}
+
+	threshold := 100 * 1024 * 1024
+
+	_ = SaveEmailBody("INBOX", body, threshold)
+
+	output := GetCachedEmailBody("INBOX", 1, "account", threshold)
+	if output == nil {
+		t.Fatal("GetCachedEmailBody returned nil")
+	}
+
+	if len(output.Attachments) != 2 {
+		t.Fatalf("expected 2 attachments, got %d", len(output.Attachments))
+	}
+
+	if output.Attachments[0].Filename != "invoice.pdf" {
+		t.Errorf("attachment[0].Filename: got %q", output.Attachments[0].Filename)
+	}
+
+	if output.Attachments[1].Filename != "meeting.ics" {
+		t.Errorf("attachment[1].Filename: got %q", output.Attachments[1].Filename)
+	}
+}
+
+func TestLRU_EvictsLeastRecentlyUsed(t *testing.T) {
+	setup(t)
+
+	body := strings.Repeat("a", 100)
+
+	threshold := 250
+
+	b1 := CachedEmailBody{UID: 1, AccountID: "account", Body: body, SizeBytes: len(body)}
+	b2 := CachedEmailBody{UID: 2, AccountID: "account", Body: body, SizeBytes: len(body)}
+	b3 := CachedEmailBody{UID: 3, AccountID: "account", Body: body, SizeBytes: len(body)}
+
+	_ = SaveEmailBody("INBOX", b1, threshold)
+	_ = SaveEmailBody("INBOX", b2, threshold)
+	_ = SaveEmailBody("INBOX", b3, threshold)
+
+	if GetCachedEmailBody("INBOX", 1, "account", threshold) != nil {
+		t.Error("UID 1 should have been evicted (LRU)")
+	}
+
+	if GetCachedEmailBody("INBOX", 2, "account", threshold) == nil {
+		t.Error("UID 2 should still be cached")
+	}
+
+	if GetCachedEmailBody("INBOX", 3, "account", threshold) == nil {
+		t.Error("UID 3 should still be cached")
+	}
+}
+
+func TestLRU_OversizedBodyRejected(t *testing.T) {
+	setup(t)
+
+	body := CachedEmailBody{
+		UID:       1,
+		AccountID: "account",
+		Body:      strings.Repeat("a", 100),
+	}
+
+	_ = SaveEmailBody("INBOX", body, 50)
+
+	if GetCachedEmailBody("INBOX", 1, "account", 50) != nil {
+		t.Error("oversized body should not be stored in LRU")
+	}
+}
+
+func TestLRU_GetPromotesToFront(t *testing.T) {
+	setup(t)
+
+	b1 := CachedEmailBody{UID: 1, AccountID: "account", Body: strings.Repeat("a", 50)}
+	b2 := CachedEmailBody{UID: 2, AccountID: "account", Body: strings.Repeat("a", 50)}
+
+	threshold := 100
+
+	_ = SaveEmailBody("INBOX", b1, threshold)
+	_ = SaveEmailBody("INBOX", b2, threshold)
+
+	GetCachedEmailBody("INBOX", 1, "account", threshold)
+
+	b3 := CachedEmailBody{UID: 3, AccountID: "account", Body: strings.Repeat("a", 50)}
+	_ = SaveEmailBody("INBOX", b3, threshold)
+
+	if GetCachedEmailBody("INBOX", 2, "account", threshold) != nil {
+		t.Error("UID 2 should have been evicted (LRU after promotion of UID 1)")
+	}
+
+	if GetCachedEmailBody("INBOX", 1, "account", threshold) == nil {
+		t.Error("UID 1 should still be cached (was promoted)")
+	}
+}
+
+func TestLRU_DeleteRemovesEntry(t *testing.T) {
+	setup(t)
+
+	b := CachedEmailBody{UID: 1, AccountID: "account", Body: strings.Repeat("a", 50)}
+
+	threshold := 100
+
+	_ = SaveEmailBody("INBOX", b, threshold)
+
+	GetLRUInstance(threshold).Delete("INBOX", 1, "account")
+
+	if GetCachedEmailBody("INBOX", 1, "account", threshold) != nil {
+		t.Error("deleted entry should not be retrievable")
+	}
+}
+
+func TestLRU_ThresholdUpdate(t *testing.T) {
+	setup(t)
+
+	lru1 := GetLRUInstance(100)
+	if lru1.threshold != 100 {
+		t.Errorf("threshold: got %d, want %d", lru1.threshold, 100)
+	}
+
+	lru2 := GetLRUInstance(50)
+	if lru2.threshold != 50 {
+		t.Errorf("updated threshold: got %d, want %d", lru2.threshold, 50)
+	}
+
+	if lru1 != lru2 {
+		t.Error("GetLRUInstance should always return the same pointer")
+	}
+}
+
+func TestEmailBody_EvictsLeastRecentlyAccessedAcrossFolders(t *testing.T) {
+	setup(t)
+
+	b1 := CachedEmailBody{UID: 1, AccountID: "account", Body: strings.Repeat("a", 50)}
+	b2 := CachedEmailBody{UID: 2, AccountID: "account", Body: strings.Repeat("a", 50)}
+	b3 := CachedEmailBody{UID: 3, AccountID: "account", Body: strings.Repeat("a", 50)}
+
+	_ = SaveEmailBody("INBOX", b1, 100)
+	_ = SaveEmailBody("Sent", b2, 100)
+	_ = SaveEmailBody("Trash", b3, 100)
+
+	if got := GetCachedEmailBody("INBOX", 1, "account", 100); got != nil {
+		t.Error("oldest INBOX body should be evicted from LRU")
+	}
+
+	if got := GetCachedEmailBody("Sent", 2, "account", 100); got == nil {
+		t.Error("recent Archive body should still be cached")
+	}
+
+	if got := GetCachedEmailBody("Trash", 3, "account", 100); got == nil {
+		t.Error("new Sent body should be cached")
+	}
+}
+
+func TestEmailBody_EvictsMultipleEntriesUntilUnderLimit(t *testing.T) {
+	setup(t)
+
+	b1 := CachedEmailBody{UID: 1, AccountID: "account", Body: strings.Repeat("a", 50)}
+	b2 := CachedEmailBody{UID: 2, AccountID: "account", Body: strings.Repeat("a", 50)}
+	b3 := CachedEmailBody{UID: 3, AccountID: "account", Body: strings.Repeat("a", 50)}
+	b4 := CachedEmailBody{UID: 4, AccountID: "account", Body: strings.Repeat("a", 150)}
+
+	_ = SaveEmailBody("INBOX", b1, 150)
+	_ = SaveEmailBody("INBOX", b2, 150)
+	_ = SaveEmailBody("INBOX", b3, 150)
+	_ = SaveEmailBody("INBOX", b4, 150)
+
+	if got := GetCachedEmailBody("INBOX", 1, "account", 150); got != nil {
+		t.Error("UID 1 should have been evicted")
+	}
+
+	if got := GetCachedEmailBody("INBOX", 2, "account", 150); got != nil {
+		t.Error("UID 2 should have been evicted")
+	}
+
+	if got := GetCachedEmailBody("INBOX", 3, "account", 150); got != nil {
+		t.Error("UID 3 should have been evicted")
+	}
+
+	if got := GetCachedEmailBody("INBOX", 4, "account", 150); got == nil {
+		t.Error("new Archive body should be cached")
+	}
+}
+
+func TestLRU_ConcurrentReadWrite(t *testing.T) {
+	setup(t)
+
+	var wg sync.WaitGroup
+	wg.Add(20)
+
+	for i := range 20 {
+		go func(i int) {
+			defer wg.Done()
+			uid := uint32(i % 5)
+			b := CachedEmailBody{
+				UID:       uid,
+				AccountID: "account",
+				Body:      "Hello World",
+			}
+
+			_ = SaveEmailBody("INBOX", b, 1000000)
+			_ = GetCachedEmailBody("INBOX", uid, "account", 1000000)
+		}(i)
 	}
+	wg.Wait()
 }

config/lru.go 🔗

@@ -213,7 +213,7 @@ func saveEmailBodyToDisk(folder string, body *CachedEmailBody) error {
 	return saveEmailBodyCache(cache)
 }
 
-func (lru *LRU) Get(folder string, uid uint32, accountID string) *Node {
+func (lru *LRU) Get(folder string, uid uint32, accountID string) *CachedEmailBody {
 	lru.mu.Lock()
 	defer lru.mu.Unlock()
 
@@ -232,7 +232,9 @@ func (lru *LRU) Get(folder string, uid uint32, accountID string) *Node {
 
 	_ = saveEmailBodyToDisk(folder, node.Body)
 
-	return node
+	bodyCopy := *node.Body
+
+	return &bodyCopy
 }
 
 func (lru *LRU) removeKey(key string) {