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"
)
// acceptStore is a real store with one open landing of feat onto trunk,
// folded into a real index by a real ingest pipeline — the accept path
// reads state from the index and merges through zit, and both races
// live in the gap between those two.
func acceptStore(t *testing.T) (dir, id string, ix *index.Index, sync 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")
zwrite(t, dir, "g.txt", "work\n")
zrun(t, dir, "add", "g.txt")
zrun(t, dir, "commit", "-m", "work", "--branch", "feat", "--user", "u")
id, err := zitcli.New(dir).LandingOpen("dan", "feat", "trunk", "land feat", "")
if err != nil {
t.Fatal(err)
}
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, id, ix, pipe.Sync
}
// Two accepts pressed at once both read "open" — the settle is a fold
// and then a merge, and nothing held the record between the two. The
// second must be told the record is already being accepted, not merge
// alongside the first.
func TestTwoSimultaneousAcceptsLetExactlyOneThrough(t *testing.T) {
dir, id, ix, pipeSync := acceptStore(t)
entered := make(chan struct{})
release := make(chan struct{})
var once sync.Once
syncFn := func() (int, error) {
first := false
once.Do(func() { first = true })
if first {
// Hold the first accept inside its settle, the window a
// second press used to walk straight through.
close(entered)
<-release
}
return pipeSync()
}
r := ops.NewRegistry()
deps := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{
"test": {Index: ix, Zit: zitcli.New(dir), Sync: syncFn}})}
if err := RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
accept := func() error {
_, err := r.Invoke(as("dan", "i"), "landing.accept", landingSayIn{Repo: "test", ID: id})
return err
}
firstErr := make(chan error, 1)
go func() { firstErr <- accept() }()
select {
case <-entered:
case <-time.After(30 * time.Second):
t.Fatal("the first accept never reached its settle")
}
secondErr := make(chan error, 1)
go func() { secondErr <- accept() }()
var second error
select {
case second = <-secondErr:
case <-time.After(30 * time.Second):
close(release)
t.Fatal("FAIL: the second accept waited on the first instead of being refused")
}
close(release)
first := <-firstErr
if first != nil {
t.Fatalf("FAIL: the first accept failed: %v", first)
}
if second == nil {
t.Fatal("FAIL: both accepts proceeded")
}
if !errors.Is(second, ops.ErrInvalid) || !strings.Contains(second.Error(), "already being accepted") {
t.Fatalf("FAIL: the second accept was refused unclearly: %v", second)
}
t.Logf("ok: first accepted, second refused: %v", second)
}
// movingChecks is the required-checks policy read at the moment a push
// can still land: it moves the branch once, then asks for nothing.
type movingChecks struct {
once sync.Once
move func()
}
func (m *movingChecks) RequiredChecks(string, string) []string {
m.once.Do(m.move)
return nil
}
func (m *movingChecks) PremergeMode(string, string) bool { return false }
// A push between the check and the merge moved the branch, and zit
// merges the branch by NAME — so the merge took a tip nothing checked.
// The accept must refuse and name the tip the branch moved to.
func TestAnAcceptRefusesWhenTheBranchMovedAfterItWasChecked(t *testing.T) {
dir, id, ix, pipeSync := acceptStore(t)
checked := ztip(t, dir, "feat")
var moved string
checks := &movingChecks{move: func() {
zwrite(t, dir, "h.txt", "unchecked\n")
zrun(t, dir, "add", "h.txt")
zrun(t, dir, "commit", "-m", "late push", "--user", "u")
moved = ztip(t, dir, "feat")
}}
r := ops.NewRegistry()
deps := Deps{Version: "v", Checks: checks, Repos: NewRepos(map[string]*Repo{
"test": {Index: ix, Zit: zitcli.New(dir), Sync: pipeSync}})}
if err := RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
_, err := r.Invoke(as("dan", "i"), "landing.accept", landingSayIn{Repo: "test", ID: id})
if moved == "" || moved == checked {
t.Fatalf("the test never moved the branch: checked %s, now %s", checked, moved)
}
if err == nil {
t.Fatal("FAIL: the accept merged a tip that was never checked")
}
if !errors.Is(err, ops.ErrInvalid) || !strings.Contains(err.Error(), short(moved)) {
t.Fatalf("FAIL: the refusal does not name the moved tip %s: %v", short(moved), err)
}
if out := zrun(t, dir, "landing", "ls"); !strings.Contains(out, id[:10]) {
t.Fatalf("FAIL: the landing is no longer open after a refused accept:\n%s", out)
}
t.Logf("ok: %v", err)
}