diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index fd109ec..50931ed 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -18,6 +18,11 @@ // question 9 item 7): a writer writes a new version only once every reader's have shows a version that reads it. A // writer writes only when the new value differs from the one before (the Same functions). // +// **Source, candidates, modules and the forge** (issue 1200; ADR 0437 §5, release 3): the gate's bucket `source` with +// its one key `main` (every repository's merged head and the group that moved it), the indexer's `candidates.` +// and `modules.` (a module's source fingerprint, or none and why), and the forge bridge's messages named +// `forge...` (source.go). +// // Standard library only. package delivery diff --git a/go/delivery/source.go b/go/delivery/source.go new file mode 100644 index 0000000..533fb32 --- /dev/null +++ b/go/delivery/source.go @@ -0,0 +1,407 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" +) + +// The modules of the parts release 3 adds (novox/hq ADR 0437): their buckets are their own state, `_`. +const ( + GateModule = "mesh-delivery-gate" + IndexerModule = "mesh-delivery-indexer" + ForgeBridgeModule = "mesh-delivery-forge-bridge" + TellerModule = "mesh-delivery-teller" +) + +// The gate's source bucket and the indexer's candidates and modules buckets (ADR 0437 §5). The source bucket has +// one key, SourceMainKey: every repository's merged head and the group that moved it, written by the gate alone by +// compare-and-set; its revision is the mesh's source revision. The indexer writes the other two. +const ( + SourceBucket = GateModule + "_source" + SourceMainKey = "main" + SourceHistory = 64 + CandidatesBucket = IndexerModule + "_candidates" + // CandidatesHistory is the revisions the candidates bucket keeps per key. + CandidatesHistory = 10 + ModulesBucket = IndexerModule + "_modules" + // ModulesHistory is the revisions the modules bucket keeps per key. + ModulesHistory = 20 +) + +// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names. +const ForgeStream = "forge" + +// The kinds of forge message, the last part of a forge event's name. +const ( + ForgePull = "pull" + ForgeMerged = "merged" + ForgeRefused = "refused" +) + +// The actions of a pull message. +const ( + PullOpened = "opened" + PullUpdated = "updated" + PullClosed = "closed" +) + +// SourcePrefix opens every source fingerprint. +const SourcePrefix = "src1:" + +// CandidatesKey is the key, in CandidatesBucket, of a group: ``, the branch name the group's pull requests +// share (a slash is allowed); a lone pull request is a group of one under its branch name. +func CandidatesKey(group string) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + return group, nil +} + +// ParseCandidatesKey is the inverse of CandidatesKey. +func ParseCandidatesKey(key string) (group string, err error) { + if group, err = CandidatesKey(key); err != nil { + return "", fmt.Errorf("delivery: %q is not a candidates key: %w", key, err) + } + return group, nil +} + +// ModulesKey is the key, in ModulesBucket, of a module: ``. +func ModulesKey(module string) (string, error) { + if err := checkName("module", module); err != nil { + return "", err + } + return module, nil +} + +// ParseModulesKey is the inverse of ModulesKey. +func ParseModulesKey(key string) (module string, err error) { + if module, err = ModulesKey(key); err != nil { + return "", fmt.Errorf("delivery: %q is not a modules key: %w", key, err) + } + return module, nil +} + +// checkRepository refuses a repository that is not `/`: exactly one slash, each part a valid name. +func checkRepository(repository string) error { + owner, name, ok := strings.Cut(repository, "/") + if !ok || strings.Contains(name, "/") { + return fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) + } + if err := checkName("repository owner", owner); err != nil { + return fmt.Errorf("delivery: the repository %q: %w", repository, err) + } + if err := checkName("repository name", name); err != nil { + return fmt.Errorf("delivery: the repository %q: %w", repository, err) + } + if strings.Contains(owner, "/") { + return fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) + } + return nil +} + +func checkForgeKind(kind string) error { + switch kind { + case ForgePull, ForgeMerged, ForgeRefused: + return nil + } + return fmt.Errorf("delivery: the forge kind %q is not pull, merged or refused", kind) +} + +// ForgeEvent is the name of a forge message of the given kind: `forge...`. +func ForgeEvent(repository, kind string) (string, error) { + if err := checkRepository(repository); err != nil { + return "", err + } + if err := checkForgeKind(kind); err != nil { + return "", err + } + owner, name, _ := strings.Cut(repository, "/") + return "forge." + owner + "." + name + "." + kind, nil +} + +// ParseForgeEvent is the inverse of ForgeEvent. +func ParseForgeEvent(event string) (repository, kind string, err error) { + parts := strings.Split(event, ".") + if len(parts) != 4 || parts[0] != "forge" { + return "", "", fmt.Errorf("delivery: %q is not a forge event, forge...", event) + } + repository = parts[1] + "/" + parts[2] + if _, err := ForgeEvent(repository, parts[3]); err != nil { + return "", "", fmt.Errorf("delivery: %q is not a forge event: %w", event, err) + } + return repository, parts[3], nil +} + +// Head is one pull request at one commit: the repository as `/` and the full sha. +type Head struct { + Repository string `json:"repository"` + Number int `json:"number"` + Head string `json:"head"` +} + +// HeadsHash is the first 12 hex digits of the sha256 over the lines `#=`, sorted and +// joined by a newline: the same for every order of heads, different when any head changes. An empty list is refused. +func HeadsHash(heads []Head) (string, error) { + if len(heads) == 0 { + return "", fmt.Errorf("delivery: a heads hash needs at least one pull request") + } + lines := make([]string, len(heads)) + for i, h := range heads { + lines[i] = fmt.Sprintf("%s#%d=%s", h.Repository, h.Number, h.Head) + } + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return hex.EncodeToString(sum[:])[:12], nil +} + +// Source is the value of SourceMainKey: every repository's merged head (repository to full sha, not only the +// group's) and the group that moved it. +type Source struct { + V int `json:"v"` + Group string `json:"group"` + Heads map[string]string `json:"heads"` +} + +func (s Source) check() error { + if len(s.Heads) == 0 { + return fmt.Errorf("delivery: a source names no repository") + } + for repository, head := range s.Heads { + if repository == "" { + return fmt.Errorf("delivery: a source names a repository with no name") + } + if head == "" { + return fmt.Errorf("delivery: the source's head of %s is empty", repository) + } + } + return nil +} + +// Candidate is the value of a candidates key: the group's pull requests and heads, the heads hash, the base (the +// source revision it was previewed onto) and the modules each pull request touches. It carries no fingerprint +// until release 4 (ADR 0437 §5). +type Candidate struct { + V int `json:"v"` + Group string `json:"group"` + Base uint64 `json:"base"` + HeadsHash string `json:"heads"` + Pulls []CandidatePull `json:"pulls"` +} + +// CandidatePull is one pull request of a candidate and the modules it touches. +type CandidatePull struct { + Head + Modules []string `json:"modules"` +} + +func (c Candidate) check() error { + if len(c.Pulls) == 0 { + return fmt.Errorf("delivery: a candidate names no pull request") + } + heads := make([]Head, len(c.Pulls)) + for i, p := range c.Pulls { + heads[i] = p.Head + } + want, _ := HeadsHash(heads) + if c.HeadsHash != want { + return fmt.Errorf("delivery: the candidate's heads hash %q is not the hash %q of its pull requests' heads", c.HeadsHash, want) + } + return nil +} + +// Module is the value of a modules key: the module's source fingerprint, or none and why (a build that resolves +// packages when built gets none, said here), and where it was computed: Repository and Commit are the trunk merge, +// Src the source revision then (0 before the gate writes source.main). Exactly one of FP and None is set. +type Module struct { + V int `json:"v"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` + Src uint64 `json:"src,omitempty"` + Repository string `json:"repository"` + Commit string `json:"commit"` +} + +func (m Module) check() error { + switch { + case m.FP != "" && m.None != "": + return fmt.Errorf("delivery: a module has both a fingerprint and a reason for none") + case m.FP == "" && m.None == "": + return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none") + case m.FP != "" && !strings.HasPrefix(m.FP, SourcePrefix): + return fmt.Errorf("delivery: the module's fingerprint %q does not start with %s", m.FP, SourcePrefix) + } + return nil +} + +// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull +// message says Action; a merged message carries the merge commit, the tree of every directory holding a +// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own +// words. +type Forge struct { + V int `json:"v"` + Kind string `json:"kind"` + Repository string `json:"repository"` + Branch string `json:"branch,omitempty"` + Number int `json:"number,omitempty"` + Head string `json:"head,omitempty"` + Action string `json:"action,omitempty"` + Merge string `json:"merge,omitempty"` + Trees map[string]string `json:"trees,omitempty"` + Root string `json:"root,omitempty"` + Act string `json:"act,omitempty"` + Words string `json:"words,omitempty"` +} + +func (f Forge) check() error { + if err := checkRepository(f.Repository); err != nil { + return err + } + if err := checkForgeKind(f.Kind); err != nil { + return err + } + switch f.Kind { + case ForgePull: + if f.Number <= 0 { + return fmt.Errorf("delivery: a pull message needs its pull request number") + } + if f.Head == "" { + return fmt.Errorf("delivery: a pull message needs its head") + } + switch f.Action { + case PullOpened, PullUpdated, PullClosed: + default: + return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action) + } + case ForgeMerged: + if f.Number <= 0 { + return fmt.Errorf("delivery: a merged message needs its pull request number") + } + if f.Head == "" { + return fmt.Errorf("delivery: a merged message needs its head") + } + if f.Merge == "" { + return fmt.Errorf("delivery: a merged message needs its merge commit") + } + if f.Root == "" { + return fmt.Errorf("delivery: a merged message needs the repository's root tree") + } + if len(f.Trees) == 0 { + return fmt.Errorf("delivery: a merged message needs the tree of at least one directory holding a module.json") + } + for dir, tree := range f.Trees { + if tree == "" { + return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir) + } + } + case ForgeRefused: + if f.Act == "" { + return fmt.Errorf("delivery: a refused message needs the act it answers") + } + if f.Words == "" { + return fmt.Errorf("delivery: a refused message needs the forge's words") + } + } + return nil +} + +// EncodeSource is s as JSON at Version; it refuses a source with no repository or an empty head. +func EncodeSource(s Source) ([]byte, error) { + if err := s.check(); err != nil { + return nil, err + } + s.V = Version + return json.Marshal(s) +} + +// DecodeSource reads a source; it refuses an unknown version (ErrUnknownVersion) and what EncodeSource refuses. +func DecodeSource(data []byte) (Source, error) { + var s Source + if err := decode(data, &s); err != nil { + return Source{}, err + } + if err := s.check(); err != nil { + return Source{}, err + } + return s, nil +} + +// EncodeCandidate is c as JSON at Version; it refuses a heads hash that is not the hash of its pull requests' heads. +func EncodeCandidate(c Candidate) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = Version + return json.Marshal(c) +} + +// DecodeCandidate reads a candidate; it refuses an unknown version (ErrUnknownVersion) and what EncodeCandidate refuses. +func DecodeCandidate(data []byte) (Candidate, error) { + var c Candidate + if err := decode(data, &c); err != nil { + return Candidate{}, err + } + if err := c.check(); err != nil { + return Candidate{}, err + } + return c, nil +} + +// EncodeModule is m as JSON at Version; it refuses both or neither of a fingerprint and a reason, and a +// fingerprint not of the form src1:. +func EncodeModule(m Module) ([]byte, error) { + if err := m.check(); err != nil { + return nil, err + } + m.V = Version + return json.Marshal(m) +} + +// DecodeModule reads a module; it refuses an unknown version (ErrUnknownVersion) and what EncodeModule refuses. +func DecodeModule(data []byte) (Module, error) { + var m Module + if err := decode(data, &m); err != nil { + return Module{}, err + } + if err := m.check(); err != nil { + return Module{}, err + } + return m, nil +} + +// EncodeForge is f as JSON at Version; it refuses a message missing what its kind needs. +func EncodeForge(f Forge) ([]byte, error) { + if err := f.check(); err != nil { + return nil, err + } + f.V = Version + return json.Marshal(f) +} + +// DecodeForge reads a forge message; it refuses an unknown version (ErrUnknownVersion) and what EncodeForge refuses. +func DecodeForge(data []byte) (Forge, error) { + var f Forge + if err := decode(data, &f); err != nil { + return Forge{}, err + } + if err := f.check(); err != nil { + return Forge{}, err + } + return f, nil +} + +// SameSource says whether two sources are the same value, so a writer writes only when they differ. +func SameSource(a, b Source) bool { return reflect.DeepEqual(a, b) } + +// SameCandidate says whether two candidates are the same value. +func SameCandidate(a, b Candidate) bool { return reflect.DeepEqual(a, b) } + +// SameModule says whether two modules are the same value. +func SameModule(a, b Module) bool { return a == b } + +// SameForge says whether two forge messages are the same value. +func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/source_test.go b/go/delivery/source_test.go new file mode 100644 index 0000000..2abe8da --- /dev/null +++ b/go/delivery/source_test.go @@ -0,0 +1,288 @@ +package delivery + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestSourceKeys(t *testing.T) { // 1200/AC1 + if k, err := CandidatesKey("feat/delivery-source-keys"); err != nil || k != "feat/delivery-source-keys" { + t.Fatalf("candidates key = %q, %v", k, err) + } + if g, err := ParseCandidatesKey("feat/x"); err != nil || g != "feat/x" { + t.Fatalf("parse candidates = %q, %v", g, err) + } + if k, err := ModulesKey("mesh-mail"); err != nil || k != "mesh-mail" { + t.Fatalf("modules key = %q, %v", k, err) + } + if m, err := ParseModulesKey("mesh-mail"); err != nil || m != "mesh-mail" { + t.Fatalf("parse modules = %q, %v", m, err) + } + for _, bad := range []string{"", "a.b", "a*b", "a>b", "a b", "a\tb"} { + if _, err := CandidatesKey(bad); err == nil { + t.Errorf("candidates key %q accepted", bad) + } + if _, err := ModulesKey(bad); err == nil { + t.Errorf("modules key %q accepted", bad) + } + if _, err := ParseCandidatesKey(bad); err == nil { + t.Errorf("parse candidates %q accepted", bad) + } + if _, err := ParseModulesKey(bad); err == nil { + t.Errorf("parse modules %q accepted", bad) + } + } + ev, err := ForgeEvent("novox/mesh-sdk", ForgeMerged) + if err != nil || ev != "forge.novox.mesh-sdk.merged" { + t.Fatalf("forge event = %q, %v", ev, err) + } + repo, kind, err := ParseForgeEvent(ev) + if err != nil || repo != "novox/mesh-sdk" || kind != ForgeMerged { + t.Fatalf("parse forge event = %q %q %v", repo, kind, err) + } + for _, k := range []string{ForgePull, ForgeMerged, ForgeRefused} { + if _, err := ForgeEvent("a/b", k); err != nil { + t.Errorf("kind %s refused: %v", k, err) + } + } + for _, bad := range []struct{ repo, kind string }{ + {"", "pull"}, {"novox", "pull"}, {"a/b/c", "pull"}, {"/b", "pull"}, {"a/", "pull"}, + {"a.b/c", "pull"}, {"a/b c", "pull"}, {"a/b", "closed"}, {"a/b", ""}, + } { + if _, err := ForgeEvent(bad.repo, bad.kind); err == nil { + t.Errorf("forge event %q %q accepted", bad.repo, bad.kind) + } else if bad.kind == "closed" && !strings.Contains(err.Error(), "closed") { + t.Errorf("error does not name the kind: %v", err) + } + } + for _, bad := range []string{"", "forge.a.b", "forge.a.b.closed", "x.a.b.pull", "forge.a.b.pull.x", "forge..b.pull"} { + if _, _, err := ParseForgeEvent(bad); err == nil { + t.Errorf("parse forge event %q accepted", bad) + } + } +} + +func TestHeadsHash(t *testing.T) { // 1200/AC2 + a := Head{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"} + b := Head{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"} + h1, err := HeadsHash([]Head{a, b}) + if err != nil { + t.Fatal(err) + } + h2, _ := HeadsHash([]Head{b, a}) + if h1 != h2 { + t.Errorf("order changes the hash: %s %s", h1, h2) + } + if len(h1) != 12 || strings.Trim(h1, "0123456789abcdef") != "" { + t.Errorf("hash %q is not 12 hex digits", h1) + } + for _, changed := range []Head{ + {Repository: "novox/mesh-sdk", Number: 36, Head: "aaab"}, + {Repository: "novox/mesh-sdk", Number: 37, Head: "aaaa"}, + {Repository: "novox/other", Number: 36, Head: "aaaa"}, + } { + h3, _ := HeadsHash([]Head{changed, b}) + if h3 == h1 { + t.Errorf("changing %v keeps the hash", changed) + } + } + if _, err := HeadsHash(nil); err == nil { + t.Error("an empty list is accepted") + } +} + +func sampleSource() Source { + return Source{Group: "feat/x", Heads: map[string]string{"novox/mesh-sdk": "abc123", "novox/mesh-control": "def456"}} +} + +func sampleCandidate() Candidate { + pulls := []CandidatePull{ + {Head: Head{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}, Modules: []string{"mesh-mail"}}, + {Head: Head{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"}, Modules: []string{"mesh-controller"}}, + } + hh, _ := HeadsHash([]Head{pulls[0].Head, pulls[1].Head}) + return Candidate{Group: "feat/x", Base: 12, HeadsHash: hh, Pulls: pulls} +} + +func sampleForges() []Forge { + return []Forge{ + {Kind: ForgePull, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 36, Head: "aaaa", Action: "opened"}, + {Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", + Trees: map[string]string{"modules/a": "t1"}, Root: "t0"}, + {Kind: ForgeRefused, Repository: "novox/mesh-sdk", Act: "novox/mesh-sdk.36.aaaa.view", Words: "403 forbidden"}, + } +} + +func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3 + check := func(name string, data []byte, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + var probe map[string]any + if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) { + t.Fatalf("%s: json %s does not carry v:1", name, data) + } + } + s := sampleSource() + data, err := EncodeSource(s) + check("source", data, err) + s.V = Version + if got, err := DecodeSource(data); err != nil || !SameSource(got, s) { + t.Errorf("source round trip: %+v %v", got, err) + } + c := sampleCandidate() + data, err = EncodeCandidate(c) + check("candidate", data, err) + c.V = Version + if got, err := DecodeCandidate(data); err != nil || !SameCandidate(got, c) { + t.Errorf("candidate round trip: %+v %v", got, err) + } + for _, m := range []Module{ + {FP: "src1:abc", Src: 5, Repository: "novox/mesh-sdk", Commit: "cccc"}, + {None: "a build that resolves packages when built", Repository: "novox/mesh-sdk", Commit: "cccc"}, + } { + data, err = EncodeModule(m) + check("module", data, err) + m.V = Version + if got, err := DecodeModule(data); err != nil || !SameModule(got, m) { + t.Errorf("module round trip: %+v %v", got, err) + } + } + for _, f := range sampleForges() { + data, err = EncodeForge(f) + check("forge "+f.Kind, data, err) + f.V = Version + if got, err := DecodeForge(data); err != nil || !SameForge(got, f) { + t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err) + } + } + for name, dec := range map[string]func([]byte) error{ + "source": func(b []byte) error { _, err := DecodeSource(b); return err }, + "candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err }, + "module": func(b []byte) error { _, err := DecodeModule(b); return err }, + "forge": func(b []byte) error { _, err := DecodeForge(b); return err }, + } { + for _, in := range []string{`{"group":"g"}`, `{"v":2,"group":"g"}`} { + if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err) + } + } + } +} + +func TestSourceValuesRefused(t *testing.T) { // 1200/AC4 + 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) + } + } + } + _, err := EncodeSource(Source{Group: "g"}) + wantErr("source without repositories", err, "repositor") + _, err = EncodeSource(Source{Group: "g", Heads: map[string]string{"a/b": ""}}) + wantErr("source with an empty head", err, "a/b") + _, err = EncodeSource(Source{Group: "g", Heads: map[string]string{"": "x"}}) + wantErr("source with an empty repository", err, "repositor") + + c := sampleCandidate() + c.HeadsHash = "000000000000" + _, err = EncodeCandidate(c) + wantErr("candidate with a stale hash", err, "heads hash") + c = sampleCandidate() + c.Pulls = nil + _, err = EncodeCandidate(c) + wantErr("candidate without pulls", err, "pull") + + _, err = EncodeModule(Module{FP: "src1:x", None: "why", Repository: "a/b", Commit: "c"}) + wantErr("module with both", err, "both") + _, err = EncodeModule(Module{Repository: "a/b", Commit: "c"}) + wantErr("module with neither", err, "fingerprint", "none") + _, err = EncodeModule(Module{FP: "abc", Repository: "a/b", Commit: "c"}) + wantErr("module with a bad fingerprint", err, "src1:") + + f := sampleForges()[1] + f.Merge = "" + _, err = EncodeForge(f) + wantErr("merged without merge commit", err, "merge") + f = sampleForges()[1] + f.Root = "" + _, err = EncodeForge(f) + wantErr("merged without root", err, "root") + f = sampleForges()[1] + f.Trees = nil + _, err = EncodeForge(f) + wantErr("merged without trees", err, "tree") + f = sampleForges()[1] + f.Trees = map[string]string{"modules/a": ""} + _, err = EncodeForge(f) + wantErr("merged with an empty tree", err, "modules/a") + f = sampleForges()[0] + f.Action = "reopened" + _, err = EncodeForge(f) + wantErr("pull with an unknown action", err, "reopened") + f = sampleForges()[0] + f.Head = "" + _, err = EncodeForge(f) + wantErr("pull without head", err, "head") + f = sampleForges()[2] + f.Words = "" + _, err = EncodeForge(f) + wantErr("refused without words", err, "words") + f = sampleForges()[2] + f.Act = "" + _, err = EncodeForge(f) + wantErr("refused without act", err, "act") + _, err = EncodeForge(Forge{Kind: "closed", Repository: "a/b"}) + wantErr("unknown kind", err, "closed") + _, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"}) + wantErr("bad repository", err, "nope") +} + +func TestSourceSame(t *testing.T) { // 1200/AC5 + if !SameSource(sampleSource(), sampleSource()) { + t.Error("equal sources differ") + } + s := sampleSource() + s.Heads["novox/mesh-sdk"] = "zzz" + if SameSource(s, sampleSource()) { + t.Error("different sources are the same") + } + if !SameCandidate(sampleCandidate(), sampleCandidate()) { + t.Error("equal candidates differ") + } + c := sampleCandidate() + c.Pulls[0].Modules = []string{"other"} + if SameCandidate(c, sampleCandidate()) { + t.Error("different candidates are the same") + } + m := Module{FP: "src1:a", Repository: "a/b", Commit: "c"} + if !SameModule(m, m) || SameModule(m, Module{FP: "src1:b", Repository: "a/b", Commit: "c"}) { + t.Error("SameModule wrong") + } + f := sampleForges()[1] + g := sampleForges()[1] + if !SameForge(f, g) { + t.Error("equal forge messages differ") + } + g.Trees["modules/a"] = "other" + if SameForge(f, g) { + t.Error("different forge messages are the same") + } +} + +func TestSourceBuckets(t *testing.T) { + if SourceBucket != "mesh-delivery-gate_source" || CandidatesBucket != "mesh-delivery-indexer_candidates" || + ModulesBucket != "mesh-delivery-indexer_modules" || SourceMainKey != "main" || + SourceHistory != 64 || CandidatesHistory != 10 || ModulesHistory != 20 { + t.Error("a bucket or history differs from the decided layout") + } +}