diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 50931ed..02c4ed9 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -23,7 +23,11 @@ // and `modules.` (a module's source fingerprint, or none and why), and the forge bridge's messages named // `forge...` (source.go). // -// Standard library only. +// **Merges, asks, owed and answers** (issue 1201; ADR 0437 §6, §8): the gate's `merges.`, its asks +// `merge..` (the router's ask whole), its owed keys `...` that the +// forge bridge performs, and the teller's answer under the ask's own key (gate.go). +// +// Standard library only, and this module's own asks package. package delivery import ( diff --git a/go/delivery/gate.go b/go/delivery/gate.go new file mode 100644 index 0000000..8ef7ed3 --- /dev/null +++ b/go/delivery/gate.go @@ -0,0 +1,390 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected +// head, its state and each merge commit), its asks (`merge..`, the router's ask whole) and owed +// (one key per act it owes the forge, which the forge bridge performs once per revision). The teller writes its +// answer under the ask's own key in its bucket answers. +const ( + MergesBucket = GateModule + "_merges" + AsksBucket = GateModule + "_asks" + OwedBucket = GateModule + "_owed" + AnswersBucket = TellerModule + "_answers" + MergesHistory = 10 + AsksHistory = 5 + OwedHistory = 5 + AnswersHistory = 5 +) + +// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key. +const AskKindMerge = "merge" + +// The acts the gate owes the forge, the last part of an owed key. +const ( + OwedStatus = "status" + OwedView = "view" + OwedNote = "note" + OwedComment = "comment" +) + +// The states of a pull request in a merge. +type MergeState string + +// The states a merge's pull request is in. +const ( + MergeMerging MergeState = "merging" + MergeMerged MergeState = "merged" + MergeRefused MergeState = "refused" +) + +// The states a status the gate owes the forge may carry. +var statusStates = []string{"pending", "success", "failure", "error", "warning"} + +var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`) + +// MergesKey is the key, in MergesBucket, of a group: ``. +func MergesKey(group string) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + return group, nil +} + +// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge..`. The teller's answer has the same key in AnswersBucket (AnswerKey). +func MergeAskKey(group string, heads []Head) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + hash, err := HeadsHash(heads) + if err != nil { + return "", err + } + return AskKindMerge + "." + group + "." + hash, nil +} + +// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash. +func ParseAskKey(key string) (kind, group, hash string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 3 || parts[0] != AskKindMerge { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key, merge..", key) + } + if err := checkName("group", parts[1]); err != nil { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err) + } + if !hashOK.MatchString(parts[2]) { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2]) + } + return parts[0], parts[1], parts[2], nil +} + +// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string. +func AnswerKey(askKey string) string { return askKey } + +// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID), +// so a dot becomes `-` and a slash `_`; a key too long for an id is cut and ends in a digest of the whole key. +// It cannot be read back: the teller keeps the key beside it. +func AskID(askKey string) string { + id := strings.NewReplacer(".", "-", "/", "_").Replace(askKey) + const most = 64 + if len(id) > most { + sum := sha256.Sum256([]byte(askKey)) + id = id[:most-13] + "-" + hex.EncodeToString(sum[:])[:12] + } + return id +} + +func checkOwedAct(act string) (kind, context string, err error) { + kind, context, hasContext := strings.Cut(act, "=") + switch kind { + case OwedStatus: + if !hasContext { + return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=", act) + } + if err := checkName("status context", context); err != nil { + return "", "", err + } + return kind, context, nil + case OwedView, OwedNote, OwedComment: + if hasContext { + return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act) + } + return kind, "", nil + } + return "", "", fmt.Errorf("delivery: the owed act %q is not status=, view, note or comment", act) +} + +// ParseOwedAct splits an owed act into its kind and, for a status, its context. +func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) } + +// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head: +// `...`, the act being `status=` (mesh/merge-gate), `view`, `note` or +// `comment`. +func OwedKey(repository string, number int, head, act string) (string, error) { + if err := checkRepository(repository); err != nil { + return "", err + } + if number <= 0 { + return "", fmt.Errorf("delivery: an owed key needs the pull request number, not %d", number) + } + if err := checkName("head", head); err != nil { + return "", err + } + if _, _, err := checkOwedAct(act); err != nil { + return "", err + } + return repository + "." + strconv.Itoa(number) + "." + head + "." + act, nil +} + +// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=` for a status. +func ParseOwedKey(key string) (repository string, number int, head, act string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 4 { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, ...", key) + } + number, convErr := strconv.Atoi(parts[1]) + if convErr != nil { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1]) + } + if _, err := OwedKey(parts[0], number, parts[2], parts[3]); err != nil { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err) + } + return parts[0], number, parts[2], parts[3], nil +} + +// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects +// and how far its merge has come. +type Merge struct { + V int `json:"v"` + Group string `json:"group"` + Ask string `json:"ask"` + Pulls []MergePull `json:"pulls"` +} + +// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the +// forge's words when refused. +type MergePull struct { + Head + State MergeState `json:"state"` + Commit string `json:"commit,omitempty"` + Words string `json:"words,omitempty"` +} + +func (m Merge) check() error { + if err := checkName("group", m.Group); err != nil { + return err + } + if m.Ask == "" { + return fmt.Errorf("delivery: a merge names no ask") + } + if len(m.Pulls) == 0 { + return fmt.Errorf("delivery: a merge names no pull request") + } + for _, p := range m.Pulls { + who := fmt.Sprintf("%s#%d", p.Repository, p.Number) + switch p.State { + case MergeMerging: + case MergeMerged: + if p.Commit == "" { + return fmt.Errorf("delivery: the merged pull request %s has no merge commit", who) + } + case MergeRefused: + if p.Words == "" { + return fmt.Errorf("delivery: the refused pull request %s has no words of the forge", who) + } + default: + return fmt.Errorf("delivery: the state %q of %s is not merging, merged or refused", p.State, who) + } + } + return nil +} + +// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it +// unchanged, and the group and heads it was composed for. +type Ask struct { + V int `json:"v"` + Group string `json:"group"` + Heads []Head `json:"heads"` + Ask asks.Ask `json:"ask"` +} + +func (a Ask) check() error { + key, err := MergeAskKey(a.Group, a.Heads) + if err != nil { + return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err) + } + if want := AskID(key); a.Ask.ID != want { + return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want) + } + if len(a.Ask.Options) == 0 { + return fmt.Errorf("delivery: the ask has no option") + } + return nil +} + +// Answer is the teller's answer to an ask: the option chosen, the warrant's id and its words. +type Answer struct { + V int `json:"v"` + Option string `json:"option"` + Warrant string `json:"warrant"` + Words string `json:"words"` +} + +func (a Answer) check() error { + for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} { + if f.value == "" { + return fmt.Errorf("delivery: an answer has no %s", f.name) + } + } + return nil +} + +// Owed is the value of an owed key: what the forge bridge performs. A status needs Context and State, a view and a +// comment need Text, a note needs Text and Ref (the notes ref, mesh-plan). +type Owed struct { + V int `json:"v"` + Act string `json:"act"` + Context string `json:"context,omitempty"` + State string `json:"state,omitempty"` + Text string `json:"text,omitempty"` + Ref string `json:"ref,omitempty"` +} + +func (o Owed) check() error { + switch o.Act { + case OwedStatus: + if o.Context == "" { + return fmt.Errorf("delivery: an owed status has no context") + } + for _, s := range statusStates { + if o.State == s { + return nil + } + } + return fmt.Errorf("delivery: the owed status state %q is not %s", o.State, strings.Join(statusStates, ", ")) + case OwedView, OwedComment: + if o.Text == "" { + return fmt.Errorf("delivery: an owed %s has no text", o.Act) + } + case OwedNote: + if o.Text == "" { + return fmt.Errorf("delivery: an owed note has no text") + } + if o.Ref == "" { + return fmt.Errorf("delivery: an owed note has no ref") + } + default: + return fmt.Errorf("delivery: the owed act %q is not status, view, note or comment", o.Act) + } + return nil +} + +// EncodeMerge is m as JSON at Version; it refuses a merged pull request without its merge commit, a refused one +// without the forge's words and a state outside the list. +func EncodeMerge(m Merge) ([]byte, error) { + if err := m.check(); err != nil { + return nil, err + } + m.V = Version + return json.Marshal(m) +} + +// DecodeMerge reads a merge; it refuses an unknown version (ErrUnknownVersion) and what EncodeMerge refuses. +func DecodeMerge(data []byte) (Merge, error) { + var m Merge + if err := decode(data, &m); err != nil { + return Merge{}, err + } + if err := m.check(); err != nil { + return Merge{}, err + } + return m, nil +} + +// EncodeAsk is a as JSON at Version; it refuses an ask whose id is not the one its group and heads give, or with +// no option. +func EncodeAsk(a Ask) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAsk reads an ask; it refuses an unknown version (ErrUnknownVersion) and what EncodeAsk refuses. +func DecodeAsk(data []byte) (Ask, error) { + var a Ask + if err := decode(data, &a); err != nil { + return Ask{}, err + } + if err := a.check(); err != nil { + return Ask{}, err + } + return a, nil +} + +// EncodeAnswer is a as JSON at Version; it refuses an answer missing its option, warrant or words. +func EncodeAnswer(a Answer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAnswer reads an answer; it refuses an unknown version (ErrUnknownVersion) and what EncodeAnswer refuses. +func DecodeAnswer(data []byte) (Answer, error) { + var a Answer + if err := decode(data, &a); err != nil { + return Answer{}, err + } + if err := a.check(); err != nil { + return Answer{}, err + } + return a, nil +} + +// EncodeOwed is o as JSON at Version; it refuses an act missing what it needs. +func EncodeOwed(o Owed) ([]byte, error) { + if err := o.check(); err != nil { + return nil, err + } + o.V = Version + return json.Marshal(o) +} + +// DecodeOwed reads an owed act; it refuses an unknown version (ErrUnknownVersion) and what EncodeOwed refuses. +func DecodeOwed(data []byte) (Owed, error) { + var o Owed + if err := decode(data, &o); err != nil { + return Owed{}, err + } + if err := o.check(); err != nil { + return Owed{}, err + } + return o, nil +} + +// SameMerge says whether two merges are the same value, so a writer writes only when they differ. +func SameMerge(a, b Merge) bool { return reflect.DeepEqual(a, b) } + +// SameAsk says whether two asks are the same value. +func SameAsk(a, b Ask) bool { return reflect.DeepEqual(a, b) } + +// SameAnswer says whether two answers are the same value. +func SameAnswer(a, b Answer) bool { return a == b } + +// SameOwed says whether two owed acts are the same value. +func SameOwed(a, b Owed) bool { return a == b } diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go new file mode 100644 index 0000000..a8b53ff --- /dev/null +++ b/go/delivery/gate_test.go @@ -0,0 +1,297 @@ +package delivery + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +func gateHeads() []Head { + return []Head{ + {Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}, + {Repository: "novox/mesh-control", Number: 7, Head: "bbbb"}, + } +} + +func TestMergeKeys(t *testing.T) { // 1201/AC1 + if k, err := MergesKey("feat/x"); err != nil || k != "feat/x" { + t.Fatalf("merges key = %q, %v", k, err) + } + if _, err := MergesKey("a.b"); err == nil { + t.Error("a group with a dot accepted") + } + hh, _ := HeadsHash(gateHeads()) + key, err := MergeAskKey("feat/x", gateHeads()) + if err != nil || key != "merge.feat/x."+hh { + t.Fatalf("ask key = %q, %v", key, err) + } + kind, group, hash, err := ParseAskKey(key) + if err != nil || kind != AskKindMerge || group != "feat/x" || hash != hh { + t.Fatalf("parse ask key = %q %q %q %v", kind, group, hash, err) + } + if AnswerKey(key) != key { + t.Error("the answer's key is not the ask's key") + } + if _, err := MergeAskKey("feat/x", nil); err == nil { + t.Error("an ask key without heads accepted") + } + if _, err := MergeAskKey("", gateHeads()); err == nil { + t.Error("an ask key without a group accepted") + } + for _, bad := range []string{"", "merge", "merge.g", "merge.g.h.x", "other.g.0123456789ab", "merge..0123456789ab", "merge.g.nothex"} { + if _, _, _, err := ParseAskKey(bad); err == nil { + t.Errorf("parse ask key %q accepted", bad) + } + } +} + +func TestAskID(t *testing.T) { // 1201/AC2 + k1, _ := MergeAskKey("feat/x", gateHeads()) + k2, _ := MergeAskKey("feat/y", gateHeads()) + k3, _ := MergeAskKey("feat/x", gateHeads()[:1]) + long, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20), gateHeads()) + long2, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20)+"2", gateHeads()) + seen := map[string]string{} + for _, k := range []string{k1, k2, k3, long, long2} { + id := AskID(k) + if strings.Contains(id, ".") { + t.Errorf("id %q holds a dot", id) + } + if !asks.UsableID(id) { + t.Errorf("id %q is not usable by the router", id) + } + if other, dup := seen[id]; dup { + t.Errorf("keys %q and %q give the id %q", k, other, id) + } + seen[id] = k + } + if AskID(k1) != AskID(k1) { + t.Error("the id is not stable") + } +} + +func TestOwedKey(t *testing.T) { // 1201/AC3 + for _, act := range []string{"status=mesh/merge-gate", "view", "note", "comment"} { + key, err := OwedKey("novox/mesh-sdk", 36, "aaaa", act) + if err != nil || key != "novox/mesh-sdk.36.aaaa."+act { + t.Fatalf("owed key for %s = %q, %v", act, key, err) + } + repo, number, head, gotAct, err := ParseOwedKey(key) + if err != nil || repo != "novox/mesh-sdk" || number != 36 || head != "aaaa" || gotAct != act { + t.Fatalf("parse %q = %q %d %q %q %v", key, repo, number, head, gotAct, err) + } + } + kind, context, err := ParseOwedAct("status=mesh/merge-gate") + if err != nil || kind != OwedStatus || context != "mesh/merge-gate" { + t.Errorf("owed act = %q %q %v", kind, context, err) + } + for _, bad := range []string{"", "merge", "status", "status=", "view=x", "status=a.b", "statuses=x"} { + if _, err := OwedKey("novox/mesh-sdk", 36, "aaaa", bad); err == nil { + t.Errorf("owed act %q accepted", bad) + } + } + if _, err := OwedKey("nope", 36, "aaaa", "view"); err == nil { + t.Error("a bad repository accepted") + } + if _, err := OwedKey("novox/mesh-sdk", 0, "aaaa", "view"); err == nil { + t.Error("pull request number 0 accepted") + } + if _, err := OwedKey("novox/mesh-sdk", 36, "", "view"); err == nil { + t.Error("an empty head accepted") + } + for _, bad := range []string{"", "novox/mesh-sdk.36.aaaa", "novox/mesh-sdk.x.aaaa.view", "novox/mesh-sdk.36.aaaa.merge", "a.b.36.aaaa.view"} { + if _, _, _, _, err := ParseOwedKey(bad); err == nil { + t.Errorf("parse owed key %q accepted", bad) + } + } +} + +func sampleMerge() Merge { + return Merge{Group: "feat/x", Ask: "merge.feat/x.0123456789ab", Pulls: []MergePull{ + {Head: gateHeads()[0], State: MergeMerged, Commit: "cccc"}, + {Head: gateHeads()[1], State: MergeRefused, Words: "the forge says no"}, + {Head: Head{Repository: "novox/other", Number: 1, Head: "dddd"}, State: MergeMerging}, + }} +} + +func sampleAsk() Ask { + key, _ := MergeAskKey("feat/x", gateHeads()) + return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{ + ID: AskID(key), Headline: "Merge into main: group feat/x", Explanation: "The group passed its checks.", + Who: asks.Operator, Expires: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), OnExpiry: "nothing is merged", + Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}, + }} +} + +func sampleOwed() []Owed { + return []Owed{ + {Act: OwedStatus, Context: "mesh/merge-gate", State: "success"}, + {Act: OwedView, Text: "the delivery reaches ace"}, + {Act: OwedNote, Text: "plan: mesh-mail to ace", Ref: "mesh-plan"}, + {Act: OwedComment, Text: "merged"}, + } +} + +func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4 + hasV := func(name string, data []byte, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + var probe map[string]any + if json.Unmarshal(data, &probe) != nil || probe["v"] != float64(1) { + t.Fatalf("%s: %s does not carry v:1", name, data) + } + } + m := sampleMerge() + data, err := EncodeMerge(m) + hasV("merge", data, err) + m.V = Version + if got, err := DecodeMerge(data); err != nil || !SameMerge(got, m) { + t.Errorf("merge round trip: %+v %v", got, err) + } + a := sampleAsk() + data, err = EncodeAsk(a) + hasV("ask", data, err) + a.V = Version + if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) { + t.Errorf("ask round trip: %+v %v", got, err) + } + an := Answer{Option: "approve", Warrant: "w-1", Words: "approved on telegram"} + data, err = EncodeAnswer(an) + hasV("answer", data, err) + an.V = Version + if got, err := DecodeAnswer(data); err != nil || !SameAnswer(got, an) { + t.Errorf("answer round trip: %+v %v", got, err) + } + for _, o := range sampleOwed() { + data, err = EncodeOwed(o) + hasV("owed "+o.Act, data, err) + o.V = Version + if got, err := DecodeOwed(data); err != nil || !SameOwed(got, o) { + t.Errorf("owed %s round trip: %+v %v", o.Act, got, err) + } + } + for name, dec := range map[string]func([]byte) error{ + "merge": func(b []byte) error { _, err := DecodeMerge(b); return err }, + "ask": func(b []byte) error { _, err := DecodeAsk(b); return err }, + "answer": func(b []byte) error { _, err := DecodeAnswer(b); return err }, + "owed": func(b []byte) error { _, err := DecodeOwed(b); return err }, + } { + for _, in := range []string{`{"group":"g"}`, `{"v":2}`} { + if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err) + } + } + } +} + +func TestGateValuesRefused(t *testing.T) { // 1201/AC5 + wantErr := func(name string, err error, words ...string) { + t.Helper() + if err == nil { + t.Errorf("%s accepted", name) + return + } + for _, w := range words { + if !strings.Contains(err.Error(), w) { + t.Errorf("%s: error %q does not name %q", name, err, w) + } + } + } + m := sampleMerge() + m.Pulls[0].Commit = "" + _, err := EncodeMerge(m) + wantErr("merged without a merge commit", err, "commit") + m = sampleMerge() + m.Pulls[1].Words = "" + _, err = EncodeMerge(m) + wantErr("refused without words", err, "words") + m = sampleMerge() + m.Pulls[2].State = "done" + _, err = EncodeMerge(m) + wantErr("unknown state", err, "done") + m = sampleMerge() + m.Pulls = nil + _, err = EncodeMerge(m) + wantErr("merge without pulls", err, "pull") + m = sampleMerge() + m.Ask = "" + _, err = EncodeMerge(m) + wantErr("merge without its ask", err, "ask") + + a := sampleAsk() + a.Ask.ID = "something-else" + _, err = EncodeAsk(a) + wantErr("ask with another id", err, "something-else", AskID("merge.feat/x."+mustHash(gateHeads()))) + a = sampleAsk() + a.Ask.Options = nil + _, err = EncodeAsk(a) + wantErr("ask without options", err, "option") + a = sampleAsk() + a.Heads = nil + _, err = EncodeAsk(a) + wantErr("ask without heads", err, "pull request") + + for name, an := range map[string]Answer{ + "option": {Warrant: "w", Words: "x"}, + "warrant": {Option: "approve", Words: "x"}, + "words": {Option: "approve", Warrant: "w"}, + } { + _, err = EncodeAnswer(an) + wantErr("answer without "+name, err, name) + } + + for name, o := range map[string]Owed{ + "context": {Act: OwedStatus, State: "success"}, + "state": {Act: OwedStatus, Context: "mesh/merge-gate", State: "green"}, + "text": {Act: OwedView}, + "ref": {Act: OwedNote, Text: "line"}, + "comment": {Act: OwedComment}, + "act": {Act: "merge"}, + } { + _, err = EncodeOwed(o) + wantErr("owed missing "+name, err) + } + _, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"}) + wantErr("note without ref", err, "ref") + _, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"}) + wantErr("status without context", err, "context") +} + +func mustHash(h []Head) string { s, _ := HeadsHash(h); return s } + +func TestGateSame(t *testing.T) { // 1201/AC6 + if !SameMerge(sampleMerge(), sampleMerge()) || !SameAsk(sampleAsk(), sampleAsk()) { + t.Error("equal values differ") + } + m := sampleMerge() + m.Pulls[0].Commit = "other" + if SameMerge(m, sampleMerge()) { + t.Error("different merges are the same") + } + a := sampleAsk() + a.Ask.Headline = "other" + if SameAsk(a, sampleAsk()) { + t.Error("different asks are the same") + } + if !SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "a", Warrant: "w", Words: "x"}) || + SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "b", Warrant: "w", Words: "x"}) { + t.Error("SameAnswer wrong") + } + if !SameOwed(sampleOwed()[0], sampleOwed()[0]) || SameOwed(sampleOwed()[0], sampleOwed()[1]) { + t.Error("SameOwed wrong") + } +} + +func TestGateBuckets(t *testing.T) { + if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" || + OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" || + MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 { + t.Error("a bucket or history differs from the decided layout") + } +}