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