Skip to content
quarry Artifact [deb3060fe3]
Login

Artifact [deb3060fe3]

Artifact deb3060fe3431a65588eaaabe72a646bf8bd695494d62dfba901a10cce958e88:


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