Skip to content
quarry Artifact [d0efe9dc23]
Login

Artifact [d0efe9dc23]

Artifact d0efe9dc23d07246f30bef6af299cddf3ef2b3291511835508e04fb745c5e6e3:


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