7d8e82c69f
Ship the Ops backstage 对象存储 page (browse / upload / move / delete / folder ops) backed by a new object-storage service + handler, with the shared storage client extended with List/Stat/Copy/Usage/DownloadURL so both MinIO and Aliyun providers cover the new surface. Add ForcePathStyle to the object-storage config and treat r2/s3/custom_s3 as external providers so Cloudflare R2 and other S3-compatibles can share the MinIO client path without spinning up the bundled MinIO. Also add CSV import/export + template download to the site-domain mappings page, raise gin MaxMultipartMemory to 8 MiB for the new multipart endpoints, register Ops swagger routes, and extend the descriptions-parity test to cover the ops router. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
4.1 KiB
Go
154 lines
4.1 KiB
Go
package swagger
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestRouteDocs_CoversEveryRouterEntry parses the tenant-api router source and
|
|
// asserts that every registered route has a corresponding entry in routeDocs.
|
|
// This is a static-analysis safety net: when a new handler is wired up in
|
|
// tenant or ops transport routers, this test fails until a Chinese
|
|
// summary is added.
|
|
func TestRouteDocs_CoversEveryRouterEntry(t *testing.T) {
|
|
routerPaths, bootstrapPath := findRepoSources(t)
|
|
|
|
routes := parseEngineRoutes(t, bootstrapPath)
|
|
for _, routerPath := range routerPaths {
|
|
routes = append(routes, parseRoutes(t, routerPath)...)
|
|
}
|
|
if len(routes) == 0 {
|
|
t.Fatalf("could not extract any routes from %s", strings.Join(routerPaths, ", "))
|
|
}
|
|
|
|
missing := make([]string, 0)
|
|
for _, r := range routes {
|
|
if shouldSkipRoute(r.path) {
|
|
continue
|
|
}
|
|
if _, ok := routeDocs[r.method+" "+r.path]; !ok {
|
|
missing = append(missing, r.method+" "+r.path)
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
if len(missing) > 0 {
|
|
t.Fatalf("routeDocs is missing %d route(s):\n %s", len(missing), strings.Join(missing, "\n "))
|
|
}
|
|
|
|
registered := map[string]struct{}{}
|
|
for _, r := range routes {
|
|
registered[r.method+" "+r.path] = struct{}{}
|
|
}
|
|
extras := make([]string, 0)
|
|
for k := range routeDocs {
|
|
if strings.HasPrefix(k, "GET /swagger") {
|
|
continue
|
|
}
|
|
if _, ok := registered[k]; !ok {
|
|
extras = append(extras, k)
|
|
}
|
|
}
|
|
sort.Strings(extras)
|
|
if len(extras) > 0 {
|
|
t.Fatalf("routeDocs has %d entry/entries with no matching route (renamed or removed):\n %s",
|
|
len(extras), strings.Join(extras, "\n "))
|
|
}
|
|
}
|
|
|
|
type parsedRoute struct {
|
|
method string
|
|
path string
|
|
}
|
|
|
|
var (
|
|
groupRe = regexp.MustCompile(`(\w+)\s*:?=\s*([\w.]+)\.Group\("([^"]*)"\)`)
|
|
methodVarRe = regexp.MustCompile(`\b(\w+)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"`)
|
|
methodEngRe = regexp.MustCompile(`\b(?:app\.Engine|engine)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"`)
|
|
)
|
|
|
|
func parseRoutes(t *testing.T, path string) []parsedRoute {
|
|
t.Helper()
|
|
src, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
text := string(src)
|
|
|
|
prefixes := map[string][2]string{} // var -> [parent, prefix]
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if m := groupRe.FindStringSubmatch(line); m != nil {
|
|
prefixes[m[1]] = [2]string{m[2], m[3]}
|
|
}
|
|
}
|
|
resolve := func(v string) string {
|
|
var parts []string
|
|
for {
|
|
pp, ok := prefixes[v]
|
|
if !ok {
|
|
break
|
|
}
|
|
parts = append([]string{pp[1]}, parts...)
|
|
v = pp[0]
|
|
}
|
|
return strings.Join(parts, "")
|
|
}
|
|
|
|
routes := make([]parsedRoute, 0)
|
|
seen := map[string]struct{}{}
|
|
add := func(method, fullPath string) {
|
|
key := method + " " + fullPath
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
routes = append(routes, parsedRoute{method: method, path: fullPath})
|
|
}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
for _, m := range methodVarRe.FindAllStringSubmatch(line, -1) {
|
|
v, meth, p := m[1], m[2], m[3]
|
|
if _, ok := prefixes[v]; !ok {
|
|
continue
|
|
}
|
|
add(meth, resolve(v)+p)
|
|
}
|
|
for _, m := range methodEngRe.FindAllStringSubmatch(line, -1) {
|
|
add(m[1], m[2])
|
|
}
|
|
}
|
|
return routes
|
|
}
|
|
|
|
func parseEngineRoutes(t *testing.T, path string) []parsedRoute {
|
|
t.Helper()
|
|
src, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
routes := make([]parsedRoute, 0)
|
|
for _, m := range methodEngRe.FindAllStringSubmatch(string(src), -1) {
|
|
routes = append(routes, parsedRoute{method: m[1], path: m[2]})
|
|
}
|
|
return routes
|
|
}
|
|
|
|
func findRepoSources(t *testing.T) (routers []string, bootstrap string) {
|
|
t.Helper()
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatalf("runtime.Caller failed")
|
|
}
|
|
// .../server/internal/shared/swagger/descriptions_parity_test.go ->
|
|
// .../server is three levels up from this file's parent dir.
|
|
serverRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
|
|
return []string{
|
|
filepath.Join(serverRoot, "internal", "tenant", "transport", "router.go"),
|
|
filepath.Join(serverRoot, "internal", "ops", "transport", "router.go"),
|
|
},
|
|
filepath.Join(serverRoot, "internal", "bootstrap", "bootstrap.go")
|
|
}
|