package api
import (
"fmt"
"path/filepath"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
func landingNumberDB(t *testing.T) *oltp.DB {
t.Helper()
db, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
// The backfill numbers landings in the order they were opened, not the
// order a listing happens to hold them in, and a landing first seen
// later takes the next number whatever its date.
func TestLandingNumbersBackfillInOpenedOrder(t *testing.T) {
d := Deps{LandingNumbers: landingNumberDB(t)}
got := d.landingNumbers("q", nil, []index.LandingSummary{
{ID: "cc", Opened: "2026-03-01 10:00:00"},
{ID: "aa", Opened: "2026-01-01T10:00:00"},
{ID: "bb", Opened: "2026-02-01 10:00:00"},
})
if got["aa"] != 1 || got["bb"] != 2 || got["cc"] != 3 {
t.Fatalf("FAIL: backfill is not in opened order: %v", got)
}
got = d.landingNumbers("q", nil, []index.LandingSummary{
{ID: "cc", Opened: "2026-03-01 10:00:00"},
{ID: "old", Opened: "2025-01-01 10:00:00"},
})
if got["old"] != 4 || got["cc"] != 3 {
t.Fatalf("FAIL: a landing seen later did not take the next number: %v", got)
}
t.Log("ok: aa bb cc are 1 2 3 by opened date; one pushed later is 4")
}
// Many readers at once on a fresh forge number each landing once.
func TestLandingNumbersConcurrentReadersAgree(t *testing.T) {
d := Deps{LandingNumbers: landingNumberDB(t)}
var all []index.LandingSummary
for i := 0; i < 30; i++ {
all = append(all, index.LandingSummary{ID: fmt.Sprintf("l%02d", i), Opened: fmt.Sprintf("2026-01-01 10:00:%02d", i)})
}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
d.landingNumbers("q", nil, all)
}()
}
wg.Wait()
got := d.landingNumbers("q", nil, all)
for i, l := range all {
if got[l.ID] != i+1 {
t.Fatalf("FAIL: %s is !%d, want !%d: %v", l.ID, got[l.ID], i+1, got)
}
}
t.Log("ok: 30 landings, 16 readers at once, numbered 1..30 in opened order")
}
// landing.list, landing.get and landing.queue carry the number, and a
// landing can be fetched by it, spelled !N or N.
func TestLandingsCarryNumbersAndFetchByNumber(t *testing.T) {
dir, ids, ix, pipeSync := premergeStore(t)
r := ops.NewRegistry()
deps := Deps{Version: "v", LandingNumbers: landingNumberDB(t),
Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: zitcli.New(dir), Sync: pipeSync}})}
if err := RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(withI(), "dan")
out, err := r.Invoke(ctx, "landing.list", landingListIn{Repo: "test"})
if err != nil {
t.Fatal(err)
}
byID := map[string]int{}
for _, l := range out.(landingListOut).Landings {
byID[l.ID] = l.Number
}
if len(byID) != 2 || byID[ids[0]] == byID[ids[1]] || byID[ids[0]]+byID[ids[1]] != 3 {
t.Fatalf("FAIL: landing.list numbers %v, want 1 and 2", byID)
}
for _, spelled := range []string{"!%d", "%d"} {
for _, id := range ids {
ref := fmt.Sprintf(spelled, byID[id])
got, err := r.Invoke(ctx, "landing.get", landingGetIn{Repo: "test", ID: ref})
if err != nil {
t.Fatalf("FAIL: landing.get %q: %v", ref, err)
}
if l := got.(landingGetOut).Landing; l.ID != id || l.Number != byID[id] {
t.Fatalf("FAIL: landing.get %q is %.12s !%d, want %.12s !%d", ref, l.ID, l.Number, id, byID[id])
}
}
}
if _, err := r.Invoke(ctx, "landing.get", landingGetIn{Repo: "test", ID: "!9"}); err == nil {
t.Fatal("FAIL: landing.get !9 found a landing that has no such number")
}
q, err := r.Invoke(ctx, "landing.queue", landingQueueIn{})
if err != nil {
t.Fatal(err)
}
for _, l := range q.(landingQueueOut).Landings {
if l.Number != byID[l.ID] || l.Number == 0 {
t.Fatalf("FAIL: landing.queue carries !%d for %.12s, want !%d", l.Number, l.ID, byID[l.ID])
}
}
res, err := r.Invoke(ctx, "ref.resolve", refResolveIn{Repo: "test", Ref: fmt.Sprintf("!%d", byID[ids[1]])})
if err != nil {
t.Fatalf("FAIL: ref.resolve !N: %v", err)
}
if card := res.(refResolveOut); card.Kind != "landing" || card.ID != ids[1] || card.Number != byID[ids[1]] || card.State != "open" || card.Title == "" {
t.Fatalf("FAIL: the !N card is %+v", card)
}
t.Log("ok: list, get, queue and the ref card carry the number; get and ref resolve !N and N; !9 is not found")
}
// A landing's notifications carry its number, opened and settled alike.
func TestLandingNotificationsCarryTheNumber(t *testing.T) {
db := landingNumberDB(t)
if err := db.LandingNumbersAssign("alpha", []string{"other", landingHash}); err != nil {
t.Fatal(err)
}
fn := &fakeNotify{}
d := Deps{Notify: fn, LandingNumbers: db, Protect: stubProtect{rules: []oltp.Protection{
{Branch: "trunk", Owners: []string{"alice"}},
}}}
d.notifyOpened("alpha", landingHash, "feature-x", "trunk", "bob")
l := index.LandingSummary{ID: landingHash, Branch: "feature-x", Target: "trunk", Author: "bob"}
d.notifySettled(ops.WithUser(t.Context(), "carol"), "alpha", l, "accept")
evs := waitNotify(fn, 3)
if len(evs) != 3 {
t.Fatalf("FAIL: %d notifications, want 3", len(evs))
}
for _, e := range evs {
if e.Payload["number"] != 2 {
t.Fatalf("FAIL: %s to %s carries number %v, want 2", e.Kind, e.To, e.Payload["number"])
}
// Words in a sentence, never the bang: a subject or a push has no icon to draw.
if sum, _ := e.Payload["summary"].(string); !strings.Contains(sum, "landing 2 ") || strings.Contains(sum, "!") {
t.Fatalf("FAIL: summary %q does not name the landing in words", sum)
}
if st, _ := e.Payload["state"].(string); st == "" {
t.Fatalf("FAIL: %s carries no state for the mail's icon", e.Kind)
}
}
t.Log("ok: opened and accepted notifications carry number 2")
}