309 lines
11 KiB
Go
309 lines
11 KiB
Go
package apigen
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func findDiagnostic(t *testing.T, out Output, code string) Diagnostic {
|
|
t.Helper()
|
|
for _, d := range out.Diagnostics {
|
|
if d.Code == code {
|
|
return d
|
|
}
|
|
}
|
|
t.Fatalf("missing %s diagnostic: %+v", code, out.Diagnostics)
|
|
return Diagnostic{}
|
|
}
|
|
|
|
func TestContractChangesPersistUntilLogicFixed(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "app")
|
|
out, err := New(Options{Dir: dir, Module: "example.com/changes", FrameworkDir: frameworkRoot(t)})
|
|
if err != nil || len(out.Diagnostics) > 0 {
|
|
t.Fatalf("initial generation: %v %+v", err, out.Diagnostics)
|
|
}
|
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
|
|
logicPath := filepath.Join(dir, "internal/logic/greet/greet.go")
|
|
logicBefore := readFile(t, logicPath)
|
|
writeFile(t, o.API, strings.ReplaceAll(readFile(t, o.API), "GreetReq", "HelloReq"))
|
|
before := snapshot(t, dir)
|
|
for _, dryRun := range []bool{true, false} {
|
|
o.DryRun, o.Check = dryRun, !dryRun
|
|
out, err = Generate(o)
|
|
if dryRun && err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !dryRun && err == nil {
|
|
t.Fatal("check accepted stale project")
|
|
}
|
|
d := findDiagnostic(t, out, "logicSignature")
|
|
if d.Line <= 0 || d.Path != "internal/logic/greet/greet.go" || !strings.Contains(d.After, "*types.HelloReq") || !strings.Contains(d.Before, "*types.GreetReq") {
|
|
t.Fatalf("imprecise diagnosis: %+v", d)
|
|
}
|
|
findDiagnostic(t, out, "contractChanged")
|
|
if !sameFiles(before, snapshot(t, dir)) {
|
|
t.Fatal("preview/check changed files")
|
|
}
|
|
}
|
|
o.DryRun, o.Check = false, false
|
|
out, err = Generate(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
findDiagnostic(t, out, "logicSignature")
|
|
if readFile(t, logicPath) != logicBefore {
|
|
t.Fatal("business source overwritten")
|
|
}
|
|
o.Check = true
|
|
out, err = Generate(o)
|
|
if err == nil || strings.Contains(err.Error(), "stale") || len(out.Changes) != 0 {
|
|
t.Fatalf("signature drift must remain after generation: %v %+v", err, out.Changes)
|
|
}
|
|
findDiagnostic(t, out, "logicSignature")
|
|
// Method location and import/parameter names are project-owned. Find the
|
|
// method in a different file, honoring aliases and named result variables.
|
|
writeFile(t, logicPath, "package greet\n// business split into visitor.go\n")
|
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/visitor.go"), `package greet
|
|
import (ctx "context"; dto "example.com/changes/internal/types")
|
|
func(s *Logic)Greet(input ctx.Context, request *dto.HelloReq)(output *dto.GreetResp, problem error){
|
|
return &dto.GreetResp{Message:request.Name},nil
|
|
}
|
|
`)
|
|
out, err = Generate(o)
|
|
if err != nil || len(out.Diagnostics) > 0 {
|
|
t.Fatalf("compatible business method rejected: %v %+v", err, out.Diagnostics)
|
|
}
|
|
before = snapshot(t, dir)
|
|
o.Check = false
|
|
if _, err = Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sameFiles(before, snapshot(t, dir)) {
|
|
t.Fatal("stable generation changed files")
|
|
}
|
|
// Field review notices should point to the actual moved business method.
|
|
writeFile(t, o.API, strings.Replace(readFile(t, o.API), `max=80`, `max=90`, 1))
|
|
o.DryRun = true
|
|
out, err = Generate(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := findDiagnostic(t, out, "contractChanged")
|
|
if d.Path != "internal/logic/greet/visitor.go" || d.Line == 0 {
|
|
t.Fatalf("wrong implementation location: %+v", d)
|
|
}
|
|
o.DryRun = false
|
|
if _, err = Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...")
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
|
if data, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("fixed generated consumer: %v\n%s", err, data)
|
|
}
|
|
}
|
|
|
|
func TestContractChangesReachNestedDTOs(t *testing.T) {
|
|
source := `syntax = "v1"
|
|
type Address { Street string ` + "`json:\"street\"`" + ` }
|
|
type Request { Address Address ` + "`json:\"address\"`" + ` }
|
|
type Reply { Accepted bool ` + "`json:\"accepted\"`" + ` }
|
|
@server(group: orders)
|
|
service orders-api {
|
|
@handler create
|
|
post /orders (Request) returns (Reply)
|
|
@handler ping
|
|
get /ping
|
|
}
|
|
`
|
|
root := t.TempDir()
|
|
api := filepath.Join(root, "source/service.api")
|
|
writeFile(t, api, source)
|
|
dir := filepath.Join(root, "app")
|
|
if _, err := Generate(Options{API: api, Dir: dir, Module: "example.com/nested", FrameworkDir: frameworkRoot(t)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o := Options{API: filepath.Join(dir, "api/service.api"), Dir: dir, DryRun: true}
|
|
writeFile(t, o.API, strings.Replace(source, "Street string", "Street int64", 1))
|
|
out, err := Generate(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out.Diagnostics) != 1 {
|
|
t.Fatalf("unrelated routes should not be warned: %+v", out.Diagnostics)
|
|
}
|
|
d := findDiagnostic(t, out, "contractChanged")
|
|
if d.Path != "internal/logic/orders/create.go" || !strings.Contains(strings.Join(d.Details, "\n"), "Address.Street: string") {
|
|
t.Fatalf("nested field not explained: %+v", d)
|
|
}
|
|
// Import/file/comment/formatting changes are not semantic DTO changes.
|
|
writeFile(t, o.API, source+"\n// a harmless documentation edit\n")
|
|
out, err = Generate(o)
|
|
if err != nil || len(out.Diagnostics) > 0 {
|
|
t.Fatalf("comment caused false diagnostic: %v %+v", err, out.Diagnostics)
|
|
}
|
|
// Removal preserves the old implementation and explicitly points it out.
|
|
writeFile(t, o.API, strings.Replace(source, " @handler create\n post /orders (Request) returns (Reply)\n", "", 1))
|
|
out, err = Generate(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d = findDiagnostic(t, out, "routeRemoved")
|
|
if d.Path != "internal/logic/orders/create.go" {
|
|
t.Fatal(d)
|
|
}
|
|
}
|
|
|
|
func TestContractSnapshotSemanticDetails(t *testing.T) {
|
|
base := &Spec{Types: []Type{{Name: "Req", Fields: []Field{{Name: "ID", Type: "ID", Tag: `form:"id"`}}}}, Scalars: []ScalarDefinition{{Name: "ID", GoImport: "example.com/id", GoType: "Value", JSONInput: "string", JSONOutput: "string"}}, Routes: []Route{{Group: "files", Handler: "send", Method: "post", Path: "/file", Request: "Req", BodyMode: "raw", MaxBodyBytes: 64, Produces: "text/plain"}}}
|
|
old := snapshotContract(base)
|
|
base.Scalars[0].Text = true
|
|
base.Routes[0].BodyMode = "multipart"
|
|
base.Routes[0].Produces = "application/octet-stream"
|
|
base.Routes[0].MaxBodyBytes = 128
|
|
base.Routes[0].Path = "/files"
|
|
diagnostics := compareContracts(old, snapshotContract(base))
|
|
if len(diagnostics) != 1 {
|
|
t.Fatal(diagnostics)
|
|
}
|
|
details := strings.Join(diagnostics[0].Details, "\n")
|
|
for _, want := range []string{"scalar ID", "body=raw", "body=multipart", "POST /file -> POST /files", "maxBytes=128"} {
|
|
if !strings.Contains(details, want) {
|
|
t.Fatal("missing", want, details)
|
|
}
|
|
}
|
|
base.Routes[0].Produces = ""
|
|
diagnostics = compareContracts(old, snapshotContract(base))
|
|
if !strings.Contains(diagnostics[0].Before, "*httpx.Stream") || !strings.HasSuffix(diagnostics[0].After, ") error") {
|
|
t.Fatal(diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestLogicDiagnosticsSourceBoundaries(t *testing.T) {
|
|
for _, tc := range []struct{ name, source, code, severity string }{
|
|
{"wrongResult", `package greet; import "context"; func(s *Logic)Greet(context.Context) error{return nil}`, "logicSignature", "error"},
|
|
{"syntax", `package greet; func(s *Logic)Greet( {`, "logicSyntax", "error"},
|
|
{"alias", `package greet; import("context";"example.com/test/internal/types");type Input = types.GreetReq; func(s *Logic)Greet(context.Context,*Input)(*types.GreetResp,error){return nil,nil}`, "logicUnchecked", "review"},
|
|
{"conditional", "//go:build projectTag\n\npackage greet\n", "logicUnchecked", "review"},
|
|
{"missing", "package greet\n", "logicNotFound", "review"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "app")
|
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), tc.source)
|
|
before := snapshot(t, dir)
|
|
out, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true})
|
|
d := findDiagnostic(t, out, tc.code)
|
|
if d.Severity != tc.severity || (tc.severity == "error") != (err != nil) {
|
|
t.Fatalf("unexpected check result: %v %+v", err, d)
|
|
}
|
|
if !sameFiles(before, snapshot(t, dir)) {
|
|
t.Fatal("analysis modified business source")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestContractSnapshotBootstrapAndDeterminism(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "app")
|
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(dir, manifestPath)
|
|
var previous manifest
|
|
if err := json.Unmarshal([]byte(readFile(t, path)), &previous); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
previous.Contract = nil
|
|
data, _ := json.MarshalIndent(previous, "", " ")
|
|
writeFile(t, path, string(data)+"\n")
|
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
|
|
a, err := Generate(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := Generate(o)
|
|
if err != nil || !reflect.DeepEqual(a, b) {
|
|
t.Fatal("non-deterministic preview", err)
|
|
}
|
|
findDiagnostic(t, a, "contractBaseline")
|
|
o.DryRun = false
|
|
if _, err = Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o.Check = true
|
|
out, err := Generate(o)
|
|
if err != nil || len(out.Diagnostics) > 0 {
|
|
t.Fatal(err, out.Diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestLogicResponseAndBodySignatureChanges(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, after string
|
|
change func(string) string
|
|
}{
|
|
{"responseType", "*types.HelloResp", func(s string) string { return strings.ReplaceAll(s, "GreetResp", "HelloResp") }},
|
|
{"bodyParameter", "*httpx.Body", func(s string) string {
|
|
return strings.Replace(s, " get /greet", " @body (mode: raw; maxBytes: 64)\n post /greet", 1)
|
|
}},
|
|
{"streamResult", "*httpx.Stream", func(s string) string {
|
|
return strings.Replace(strings.Replace(s, " returns (GreetResp)", "", 1), " get /greet", " @produces \"text/plain\"\n get /greet", 1)
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "app")
|
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
|
|
writeFile(t, o.API, tc.change(readFile(t, o.API)))
|
|
out, err := Generate(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := findDiagnostic(t, out, "logicSignature")
|
|
if !strings.Contains(d.After, tc.after) {
|
|
t.Fatal(d)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLogicDuplicateAndTestFiles(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "app")
|
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := readFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"))
|
|
// String literals mentioning build constraints do not make a file conditional.
|
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), source+"\nconst diagnosticText=\"//go:build special\"\n")
|
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/fixture_test.go"), source)
|
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}
|
|
out, err := Generate(o)
|
|
if err != nil || len(out.Diagnostics) > 0 {
|
|
t.Fatal(err, out.Diagnostics)
|
|
}
|
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/duplicate.go"), source)
|
|
out, err = Generate(o)
|
|
if err == nil {
|
|
t.Fatal("duplicate method passed check")
|
|
}
|
|
d := findDiagnostic(t, out, "logicDuplicate")
|
|
if len(d.Details) != 2 || !strings.Contains(d.Details[0], "duplicate.go:") {
|
|
t.Fatal(d)
|
|
}
|
|
}
|