package api
import (
"errors"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"craftdesign.group/quarry/internal/events"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ingest"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// premergeChecks requires "ci" on every branch, judged as the merged
// pair.
type premergeChecks struct{}
func (premergeChecks) RequiredChecks(string, string) []string { return []string{"ci"} }
func (premergeChecks) PremergeMode(string, string) bool { return true }
// pairRuns answers the runs recorded against exactly one premerge pair
// each; a pair with no entry was never tested. judge runs each time a
// pair's runs are read, the moment an accept judges its pair.
type pairRuns struct {
mu sync.Mutex
runs map[string][]CIRun
judge func()
}
func (p *pairRuns) CIRuns(string, int) ([]CIRun, error) { return nil, nil }
func (p *pairRuns) CIRunsPage(string, int, int) ([]CIRun, error) { return nil, nil }
func (p *pairRuns) CIRunsFor(string, string) ([]CIRun, error) { return nil, nil }
func (p *pairRuns) CIRunsForPair(_ string, pair string) ([]CIRun, error) {
if p.judge != nil {
p.judge()
}
p.mu.Lock()
defer p.mu.Unlock()
return p.runs[pair], nil
}
func (p *pairRuns) green(branchTip, targetTip string) {
p.mu.Lock()
defer p.mu.Unlock()
pair := "pair:" + branchTip + ":" + targetTip
p.runs[pair] = append(p.runs[pair], CIRun{Checkin: pair, Workflow: "ci", Pipeline: "ci", Status: "success"})
}
// premergeStore is a real store with two open landings, one and two,
// each a branch off the root onto trunk touching its own file, folded
// into a real index. The checkout is left on two.
func premergeStore(t *testing.T) (dir string, ids [2]string, ix *index.Index, pipeSync func() (int, error)) {
t.Helper()
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
dir = t.TempDir()
zrun(t, dir, "init", ".")
zwrite(t, dir, "f.txt", "root\n")
zrun(t, dir, "add", "f.txt")
zrun(t, dir, "commit", "-m", "root", "--user", "u")
for i, branch := range []string{"one", "two"} {
zrun(t, dir, "update", "trunk")
zwrite(t, dir, branch+".txt", branch+"\n")
zrun(t, dir, "add", branch+".txt")
zrun(t, dir, "commit", "-m", branch, "--branch", branch, "--user", "u")
id, err := zitcli.New(dir).LandingOpen(testToken, "dan", branch, "trunk", "land "+branch, "")
if err != nil {
t.Fatal(err)
}
ids[i] = id
}
ev, err := events.Start(filepath.Join(t.TempDir(), "nats"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(ev.Close)
ix, err = index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
pipe := ingest.NewPipeline(zitcli.New(dir), ev, ix, "test", "n1")
if _, err := pipe.Sync(); err != nil {
t.Fatal(err)
}
return dir, ids, ix, pipe.Sync
}
// premergeAccepter wires a registry over the store with both landings'
// pairs against the root trunk green, and answers an accept function.
func premergeAccepter(t *testing.T, dir string, ix *index.Index, pipeSync func() (int, error), judge func()) (accept func(id string) error, root string) {
t.Helper()
root = ztip(t, dir, "trunk")
ci := &pairRuns{runs: map[string][]CIRun{}, judge: judge}
ci.green(ztip(t, dir, "one"), root)
ci.green(ztip(t, dir, "two"), root)
r := ops.NewRegistry()
deps := Deps{Version: "v", Checks: premergeChecks{}, CI: ci, Repos: NewRepos(map[string]*Repo{
"test": {Index: ix, Zit: zitcli.New(dir), Sync: pipeSync}})}
if err := RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
return func(id string) error {
_, err := r.Invoke(as("dan", "i"), "landing.accept", landingSayIn{Repo: "test", ID: id})
return err
}, root
}
// untestedOrMoved is the refusal a second accept on a moved target
// owes: re-judged against the new pair and found untested, or refused
// because the target moved under the pair it judged.
func untestedOrMoved(err error) bool {
var ref *ops.Refusal
if errors.As(err, &ref) && ref.Code == RefusalTargetMoved {
return true
}
return errors.Is(err, ops.ErrForbidden) && strings.Contains(err.Error(), "required checks not green")
}
func assertStillOpen(t *testing.T, dir, id string) {
t.Helper()
if out := zrun(t, dir, "landing", "ls"); !strings.Contains(out, id[:10]) {
t.Fatalf("FAIL: landing %s is no longer open, so it merged:\n%s", id[:10], out)
}
}
// Both pairs were green against the root trunk. The first accept moves
// trunk; the second pair was never tested against the new trunk and
// must not merge, even with no push in between.
func TestTwoPremergeAcceptsBackToBackNeverMergeAnUntestedPair(t *testing.T) {
dir, ids, ix, pipeSync := premergeStore(t)
accept, _ := premergeAccepter(t, dir, ix, pipeSync, nil)
if err := accept(ids[0]); err != nil {
t.Fatalf("FAIL: the first accept of a green pair failed: %v", err)
}
err := accept(ids[1])
if err == nil {
t.Fatal("FAIL: the second accept merged a pair CI never tested")
}
if !untestedOrMoved(err) {
t.Fatalf("FAIL: the second accept was refused for the wrong reason: %v", err)
}
assertStillOpen(t, dir, ids[1])
t.Logf("ok: %v", err)
}
// The same two accepts pressed at once: each judged its pair against
// the root trunk before either merged. They must serialise on the
// target, so the second re-judges after the first lands.
func TestTwoSimultaneousPremergeAcceptsOnOneTargetSerialise(t *testing.T) {
dir, ids, ix, pipeSync := premergeStore(t)
entered := make(chan struct{})
release := make(chan struct{})
var once sync.Once
judge := func() {
first := false
once.Do(func() { first = true })
if first {
// Hold the first accept at its judgement, after it read
// the pair and before it merged.
close(entered)
<-release
}
}
accept, _ := premergeAccepter(t, dir, ix, pipeSync, judge)
firstErr := make(chan error, 1)
go func() { firstErr <- accept(ids[0]) }()
select {
case <-entered:
case <-time.After(30 * time.Second):
t.Fatal("the first accept never reached its judgement")
}
secondErr := make(chan error, 1)
go func() { secondErr <- accept(ids[1]) }()
// Give an unserialised second accept the time to judge and merge
// while the first is held; a serialised one waits here.
var second error
secondDone := false
select {
case second = <-secondErr:
secondDone = true
case <-time.After(2 * time.Second):
}
close(release)
if first := <-firstErr; first != nil && secondDone && second == nil {
t.Fatalf("FAIL: the second accept merged while the first was judging, and the first then failed: %v", first)
} else if first != nil {
t.Fatalf("FAIL: the first accept failed: %v", first)
}
if !secondDone {
select {
case second = <-secondErr:
case <-time.After(60 * time.Second):
t.Fatal("FAIL: the second accept never finished after the first landed")
}
}
if second == nil {
t.Fatal("FAIL: both accepts merged, so one landed a pair CI never tested")
}
if !untestedOrMoved(second) {
t.Fatalf("FAIL: the second accept was refused for the wrong reason: %v", second)
}
assertStillOpen(t, dir, ids[1])
t.Logf("ok: second refused: %v", second)
}
// One accept judged its pair green; then trunk moved before the merge.
// The merge would land a pair nothing tested, so it is refused with the
// target tip judged and the tip it moved to.
func TestAPremergeAcceptRefusesWhenTheTargetMovedAfterJudgement(t *testing.T) {
dir, ids, ix, pipeSync := premergeStore(t)
var moved string
var once sync.Once
judge := func() {
once.Do(func() {
zrun(t, dir, "update", "trunk")
zwrite(t, dir, "late.txt", "untested\n")
zrun(t, dir, "add", "late.txt")
zrun(t, dir, "commit", "-m", "late push", "--user", "u")
moved = ztip(t, dir, "trunk")
})
}
accept, root := premergeAccepter(t, dir, ix, pipeSync, judge)
err := accept(ids[0])
if moved == "" || moved == root {
t.Fatalf("the test never moved trunk: judged %s, now %s", root, moved)
}
if err == nil {
t.Fatal("FAIL: the accept merged onto a target that moved after its pair was judged")
}
var ref *ops.Refusal
if !errors.As(err, &ref) || ref.Code != RefusalTargetMoved {
t.Fatalf("FAIL: accept = %v; want a %s refusal", err, RefusalTargetMoved)
}
if !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("FAIL: the refusal is not an invalid request: %v", err)
}
if ref.Fields["expected"] != root || ref.Fields["current"] != moved {
t.Fatalf("FAIL: refusal fields %v; want expected %s, current %s", ref.Fields, root, moved)
}
assertStillOpen(t, dir, ids[0])
t.Logf("ok: %v", err)
}