Skip to content
quarry Artifact [ba6f20b511]
Login

Artifact [ba6f20b511]

Artifact ba6f20b51159abaf53c3ad22d6da4e75ebcdf5d689baccfa9049751143bb6963:


package api

import (
	"context"
	"path/filepath"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/evt"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// stubProtect is RFC 0007's policy with the rows written by hand: the
// api package must not need an OLTP to prove who a settled landing is
// addressed to.
type stubProtect struct{ rules []oltp.Protection }

func (s stubProtect) Protections(string) ([]oltp.Protection, error) { return s.rules, nil }

func (s stubProtect) MayJoin(repo, user, branch string) bool {
	for _, p := range s.rules {
		if p.Branch != branch {
			continue
		}
		for _, o := range p.Owners {
			if o == user {
				return true
			}
		}
		return false
	}
	return true
}

// landingIndex folds one landing record, optionally settled, so a test
// can drive landingSay against a real index without a store.
func landingIndex(t *testing.T, opener, branch, target, settled string) *index.Index {
	t.Helper()
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	batch := []index.Parsed{{Hash: landingHash, Art: &artifact.Artifact{
		Type: artifact.Forum, ThreadTitle: "land it", Date: "2026-01-01T10:00:00",
		User: opener,
		Body: []byte("zit-landing: 1\nbranch: " + branch + "\ntarget: " + target + "\n\nready\n"),
	}}}
	if settled != "" {
		batch = append(batch, index.Parsed{Hash: "bbbb2222bbbb2222bbbb2222bbbb2222",
			Art: &artifact.Artifact{Type: artifact.Forum, ThreadRoot: landingHash,
				Date: "2026-01-01T11:00:00", User: "carol",
				Body: []byte("zit-landing-state: " + settled + "\n\ndone\n")}})
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	return ix
}

const landingHash = "aaaa1111aaaa1111aaaa1111aaaa1111"

// waitNotify collects the fired events, giving fireNotify's goroutines
// time to run. It waits the full window when want is 0, so "fires
// nothing" is proved by absence rather than by racing the goroutine.
func waitNotify(fn *fakeNotify, want int) []NotifyEvent {
	var evs []NotifyEvent
	for i := 0; i < 50; i++ {
		if evs = fn.snapshot(); want > 0 && len(evs) >= want {
			return evs
		}
		time.Sleep(20 * time.Millisecond)
	}
	return fn.snapshot()
}

// A settled landing is addressed to the person who opened it and to the
// owners of the branch it settled onto — once each, never the hand that
// settled it.
func TestSettledLandingNotifiesOpenerAndOwners(t *testing.T) {
	fn := &fakeNotify{}
	d := Deps{Notify: fn, Protect: stubProtect{rules: []oltp.Protection{
		{Branch: "trunk", Owners: []string{"alice", "carol", "bob"}},
		{Branch: "release/*", Owners: []string{"eve"}},
	}}}
	l := index.LandingSummary{ID: landingHash, Branch: "feature-x", Target: "trunk", Author: "bob"}
	d.notifySettled(ops.WithUser(context.Background(), "carol"), "alpha", l, "accept")

	evs := waitNotify(fn, 2)
	got := map[string]NotifyEvent{}
	for _, e := range evs {
		if e.Workflow != "quarry-landing" {
			t.Fatalf("workflow = %q, want quarry-landing", e.Workflow)
		}
		if _, dup := got[e.To]; dup {
			t.Fatalf("%s was told twice", e.To)
		}
		got[e.To] = e
	}
	if len(got) != 2 || got["bob"].To == "" || got["alice"].To == "" {
		t.Fatalf("recipients = %v, want the opener bob and the owner alice, never the actor carol", got)
	}
	e := got["alice"]
	if e.Kind != evt.LandingAccepted {
		t.Fatalf("kind = %q, want %q", e.Kind, evt.LandingAccepted)
	}
	if want := "landing:accept:" + landingHash + ":alice"; e.TxnID != want {
		t.Fatalf("TxnID = %q, want the address-based %q", e.TxnID, want)
	}
	if e.Payload["repo"] != "alpha" || e.Payload["from"] != "carol" {
		t.Fatalf("payload repo/from = %v/%v", e.Payload["repo"], e.Payload["from"])
	}
	if want := "/r/alpha/landing/" + landingHash; e.Payload["link"] != want {
		t.Fatalf("link = %v, want %q", e.Payload["link"], want)
	}
	if s, _ := e.Payload["summary"].(string); s == "" {
		t.Fatal("summary is empty")
	}
	// The same landing rejected is a different event, and its key says so.
	fn2 := &fakeNotify{}
	d.Notify = fn2
	d.notifySettled(ops.WithUser(context.Background(), "carol"), "alpha", l, "reject")
	rej := waitNotify(fn2, 2)
	for _, e := range rej {
		if e.Kind != evt.LandingRejected {
			t.Fatalf("reject kind = %q", e.Kind)
		}
		if e.TxnID != "landing:reject:"+landingHash+":"+e.To {
			t.Fatalf("reject TxnID = %q", e.TxnID)
		}
	}
}

// A repository with no protection rule has no owners. That is not a
// failure: the opener is still told.
func TestSettledLandingWithoutProtectionTellsOnlyTheOpener(t *testing.T) {
	fn := &fakeNotify{}
	d := Deps{Notify: fn}
	l := index.LandingSummary{ID: landingHash, Branch: "feature-x", Target: "trunk", Author: "bob"}
	d.notifySettled(ops.WithUser(context.Background(), "carol"), "alpha", l, "accept")

	evs := waitNotify(fn, 1)
	if len(evs) != 1 || evs[0].To != "bob" {
		t.Fatalf("events = %+v, want exactly one to the opener", evs)
	}
}

// A remark settles nothing, so it is addressed to nobody.
func TestCommentOnALandingNotifiesNobody(t *testing.T) {
	fn := &fakeNotify{}
	d := Deps{Notify: fn, Protect: stubProtect{rules: []oltp.Protection{
		{Branch: "trunk", Owners: []string{"alice"}}}}}
	l := index.LandingSummary{ID: landingHash, Branch: "feature-x", Target: "trunk", Author: "bob"}
	d.notifySettled(ops.WithUser(context.Background(), "carol"), "alpha", l, "comment")

	if evs := waitNotify(fn, 0); len(evs) != 0 {
		t.Fatalf("a comment fired %+v", evs)
	}
}

// A second settle of a settled record writes nothing — the record is
// already in an end state — so it must tell nobody again. Without this
// the four stacked replies zit#131 left behind would have been four
// pings apiece.
func TestSecondSettleOfASettledLandingNotifiesNobody(t *testing.T) {
	fn := &fakeNotify{}
	ix := landingIndex(t, "bob", "feature-x", "trunk", "rejected")
	d := Deps{Notify: fn, Repos: NewRepos(map[string]*Repo{"alpha": {Index: ix}}),
		Protect: stubProtect{rules: []oltp.Protection{
			{Branch: "trunk", Owners: []string{"alice", "carol"}}}}}

	ctx := ops.WithUser(context.Background(), "carol")
	out, err := d.landingSay(ctx, landingSayIn{Repo: "alpha", ID: landingHash}, "accept")
	if err != nil {
		t.Fatal(err)
	}
	if out.State != "rejected" || out.Hash != "" {
		t.Fatalf("out = %+v, want the settled state and no write", out)
	}
	if evs := waitNotify(fn, 0); len(evs) != 0 {
		t.Fatalf("a no-op settle fired %+v", evs)
	}
}

// A landing opening onto a protected branch is waiting on that branch's
// owners, and they hear immediately on the landing family — never the
// person who opened it. On an unprotected branch it waits on the
// repository's owner, who hears the same way.
func TestOpenedLandingNotifiesTheTargetsOwnersNow(t *testing.T) {
	fn := &fakeNotify{}
	d := Deps{Notify: fn, Protect: stubProtect{rules: []oltp.Protection{
		{Branch: "trunk", Owners: []string{"alice", "bob", "carol"}},
	}}}
	d.notifyOpened("alpha", landingHash, "feature-x", "trunk", "bob")
	evs := waitNotify(fn, 2)
	got := map[string]NotifyEvent{}
	for _, e := range evs {
		if e.Workflow != "quarry-landing" {
			t.Fatalf("FAIL: workflow = %q, want quarry-landing", e.Workflow)
		}
		if _, dup := got[e.To]; dup {
			t.Fatalf("FAIL: %s was told twice", e.To)
		}
		got[e.To] = e
	}
	if len(got) != 2 || got["alice"].To == "" || got["carol"].To == "" {
		t.Fatalf("FAIL: recipients = %v, want the owners alice and carol, never the opener bob", got)
	}
	e := got["alice"]
	if e.Kind != evt.LandingOpened {
		t.Errorf("FAIL: kind = %q, want %q", e.Kind, evt.LandingOpened)
	}
	if want := "landing:open:" + landingHash + ":alice"; e.TxnID != want {
		t.Errorf("FAIL: TxnID = %q, want %q", e.TxnID, want)
	}
	if want := "/r/alpha/landing/" + landingHash; e.Payload["link"] != want || e.Payload["from"] != "bob" {
		t.Errorf("FAIL: payload link/from = %v/%v", e.Payload["link"], e.Payload["from"])
	}
	// Unprotected target with no repository owner: nobody is waiting.
	fn2 := &fakeNotify{}
	d.Notify = fn2
	d.notifyOpened("alpha", landingHash, "feature-x", "scratch", "bob")
	if evs := waitNotify(fn2, 0); len(evs) != 0 {
		t.Fatalf("FAIL: an unprotected target with no owner pinged %+v", evs)
	}
	// Unprotected target on an owned repository: the owner hears.
	fn3 := &fakeNotify{}
	d.Notify = fn3
	d.Owners = oneOwner("dan")
	d.notifyOpened("alpha", landingHash, "feature-x", "scratch", "bob")
	if evs := waitNotify(fn3, 1); len(evs) != 1 || evs[0].To != "dan" || evs[0].Workflow != "quarry-landing" {
		t.Fatalf("FAIL: an unprotected target on dan's repository told %+v, want dan alone", evs)
	}
	// The owner opening their own landing is not told about it.
	fn4 := &fakeNotify{}
	d.Notify = fn4
	d.notifyOpened("alpha", landingHash, "feature-x", "scratch", "dan")
	if evs := waitNotify(fn4, 0); len(evs) != 0 {
		t.Fatalf("FAIL: the owner was told about their own landing: %+v", evs)
	}
	t.Log("ok: protected owners hear at once, else the repository owner, never the opener")
}

// oneOwner answers one owner for every repository.
type oneOwner string

func (o oneOwner) OwnerOf(string) (string, error)                       { return string(o), nil }
func (o oneOwner) SetOwner(string, string) error                        { return nil }
func (o oneOwner) NamespaceOf(string) (string, error)                   { return "", nil }
func (o oneOwner) SetNamespace(string, string, string, time.Time) error { return nil }