package api
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// defaultIn carries one of each shape the default= tag reaches, plus a
// pointer field, which must NOT be filled in: its nil is the "caller
// did not mention it" signal an upsert needs to tell "set this false"
// from silence.
type defaultIn struct {
Repo string `json:"repo"`
Listed bool `json:"listed" validate:"default=1"`
Limit int `json:"limit" validate:"default=50"`
Owner *bool `json:"owner"`
}
func defaultRouter(t *testing.T, seen *defaultIn) http.Handler {
t.Helper()
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[defaultIn, struct{}]{
Name: "dflt.post", Kind: ops.Command, Auth: ops.Anonymous,
Route: "POST /api/v1/dflt",
Doc: "test double: a defaulted bool, a defaulted int and a pointer",
Run: func(_ context.Context, in defaultIn) (struct{}, error) {
*seen = in
return struct{}{}, nil
},
}); err != nil {
t.Fatal(err)
}
h, err := RouterWithCaps(r, "test", func(_, _ string) CapSet { return oltp.CapSet("s") })
if err != nil {
t.Fatal(err)
}
return h
}
func postJSON(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", "/api/v1/dflt", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// A field the caller omits takes its declared default on the JSON path,
// as it already did on the query and form paths.
//
// applyDefault was reached only from DecodeValues, so a JSON body went
// straight into the struct and never met a validate tag. Descriptor
// publishes the default from that same tag regardless of transport, so
// the documentation said one thing and every JSON caller got another —
// found when team.member's `listed` (default=1) produced an UNLISTED
// member and answered ok (9e71cc15).
func TestAJSONBodyTakesADeclaredDefault(t *testing.T) {
var seen defaultIn
h := defaultRouter(t, &seen)
if w := postJSON(t, h, `{"repo":"demo"}`); w.Code != 200 {
t.Fatalf("FAIL: status %d: %s", w.Code, w.Body.String())
}
if !seen.Listed {
t.Errorf("FAIL: listed = false, want the declared default true")
}
if seen.Limit != 50 {
t.Errorf("FAIL: limit = %d, want the declared default 50", seen.Limit)
}
t.Logf("ok: omitted fields defaulted: listed=%v limit=%d", seen.Listed, seen.Limit)
}
// An explicitly sent zero is left alone. This is the half that makes
// the fix safe: absence is judged from the body's own keys, never from
// the decoded value, because a zero looks identical whether the caller
// sent it or not. Reading it off the struct would silently overwrite
// every caller who means false.
func TestAnExplicitZeroIsNotOverwrittenByItsDefault(t *testing.T) {
var seen defaultIn
h := defaultRouter(t, &seen)
if w := postJSON(t, h, `{"repo":"demo","listed":false,"limit":0}`); w.Code != 200 {
t.Fatalf("FAIL: status %d: %s", w.Code, w.Body.String())
}
if seen.Listed {
t.Errorf("FAIL: an explicit listed=false was overwritten by default=1")
}
if seen.Limit != 0 {
t.Errorf("FAIL: an explicit limit=0 became %d", seen.Limit)
}
t.Logf("ok: explicit zeros survived: listed=%v limit=%d", seen.Listed, seen.Limit)
}
// encoding/json matches field names case-insensitively, so {"Listed":
// false} sets the same field as {"listed": false}. A case-sensitive
// absence test would decide the caller had said nothing and put the
// default back over what they sent.
func TestAnExplicitZeroSurvivesAnyCasing(t *testing.T) {
var seen defaultIn
h := defaultRouter(t, &seen)
if w := postJSON(t, h, `{"repo":"demo","Listed":false,"LIMIT":0}`); w.Code != 200 {
t.Fatalf("FAIL: status %d: %s", w.Code, w.Body.String())
}
if seen.Listed || seen.Limit != 0 {
t.Fatalf("FAIL: differently-cased keys were read as absent: listed=%v limit=%d", seen.Listed, seen.Limit)
}
t.Log("ok: a differently-cased key still counts as sent")
}
// A pointer field is never filled in. Its nil IS the signal — the shape
// team.member adopted so an upsert could tell "set this false" from
// silence — and a default written over it would destroy exactly the
// distinction it was declared to carry.
func TestAPointerFieldIsLeftNil(t *testing.T) {
var seen defaultIn
h := defaultRouter(t, &seen)
if w := postJSON(t, h, `{"repo":"demo"}`); w.Code != 200 {
t.Fatalf("FAIL: status %d: %s", w.Code, w.Body.String())
}
if seen.Owner != nil {
t.Fatalf("FAIL: an unmentioned pointer field was filled in: %v", *seen.Owner)
}
t.Log("ok: an unmentioned pointer field stays nil")
}
// The query path keeps the behaviour it already had — this change adds
// a transport, it does not move the rule.
func TestTheQueryPathStillDefaults(t *testing.T) {
var seen defaultIn
h := defaultRouter(t, &seen)
req := httptest.NewRequest("POST", "/api/v1/dflt", bytes.NewReader([]byte("repo=demo")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("FAIL: status %d: %s", w.Code, w.Body.String())
}
if !seen.Listed || seen.Limit != 50 {
t.Fatalf("FAIL: the form path stopped defaulting: listed=%v limit=%d", seen.Listed, seen.Limit)
}
t.Log("ok: the form path is unchanged")
}