Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/mirror_test.go
T

584 lines
21 KiB
Go

package main
// The account bucket (novox/hq ADR 0439 §2, issue 1255) and the mail bucket (ADR 0439 §3, issue 1282): a fake
// MirrorState, so these tests need no runtime, and memStore for the index, as the rest of this package's unit
// tests already use it.
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"unicode/utf8"
)
// memMirrorState is a fake MirrorState (mirror.go), kept in memory: a map, no revision, since the Mirror is a
// bucket's only writer and never writes it by compare-and-set.
type memMirrorState struct {
vals map[string]json.RawMessage
calls map[string]int // Put calls per key, so a test can tell a skipped write from a repeated one
}
func newMemMirrorState() *memMirrorState {
return &memMirrorState{vals: map[string]json.RawMessage{}, calls: map[string]int{}}
}
func (m *memMirrorState) Get(key string) (json.RawMessage, bool, error) {
v, ok := m.vals[key]
return v, ok, nil
}
func (m *memMirrorState) Put(key string, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
m.vals[key] = raw
m.calls[key]++
return nil
}
func (m *memMirrorState) Delete(key string) error {
delete(m.vals, key)
return nil
}
func (m *memMirrorState) Keys() ([]string, error) {
out := make([]string, 0, len(m.vals))
for k := range m.vals {
out = append(out, k)
}
return out, nil
}
// accountsAndStore is one account "mesh", granted no caller (the Mirror reads no grant), with the index given.
func accountsAndStore(t *testing.T) ([]Account, *memStore) {
t.Helper()
return []Account{{Name: "mesh", Kind: KindIMAP, Address: "jschoubben@novox.be"}}, newMemStore()
}
func mirrorOf(accounts []Account, st *memStore, bucket MirrorState) *Mirror {
return &Mirror{
Index: func(context.Context) (Store, error) { return st, nil },
Accounts: func() ([]Account, error) { return accounts, nil },
Accts: bucket,
}
}
// mirrorWithMails is mirrorOf, also mirroring the `mails` bucket (issue 1282).
func mirrorWithMails(accounts []Account, st *memStore, accts, mails MirrorState) *Mirror {
m := mirrorOf(accounts, st, accts)
m.Mails = mails
return m
}
// 1255/AC1: the account's value holds its state word, the number of newest mails kept (200, no other account
// configured, so none of ADR 0439 §4's 81-account rule applies), and its folders with their counts.
func TestMirrorRoundWritesTheAccountsStateWord200AndItsFolders(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent, Why: "watching"})
addMail(t, st, "mesh", "INBOX", 1, []string{}) // unread
addMail(t, st, "mesh", "INBOX", 2, []string{`\Seen`}) // read
addMail(t, st, "mesh", "Sent", 3, []string{`\Seen`}) // read
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 10})
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "Sent", Messages: 5})
bucket := newMemMirrorState()
m := mirrorOf(accounts, st, bucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
raw, ok, err := bucket.Get("mesh")
if err != nil || !ok {
t.Fatalf("no value kept for mesh: %v %v", ok, err)
}
var v AccountValue
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
if v.State != AccountCurrent || v.Kept != DefaultKeptMails {
t.Fatalf("state %q, kept %d; want %q, %d", v.State, v.Kept, AccountCurrent, DefaultKeptMails)
}
byName := map[string]AccountFolderValue{}
for _, f := range v.Folders {
byName[f.Name] = f
}
if byName["INBOX"].Mails != 10 || byName["INBOX"].Unread != 1 {
t.Fatalf("INBOX: %+v", byName["INBOX"])
}
if byName["Sent"].Mails != 5 || byName["Sent"].Unread != 0 {
t.Fatalf("Sent: %+v", byName["Sent"])
}
}
// addMail indexes one bare message with the given flags, for folder-count tests that only need its UID and
// flags (mirroring the shape imap.go's Keeper writes, store.go's Message).
func addMail(t *testing.T, st *memStore, account, folder string, uid uint32, flags []string) {
t.Helper()
m := Message{Account: account, Folder: folder, UID: uid, Flags: flags}
if err := st.Add(context.Background(), m, nil); err != nil {
t.Fatal(err)
}
}
// 1255/AC2: nothing is written when the index is unchanged since the last round.
func TestMirrorRoundWritesNothingWhenUnchanged(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1})
bucket := newMemMirrorState()
m := mirrorOf(accounts, st, bucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
if bucket.calls["mesh"] != 1 {
t.Fatalf("the first round wrote %d time(s); want 1", bucket.calls["mesh"])
}
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
if bucket.calls["mesh"] != 1 {
t.Fatalf("a round with nothing changed wrote again: %d time(s)", bucket.calls["mesh"])
}
}
// 1255/AC3: one mail of INBOX marked read changes only that account's key, to 1 unread.
func TestMirrorRoundWritesOnlyTheChangedAccountsKey(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
accounts = append(accounts, Account{Name: "other", Kind: KindIMAP, Address: "other@novox.be"})
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
_ = st.SetState(ctx, AccountState{Account: "other", State: AccountCurrent})
addMail(t, st, "mesh", "INBOX", 1, []string{})
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1})
_ = st.SetFolder(ctx, Folder{Account: "other", Name: "INBOX", Messages: 0})
bucket := newMemMirrorState()
m := mirrorOf(accounts, st, bucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
bucket.calls = map[string]int{} // reset the counters; only the next round's writes matter here
// mesh's one message is now marked read.
if err := st.SetFlags(ctx, "mesh", "INBOX", 1, []string{`\Seen`}, nil); err != nil {
t.Fatal(err)
}
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
if bucket.calls["mesh"] != 1 {
t.Fatalf("mesh's key was not rewritten: %d call(s)", bucket.calls["mesh"])
}
if bucket.calls["other"] != 0 {
t.Fatalf("other's key was written though nothing of it changed: %d call(s)", bucket.calls["other"])
}
raw, _, _ := bucket.Get("mesh")
var v AccountValue
_ = json.Unmarshal(raw, &v)
for _, f := range v.Folders {
if f.Name == "INBOX" && f.Unread != 0 {
t.Fatalf("INBOX unread after marking read: %d", f.Unread)
}
}
}
// 1255/AC4: a folder name of 200 bytes of multibyte characters is cut to at most 120 bytes at a character's
// boundary (never split mid-character).
func TestMirrorRoundCutsALongMultibyteFolderNameAtACharacterBoundary(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
// 119 one-byte letters, then 2-byte "é"s: the 120th byte (MostFolderName) lands mid-character, inside the
// first é's 2-byte encoding, so a naive byte cut would split it.
long := strings.Repeat("a", 119) + strings.Repeat("é", 40)
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: long, Messages: 1})
bucket := newMemMirrorState()
m := mirrorOf(accounts, st, bucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
raw, _, _ := bucket.Get("mesh")
var v AccountValue
_ = json.Unmarshal(raw, &v)
if len(v.Folders) != 1 {
t.Fatalf("folders: %+v", v.Folders)
}
got := v.Folders[0].Name
if len(got) > MostFolderName {
t.Fatalf("the folder name is %d bytes, more than %d", len(got), MostFolderName)
}
if !strings.HasPrefix(long, got) {
t.Fatalf("the cut name is not a prefix of the original: %q", got)
}
if !utf8.ValidString(got) {
t.Fatalf("the cut name is not valid UTF-8: a multibyte character was split: %q", got)
}
}
// 1255/AC5: an account with enough folders that the value would pass 64 KiB has the folders with the fewest
// mails dropped, their number counted, and the value fits.
func TestMirrorRoundDropsTheFewestMailFoldersToFitUnder64KiB(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
// Enough folders, with long enough names, that the whole value passes MostAccountValue; each folder's own
// mail count is distinct, so "fewest first" is unambiguous.
const folders = 1200
for i := 0; i < folders; i++ {
name := "folder-" + itoa(int64(i)) + "-abcdefghijklmnopqrstuvwxyz0123456789"
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: name, Messages: i + 1})
}
bucket := newMemMirrorState()
m := mirrorOf(accounts, st, bucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
raw, _, _ := bucket.Get("mesh")
if len(raw) > MostAccountValue {
t.Fatalf("the value is %d bytes, more than %d", len(raw), MostAccountValue)
}
var v AccountValue
_ = json.Unmarshal(raw, &v)
if v.DroppedFolders == 0 {
t.Fatalf("no folder was dropped, though %d folders would overflow %d bytes", folders, MostAccountValue)
}
if v.DroppedFolders+len(v.Folders) != folders {
t.Fatalf("dropped %d + kept %d != %d folders in all", v.DroppedFolders, len(v.Folders), folders)
}
// The kept folders are never among the ones with the fewest mails: folder i has i+1 mails, so every kept
// folder's count is at least as large as every dropped folder's would have been.
fewestKept := -1
for _, f := range v.Folders {
if fewestKept == -1 || f.Mails < fewestKept {
fewestKept = f.Mails
}
}
if fewestKept < v.DroppedFolders {
t.Fatalf("the fewest-mail folders were not the ones dropped: kept as few as %d mails, dropped %d folders", fewestKept, v.DroppedFolders)
}
}
// 1255/AC6: two rounds an hour apart with nothing indexed between write the byte-identical value (no time that
// moves on each sync).
func TestMirrorRoundTwoRoundsWithNothingChangedWriteByteIdenticalValues(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 3})
bucket := newMemMirrorState()
m := mirrorOf(accounts, st, bucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
first, _, _ := bucket.Get("mesh")
firstCopy := append([]byte(nil), first...)
// Force a second write by deleting the key directly and running again: the value computed must still be
// byte-identical to the first, since nothing in the index changed.
_ = bucket.Delete("mesh")
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
second, _, _ := bucket.Get("mesh")
if string(firstCopy) != string(second) {
t.Fatalf("the two rounds wrote different bytes:\n%s\n%s", firstCopy, second)
}
}
// 1255/AC7: the manifest declares the state accounts (manifest_test.go asserts the exact list; this just
// anchors the constant the code and the manifest both use).
func TestAccountsStateNameMatchesTheManifestsWord(t *testing.T) {
if AccountsState != "accounts" {
t.Fatalf("AccountsState = %q", AccountsState)
}
}
// ---- the mail bucket (novox/hq ADR 0439 §3, issue 1282) -----------------------------------------------------
// 1282/AC1: a new mail indexed gets one key in `mails`, named by its account and a digest of its message-id,
// whose value carries its account, folder, date, sender, subject, recipients, attachment names, size and
// flags, and no body.
func TestMirrorRoundWritesANewMailsKeyWithItsFields(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
msg := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "m1@example.org", Date: judgedAt,
From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "ops@example.org"}},
Subject: "Hi", Size: 1234, Flags: []string{`\Seen`},
Attachments: []AttachmentRef{{Key: "k1", Name: "invoice.pdf", Size: 10, Type: "application/pdf"}},
Body: "should never appear in the mirrored value",
}
if err := st.Add(ctx, msg, nil); err != nil {
t.Fatal(err)
}
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1})
acctsBucket, mailsBucket := newMemMirrorState(), newMemMirrorState()
m := mirrorWithMails(accounts, st, acctsBucket, mailsBucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
raw, ok, err := mailsBucket.Get(mailKey("mesh", msg))
if err != nil || !ok {
t.Fatalf("no value kept for the new mail: %v %v", ok, err)
}
var v MailValue
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
if v.Account != "mesh" || v.Folder != "INBOX" || v.Subject != "Hi" || v.Size != 1234 {
t.Fatalf("%+v", v)
}
if v.Sender.Name != "Bob" || v.Sender.Address != "bob@example.org" {
t.Fatalf("sender: %+v", v.Sender)
}
if len(v.Recipients) != 1 || v.Recipients[0].Address != "ops@example.org" {
t.Fatalf("recipients: %+v", v.Recipients)
}
if len(v.Attachments) != 1 || v.Attachments[0] != "invoice.pdf" {
t.Fatalf("attachments: %+v", v.Attachments)
}
if len(v.Flags) != 1 || v.Flags[0] != `\Seen` {
t.Fatalf("flags: %+v", v.Flags)
}
if strings.Contains(string(raw), "should never appear") {
t.Fatalf("the value carries the body: %s", raw)
}
}
// 1282/AC2: a kept mail whose flags change has its key rewritten; a round with nothing changed writes nothing.
func TestMirrorRoundRewritesOnlyAChangedMailsKey(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
a := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "a@example.org", Date: judgedAt, Subject: "a"}
b := Message{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "b@example.org", Date: judgedAt, Subject: "b"}
if err := st.Add(ctx, a, nil); err != nil {
t.Fatal(err)
}
if err := st.Add(ctx, b, nil); err != nil {
t.Fatal(err)
}
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 2})
acctsBucket, mailsBucket := newMemMirrorState(), newMemMirrorState()
m := mirrorWithMails(accounts, st, acctsBucket, mailsBucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
mailsBucket.calls = map[string]int{}
// Nothing changed: a second round writes neither key again.
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
if mailsBucket.calls[mailKey("mesh", a)] != 0 || mailsBucket.calls[mailKey("mesh", b)] != 0 {
t.Fatalf("a round with nothing changed wrote a mail key: %v", mailsBucket.calls)
}
// a's flags change: only a's key is rewritten.
if err := st.SetFlags(ctx, "mesh", "INBOX", 1, []string{`\Seen`}, nil); err != nil {
t.Fatal(err)
}
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
if mailsBucket.calls[mailKey("mesh", a)] != 1 {
t.Fatalf("a's key was not rewritten: %v", mailsBucket.calls)
}
if mailsBucket.calls[mailKey("mesh", b)] != 0 {
t.Fatalf("b's key was written though nothing of it changed: %v", mailsBucket.calls)
}
}
// 1282/AC3 (removed): a mail removed from the index has its key deleted.
func TestMirrorRoundDeletesAMailsKeyForARemovedMail(t *testing.T) {
ctx := context.Background()
_, st := accountsAndStore(t)
msg := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "m@example.org", Date: judgedAt, Subject: "s"}
if err := st.Add(context.Background(), msg, nil); err != nil {
t.Fatal(err)
}
bucket := newMemMirrorState()
mir := &Mirror{Mails: bucket}
if err := mir.roundMails(ctx, st, "mesh", 200); err != nil {
t.Fatal(err)
}
if _, ok, _ := bucket.Get(mailKey("mesh", msg)); !ok {
t.Fatalf("the mail's key was not written")
}
if err := st.Remove(ctx, "mesh", "INBOX", 1, nil); err != nil {
t.Fatal(err)
}
if err := mir.roundMails(ctx, st, "mesh", 200); err != nil {
t.Fatal(err)
}
if _, ok, _ := bucket.Get(mailKey("mesh", msg)); ok {
t.Fatalf("the removed mail's key was not deleted")
}
}
// 1282/AC3 (pushed out): a mail pushed out of the newest kept by a newer one has its key deleted.
func TestMirrorRoundDeletesAMailsKeyPushedOutOfTheNewestKept(t *testing.T) {
ctx := context.Background()
_, st := accountsAndStore(t)
old := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "old@example.org", Date: judgedAt.Add(-time.Hour), Subject: "old"}
if err := st.Add(ctx, old, nil); err != nil {
t.Fatal(err)
}
bucket := newMemMirrorState()
mir := &Mirror{Mails: bucket}
if err := mir.roundMails(ctx, st, "mesh", 1); err != nil {
t.Fatal(err)
}
if _, ok, _ := bucket.Get(mailKey("mesh", old)); !ok {
t.Fatalf("the old mail's key was not written")
}
newer := Message{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "new@example.org", Date: judgedAt, Subject: "new"}
if err := st.Add(ctx, newer, nil); err != nil {
t.Fatal(err)
}
if err := mir.roundMails(ctx, st, "mesh", 1); err != nil {
t.Fatal(err)
}
if _, ok, _ := bucket.Get(mailKey("mesh", old)); ok {
t.Fatalf("the old mail's key was not deleted once pushed out of the newest kept")
}
if _, ok, _ := bucket.Get(mailKey("mesh", newer)); !ok {
t.Fatalf("the newer mail's key was not written")
}
}
// 1282/AC4: a mail with 500 recipients of 254-byte addresses, 50 attachments of 255-byte names and a
// 5,000-character multibyte subject is mirrored at most MostMailValue bytes, its subject at most
// MostMailSubject, each address and attachment name at most MostFolderName, and the dropped counts are in it.
func TestMirrorRoundTrimsAnOversizedMailValueToAtMost4KiB(t *testing.T) {
ctx := context.Background()
accounts, st := accountsAndStore(t)
var to []Person
for i := 0; i < 500; i++ {
to = append(to, Person{Address: strings.Repeat("r", 240) + "@example.org"}) // 254 bytes
}
var atts []AttachmentRef
for i := 0; i < 50; i++ {
atts = append(atts, AttachmentRef{Key: "k" + itoa(int64(i)), Name: strings.Repeat("n", 255), Size: 10})
}
subject := strings.Repeat("é", 5000)
msg := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "big@example.org", Date: judgedAt,
From: []Person{{Address: "sender@example.org"}}, To: to, Subject: subject, Size: 99, Attachments: atts}
if err := st.Add(ctx, msg, nil); err != nil {
t.Fatal(err)
}
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1})
acctsBucket, mailsBucket := newMemMirrorState(), newMemMirrorState()
m := mirrorWithMails(accounts, st, acctsBucket, mailsBucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
raw, ok, err := mailsBucket.Get(mailKey("mesh", msg))
if err != nil || !ok {
t.Fatalf("no value kept: %v %v", ok, err)
}
if len(raw) > MostMailValue {
t.Fatalf("the value is %d bytes, more than %d", len(raw), MostMailValue)
}
var v MailValue
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
if len(v.Subject) > MostMailSubject {
t.Fatalf("the subject is %d bytes, more than %d", len(v.Subject), MostMailSubject)
}
if len(v.Recipients) > MostMailShown {
t.Fatalf("%d recipients shown, more than %d", len(v.Recipients), MostMailShown)
}
for _, r := range v.Recipients {
if len(r.Address) > MostFolderName {
t.Fatalf("a recipient's address is %d bytes, more than %d", len(r.Address), MostFolderName)
}
}
for _, a := range v.Attachments {
if len(a) > MostFolderName {
t.Fatalf("an attachment name is %d bytes, more than %d", len(a), MostFolderName)
}
}
if v.RecipientsDropped == 0 {
t.Fatalf("recipients-dropped is 0, though 500 recipients were given")
}
if v.AttachmentsDropped == 0 {
t.Fatalf("attachments-dropped is 0, though 50 attachments were given")
}
}
// 1282/AC5: two mails without a message-id, in different folders or uids, digest apart (two keys); one
// message-id kept in two folders of one account digests the same (one key).
func TestMailDigestDistinguishesByFolderAndUIDAndMergesByMessageID(t *testing.T) {
a := Message{Folder: "INBOX", UID: 1}
b := Message{Folder: "INBOX", UID: 2}
if mailDigest(a) == mailDigest(b) {
t.Fatalf("two mails without a message-id, different uids, digest the same")
}
c := Message{Folder: "Archive", UID: 1}
if mailDigest(a) == mailDigest(c) {
t.Fatalf("two mails without a message-id, different folders, digest the same")
}
withID1 := Message{Folder: "INBOX", UID: 9, MessageID: "x@example.org"}
withID2 := Message{Folder: "Sent", UID: 10, MessageID: "x@example.org"}
if mailDigest(withID1) != mailDigest(withID2) {
t.Fatalf("one message-id kept in two folders of the account digests apart: %q vs %q",
mailDigest(withID1), mailDigest(withID2))
}
}
// 1282/AC6: with 82 accounts, every account keeps the same smaller number n of newest mails, with
// 82 x n x MostMailValue at most MostMailBytes, and n is written in each account's own `accounts` value
// (issue 1255's Kept field).
func TestMirrorRoundUses81AccountRuleForTheKeptNumber(t *testing.T) {
ctx := context.Background()
var accounts []Account
for i := 0; i < 82; i++ {
accounts = append(accounts, Account{Name: "a" + itoa(int64(i)), Kind: KindIMAP, Address: "a@example.org"})
}
st := newMemStore()
for _, a := range accounts {
if err := st.SetState(ctx, AccountState{Account: a.Name, State: AccountCurrent}); err != nil {
t.Fatal(err)
}
}
n := keptPerAccount(82)
if n >= DefaultKeptMails {
t.Fatalf("82 accounts did not reduce the kept number below %d: got %d", DefaultKeptMails, n)
}
if 82*n*MostMailValue > MostMailBytes {
t.Fatalf("82 x %d x %d bytes is more than %d", n, MostMailValue, MostMailBytes)
}
acctsBucket, mailsBucket := newMemMirrorState(), newMemMirrorState()
m := mirrorWithMails(accounts, st, acctsBucket, mailsBucket)
if err := m.Round(ctx); err != nil {
t.Fatal(err)
}
for _, a := range accounts {
raw, ok, err := acctsBucket.Get(a.Name)
if err != nil || !ok {
t.Fatalf("%s: no value: %v %v", a.Name, ok, err)
}
var v AccountValue
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
if v.Kept != n {
t.Fatalf("%s: kept %d, want %d", a.Name, v.Kept, n)
}
}
}