Files
go-omni/cmd/go-omni/apigen/customRoutes_test.go

186 lines
8.0 KiB
Go

package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
// Exercise the generated consumer, rather than matching a template substring:
// the optional hook must compile without changes to existing ServiceContext,
// survive regeneration, serve requests, and roll back App-owned resources.
func TestGeneratedCustomRoutesConsumer(t *testing.T) {
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
releaseVersion := os.Getenv("GO_OMNI_RELEASE_VERSION")
if (releaseCLI == "") != (releaseVersion == "") {
t.Fatal("GO_OMNI_RELEASE_CLI and GO_OMNI_RELEASE_VERSION must be set together")
}
dir := filepath.Join(t.TempDir(), "customroutes")
api := filepath.Join(t.TempDir(), "service.api")
writeFile(t, api, "service customroutes {\n @handler ping\n get /ping\n}\n")
opts := Options{Dir: dir, API: api, Module: "example.com/customroutes", FrameworkDir: frameworkRoot(t)}
if releaseCLI != "" {
// The installed CLI must select its own published framework version.
// Never supply a source replacement in remote acceptance mode.
opts.FrameworkDir = ""
}
if err := generateManagedTestProject(t, opts); err != nil {
t.Fatal(err)
}
consumer := filepath.Join(dir, "custom_routes_test.go")
writeFile(t, consumer, customRoutesConsumerImports+`
func TestExistingServiceWithoutCustomRoutes(t *testing.T) {
deps:=newDependencies(t)
if _,ok:=any(deps).(interface{RegisterCustomRoutes(*omni.App) error});ok{t.Fatal("baseline unexpectedly implements extension")}
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
response:=httptest.NewRecorder();app.ServeHTTP(response,httptest.NewRequest("GET","/ping",nil))
if response.Code!=http.StatusNotImplemented{t.Fatalf("generated route: %d %s",response.Code,response.Body.String())}
}
`)
// First compile and run the old project shape before adding any method.
runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...")
hookPath := filepath.Join(dir, "internal/svc/customRoutes.go")
writeFile(t, hookPath, customRoutesHook)
writeFile(t, consumer, customRoutesConsumerImports+customRoutesConsumerTests)
serviceContext := filepath.Join(dir, "internal/svc/serviceContext.go")
projectConfig := filepath.Join(dir, "internal/config/config.go")
handwritten := map[string]string{}
for _, path := range []string{hookPath, consumer, serviceContext, projectConfig} {
handwritten[path] = readFile(t, path)
}
opts.API = filepath.Join(dir, "api/service.api")
for _, mode := range []string{"dry-run", "generate", "check"} {
opts.DryRun = mode == "dry-run"
opts.Check = mode == "check"
if err := generateManagedTestProject(t, opts); err != nil {
t.Fatalf("%s: %v", mode, err)
}
for path, before := range handwritten {
if readFile(t, path) != before {
t.Fatalf("%s changed project-owned file %s", mode, path)
}
}
}
runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...")
runConsumerGo(t, dir, "build", "-mod=mod", ".")
if releaseCLI != "" {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err := json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != releaseVersion || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("custom routes consumer is not using the fixed remote framework without replace")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}
const customRoutesConsumerImports = `package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"example.com/customroutes/internal/config"
"example.com/customroutes/internal/handler"
"example.com/customroutes/internal/svc"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func newDependencies(t *testing.T)*svc.ServiceContext{
t.Helper();cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};t.Cleanup(func(){_ = logger.Close()})
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};t.Cleanup(func(){if err:=deps.Close();err!=nil{t.Error(err)}})
return deps
}
`
const customRoutesHook = `package svc
import (
"context"
"errors"
"net/http"
"sync/atomic"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
var ErrCustomRegistration = errors.New("custom registration failed")
var ErrCustomCleanup = errors.New("custom cleanup failed")
var CustomCalls, CustomStopped, CustomDrained, CustomCleaned atomic.Int32
var CustomFailure bool
var CustomApp *omni.App
// This file belongs to the consuming project and must never be generated.
func(c *ServiceContext)RegisterCustomRoutes(app *omni.App)error{
CustomCalls.Add(1);CustomApp=app
app.OnStop("project-custom",func(context.Context)error{CustomStopped.Add(1);return nil})
app.OnDrain("project-custom",func(context.Context)error{CustomDrained.Add(1);return nil})
fail:=CustomFailure
app.OnShutdown(func()error{CustomCleaned.Add(1);if fail{return ErrCustomCleanup};return nil})
app.GET("/custom",httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){
w.Header().Set("Content-Type","text/plain");_,_ = w.Write([]byte("project-owned route"))
})))
if fail{return ErrCustomRegistration}
return nil
}
func ResetCustomRoutes(){CustomCalls.Store(0);CustomStopped.Store(0);CustomDrained.Store(0);CustomCleaned.Store(0);CustomFailure=false;CustomApp=nil}
func IsCustomFailure(err error)bool{return errors.Is(err,ErrCustomRegistration)&&errors.Is(err,ErrCustomCleanup)}
`
const customRoutesConsumerTests = `
func TestCustomRoutesServeAndClose(t *testing.T){
svc.ResetCustomRoutes();deps:=newDependencies(t)
var extension interface{RegisterCustomRoutes(*omni.App) error}=deps
if extension==nil{t.Fatal("missing extension")}
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
if svc.CustomCalls.Load()!=1{t.Fatal("hook called wrong number of times")}
server:=httptest.NewServer(app);defer server.Close()
for _,test:=range []struct{path string;status int;body string}{{"/custom",200,"project-owned route"},{"/ping",501,""},{"/health/ready",200,""}}{
response,err:=http.Get(server.URL+test.path);if err!=nil{t.Fatal(err)}
body,err:=io.ReadAll(response.Body);response.Body.Close();if err!=nil{t.Fatal(err)}
if response.StatusCode!=test.status||(test.body!=""&&string(body)!=test.body){t.Fatalf("%s: %d %s",test.path,response.StatusCode,body)}
}
if svc.CustomCleaned.Load()!=0{t.Fatal("resource closed during successful construction")}
if err:=app.Close();err!=nil{t.Fatal(err)}
if svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("custom lifecycle did not finish")}
}
func TestCustomRoutesFailureRollsBackOnlyAppResources(t *testing.T){
svc.ResetCustomRoutes();svc.CustomFailure=true;defer func(){svc.CustomFailure=false}()
deps:=newDependencies(t);dependencyClosed:=false;deps.OnClose(func()error{dependencyClosed=true;return nil})
app,err:=handler.NewApp(deps)
if app!=nil||!svc.IsCustomFailure(err){t.Fatalf("expected both hook and cleanup errors, got app=%v err=%v",app,err)}
if svc.CustomCalls.Load()!=1||svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("failed construction leaked App resources")}
if dependencyClosed{t.Fatal("NewApp closed caller-owned dependencies")}
response:=httptest.NewRecorder();svc.CustomApp.ServeHTTP(response,httptest.NewRequest("GET","/custom",nil))
if response.Code!=http.StatusServiceUnavailable{t.Fatalf("failed App still accepts requests: %d",response.Code)}
_=svc.CustomApp.Close();if svc.CustomCleaned.Load()!=1{t.Fatal("rollback was not idempotent")}
if err:=deps.Close();err!=nil{t.Fatal(err)};if !dependencyClosed{t.Fatal("caller could not close dependencies")}
}
`