diff --git a/internal/compiler/validate_test.go b/internal/compiler/validate_test.go index 29630cf..5641ca6 100644 --- a/internal/compiler/validate_test.go +++ b/internal/compiler/validate_test.go @@ -3621,6 +3621,31 @@ func TestValidateManifestRejectsInvalidContractReferenceRoutes(t *testing.T) { } } +func TestValidateManifestRejectsDefaultContractRouteOnDynamicPage(t *testing.T) { + app := appFixture{ + Pages: []gwdkir.Page{{ + Package: "pages", + ID: "blog.show", + Route: "/blog/{slug}", + Source: "pages/blog-show.page.gwdk", + Blocks: gwdkir.Blocks{ + Paths: true, + View: true, + ViewBody: `
`, + }, + }}, + } + + err := validateManifest(gowdk.Config{}, app) + if err == nil { + t.Fatal("expected invalid dynamic default contract route diagnostic") + } + diagnostics := err.(ValidationErrors) + if !hasDiagnosticMessage(diagnostics, "contract_route_invalid", "dynamic page route", "/blog/{slug}") { + t.Fatalf("Missing dynamic default contract_route_invalid diagnostic: %#v", diagnostics) + } +} + func TestValidateManifestRejectsDefaultQueryRouteWithDynamicParams(t *testing.T) { app := appFixture{ Pages: []gwdkir.Page{{ @@ -3643,7 +3668,7 @@ func TestValidateManifestRejectsDefaultQueryRouteWithDynamicParams(t *testing.T) if !hasDiagnosticCode(diagnostics, "contract_route_invalid") { t.Fatalf("Missing contract_route_invalid diagnostic: %#v", diagnostics) } - if !strings.Contains(diagnostics[0].Message, "without query, fragment, or params") { + if !strings.Contains(diagnostics[0].Message, "dynamic page route") { t.Fatalf("unexpected diagnostic message: %s", diagnostics[0].Message) } } @@ -3820,6 +3845,29 @@ func TestValidateManifestRejectsRouteMethodConflicts(t *testing.T) { } }) + t.Run("default command route conflicts with inherited action route", func(t *testing.T) { + app := appFixture{ + Pages: []gwdkir.Page{{ + ID: "patients", + Route: "/patients", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
`, + Actions: []gwdkir.Action{{Name: "Save"}}, + }, + }}, + } + + err := validateManifest(gowdk.Config{}, app) + if err == nil { + t.Fatal("expected default command/action route method conflict") + } + diagnostics := err.(ValidationErrors) + if !hasDiagnosticMessage(diagnostics, "route_method_conflict", "POST", "/patients", "command contract patients.CreatePatient", "action patients.Save") { + t.Fatalf("Missing default command/action route_method_conflict diagnostic: %#v", diagnostics) + } + }) + t.Run("duplicate command routes conflict", func(t *testing.T) { app := appFixture{ Pages: []gwdkir.Page{{ diff --git a/internal/gwdkanalysis/ir_contracts.go b/internal/gwdkanalysis/ir_contracts.go index 89fb173..ceddf5a 100644 --- a/internal/gwdkanalysis/ir_contracts.go +++ b/internal/gwdkanalysis/ir_contracts.go @@ -24,12 +24,22 @@ func appendContractReferences(program *gwdkir.Program, template gwdkir.Template) method := source.BackendRouteMethod(ref.Method) path := ref.Path if path == "" && template.Route != "" { - if ref.Kind == view.ContractReferenceQuery { + routeIsDynamic := routeHasDynamicParams(template.Route) + if routeIsDynamic { + program.Diagnostics = append(program.Diagnostics, gwdkir.Diagnostic{ + Code: "contract_route_invalid", + Source: template.Source, + Span: templateOffsetSpan(template, ref.Start, ref.End), + Message: fmt.Sprintf("%s %s must declare an explicit route on dynamic page route %q", irContractReferenceKind(ref.Kind), ref.Name, template.Route), + }) + } else if ref.Kind == view.ContractReferenceQuery { method = "GET" } else if method == "" { method = "POST" } - path = template.Route + if !routeIsDynamic { + path = template.Route + } } importAlias, contractType := splitContractReferenceName(ref.Name) importPath := contractReferenceImportPath(template.Imports, importAlias) @@ -51,6 +61,10 @@ func appendContractReferences(program *gwdkir.Program, template gwdkir.Template) } } +func routeHasDynamicParams(route string) bool { + return strings.Contains(route, "{") +} + func splitContractReferenceName(name string) (string, string) { before, after, ok := strings.Cut(name, ".") if !ok { diff --git a/internal/gwdkanalysis/ir_contracts_test.go b/internal/gwdkanalysis/ir_contracts_test.go index 5befb30..9d2d84d 100644 --- a/internal/gwdkanalysis/ir_contracts_test.go +++ b/internal/gwdkanalysis/ir_contracts_test.go @@ -34,6 +34,38 @@ func TestBuildProgramDerivesPageOwnedContractRoutes(t *testing.T) { } } +func TestBuildProgramRejectsDynamicPageOwnedDefaultContractRoutes(t *testing.T) { + program := BuildProgram(gowdk.Config{}, Sources{Pages: []gwdkir.Page{{ + Package: "pages", + ID: "blog.show", + Route: "/blog/{slug}", + Blocks: gwdkir.Blocks{ + Paths: true, + View: true, + ViewBody: `
+
+
+
`, + }, + }}}) + + refs := contractRefsByName(program.ContractRefs) + if ref := refs["posts.CreateComment"]; ref.Method != "POST" || ref.Path != "" { + t.Fatalf("dynamic default command method/path = %s %s, want POST with empty non-routable path", ref.Method, ref.Path) + } + if ref := refs["posts.GetComments"]; ref.Method != "" || ref.Path != "" { + t.Fatalf("dynamic default query method/path = %s %s, want empty non-routable metadata", ref.Method, ref.Path) + } + if len(program.Diagnostics) != 2 { + t.Fatalf("expected two dynamic default contract route diagnostics, got %#v", program.Diagnostics) + } + for _, diagnostic := range program.Diagnostics { + if diagnostic.Code != "contract_route_invalid" { + t.Fatalf("expected contract_route_invalid diagnostic, got %#v", diagnostic) + } + } +} + func TestBuildProgramKeepsComponentQueryNonRoutable(t *testing.T) { program := BuildProgram(gowdk.Config{}, Sources{Components: []gwdkir.Component{{ Package: "components",