package harness
import (
"encoding/json"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ops"
)
// The whole landing lifecycle through quarry's own surfaces, against a real
// store and a real zit: open a branch for landing, discuss it, and settle it.
// Every step mints ordinary fossil artifacts, so the proof that this works is
// the same proof that a stock fossil clone would still read the repository.
func TestLandingLifecycleThroughTheForge(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
store := buildStore(t, "flowseed")
// A branch worth landing: a real edit, on its own branch.
if err := os.WriteFile(filepath.Join(store, "feature.txt"),
[]byte("the work\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, store, "add", "feature.txt")
zrun(t, store, "commit", "-m", "work on the branch", "--branch", "feat")
srv := ingestAndServeAuth(t, map[string]string{"alpha": store}, t.TempDir(),
func(repo, user, pass string, _ ops.Auth) api.Decision {
if user == "dan" && pass == "hunter2" {
return api.Allowed
}
return api.Denied
})
post := func(path, body string) (int, string) {
t.Helper()
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth("dan", "hunter2")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
code, body := post("/api/v1/r/alpha/landing",
`{"branch":"feat","target":"trunk","message":"land the feat work"}`)
if code != http.StatusOK {
t.Fatalf("opening a landing: %d %s", code, body)
}
var opened struct {
ID string `json:"id"`
}
json.Unmarshal([]byte(body), &opened)
if opened.ID == "" {
t.Fatalf("no record id came back: %s", body)
}
// It is a forum thread, so it reads as one — the compatibility
// promise, checked rather than asserted.
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/r/alpha/forum/"+opened.ID, nil)
req.SetBasicAuth("dan", "hunter2")
resp, _ := srv.Client().Do(req)
if resp != nil {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("the record is not readable as an ordinary forum thread: %d", resp.StatusCode)
}
}
if code, body := post("/api/v1/r/alpha/landing/"+opened.ID[:12]+"/comment",
`{"message":"looks right to me"}`); code != http.StatusOK {
t.Fatalf("commenting: %d %s", code, body)
}
// Settling it. reject writes no check-in, so it needs nothing more
// than the capability to speak.
code, body = post("/api/v1/r/alpha/landing/"+opened.ID[:12]+"/reject",
`{"message":"not this week"}`)
if code != http.StatusOK {
t.Fatalf("rejecting: %d %s", code, body)
}
// A reject names the post it wrote. It always did — zit prints that
// hash — and the G-44 fix must not trade it away while it stops the
// accept verb travelling as one.
var rejected struct {
Hash string `json:"hash"`
}
json.Unmarshal([]byte(body), &rejected)
if len(rejected.Hash) != 64 && len(rejected.Hash) != 40 {
t.Errorf("reject answered hash %q, want the rejection post's full hash", rejected.Hash)
}
// A settled record does not reopen, and settling it again is a
// NO-OP rather than an error: already-rejected is the end state the
// caller asked for, so reporting failure would make a double press
// look broken when the landing is exactly as they wanted.
//
// What must not happen is a second state reply. That is the bug this
// pins — the guard used to read index state that lagged the store,
// so a second press inside one ingest round wrote another reply, and
// zit does not refuse those either.
reCode, reBody := post("/api/v1/r/alpha/landing/"+opened.ID[:12]+"/reject",
`{"message":"again"}`)
if reCode != http.StatusOK {
t.Errorf("re-settling a closed record: %d %s, want 200", reCode, reBody)
}
if !strings.Contains(reBody, `"state":"rejected"`) {
t.Errorf("re-settle should report the settled state, got %s", reBody)
}
if !strings.Contains(reBody, `"hash":""`) {
t.Errorf("re-settle wrote a reply rather than doing nothing: %s", reBody)
}
// Accept: the one op that MERGES. It writes a check-in to the
// target, which is why it takes `i` and why nothing else here does.
// Against a bare store this is zit#131's path — the forge has no
// checkout, and until that shipped this could not run at all.
if err := os.WriteFile(filepath.Join(store, "second.txt"),
[]byte("more work\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, store, "update", "trunk")
zrun(t, store, "add", "second.txt")
zrun(t, store, "commit", "-m", "second branch work", "--branch", "feat2")
// A ticket in the STORE for the landing to close, so the closing
// note can be read back: that note is the second place the verb
// travelled as a hash, ending "(accepted)" where the merge belongs.
tkt := strings.Fields(zout(t, store, "ticket", "new", "--user", "dan",
"title=the second lot is missing", "status=Open", "type=Code_Defect"))[0]
code, body = post("/api/v1/r/alpha/landing",
`{"branch":"feat2","target":"trunk","message":"land the second lot, closes [`+tkt[:10]+`]"}`)
if code != http.StatusOK {
t.Fatalf("opening the second landing: %d %s", code, body)
}
var two struct {
ID string `json:"id"`
}
json.Unmarshal([]byte(body), &two)
code, body = post("/api/v1/r/alpha/landing/"+two.ID[:12]+"/accept",
`{"message":"landing it"}`)
if code != http.StatusOK {
t.Fatalf("accepting: %d %s", code, body)
}
// G-44: the answer names the MERGE, and it is the same check-in the
// record reports — one fact, one producer, two surfaces that agree.
var accepted struct {
Hash string `json:"hash"`
}
json.Unmarshal([]byte(body), &accepted)
getReq, _ := http.NewRequest("GET", srv.URL+"/api/v1/r/alpha/landing/"+two.ID[:12], nil)
getReq.SetBasicAuth("dan", "hunter2")
getResp, gerr := srv.Client().Do(getReq)
if gerr != nil {
t.Fatal(gerr)
}
var record struct {
Merge struct {
Checkin string `json:"checkin"`
} `json:"merge"`
}
json.NewDecoder(getResp.Body).Decode(&record)
getResp.Body.Close()
if accepted.Hash == "" || accepted.Hash != record.Merge.Checkin {
t.Errorf("accept answered hash %q; the record's merge is %q — the answer must BE the merge",
accepted.Hash, record.Merge.Checkin)
}
// And the ticket the landing closed says which merge closed it.
tReq, _ := http.NewRequest("GET", srv.URL+"/api/v1/r/alpha/ticket/"+tkt[:12], nil)
tReq.SetBasicAuth("dan", "hunter2")
tResp, terr := srv.Client().Do(tReq)
if terr != nil {
t.Fatal(terr)
}
tBody, _ := io.ReadAll(tResp.Body)
tResp.Body.Close()
if strings.Contains(string(tBody), "(accepted)") {
t.Errorf("the closing note names the verb where the merge belongs: %s", tBody)
}
if record.Merge.Checkin != "" && !strings.Contains(string(tBody), "("+record.Merge.Checkin[:12]+")") {
t.Errorf("the closing note does not name the merge %s: %s", record.Merge.Checkin[:12], tBody)
}
// The branch is on trunk now — the whole point of the ceremony.
merged := zout(t, store, "timeline", "-n", "5")
if !strings.Contains(merged, "second branch work") && !strings.Contains(merged, "feat2") {
t.Errorf("the accepted branch does not appear on trunk:\n%s", merged)
}
// And the listing agrees about where it ended up.
req, _ = http.NewRequest("GET", srv.URL+"/api/v1/r/alpha/landing?state=rejected", nil)
req.SetBasicAuth("dan", "hunter2")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(b), opened.ID) {
t.Errorf("the rejected record is not listed as rejected: %s", b)
}
}
// zout runs a zit verb and returns its output, for the assertions that have
// to ask the STORE what happened rather than trusting the forge's own report.
func zout(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("zit", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("zit %v: %v\n%s", args, err, out)
}
return string(out)
}