a73x

internal/server/api/routes_test.go

Ref:   Size: 2.8 KiB   History

package api

import (
	"net/http"
	"reflect"
	"testing"
)

// typesPkgPath is where every wire exemplar's element type must live — the
// contract package, and nothing else.
const typesPkgPath = "github.com/a73x/eitri/internal/server/api/types"

// exemplarElem unwraps a table exemplar (typed nil pointer-to-struct or typed
// nil slice) to its element struct type, failing the test on any other shape.
func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type {
	t.Helper()
	rt := reflect.TypeOf(v)
	switch rt.Kind() {
	case reflect.Pointer, reflect.Slice:
		elem := rt.Elem()
		if elem.Kind() != reflect.Struct {
			t.Fatalf("%s %s: %s exemplar %v is not pointer-to-struct or slice-of-struct", route.Method, route.Path, role, rt)
		}
		return elem
	default:
		t.Fatalf("%s %s: %s exemplar has kind %v; want typed nil pointer or slice", route.Method, route.Path, role, rt.Kind())
		return nil
	}
}

// TestRouteTable pins the structural invariants the OpenAPI generator relies
// on: complete entries, unique method+path, exemplars drawn from the contract
// package, and disjoint request/response type sets (the generator emits no
// `required` array for request schemas, so a type serving both roles would
// get the wrong treatment on one of them).
func TestRouteTable(t *testing.T) {
	const wantRoutes = 39
	if len(routeTable) != wantRoutes {
		t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes)
	}

	seen := make(map[string]bool, len(routeTable))
	requestTypes := map[reflect.Type]bool{}
	responseTypes := map[reflect.Type]bool{}

	for _, rt := range routeTable {
		if rt.Method == "" || rt.Path == "" || rt.Doc == "" || rt.handler == nil {
			t.Errorf("%s %s: incomplete entry (method/path/doc/handler must all be set)", rt.Method, rt.Path)
		}
		key := rt.Method + " " + rt.Path
		if seen[key] {
			t.Errorf("duplicate route %s", key)
		}
		seen[key] = true

		if rt.Request != nil {
			elem := exemplarElem(t, rt, "request", rt.Request)
			if elem.PkgPath() != typesPkgPath {
				t.Errorf("%s: request exemplar %v lives outside the contract package", key, elem)
			}
			requestTypes[elem] = true
		}
		if rt.Response != nil {
			elem := exemplarElem(t, rt, "response", rt.Response)
			if elem.PkgPath() != typesPkgPath {
				t.Errorf("%s: response exemplar %v lives outside the contract package", key, elem)
			}
			responseTypes[elem] = true
		}

		if rt.Kind == KindJSON && (rt.Success == http.StatusOK || rt.Success == http.StatusCreated) && rt.Response == nil {
			t.Errorf("%s: succeeds with %d but declares no response body", key, rt.Success)
		}
	}

	for typ := range requestTypes {
		if responseTypes[typ] {
			t.Errorf("type %v is used as both request and response exemplar; the sets must stay disjoint", typ)
		}
	}
}