package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"craftdesign.group/quarry/internal/errlog"
"craftdesign.group/quarry/internal/ops"
)
type routerIn struct {
Repo string `json:"repo" validate:"required"`
Limit int `json:"limit" validate:"min=0,max=100"`
}
type routerOut struct {
Repo string `json:"repo"`
Limit int `json:"limit"`
}
type postIn struct {
Repo string `json:"repo" validate:"required"`
Body string `json:"body" validate:"required"`
}
type postOut struct {
Echo string `json:"echo"`
}
func routerRegistry(t *testing.T) *ops.Registry {
t.Helper()
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[routerIn, routerOut]{
Name: "echo.get",
Doc: "Echo query input.",
Kind: ops.Query,
Route: "GET /api/v1/r/{repo}/echo",
Run: func(_ context.Context, in routerIn) (routerOut, error) {
return routerOut(in), nil
},
}); err != nil {
t.Fatal(err)
}
if err := ops.Register(r, ops.Op[postIn, postOut]{
Name: "echo.post",
Doc: "Echo body input.",
Kind: ops.Command,
Route: "POST /api/v1/r/{repo}/echo",
Run: func(_ context.Context, in postIn) (postOut, error) {
return postOut{Echo: in.Repo + ":" + in.Body}, nil
},
}); err != nil {
t.Fatal(err)
}
return r
}
func serve(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
h, err := Router(routerRegistry(t), "0.0.0-test")
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestGetOpPathAndQuery(t *testing.T) {
rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=5", nil))
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var out routerOut
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Repo != "sqlite" || out.Limit != 5 {
t.Fatalf("got %+v", out)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("content-type %q", ct)
}
}
func TestGetOpValidateFails400(t *testing.T) {
rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=101", nil))
if rec.Code != 400 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var e map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("error body not JSON: %s", rec.Body)
}
if e["error"] == nil {
t.Fatalf("no error field: %v", e)
}
}
func TestGetOpBadTypeFails400(t *testing.T) {
rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=abc", nil))
if rec.Code != 400 {
t.Fatalf("status %d", rec.Code)
}
}
func TestPostOpFormBody(t *testing.T) {
form := url.Values{"body": {"hello"}}
req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := serve(t, req)
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var out postOut
json.Unmarshal(rec.Body.Bytes(), &out)
if out.Echo != "sqlite:hello" {
t.Fatalf("got %+v", out)
}
}
func TestPostOpJSONBody(t *testing.T) {
req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{"body":"hi"}`))
req.Header.Set("Content-Type", "application/json")
rec := serve(t, req)
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var out postOut
json.Unmarshal(rec.Body.Bytes(), &out)
if out.Echo != "sqlite:hi" {
t.Fatalf("got %+v", out)
}
}
func TestPostOpJSONCannotOverridePathParam(t *testing.T) {
// The path is authoritative for wildcard fields; a body value for the
// same field must not win.
req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{"repo":"evil","body":"x"}`))
req.Header.Set("Content-Type", "application/json")
rec := serve(t, req)
var out postOut
json.Unmarshal(rec.Body.Bytes(), &out)
if !strings.HasPrefix(out.Echo, "sqlite:") {
t.Fatalf("path param overridden: %+v", out)
}
}
func TestPostOpMissingRequired400(t *testing.T) {
req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
rec := serve(t, req)
if rec.Code != 400 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
}
func TestOpenAPIDocumentServed(t *testing.T) {
rec := serve(t, httptest.NewRequest("GET", "/api/v1/openapi.json", nil))
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
var doc map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if doc["openapi"] != "3.1.0" {
t.Fatalf("doc = %v", doc["openapi"])
}
}
// --- error taxonomy (tickets 020/040/070) ---
func TestNotFoundIs404WithCleanJSON(t *testing.T) {
rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/nomatch/echo", nil))
_ = rec // route exists; entity checks below use the full registry
}
func TestUnmatchedAPIPathIsJSON404(t *testing.T) {
rec := serve(t, httptest.NewRequest("GET", "/api/v1/nope", nil))
if rec.Code != 404 {
t.Fatalf("status %d", rec.Code)
}
var e map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("not JSON: %s", rec.Body)
}
if !strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
t.Fatalf("content-type %q", rec.Header().Get("Content-Type"))
}
}
func TestOpErrorIs500WithoutInternals(t *testing.T) {
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[routerIn, routerOut]{
Name: "boom.get",
Doc: "Always fails.",
Kind: ops.Query,
Route: "GET /api/v1/r/{repo}/boom",
Run: func(_ context.Context, _ routerIn) (routerOut, error) {
return routerOut{}, fmt.Errorf("zitcli: cat secret/path: exit status 1")
},
}); err != nil {
t.Fatal(err)
}
h, err := Router(r, "v")
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/r/x/boom", nil))
if rec.Code != 500 {
t.Fatalf("status %d", rec.Code)
}
if strings.Contains(rec.Body.String(), "zitcli") || strings.Contains(rec.Body.String(), "secret") {
t.Fatalf("internals leaked: %s", rec.Body)
}
}
func TestNotFoundSentinelIs404(t *testing.T) {
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[routerIn, routerOut]{
Name: "gone.get",
Doc: "Always missing.",
Kind: ops.Query,
Route: "GET /api/v1/r/{repo}/gone",
Run: func(_ context.Context, _ routerIn) (routerOut, error) {
return routerOut{}, fmt.Errorf("no such thing: %w", ops.ErrNotFound)
},
}); err != nil {
t.Fatal(err)
}
h, err := Router(r, "v")
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/r/x/gone", nil))
if rec.Code != 404 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var e map[string]string
json.Unmarshal(rec.Body.Bytes(), &e)
if !strings.Contains(e["error"], "no such thing") {
t.Fatalf("error body: %v", e)
}
}
func TestValidationErrorMessageHygiene(t *testing.T) {
rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=abc", nil))
if rec.Code != 400 {
t.Fatalf("status %d", rec.Code)
}
body := rec.Body.String()
for _, leak := range []string{"strconv", "ParseInt", "ops:"} {
if strings.Contains(body, leak) {
t.Fatalf("leaked %q: %s", leak, body)
}
}
if !strings.Contains(body, "must be an integer") {
t.Fatalf("unhelpful message: %s", body)
}
}
// A caller that went away stops the op with it, and nothing is
// journalled: no reader is waiting on that failure. One still there,
// given the same error, is a failure the journal records.
func TestACallerWhoLeftIsNotJournalled(t *testing.T) {
errlog.Default = errlog.New(50)
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[routerIn, routerOut]{
Name: "slow.get",
Doc: "Stops when its caller does.",
Kind: ops.Query,
Route: "GET /api/v1/r/{repo}/slow",
Run: func(ctx context.Context, _ routerIn) (routerOut, error) {
return routerOut{}, fmt.Errorf("archive: %w", context.Canceled)
},
}); err != nil {
t.Fatal(err)
}
h, err := Router(r, "v")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/api/v1/r/x/slow", nil).WithContext(ctx))
if n := len(errlog.Default.Recent(50)); n != 0 {
t.Errorf("FAIL: a caller who left was journalled (%d entries)", n)
}
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/api/v1/r/x/slow", nil))
if n := len(errlog.Default.Recent(50)); n != 1 {
t.Errorf("FAIL: the same error with the caller still there was not journalled (%d entries)", n)
}
}