package harness
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ops"
)
// A store ticket carries its number in its own history (54c5175f): one
// filed through the forge has it from its first artifact; ones that
// predate numbering are backfilled by an admin, who first sees what
// would be written; a second run finds nothing to do.
func TestAStoreTicketCarriesItsNumber(t *testing.T) {
store := buildStore(t, "num")
zrun(t, store, "ticket", "new", "--user", "dan", "title=older one", "status=Open")
zrun(t, store, "ticket", "new", "--user", "dan", "title=newer one", "status=Open")
srv := ingestAndServeAuth(t, map[string]string{"alpha": store}, t.TempDir(),
func(repo, user, pass string, need ops.Auth) api.Decision { return api.Allowed })
call := func(method, path, body string) map[string]any {
t.Helper()
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth("dan", "pw")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s %s: %d %s", method, path, resp.StatusCode, b)
}
var out map[string]any
json.Unmarshal(b, &out)
return out
}
dry := call("POST", "/api/v1/r/alpha/ticket/numbers/record", `{}`)
have := dry["missing"].(float64)
if have < 2 || dry["written"].(float64) != 0 {
t.Fatalf("FAIL: a dry run answered %v", dry)
}
wrote := call("POST", "/api/v1/r/alpha/ticket/numbers/record", `{"write":true}`)
if wrote["written"].(float64) != have {
t.Fatalf("FAIL: the backfill answered %v", wrote)
}
if again := call("POST", "/api/v1/r/alpha/ticket/numbers/record", `{}`); again["missing"].(float64) != 0 {
t.Fatalf("FAIL: after the backfill %v are still missing", again["missing"])
}
filed := call("POST", "/api/v1/r/alpha/ticket", `{"title":"filed after numbering"}`)
next := int(have) + 1
if int(filed["number"].(float64)) != next {
t.Fatalf("FAIL: the new ticket is %v, want #%d", filed["number"], next)
}
got := call("GET", "/api/v1/r/alpha/ticket/"+strconv.Itoa(next), "")
fields := map[string]string{}
for _, f := range got["ticket"].(map[string]any)["fields"].([]any) {
m := f.(map[string]any)
fields[m["name"].(string)] = m["value"].(string)
}
if fields["number"] != strconv.Itoa(next) || fields["title"] != "filed after numbering" {
t.Fatalf("FAIL: #%d opened %v", next, fields)
}
t.Logf("ok: dry run %v missing, backfill wrote them, rerun 0; the new ticket is #%d from its first artifact and opens by number", have, next)
}
// A ticket that arrives by push after numbering began gets the next
// number, and the forge writes it into the ticket on its own, so the
// clone it came from learns the name on its next pull (54c5175f).
func TestAPushedTicketRecordsItsNumber(t *testing.T) {
store := buildStore(t, "pushed")
srv := ingestAndServeAuth(t, map[string]string{"alpha": store}, t.TempDir(),
func(repo, user, pass string, need ops.Auth) api.Decision { return api.Allowed })
call := func(method, path, body string) map[string]any {
t.Helper()
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth("dan", "pw")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s %s: %d %s", method, path, resp.StatusCode, b)
}
var out map[string]any
json.Unmarshal(b, &out)
return out
}
list := call("GET", "/api/v1/r/alpha/ticket?state=all", "") // the first pass
before := len(list["tickets"].([]any))
// As if pushed from a clone: written straight into the store.
zrun(t, store, "ticket", "new", "--user", "eve", "title=filed on a clone", "status=Open")
// Any write re-reads the store; filing one more through the API is
// the ordinary way the pushed ticket is first seen.
call("POST", "/api/v1/r/alpha/ticket", `{"title":"filed on the forge"}`)
list = call("GET", "/api/v1/r/alpha/ticket?state=all", "")
var id string
var n int
for _, row := range list["tickets"].([]any) {
m := row.(map[string]any)
if m["title"] == "filed on a clone" {
id, n = m["id"].(string), int(m["number"].(float64))
}
}
// The forge-filed ticket took #before+1 as it was written; the
// pushed one is seen by the re-read that write made, so it is next.
if id == "" || n != before+2 {
t.Fatalf("FAIL: the pushed ticket is #%d (%q), want #%d", n, id, before+2)
}
deadline := time.Now().Add(10 * time.Second)
for {
got := call("GET", "/api/v1/r/alpha/ticket/"+id, "")
for _, f := range got["ticket"].(map[string]any)["fields"].([]any) {
m := f.(map[string]any)
if m["name"] == "number" && m["value"] == strconv.Itoa(n) {
t.Logf("ok: the pushed ticket became #%d and the forge wrote number=%d into it", n, n)
return
}
}
if time.Now().After(deadline) {
t.Fatalf("FAIL: #%d was never written into the pushed ticket", n)
}
time.Sleep(200 * time.Millisecond)
}
}