package api
import (
"context"
"craftdesign.group/quarry/internal/markup"
"errors"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// A forge numbers every ticket of a repository it owns, store and local
// together, oldest first; a ticket filed later takes the next number;
// "#2" opens the same ticket as its hex id; a mirror is never numbered
// (54c5175f option C).
func TestTicketsAreNumberedInCreationOrder(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
store := func(id, date, title string) index.Parsed {
return index.Parsed{Hash: id, Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: id, Date: date, User: "dan",
Fields: []artifact.Field{{Name: "title", Value: title}, {Name: "status", Value: "Open"}}}}
}
older := "aaaa000000000000000000000000000000000000000000000000000000000001"
newer := "bbbb000000000000000000000000000000000000000000000000000000000002"
if err := ix.FoldNow([]index.Parsed{store(newer, "2026-09-02T10:00:00", "second"), store(older, "2026-09-01T10:00:00", "first")}); err != nil {
t.Fatal(err)
}
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
mirrors := memMirrors{}
_ = mirrors.SetMirror("upstream", "https://upstream.example/x", 3600)
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"q": {Index: ix}, "upstream": {Index: ix}}),
Local: db, Idem: db, Numbers: db, Mirrors: mirrors}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
filed, err := r.Invoke(ctx, "ticket.intake", ticketIntakeIn{Repo: "q", Title: "third"})
if err != nil {
t.Fatal(err)
}
third := filed.(ticketIntakeOut)
if third.Number != 3 {
t.Fatalf("FAIL: the new ticket is #%d, want 3 after the two older store tickets", third.Number)
}
list, err := r.Invoke(ctx, "ticket.list", ticketListIn{Repo: "q", State: "all"})
if err != nil {
t.Fatal(err)
}
by := map[string]int{}
for _, row := range list.(ticketListOut).Tickets {
by[row.ID] = row.Number
}
if by[older] != 1 || by[newer] != 2 || by[third.ID] != 3 {
t.Fatalf("FAIL: list numbers %v", by)
}
got, err := r.Invoke(ctx, "ticket.get", ticketGetIn{Repo: "q", ID: "2"})
if err != nil || got.(ticketGetOut).Ticket.ID != newer || got.(ticketGetOut).Number != 2 {
t.Fatalf("FAIL: #2 opened %+v (%v)", got, err)
}
if loc, err := r.Invoke(ctx, "ticket.get", ticketGetIn{Repo: "q", ID: "3"}); err != nil || loc.(ticketGetOut).Number != 3 {
t.Fatalf("FAIL: #3 (a local ticket) did not open as itself: %v", err)
}
up, err := r.Invoke(ctx, "ticket.list", ticketListIn{Repo: "upstream", State: "all"})
if err != nil {
t.Fatal(err)
}
for _, row := range up.(ticketListOut).Tickets {
if row.Number != 0 {
t.Fatalf("FAIL: a mirror's ticket was numbered #%d", row.Number)
}
}
t.Log("ok: store tickets #1 #2 by creation, the new local ticket #3, #2 and #3 open by number, the mirror unnumbered")
}
// A number two forges both recorded names both tickets: ticket.named
// lists them, this forge's own first, and ticket.get refuses to pick.
// A number only one ticket carries opens it (54c5175f).
func TestASharedNumberOffersAChoice(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
tkt := func(id, date, title string, extra ...artifact.Field) index.Parsed {
f := append([]artifact.Field{{Name: "title", Value: title}, {Name: "status", Value: "Open"}}, extra...)
return index.Parsed{Hash: id, Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: id, Date: date, User: "dan", Fields: f}}
}
a := "aaaa000000000000000000000000000000000000000000000000000000000001"
b := "bbbb000000000000000000000000000000000000000000000000000000000002"
c := "cccc000000000000000000000000000000000000000000000000000000000003"
if err := ix.FoldNow([]index.Parsed{
tkt(a, "2026-09-01T10:00:00", "ours, first"),
tkt(b, "2026-09-02T10:00:00", "ours, second"),
}); err != nil {
t.Fatal(err)
}
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"q": {Index: ix}}), Numbers: db}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
// This forge numbers its own two first; then a ticket numbered 1 on
// another forge is pushed here, after #1 is already taken.
if _, err := r.Invoke(ctx, "ticket.list", ticketListIn{Repo: "q", State: "all"}); err != nil {
t.Fatal(err)
}
if err := ix.FoldNow([]index.Parsed{tkt(c, "2026-09-03T10:00:00", "numbered 1 by another forge", artifact.Field{Name: "number", Value: "1"})}); err != nil {
t.Fatal(err)
}
named := func(n int) []string {
out, err := r.Invoke(ctx, "ticket.named", ticketNamedIn{Repo: "q", Number: n})
if err != nil {
t.Fatal(err)
}
var ids []string
for _, tk := range out.(ticketNamedOut).Tickets {
ids = append(ids, tk.ID)
}
return ids
}
if got := named(1); len(got) != 2 || got[0] != a || got[1] != c {
t.Fatalf("FAIL: #1 names %v, want ours then the other forge's", got)
}
if _, err := r.Invoke(ctx, "ticket.get", ticketGetIn{Repo: "q", ID: "1"}); !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("FAIL: ticket.get #1 answered %v, want a refusal naming both", err)
}
if got := named(2); len(got) != 1 || got[0] != b {
t.Fatalf("FAIL: #2 names %v", got)
}
if out, err := r.Invoke(ctx, "ticket.get", ticketGetIn{Repo: "q", ID: "2"}); err != nil || out.(ticketGetOut).Ticket.ID != b {
t.Fatalf("FAIL: #2 did not open ours (%v)", err)
}
t.Log("ok: #1 names ours and the other forge's, get refuses to pick; #2 opens ours")
}
// numberedIndex holds four store tickets, oldest first: two recorded by
// another forge as #3 and #8, two with no number at all.
func numberedIndex(t *testing.T) (*index.Index, []string) {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
ids := []string{
"a100000000000000000000000000000000000000000000000000000000000001",
"b200000000000000000000000000000000000000000000000000000000000002",
"c300000000000000000000000000000000000000000000000000000000000003",
"d400000000000000000000000000000000000000000000000000000000000004",
}
tk := func(id, date string, extra ...artifact.Field) index.Parsed {
f := append([]artifact.Field{{Name: "title", Value: "t " + id[:4]}, {Name: "status", Value: "Open"}}, extra...)
return index.Parsed{Hash: id, Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: id, Date: date, User: "dan", Fields: f}}
}
if err := ix.FoldNow([]index.Parsed{
tk(ids[0], "2026-09-01T10:00:00"),
tk(ids[1], "2026-09-02T10:00:00", artifact.Field{Name: "number", Value: "3"}),
tk(ids[2], "2026-09-03T10:00:00"),
tk(ids[3], "2026-09-04T10:00:00", artifact.Field{Name: "number", Value: "8"}),
}); err != nil {
t.Fatal(err)
}
return ix, ids
}
// A mirror numbers nothing but shows what its upstream recorded, and
// #N opens it; a ticket the upstream never numbered stays unnumbered.
func TestAMirrorShowsItsUpstreamsNumbers(t *testing.T) {
ix, ids := numberedIndex(t)
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
mirrors := memMirrors{}
_ = mirrors.SetMirror("m", "https://upstream.example/m", 3600)
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"m": {Index: ix}}), Numbers: db, Mirrors: mirrors}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
list, err := r.Invoke(ctx, "ticket.list", ticketListIn{Repo: "m", State: "all"})
if err != nil {
t.Fatal(err)
}
by := map[string]int{}
for _, row := range list.(ticketListOut).Tickets {
by[row.ID] = row.Number
}
if by[ids[1]] != 3 || by[ids[3]] != 8 || by[ids[0]] != 0 || by[ids[2]] != 0 {
t.Fatalf("FAIL: mirror numbers %v, want the recorded 3 and 8 only", by)
}
if got, err := r.Invoke(ctx, "ticket.get", ticketGetIn{Repo: "m", ID: "8"}); err != nil || got.(ticketGetOut).Ticket.ID != ids[3] {
t.Fatalf("FAIL: #8 on the mirror did not open the recorded ticket (%v)", err)
}
if own, _ := db.TicketNumbers("m"); len(own) != 0 {
t.Fatalf("FAIL: the mirror assigned numbers of its own: %v", own)
}
out, _ := markup.Render([]byte("see #3"), resolver{ix: ix, repo: "m"})
if !strings.Contains(string(out), "/r/m/ticket/"+ids[1]) {
t.Fatalf("FAIL: #3 in prose does not link the recorded ticket:\n%s", out)
}
t.Log("ok: the mirror shows #3 and #8 as recorded, opens #8, links #3, numbers nothing itself")
}
// A fork keeps the numbers its upstream recorded and numbers the rest
// after the highest, oldest first — never renumbering from 1.
func TestAForkKeepsRecordedNumbers(t *testing.T) {
ix, ids := numberedIndex(t)
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"f": {Index: ix}}), Numbers: db}
if err := RegisterAll(ops.NewRegistry(), d); err != nil {
t.Fatal(err)
}
got := d.ticketNumbers("f", &Repo{Index: ix})
want := map[string]int{ids[1]: 3, ids[3]: 8, ids[0]: 9, ids[2]: 10}
for id, n := range want {
if got[id] != n {
t.Fatalf("FAIL: fork numbers %v, want %v", got, want)
}
}
t.Log("ok: recorded #3 and #8 kept; the unnumbered two became #9 and #10, oldest first")
}
// After the first numbering pass, a ticket that arrives by push takes
// the next number whatever it carries — a recorded number cannot jump
// the sequence — and no caller can set a number through the API.
func TestAPushedTicketTakesTheNextNumber(t *testing.T) {
ix, ids := numberedIndex(t)
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"f": {Index: ix}}), Numbers: db}
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
repo := &Repo{Index: ix}
d.ticketNumbers("f", repo) // the first pass: #3, #8, #9, #10
pushed := "e500000000000000000000000000000000000000000000000000000000000005"
plain := "f600000000000000000000000000000000000000000000000000000000000006"
if err := ix.FoldNow([]index.Parsed{
{Hash: pushed, Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: pushed, Date: "2026-09-05T10:00:00", User: "mallory",
Fields: []artifact.Field{{Name: "title", Value: "squats 500"}, {Name: "status", Value: "Open"}, {Name: "number", Value: "500"}}}},
{Hash: plain, Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: plain, Date: "2026-09-06T10:00:00", User: "dan",
Fields: []artifact.Field{{Name: "title", Value: "filed offline"}, {Name: "status", Value: "Open"}}}},
}); err != nil {
t.Fatal(err)
}
got := d.ticketNumbers("f", repo)
if got[pushed] != 11 || got[plain] != 12 || got[ids[3]] != 8 {
t.Fatalf("FAIL: after the push %v, want the squatter at #11 and the offline ticket at #12", got)
}
_, err = r.Invoke(ops.WithUser(context.Background(), "dan"), "ticket.set",
ticketSetIn{Repo: "f", ID: ids[0], Fields: map[string]string{"number": "7"}})
if !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("FAIL: setting a number through the API answered %v", err)
}
t.Log("ok: a pushed number=500 is ignored (#11), an offline ticket is #12, and number cannot be set through ticket.set")
}