internal/server/api/spec/spec_test.go
Ref: Size: 7.6 KiB History
package spec_test
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/a73x/eitri/internal/server/api"
"github.com/a73x/eitri/internal/server/api/spec"
)
// generate runs the generator once and unmarshals the document.
func generate(t *testing.T) (map[string]any, []byte) {
t.Helper()
out, err := spec.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("generated spec is not valid JSON: %v", err)
}
return doc, out
}
// dig walks nested map[string]any keys, failing the test on a missing step.
func dig(t *testing.T, v any, keys ...string) any {
t.Helper()
for _, k := range keys {
m, ok := v.(map[string]any)
if !ok {
t.Fatalf("dig %v: not an object at %q", keys, k)
}
v, ok = m[k]
if !ok {
t.Fatalf("dig %v: missing key %q", keys, k)
}
}
return v
}
func TestOpenAPIVersion(t *testing.T) {
doc, _ := generate(t)
if got := doc["openapi"]; got != "3.1.0" {
t.Errorf("openapi = %v, want 3.1.0", got)
}
}
func TestEveryRouteHasAnOperation(t *testing.T) {
doc, _ := generate(t)
for _, r := range api.Routes() {
op, ok := dig(t, doc, "paths", r.Path).(map[string]any)[strings.ToLower(r.Method)]
if !ok {
t.Errorf("%s %s: no operation in paths", r.Method, r.Path)
continue
}
// {id}/{tenant} path params must be declared required string params.
for _, name := range []string{"id", "tenant"} {
if !strings.Contains(r.Path, "{"+name+"}") {
continue
}
var found bool
params, _ := op.(map[string]any)["parameters"].([]any)
for _, p := range params {
pm := p.(map[string]any)
if pm["name"] == name && pm["in"] == "path" {
found = true
if pm["required"] != true {
t.Errorf("%s %s: path param %q not required", r.Method, r.Path, name)
}
if typ := dig(t, pm, "schema", "type"); typ != "string" {
t.Errorf("%s %s: path param %q type = %v, want string", r.Method, r.Path, name, typ)
}
}
}
if !found {
t.Errorf("%s %s: path param %q not declared", r.Method, r.Path, name)
}
}
}
}
func TestCreateVMOperation(t *testing.T) {
doc, _ := generate(t)
op := dig(t, doc, "paths", "/api/v1/vms", "post")
// The requestBody schema is a plain $ref — never wrapped in anyOf/nullable.
reqSchema := dig(t, op, "requestBody", "content", "application/json", "schema").(map[string]any)
if len(reqSchema) != 1 {
t.Errorf("requestBody schema has extra keys: %v", reqSchema)
}
if ref, _ := reqSchema["$ref"].(string); !strings.HasSuffix(ref, "CreateVMRequest") {
t.Errorf("requestBody $ref = %v, want ...CreateVMRequest", reqSchema["$ref"])
}
respRef := dig(t, op, "responses", "201", "content", "application/json", "schema", "$ref").(string)
if !strings.HasSuffix(respRef, "CreateVMResponse") {
t.Errorf("201 $ref = %q, want ...CreateVMResponse", respRef)
}
}
func TestRequiredArrays(t *testing.T) {
doc, _ := generate(t)
// Request schemas claim NO required fields: the server defaults absent
// fields and hand-rolled validation is the authority.
req := dig(t, doc, "components", "schemas", "CreateVMRequest").(map[string]any)
if _, ok := req["required"]; ok {
t.Errorf("CreateVMRequest has a required array: %v", req["required"])
}
// Response schemas DO claim required (writeJSON always emits every field),
// and the array is sorted for deterministic output.
host := dig(t, doc, "components", "schemas", "Host").(map[string]any)
raw, ok := host["required"].([]any)
if !ok {
t.Fatalf("Host has no required array")
}
var required []string
for _, v := range raw {
required = append(required, v.(string))
}
if len(required) == 0 {
t.Fatal("Host required array is empty")
}
for i := 1; i < len(required); i++ {
if required[i-1] >= required[i] {
t.Errorf("Host required not sorted: %q before %q", required[i-1], required[i])
}
}
// Pointer fields are nullable, not required.
for _, name := range []string{"last_seen", "seconds_since_last_seen", "metrics"} {
for _, r := range required {
if r == name {
t.Errorf("pointer field %q must not be required", name)
}
}
}
}
func TestSecurity(t *testing.T) {
doc, _ := generate(t)
for _, r := range api.Routes() {
op := dig(t, doc, "paths", r.Path, strings.ToLower(r.Method)).(map[string]any)
sec, has := op["security"]
if r.Auth == api.AuthUser {
want := []any{map[string]any{"patToken": []any{}}}
if !has {
t.Errorf("%s %s: user route missing security", r.Method, r.Path)
} else if wantJSON, _ := json.Marshal(want); string(mustJSON(t, sec)) != string(wantJSON) {
t.Errorf("%s %s: security = %v", r.Method, r.Path, sec)
}
} else if has {
t.Errorf("%s %s: unauthenticated route carries security %v", r.Method, r.Path, sec)
}
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
func TestHostSchemaFields(t *testing.T) {
doc, _ := generate(t)
props := dig(t, doc, "components", "schemas", "Host", "properties")
// Nested contract struct → $ref.
if ref := dig(t, props, "capacity", "$ref").(string); !strings.HasSuffix(ref, "Capacity") {
t.Errorf("capacity $ref = %q", ref)
}
// Pointer-to-struct field → anyOf [$ref, {type: null}].
anyOf, ok := dig(t, props, "metrics", "anyOf").([]any)
if !ok || len(anyOf) != 2 {
t.Fatalf("metrics anyOf = %v", dig(t, props, "metrics"))
}
if ref, _ := anyOf[0].(map[string]any)["$ref"].(string); !strings.HasSuffix(ref, "Metrics") {
t.Errorf("metrics anyOf[0] = %v, want $ref ...Metrics", anyOf[0])
}
if typ, _ := anyOf[1].(map[string]any)["type"].(string); typ != "null" {
t.Errorf("metrics anyOf[1] = %v, want {type: null}", anyOf[1])
}
// Pointer-to-time field → type ["string","null"] with date-time format.
lastSeen := dig(t, props, "last_seen").(map[string]any)
typJSON := string(mustJSON(t, lastSeen["type"]))
if typJSON != `["string","null"]` {
t.Errorf("last_seen type = %s, want [\"string\",\"null\"]", typJSON)
}
if lastSeen["format"] != "date-time" {
t.Errorf("last_seen format = %v, want date-time", lastSeen["format"])
}
}
func TestSSEAndWSResponses(t *testing.T) {
doc, _ := generate(t)
// SSE stream: 200 with a text/event-stream body carrying StateSnapshot.
ref := dig(t, doc, "paths", "/api/v1/events", "get", "responses", "200",
"content", "text/event-stream", "schema", "$ref").(string)
if !strings.HasSuffix(ref, "StateSnapshot") {
t.Errorf("events stream $ref = %q, want ...StateSnapshot", ref)
}
// Console WebSocket: a 101 response with no content schema.
ws := dig(t, doc, "paths", "/api/v1/vms/{id}/console/ws", "get", "responses", "101").(map[string]any)
if _, has := ws["content"]; has {
t.Errorf("console/ws 101 response carries content: %v", ws["content"])
}
}
func TestDefaultErrorResponse(t *testing.T) {
doc, _ := generate(t)
for _, r := range api.Routes() {
op := dig(t, doc, "paths", r.Path, strings.ToLower(r.Method))
typ := dig(t, op, "responses", "default", "content", "text/plain", "schema", "type")
if typ != "string" {
t.Errorf("%s %s: default error schema type = %v, want string", r.Method, r.Path, typ)
}
}
}
func TestSecuritySchemes(t *testing.T) {
doc, _ := generate(t)
scheme := dig(t, doc, "components", "securitySchemes", "patToken").(map[string]any)
if scheme["type"] != "http" || scheme["scheme"] != "bearer" {
t.Errorf("patToken scheme = %v, want {type: http, scheme: bearer}", scheme)
}
}
func TestDeterministicOutput(t *testing.T) {
_, first := generate(t)
_, second := generate(t)
if !bytes.Equal(first, second) {
t.Fatal("two Generate() calls differ")
}
if !bytes.HasSuffix(first, []byte("\n")) {
t.Error("output missing trailing newline")
}
}