Feat v0.6.1-v0.6.2: backup/restore, docs surface, dynamic OpenAPI
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
v0.6.1 — Backup tooling (.swb ZIP export/import), admin backup section, docs surface with markdown rendering, 5 reference docs, 6 handler tests. v0.6.2 — Dark mode contrast fixes, topbar nav, 📖 docs icon, dynamic OpenAPI spec with extension route merging, docs outline sidebar, scroll/routing fixes; 7 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
406
server/handlers/openapi_test.go
Normal file
406
server/handlers/openapi_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/store/sqlite"
|
||||
)
|
||||
|
||||
// Minimal static spec for testing — valid OpenAPI 3.0 structure.
|
||||
var testStaticSpec = []byte(`
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: Switchboard Core
|
||||
version: "${VERSION}"
|
||||
paths:
|
||||
/api/v1/health:
|
||||
get:
|
||||
summary: Health check
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
tags:
|
||||
- name: system
|
||||
description: System endpoints
|
||||
`)
|
||||
|
||||
func openapiTestStores(t *testing.T) store.Stores {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
return sqlite.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
// ── Test: Zero extensions → kernel spec only ──────────────────
|
||||
|
||||
func TestBuildOpenAPISpec_NoExtensions(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
// Version should be substituted
|
||||
info, _ := spec["info"].(map[string]any)
|
||||
if info == nil {
|
||||
t.Fatal("missing info field")
|
||||
}
|
||||
if v, _ := info["version"].(string); v != "0.6.2" {
|
||||
t.Errorf("expected version 0.6.2, got %q", v)
|
||||
}
|
||||
|
||||
// Paths should contain the kernel endpoint
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
if paths == nil {
|
||||
t.Fatal("missing paths field")
|
||||
}
|
||||
if _, ok := paths["/api/v1/health"]; !ok {
|
||||
t.Error("expected /api/v1/health in paths")
|
||||
}
|
||||
|
||||
// Tags should contain kernel tag
|
||||
tags, _ := spec["tags"].([]any)
|
||||
if len(tags) < 1 {
|
||||
t.Error("expected at least 1 tag")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Extension with api_routes but no api_schema → stubs ────
|
||||
|
||||
func TestBuildOpenAPISpec_StubEntries(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
// Install a test extension with api_routes
|
||||
manifest := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/items"},
|
||||
map[string]any{"method": "POST", "path": "/items"},
|
||||
},
|
||||
}
|
||||
manifestJSON, _ := json.Marshal(manifest)
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: "test-ext",
|
||||
Title: "Test Extension",
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
_ = manifestJSON
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test package: %v", err)
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
|
||||
// Check GET stub
|
||||
getPath, ok := paths["/s/test-ext/api/items"]
|
||||
if !ok {
|
||||
t.Fatal("expected /s/test-ext/api/items in paths")
|
||||
}
|
||||
pathItem, _ := getPath.(map[string]any)
|
||||
getOp, _ := pathItem["get"].(map[string]any)
|
||||
if getOp == nil {
|
||||
t.Fatal("expected GET operation")
|
||||
}
|
||||
summary, _ := getOp["summary"].(string)
|
||||
if summary != "test-ext: GET /items" {
|
||||
t.Errorf("unexpected stub summary: %q", summary)
|
||||
}
|
||||
|
||||
// Check POST stub
|
||||
postOp, _ := pathItem["post"].(map[string]any)
|
||||
if postOp == nil {
|
||||
t.Fatal("expected POST operation")
|
||||
}
|
||||
|
||||
// Check tag
|
||||
tags, _ := getOp["tags"].([]any)
|
||||
if len(tags) != 1 || tags[0] != "extensions/test-ext" {
|
||||
t.Errorf("unexpected tags: %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Extension with api_schema → rich entries ────────
|
||||
|
||||
func TestBuildOpenAPISpec_SchemaEntries(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
manifest := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/items"},
|
||||
map[string]any{"method": "POST", "path": "/items"},
|
||||
map[string]any{"method": "DELETE", "path": "/items/:id"},
|
||||
},
|
||||
"api_schema": []any{
|
||||
map[string]any{
|
||||
"path": "/items",
|
||||
"method": "GET",
|
||||
"summary": "List items",
|
||||
"params": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "default": 50},
|
||||
},
|
||||
"response": map[string]any{
|
||||
"type": "object",
|
||||
"example": map[string]any{"data": []any{}},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"path": "/items",
|
||||
"method": "POST",
|
||||
"summary": "Create item",
|
||||
"body": map[string]any{
|
||||
"name": map[string]any{"type": "string", "required": true},
|
||||
"description": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: "rich-ext",
|
||||
Title: "Rich Extension",
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test package: %v", err)
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
|
||||
// GET /items should have rich schema (summary = "List items")
|
||||
itemsPath, ok := paths["/s/rich-ext/api/items"]
|
||||
if !ok {
|
||||
t.Fatal("expected /s/rich-ext/api/items in paths")
|
||||
}
|
||||
pathItem, _ := itemsPath.(map[string]any)
|
||||
getOp, _ := pathItem["get"].(map[string]any)
|
||||
if getOp == nil {
|
||||
t.Fatal("expected GET operation")
|
||||
}
|
||||
if s, _ := getOp["summary"].(string); s != "List items" {
|
||||
t.Errorf("expected rich summary, got %q", s)
|
||||
}
|
||||
// Should have parameters
|
||||
params, _ := getOp["parameters"].([]any)
|
||||
if len(params) == 0 {
|
||||
t.Error("expected parameters from api_schema")
|
||||
}
|
||||
|
||||
// POST /items should have requestBody
|
||||
postOp, _ := pathItem["post"].(map[string]any)
|
||||
if postOp == nil {
|
||||
t.Fatal("expected POST operation")
|
||||
}
|
||||
if _, ok := postOp["requestBody"]; !ok {
|
||||
t.Error("expected requestBody from api_schema body")
|
||||
}
|
||||
|
||||
// DELETE /items/:id should be a stub (no api_schema for it)
|
||||
deletePath, ok := paths["/s/rich-ext/api/items/:id"]
|
||||
if !ok {
|
||||
t.Fatal("expected /s/rich-ext/api/items/:id in paths")
|
||||
}
|
||||
deleteItem, _ := deletePath.(map[string]any)
|
||||
deleteOp, _ := deleteItem["delete"].(map[string]any)
|
||||
if deleteOp == nil {
|
||||
t.Fatal("expected DELETE operation")
|
||||
}
|
||||
deleteSummary, _ := deleteOp["summary"].(string)
|
||||
if deleteSummary != "rich-ext: DELETE /items/:id" {
|
||||
t.Errorf("expected stub summary for undeclared route, got %q", deleteSummary)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Multiple extensions → all appear ────────
|
||||
|
||||
func TestBuildOpenAPISpec_MultipleExtensions(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
for _, ext := range []struct {
|
||||
id string
|
||||
title string
|
||||
route string
|
||||
}{
|
||||
{"ext-a", "Extension A", "/status"},
|
||||
{"ext-b", "Extension B", "/health"},
|
||||
} {
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: ext.id,
|
||||
Title: ext.title,
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": ext.route},
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create %s: %v", ext.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
if _, ok := paths["/s/ext-a/api/status"]; !ok {
|
||||
t.Error("expected ext-a path")
|
||||
}
|
||||
if _, ok := paths["/s/ext-b/api/health"]; !ok {
|
||||
t.Error("expected ext-b path")
|
||||
}
|
||||
|
||||
// Verify tags
|
||||
tags, _ := spec["tags"].([]any)
|
||||
tagNames := make(map[string]bool)
|
||||
for _, tag := range tags {
|
||||
if m, ok := tag.(map[string]any); ok {
|
||||
if n, ok := m["name"].(string); ok {
|
||||
tagNames[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !tagNames["extensions/ext-a"] {
|
||||
t.Error("expected extensions/ext-a tag")
|
||||
}
|
||||
if !tagNames["extensions/ext-b"] {
|
||||
t.Error("expected extensions/ext-b tag")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Malformed api_schema → stubs, no crash ────────
|
||||
|
||||
func TestBuildOpenAPISpec_MalformedSchema(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
manifest := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/data"},
|
||||
},
|
||||
"api_schema": []any{
|
||||
"not-an-object", // malformed
|
||||
map[string]any{"path": "/data"}, // missing method
|
||||
map[string]any{"method": "GET"}, // missing path
|
||||
map[string]any{"path": "/data", "method": "POST"}, // valid but no matching route
|
||||
},
|
||||
}
|
||||
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: "bad-schema",
|
||||
Title: "Bad Schema",
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create package: %v", err)
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec should not error on malformed schema: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
// GET /data should still appear as a stub
|
||||
if _, ok := paths["/s/bad-schema/api/data"]; !ok {
|
||||
t.Error("expected stub entry for /data despite malformed api_schema")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Disabled extension → excluded ────────
|
||||
|
||||
func TestBuildOpenAPISpec_DisabledExcluded(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: "disabled-ext",
|
||||
Title: "Disabled",
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/secret"},
|
||||
},
|
||||
},
|
||||
Enabled: false,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create package: %v", err)
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
if _, ok := paths["/s/disabled-ext/api/secret"]; ok {
|
||||
t.Error("disabled extension should not appear in spec")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Required OpenAPI 3.0 fields present ────────
|
||||
|
||||
func TestBuildOpenAPISpec_RequiredFields(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "1.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := spec["openapi"]; !ok {
|
||||
t.Error("missing required 'openapi' field")
|
||||
}
|
||||
if _, ok := spec["info"]; !ok {
|
||||
t.Error("missing required 'info' field")
|
||||
}
|
||||
if _, ok := spec["paths"]; !ok {
|
||||
t.Error("missing required 'paths' field")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user