package openapi import ( "encoding/json" "os" "path/filepath" "runtime" "strings" "testing" "gitea.mynodes.xyz/ilker/llmgw/internal/clientapi" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" ) func repoRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("cannot locate this test") } return filepath.Join(filepath.Dir(file), "..", "..", "..") } func generated(t *testing.T) map[string]any { t.Helper() b, err := Generate(repoRoot(t), adminapi.Routes, adminapi.PathParams) if err != nil { t.Fatal(err) } var doc map[string]any if err := json.Unmarshal(b, &doc); err != nil { t.Fatalf("the document is not JSON: %v", err) } return doc } // at walks a decoded document by keys. func at(t *testing.T, v any, keys ...string) any { t.Helper() for _, k := range keys { m, ok := v.(map[string]any) if !ok { t.Fatalf("%s: not an object", strings.Join(keys, ".")) } v, ok = m[k] if !ok { t.Fatalf("no %s (of %s)", k, strings.Join(keys, ".")) } } return v } func TestTheAdminAPIDocumentIsNotStale(t *testing.T) { // Like the configuration reference: a description of the API that is not // regenerated describes some earlier week's API, and the server serves the // committed copy to every client generated from it. want, err := Generate(repoRoot(t), adminapi.Routes, adminapi.PathParams) if err != nil { t.Fatal(err) } got, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "admin-api.openapi.json")) if err != nil { t.Fatalf("docs/admin-api.openapi.json is missing; run go generate ./internal/docgen: %v", err) } if string(got) != string(want) { t.Error("docs/admin-api.openapi.json no longer matches the admin API's routes and types.\n" + "Run: go generate ./internal/docgen") } } func TestEveryReferenceInTheDocumentResolves(t *testing.T) { doc := generated(t) var walk func(v any) n := 0 walk = func(v any) { switch x := v.(type) { case map[string]any: if r, ok := x["$ref"].(string); ok { n++ parts := strings.Split(strings.TrimPrefix(r, "#/"), "/") at(t, doc, parts...) } for _, e := range x { walk(e) } case []any: for _, e := range x { walk(e) } } } walk(doc) if n < 50 { t.Errorf("only %d references; the schemas are not being shared", n) } } func TestABodyIsDescribedByItsGoType(t *testing.T) { doc := generated(t) scale := at(t, doc, "paths", "/api/v1/models/{name}/scale", "post") if r := at(t, scale, "requestBody", "content", "application/json", "schema", "$ref"); r != "#/components/schemas/ScaleRequest" { t.Errorf("scale's body is %v", r) } props := at(t, doc, "components", "schemas", "ScaleRequest", "properties").(map[string]any) // The tag's name, not the field's. for _, k := range []string{"min", "max", "idle_timeout"} { if _, ok := props[k]; !ok { t.Errorf("ScaleRequest has no %s: %v", k, props) } } // The field's comment is its description, and a duration says its unit. if d := at(t, props, "max", "description").(string); !strings.Contains(d, "follows Min") { t.Errorf("max lost its comment: %q", d) } if d := at(t, props, "idle_timeout", "description").(string); !strings.Contains(d, "nanoseconds") { t.Errorf("idle_timeout does not say it is nanoseconds: %q", d) } // A type from another package is named with its package. if r := at(t, doc, "paths", "/api/v1/models", "post", "requestBody", "content", "application/json", "schema", "$ref"); r != "#/components/schemas/catalog.Model" { t.Errorf("adding a model takes %v", r) } } func TestAnEmbeddedStructsFieldsAreItsOwn(t *testing.T) { // CapacityView embeds bench.Capacity, and encoding/json writes the // embedded fields at the top level: so must the schema. doc := generated(t) props := at(t, doc, "components", "schemas", "CapacityView", "properties").(map[string]any) for _, k := range []string{"measured_by", "model", "profile"} { if _, ok := props[k]; !ok { t.Errorf("CapacityView has no %s: %v", k, props) } } } func TestEveryChangeNeedsTheTokenAndNamesItsActor(t *testing.T) { doc := generated(t) if s := at(t, doc, "components", "securitySchemes", "adminToken", "scheme"); s != "bearer" { t.Errorf("scheme %v", s) } if len(at(t, doc, "security").([]any)) != 1 { t.Error("the token is not required document-wide") } del := at(t, doc, "paths", "/api/v1/models/{name}", "delete") found := false for _, p := range at(t, del, "parameters").([]any) { if p.(map[string]any)["$ref"] == "#/components/parameters/Actor" { found = true } } if !found { t.Error("removing a model does not take X-Llmgw-Actor") } if _, ok := at(t, del, "responses").(map[string]any)["401"]; !ok { t.Error("removing a model does not say it can be refused for the token") } } func TestAPathParameterWithNoDescriptionIsAnError(t *testing.T) { routes := []adminapi.Route{{Method: "GET", Path: "/api/v1/things/{id}", Summary: "x", Response: adminapi.Result{}}} if _, err := Generate(repoRoot(t), routes, adminapi.PathParams); err == nil { t.Error("a parameter nobody described was let through") } } func TestTheClientAPIDocumentIsNotStaleAndDescribesWhatClientsSend(t *testing.T) { want, err := GenerateDoc(repoRoot(t), Client, clientapi.Routes, clientapi.PathParams) if err != nil { t.Fatal(err) } got, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "client-api.openapi.json")) if err != nil || string(got) != string(want) { t.Fatal("docs/client-api.openapi.json no longer matches the client API's routes and types.\n" + "Run: go generate ./internal/docgen") } var doc map[string]any if err := json.Unmarshal(want, &doc); err != nil { t.Fatal(err) } chat := at(t, doc, "paths", "/v1/chat/completions", "post") if at(t, chat, "requestBody", "content", "application/json", "schema", "$ref") != "#/components/schemas/ChatCompletionRequest" { t.Error("chat's body is not its request type") } if _, ok := at(t, chat, "responses", "200", "content").(map[string]any)["text/event-stream"]; !ok { t.Error("chat does not say it may stream") } if _, ok := at(t, doc, "paths", "/v1/files", "post", "requestBody", "content").(map[string]any)["multipart/form-data"]; !ok { t.Error("an upload is not a form") } if at(t, doc, "paths", "/v1/3d/generations", "post", "responses", "202", "description") == nil { t.Error("a 3D job is not answered 202") } if _, ok := at(t, doc, "components", "securitySchemes").(map[string]any)["apiKey"]; !ok { t.Error("no API key scheme") } // The admin API's actor header is the admin API's. if _, ok := at(t, doc, "components").(map[string]any)["parameters"]; ok { t.Error("the client API takes the admin API's X-Llmgw-Actor") } }