Keep each account's state in a bucket (issue 1255)

This commit is contained in:
jochen
2026-10-19 21:50:12 +02:00
parent a9ff48e200
commit 08200bc178
6 changed files with 586 additions and 4 deletions
+4 -2
View File
@@ -221,12 +221,14 @@ func TestNothingButTheTestsWritesToAMailbox(t *testing.T) {
}
writes := regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)\.(Store|Move|Copy|Expunge|UIDExpunge|Append|Create|Delete|Rename|Subscribe|Unsubscribe|SetMetadata|SetQuota)\(`)
for _, f := range files {
if strings.HasSuffix(f, "_test.go") || f == "send.go" || f == "smtp.go" || f == "sender.go" {
if strings.HasSuffix(f, "_test.go") || f == "send.go" || f == "smtp.go" || f == "sender.go" || f == "mirror.go" {
// None touches a mailbox: send.go keeps the `sends` state on the bus, never IMAP; smtp.go speaks SMTP
// to the account's outgoing server, never to a mailbox, and only on the operator's Approve (ADR 0361
// §2); sender.go is the sender process (novox/hq ADR 0436 §7) — its own bus account's `sends` state
// (process.State.Create, matched here only because its receiver is named s) and router record, never
// IMAP either.
// IMAP either; mirror.go (novox/hq ADR 0439 §2, §3, issue 1255) keeps the `accounts` and `mails`
// state buckets on the bus, never IMAP — its Delete is a bucket key's, matched here only because its
// KeptState field is named s.
continue
}
raw, err := os.ReadFile(f)
+48 -1
View File
@@ -187,8 +187,22 @@ func serve(env Env) {
},
}
b.Logf = logf
p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf}
mirror := &Mirror{
Index: b.Index,
Accounts: func() ([]Account, error) { return ReadAccounts(env.Accounts) },
Accts: keptMirrorState{stdio.State(AccountsState)},
Logf: logf,
}
trigger := make(chan struct{}, 1)
p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf,
Said: func(int) {
select {
case trigger <- struct{}{}:
default:
}
}}
go p.Run(context.Background())
go mirrorTicker(mirror, trigger)
go hear(b)
go sweepSends(b)
go waitsTellTicker(b)
@@ -252,6 +266,32 @@ func hear(b *Bundle) {
}
}
// MirrorEvery is the Mirror's own round's slowest pace (settlement 6, issue 1255): at least this often, even
// with nothing published in between.
const MirrorEvery = time.Minute
// mirrorTicker runs the Mirror's round at least every MirrorEvery, and at once again whenever trigger fires (a
// Publisher round that said something, settlement 6), until the process ends.
func mirrorTicker(m *Mirror, trigger <-chan struct{}) {
tick := time.NewTicker(MirrorEvery)
defer tick.Stop()
round := func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := m.Round(ctx); err != nil && m.Logf != nil {
m.Logf("[mesh-mail] the state buckets could not be mirrored: %v", err)
}
}
for {
select {
case <-trigger:
round()
case <-tick.C:
round()
}
}
}
// sweepSends reads every send still undecided once a minute and asks the router's own record for its answer, so
// an Approve answered while this module could not hear it directly is still acted on (novox/hq issue 790, review
// round 1).
@@ -272,6 +312,10 @@ type Publisher struct {
Emit func(key string, body any) error
Logf func(format string, a ...any)
Every time.Duration
// Said, when given, is called after a round that said one event or more (novox/hq issue 1255, settlement
// 6): the Mirror's own round runs beside it, so the state buckets catch up without waiting for the next
// tick.
Said func(n int)
}
// PublishBatch is how many events one round says at most.
@@ -300,6 +344,9 @@ func (p *Publisher) Once(ctx context.Context) (int, error) {
if err := st.Said(ctx, said); err != nil {
return 0, err
}
if p.Said != nil {
p.Said(len(said))
}
}
return len(said), failed
}
@@ -88,6 +88,10 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
if fam["issued-by"] != "outside" || fam["path"] != "${dir:state}/"+SecretFamily+"*.secret" || len(m.OwnSecrets) != 2 {
t.Fatalf("own secrets %v", m.OwnSecrets)
}
// 1255/AC7: the module declares the account bucket's own state, beside `sends`.
if !reflect.DeepEqual(m.State, []string{"sends", "accounts"}) {
t.Fatalf("state %v, want [sends accounts]", m.State)
}
var names []string
for _, tool := range (&Bundle{}).Tools() {
names = append(names, tool.Name)
+238
View File
@@ -0,0 +1,238 @@
package main
// The account bucket (novox/hq ADR 0439 §2, issue 1255): a view (such as the mail web client) can watch it
// instead of asking mail-store's verbs for an account's state and folder counts. The bundle's own Mirror keeps
// it in step with the index (design 59's prepare step, settlement 6): it reads the index fresh each round and
// writes only what changed — a key put when its value differs, never otherwise (novox/hq ADR 0201 §12). keep
// (imap.go) is not changed: it writes the index; the Mirror alone writes this bucket.
import (
"bytes"
"context"
"encoding/json"
"unicode/utf8"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// AccountsState is the module's state the Mirror keeps one key per account in (novox/hq ADR 0439 §2).
const AccountsState = "accounts"
// The bounds ADR 0439 §2 gives the account bucket's value.
const (
// MostAccountValue is the value's own bound, 64 KiB.
MostAccountValue = 64 << 10
// MostFolderName is one folder's name, cut at a character's boundary when it is longer.
MostFolderName = 120
// DefaultKeptMails is how many of an account's newest mails the mail bucket keeps, 200 unless ADR 0439 §4's
// 81-account rule (issue 1282) gives every account the same smaller number instead.
DefaultKeptMails = 200
// MostMailValue is ADR 0439 §3's own bound on one mail's value in the mail bucket, 4 KiB.
MostMailValue = 4 << 10
// MostMailBytes is ADR 0439 §4's own bound across every account's newest mails together, 64 MiB.
MostMailBytes = 64 << 20
)
// AccountValue is state accounts' own value, one per account (ADR 0439 §2): never a time that moves on each
// sync, so two rounds with nothing changed write the byte-identical value.
type AccountValue struct {
// State is the account's state as its keeper last said it (current, syncing, waits-for-sign-in or failing),
// or "unknown" while its keeper has said nothing of it yet (as mail_accounts itself answers, tools.go).
State string `json:"state"`
// Kept is the number of the account's newest mails the mail bucket (issue 1282) keeps.
Kept int `json:"kept"`
// Folders are the account's folders, each by its name (cut to MostFolderName bytes), its count of mails and
// its count of unread mails.
Folders []AccountFolderValue `json:"folders"`
// DroppedFolders is how many folders were left out, fewest-mail first, to keep the value at MostAccountValue
// bytes or fewer; left out of the value (omitted, not zero) when nothing was dropped.
DroppedFolders int `json:"dropped-folders,omitempty"`
}
// AccountFolderValue is one folder in an AccountValue.
type AccountFolderValue struct {
Name string `json:"name"`
Mails int `json:"mails"`
Unread int `json:"unread"`
}
// MirrorState is what the Mirror needs of one state bucket it writes: a key's current raw value (nil when it
// has none), a value put — the Mirror is this bucket's only writer, so never by compare-and-set — and every key
// it holds. An interface, so a unit test gives a fake instead of a runtime (novox/hq ADR 0439 §2, §3; issue
// 1255, 1282's "Prepare findings").
type MirrorState interface {
Get(key string) (json.RawMessage, bool, error)
Put(key string, value any) error
Delete(key string) error
Keys() ([]string, error)
}
// keptMirrorState is a MirrorState on the bus, over the sdk's own KeptState (state.go, seat.go).
type keptMirrorState struct{ s stdio.KeptState }
func (k keptMirrorState) Get(key string) (json.RawMessage, bool, error) {
e, err := k.s.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
func (k keptMirrorState) Put(key string, value any) error {
_, err := k.s.Put(key, value)
return err
}
func (k keptMirrorState) Delete(key string) error { return k.s.Delete(key) }
func (k keptMirrorState) Keys() ([]string, error) { return k.s.Keys() }
// Mirror keeps the module's state buckets in step with the index: one round reads the index fresh and writes
// only what changed (ADR 0201 §12). It runs beside the Publisher (serve(), main.go): after each Publisher
// round that said something, and at least every MirrorEvery either way (settlement 6).
type Mirror struct {
Index func(ctx context.Context) (Store, error)
Accounts func() ([]Account, error)
Accts MirrorState // the `accounts` state
Logf func(format string, a ...any)
}
// Round mirrors every configured account's current state into the `accounts` bucket (issue 1255's AC1-AC7).
func (m *Mirror) Round(ctx context.Context) error {
accounts, err := m.Accounts()
if err != nil {
return err
}
st, err := m.Index(ctx)
if err != nil {
return err
}
states, err := st.States(ctx)
if err != nil {
return err
}
kept := keptPerAccount(len(accounts))
for _, a := range accounts {
v, err := accountValue(ctx, st, a.Name, states[a.Name], kept)
if err != nil {
return err
}
if err := putIfChanged(m.Accts, a.Name, v); err != nil {
return err
}
}
return nil
}
// accountValue is one account's own value: its state word, the number of mails kept for it, and its folders
// with their counts, trimmed to MostAccountValue.
func accountValue(ctx context.Context, st Store, account string, state AccountState, kept int) (AccountValue, error) {
folders, err := st.Folders(ctx, account)
if err != nil {
return AccountValue{}, err
}
fv := make([]AccountFolderValue, 0, len(folders))
for _, f := range folders {
unread, err := unreadCount(ctx, st, account, f.Name)
if err != nil {
return AccountValue{}, err
}
fv = append(fv, AccountFolderValue{Name: cutUTF8(f.Name, MostFolderName), Mails: f.Messages, Unread: unread})
}
word := state.State
if word == "" {
word = "unknown"
}
return trimAccountValue(AccountValue{State: word, Kept: kept, Folders: fv}), nil
}
// unreadCount is a folder's count of messages without the \Seen flag.
func unreadCount(ctx context.Context, st Store, account, folder string) (int, error) {
flags, err := st.Flags(ctx, account, folder)
if err != nil {
return 0, err
}
n := 0
for _, fl := range flags {
if !hasSeenFlag(fl) {
n++
}
}
return n, nil
}
// trimAccountValue is issue 1255/AC5: while the value marshals to more than MostAccountValue bytes, the folder
// with the fewest mails is dropped, and DroppedFolders counts it, until it fits (or no folder is left).
func trimAccountValue(v AccountValue) AccountValue {
for accountValueSize(v) > MostAccountValue && len(v.Folders) > 0 {
fewest := 0
for i := 1; i < len(v.Folders); i++ {
if v.Folders[i].Mails < v.Folders[fewest].Mails {
fewest = i
}
}
v.Folders = append(append([]AccountFolderValue{}, v.Folders[:fewest]...), v.Folders[fewest+1:]...)
v.DroppedFolders++
}
return v
}
func accountValueSize(v AccountValue) int {
raw, err := json.Marshal(v)
if err != nil {
return 0
}
return len(raw)
}
// keptPerAccount is ADR 0439 §4's 81-account rule (issue 1282): DefaultKeptMails, unless that many of every
// account's mails could together pass MostMailBytes, in which case every account keeps the same smaller number
// instead, so that all of them fit.
func keptPerAccount(numAccounts int) int {
if numAccounts <= 0 {
return DefaultKeptMails
}
if numAccounts*DefaultKeptMails*MostMailValue <= MostMailBytes {
return DefaultKeptMails
}
n := MostMailBytes / (numAccounts * MostMailValue)
if n < 1 {
n = 1
}
if n > DefaultKeptMails {
n = DefaultKeptMails
}
return n
}
// putIfChanged writes value at key only when it differs from what the bucket currently holds there (ADR 0201
// §12): the Mirror's own values never carry a time that moves on each sync, so the same JSON bytes (Go's own
// json.Marshal is stable for a fixed type) mean nothing changed.
func putIfChanged(bucket MirrorState, key string, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
cur, ok, err := bucket.Get(key)
if err != nil {
return err
}
if ok && bytes.Equal(cur, raw) {
return nil
}
return bucket.Put(key, value)
}
// cutUTF8 cuts s to at most n bytes, never splitting a multi-byte character (novox/hq ADR 0439 §2, §3).
func cutUTF8(s string, n int) string {
if len(s) <= n {
return s
}
b := s[:n]
for len(b) > 0 {
r, size := utf8.DecodeLastRuneInString(b)
if r == utf8.RuneError && size <= 1 {
b = b[:len(b)-1]
continue
}
break
}
return b
}
@@ -0,0 +1,290 @@
package main
// The account bucket (novox/hq ADR 0439 §2, issue 1255): 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"
"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,
}
}
// 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)
}
}
+2 -1
View File
@@ -87,7 +87,8 @@
"operator-channel"
],
"state": [
"sends"
"sends",
"accounts"
],
"emits": [
"mail.send.sent",