Skip to content
quarry Artifact [40deb77907]
Login

Artifact [40deb77907]

Artifact 40deb7790728417ee9461e5b4788b7d2caba6679e50e03f84ebbcb62de11233a:


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)
}