package web // The accounts and folder pages (novox/hq ADR 0440 ยง4, issue 1264). import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" stdio "git.novox.be/novox/mesh-sdk/go" ) // fakeAccountsBucket is a fake AccountsBucketGet (tools.go): a map, and a counter of every Get call so a test // can prove which keys were read. type fakeAccountsBucket struct { values map[string]accountBucketValue gets []string } func (f *fakeAccountsBucket) get(account string) (*stdio.StateEntry, error) { f.gets = append(f.gets, account) v, ok := f.values[account] if !ok { return nil, nil } raw, _ := json.Marshal(v) return &stdio.StateEntry{Key: account, Value: raw}, nil } func accountsAnswerJSON(accounts ...map[string]any) ([]byte, error) { return json.Marshal(map[string]any{"accounts": accounts}) } // 1264/AC1: mail-store answers accounts a and b, both held in the bucket; the page lists both, each with its // state and its folders' mail and unread counts. func TestAccountsPageListsEachAccountWithItsStateAndFolderCounts(t *testing.T) { raw, err := accountsAnswerJSON( map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}, map[string]any{"account": "b", "kind": "imap", "address": "b@example.org", "state": "syncing"}, ) if err != nil { t.Fatal(err) } bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{ "a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 10, Unread: 2}}}, "b": {State: "syncing", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 3, Unread: 0}}}, }} b := &Bundle{ Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil }, AccountBucket: bucket.get, } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) b.accountsPage(w, r) body := w.Body.String() for _, want := range []string{"a", "current", "INBOX", "10 mail(s)", "2 unread", "b", "syncing", "3 mail(s)", "0 unread"} { if !strings.Contains(body, want) { t.Fatalf("the page is missing %q:\n%s", want, body) } } } // 1264/AC2: the bucket also holds account c, which mail-store did not answer; nothing of c appears, and its // key is never read. func TestAccountsPageNeverShowsOrReadsAnAccountMailStoreDidNotAnswer(t *testing.T) { raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}) if err != nil { t.Fatal(err) } bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{ "a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 1, Unread: 0}}}, "c": {State: "current", Folders: []accountBucketFolder{{Name: "Secret", Mails: 99, Unread: 99}}}, }} b := &Bundle{ Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil }, AccountBucket: bucket.get, } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) b.accountsPage(w, r) if strings.Contains(w.Body.String(), "c") && strings.Contains(w.Body.String(), "Secret") { t.Fatalf("the page names account c, which mail-store did not answer:\n%s", w.Body.String()) } for _, g := range bucket.gets { if g == "c" { t.Fatalf("account c's key was read, though mail-store did not answer it: %v", bucket.gets) } } } // 1264/AC3: a folder with 120 mails shows the 50 newest, newest first, and its next link asks mail-store.search // for that folder with before the date of the 50th shown. func TestFolderPageShows50NewestAndLinksToTheNextPageByDate(t *testing.T) { var found []map[string]any for i := 0; i < 50; i++ { found = append(found, map[string]any{ "account": "mesh", "folder": "INBOX", "uid": 120 - i, "subject": "s", "date": "2026-01-01T12:00:00Z", "from": []map[string]any{{"address": "x@example.org"}}, }) } raw, _ := json.Marshal(map[string]any{"found": found}) var gotArgs map[string]any b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) { gotArgs = args return raw, nil }} w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/folder?account=mesh&folder=INBOX", nil) b.folderPage(w, r) if gotArgs["account"] != "mesh" || gotArgs["folder"] != "INBOX" || gotArgs["limit"] != float64(50) { t.Fatalf("mail-store.search called with %v", gotArgs) } body := strings.ToLower(w.Body.String()) if !strings.Contains(body, "before=2026-01-01t12") { t.Fatalf("no next link with the 50th mail's date:\n%s", w.Body.String()) } } // 1264/AC4: an account mail-store answered with no key in the bucket yet is listed, its folders said not known // yet, and the page does not fail. func TestAccountsPageSaysFoldersNotKnownYetWithoutFailing(t *testing.T) { raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "unknown"}) if err != nil { t.Fatal(err) } bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{}} b := &Bundle{ Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil }, AccountBucket: bucket.get, } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) b.accountsPage(w, r) if w.Code != http.StatusOK { t.Fatalf("status %d, want 200", w.Code) } if !strings.Contains(w.Body.String(), "not known yet") { t.Fatalf("the page does not say folders are not known yet:\n%s", w.Body.String()) } } // 1264/AC5: across the accounts and folder pages, the only verbs called are mail-store.accounts and // mail-store.search. func TestAccountsAndFolderPagesCallOnlyAccountsAndSearch(t *testing.T) { var accountsCalled, searchCalled int raw, _ := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}) b := &Bundle{ Accounts: func(context.Context) (json.RawMessage, error) { accountsCalled++; return raw, nil }, Search: func(context.Context, map[string]any) (json.RawMessage, error) { searchCalled++ return json.Marshal(map[string]any{"found": []any{}}) }, AccountBucket: (&fakeAccountsBucket{values: map[string]accountBucketValue{}}).get, Read: func(context.Context, map[string]any) (json.RawMessage, error) { t.Fatal("mail-store.read called") return nil, nil }, Send: func(context.Context, map[string]any) (json.RawMessage, error) { t.Fatal("mail-store.send called") return nil, nil }, SendStatus: func(context.Context, map[string]any) (json.RawMessage, error) { t.Fatal("mail-store.send-status called") return nil, nil }, } b.accountsPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) b.folderPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/folder?account=a&folder=INBOX", nil)) if accountsCalled != 1 || searchCalled != 1 { t.Fatalf("accounts called %d time(s), search %d time(s)", accountsCalled, searchCalled) } }