Files
mesh-catalog/modules/mesh-mail-web/cmd/mesh-mail-web/accounts_test.go
T
jochen 28fdbbd346 Show every granted account, its state and its folders' counts (issue 1264)
Accounts bucket fixture (accountBucketValue/accountBucketFolder) copied from mesh-mail's own
modules/mesh-mail/cmd/mesh-mail/mirror.go AccountValue/AccountFolderValue (issue 1255); mesh-mail-web is its
own Go module, so this is a copy, not an import.
2026-10-19 22:22:52 +02:00

181 lines
6.8 KiB
Go

package main
// 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)
}
}