package ops
import (
"context"
"encoding/json"
"strings"
"testing"
)
type specListIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Limit int `json:"limit" validate:"min=1,max=500"`
Sort string `json:"sort" validate:"oneof=asc|desc"`
}
type specListOut struct {
Items []string `json:"items"`
Total int `json:"total"`
}
type specPostIn struct {
Repo string `json:"repo" validate:"required"`
Body string `json:"body" validate:"required,max=4000"`
}
type specPostOut struct {
ID string `json:"id"`
}
func specRegistry(t *testing.T) *Registry {
t.Helper()
r := NewRegistry()
if err := Register(r, Op[specListIn, specListOut]{
Name: "timeline.list",
Doc: "List a repository timeline.",
Kind: Query,
Auth: Anonymous,
Route: "GET /api/v1/r/{repo}/timeline",
Run: func(_ context.Context, _ specListIn) (specListOut, error) { return specListOut{}, nil },
}); err != nil {
t.Fatal(err)
}
if err := Register(r, Op[specPostIn, specPostOut]{
Name: "chat.post",
Doc: "Post a chat message.",
Kind: Command,
Auth: Token,
Route: "POST /api/v1/r/{repo}/chat",
Run: func(_ context.Context, _ specPostIn) (specPostOut, error) { return specPostOut{}, nil },
}); err != nil {
t.Fatal(err)
}
return r
}
// specJSON renders the generated spec and re-parses it into a generic map
// so tests assert on the document consumers actually receive.
func specJSON(t *testing.T) map[string]any {
t.Helper()
spec, err := Spec(specRegistry(t), "quarry", "0.0.0-test")
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(spec)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
return doc
}
func dig(t *testing.T, doc any, path ...string) any {
t.Helper()
for _, key := range path {
m, ok := doc.(map[string]any)
if !ok || m[key] == nil {
t.Fatalf("missing %q in path %v", key, path)
}
doc = m[key]
}
return doc
}
func TestSpecTopLevel(t *testing.T) {
doc := specJSON(t)
if v := dig(t, doc, "openapi"); v != "3.1.0" {
t.Fatalf("openapi = %v", v)
}
if v := dig(t, doc, "info", "version"); v != "0.0.0-test" {
t.Fatalf("version = %v", v)
}
}
func TestSpecQueryOpParameters(t *testing.T) {
doc := specJSON(t)
op := dig(t, doc, "paths", "/api/v1/r/{repo}/timeline", "get")
if v := dig(t, op, "operationId"); v != "timeline.list" {
t.Fatalf("operationId = %v", v)
}
params := dig(t, op, "parameters").([]any)
byName := map[string]map[string]any{}
for _, p := range params {
pm := p.(map[string]any)
byName[pm["name"].(string)] = pm
}
repo, ok := byName["repo"]
if !ok || repo["in"] != "path" || repo["required"] != true {
t.Fatalf("repo param wrong: %v", byName)
}
limit, ok := byName["limit"]
if !ok || limit["in"] != "query" {
t.Fatalf("limit param wrong: %v", byName)
}
var schema map[string]any
if err := json.Unmarshal([]byte(mustJSON(t, limit["schema"])), &schema); err != nil {
t.Fatal(err)
}
if schema["type"] != "integer" || schema["minimum"] != 1.0 || schema["maximum"] != 500.0 {
t.Fatalf("limit schema = %v", schema)
}
var sortSchema map[string]any
if err := json.Unmarshal([]byte(mustJSON(t, byName["sort"]["schema"])), &sortSchema); err != nil {
t.Fatal(err)
}
enum, _ := sortSchema["enum"].([]any)
if len(enum) != 2 || enum[0] != "asc" || enum[1] != "desc" {
t.Fatalf("sort enum = %v", sortSchema)
}
}
func TestSpecCommandOpBothContentTypes(t *testing.T) {
doc := specJSON(t)
content := dig(t, doc, "paths", "/api/v1/r/{repo}/chat", "post", "requestBody", "content").(map[string]any)
if content["application/json"] == nil || content["application/x-www-form-urlencoded"] == nil {
t.Fatalf("want both codec content types, got %v", content)
}
// Path param stays a parameter, not a body property.
var body map[string]any
schema := dig(t, content, "application/json", "schema")
if err := json.Unmarshal([]byte(mustJSON(t, schema)), &body); err != nil {
t.Fatal(err)
}
props := body["properties"].(map[string]any)
if props["repo"] != nil {
t.Fatal("path wildcard leaked into body schema")
}
if props["body"] == nil {
t.Fatalf("body property missing: %v", props)
}
req, _ := body["required"].([]any)
if len(req) != 1 || req[0] != "body" {
t.Fatalf("required = %v", req)
}
}
func TestSpecResponses(t *testing.T) {
doc := specJSON(t)
schema := dig(t, doc, "paths", "/api/v1/r/{repo}/timeline", "get",
"responses", "200", "content", "application/json", "schema")
var out map[string]any
if err := json.Unmarshal([]byte(mustJSON(t, schema)), &out); err != nil {
t.Fatal(err)
}
props := out["properties"].(map[string]any)
if props["items"] == nil || props["total"] == nil {
t.Fatalf("out schema = %v", out)
}
}
func TestSpecAuth(t *testing.T) {
doc := specJSON(t)
// Anonymous op: no security.
get := dig(t, doc, "paths", "/api/v1/r/{repo}/timeline", "get").(map[string]any)
if get["security"] != nil {
t.Fatalf("anonymous op has security: %v", get["security"])
}
// Token op: security + scheme present.
post := dig(t, doc, "paths", "/api/v1/r/{repo}/chat", "post").(map[string]any)
if post["security"] == nil {
t.Fatal("token op missing security")
}
if post["x-quarry-auth"] != "token" {
t.Fatalf("x-quarry-auth = %v", post["x-quarry-auth"])
}
if dig(t, doc, "components", "securitySchemes", "basicAuth", "scheme") != "basic" {
t.Fatal("basicAuth scheme missing")
}
}
func TestSpecTags(t *testing.T) {
doc := specJSON(t)
tags := dig(t, doc, "paths", "/api/v1/r/{repo}/timeline", "get", "tags").([]any)
if len(tags) != 1 || tags[0] != "timeline" {
t.Fatalf("tags = %v", tags)
}
// Every op tag is declared globally (merged via the extension map).
global := dig(t, doc, "tags").([]any)
if len(global) != 2 {
t.Fatalf("global tags = %v", global)
}
}
func TestSpecSummariesAndInfoDescription(t *testing.T) {
doc := specJSON(t)
if dig(t, doc, "info", "description") == "" {
t.Fatal("info.description missing")
}
if v := dig(t, doc, "paths", "/api/v1/r/{repo}/timeline", "get", "summary"); v != "List a repository timeline." {
t.Fatalf("summary = %v", v)
}
}
func mustJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestSpecDeterministic(t *testing.T) {
a, err := Spec(specRegistry(t), "quarry", "v")
if err != nil {
t.Fatal(err)
}
b, err := Spec(specRegistry(t), "quarry", "v")
if err != nil {
t.Fatal(err)
}
ja, jb := mustJSON(t, a), mustJSON(t, b)
if ja != jb {
t.Fatalf("spec not deterministic:\n%s\n%s", ja, jb)
}
if !strings.Contains(ja, "timeline.list") {
t.Fatal("spec missing op")
}
}