Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 4 additions & 26 deletions wftest/bdd/steps_assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ package bdd

import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"

"github.com/GoCodeAlone/workflow/wftest"
"github.com/cucumber/godog"
)

Expand Down Expand Up @@ -162,7 +161,7 @@ func (sc *ScenarioContext) theResponseJSONShouldBe(path, expected string) error
if err := sc.ensureResult(); err != nil {
return err
}
val, err := jsonPath(sc.result.RawBody, path)
val, err := wftest.JSONPath(sc.result.RawBody, path)
if err != nil {
return err
}
Expand All @@ -178,11 +177,11 @@ func (sc *ScenarioContext) theResponseJSONShouldNotBeEmpty(path string) error {
if err := sc.ensureResult(); err != nil {
return err
}
val, err := jsonPath(sc.result.RawBody, path)
val, err := wftest.JSONPath(sc.result.RawBody, path)
if err != nil {
return err
}
if val == nil || fmt.Sprintf("%v", val) == "" {
if wftest.IsJSONEmpty(val) {
return fmt.Errorf("response JSON %q: expected non-empty, got %v", path, val)
}
return nil
Expand All @@ -199,24 +198,3 @@ func (sc *ScenarioContext) theResponseHeaderShouldBe(header, expected string) er
}
return nil
}

// jsonPath traverses a JSON body using a dot-separated path (e.g., "user.name").
func jsonPath(body []byte, path string) (any, error) {
var root any
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("JSON path %q: invalid JSON body: %w", path, err)
}
parts := strings.Split(path, ".")
current := root
for _, part := range parts {
m, ok := current.(map[string]any)
if !ok {
return nil, fmt.Errorf("JSON path %q: cannot traverse into non-object at %q", path, part)
}
current, ok = m[part]
if !ok {
return nil, fmt.Errorf("JSON path %q: key %q not found", path, part)
}
}
return current, nil
}
46 changes: 46 additions & 0 deletions wftest/json_path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package wftest

import (
"encoding/json"
"fmt"
"strings"
)

// JSONPath traverses a JSON body using a dot-separated path (e.g., "user.name").
// Returns the value at the path, or an error if the path cannot be traversed.
func JSONPath(body []byte, path string) (any, error) {
var root any
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("JSON path %q: invalid JSON body: %w", path, err)
}
parts := strings.Split(path, ".")
current := root
for _, part := range parts {
m, ok := current.(map[string]any)
if !ok {
return nil, fmt.Errorf("JSON path %q: cannot traverse into non-object at %q", path, part)
}
current, ok = m[part]
if !ok {
return nil, fmt.Errorf("JSON path %q: key %q not found", path, part)
}
}
return current, nil
}

// IsJSONEmpty reports whether a JSON value should be considered empty.
// A value is empty if it is nil, an empty string, an empty slice, or an empty map.
func IsJSONEmpty(val any) bool {
if val == nil {
return true
}
switch v := val.(type) {
case string:
return v == ""
case []any:
return len(v) == 0
case map[string]any:
return len(v) == 0
}
return false
}
37 changes: 37 additions & 0 deletions wftest/yaml_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -248,6 +249,42 @@ func applyAssertion(t *testing.T, label string, result *Result, a *Assertion, h
if a.Response.Body != "" && !strings.Contains(string(result.RawBody), a.Response.Body) {
t.Errorf("assertion %s: body %q not found in %q", label, a.Response.Body, string(result.RawBody))
}
for path, expected := range a.Response.JSON {
val, err := JSONPath(result.RawBody, path)
if err != nil {
t.Errorf("assertion %s: %v", label, err)
continue
}
wantJSON, err := json.Marshal(expected)
if err != nil {
t.Errorf("assertion %s: JSON %q: cannot marshal expected value: %v", label, path, err)
continue
}
gotJSON, err := json.Marshal(val)
if err != nil {
t.Errorf("assertion %s: JSON %q: cannot marshal actual value: %v", label, path, err)
continue
}
if !bytes.Equal(wantJSON, gotJSON) {
t.Errorf("assertion %s: JSON %q: want %s, got %s", label, path, string(wantJSON), string(gotJSON))
}
}
for _, path := range a.Response.JSONNotEmpty {
val, err := JSONPath(result.RawBody, path)
if err != nil {
t.Errorf("assertion %s: %v", label, err)
continue
}
if IsJSONEmpty(val) {
t.Errorf("assertion %s: JSON %q: expected non-empty, got %v", label, path, val)
}
Comment on lines +272 to +280

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json_not_empty currently treats empty arrays/maps as non-empty because it only checks val == nil or fmt.Sprintf("%v", val) == "". This will incorrectly pass for {} and []. Consider handling common JSON container types explicitly (string/[]any/map[string]any) and checking length == 0 as empty.

Copilot uses AI. Check for mistakes.
}
for header, expected := range a.Response.Headers {
actual := result.Header(http.CanonicalHeaderKey(header))
if actual != expected {
t.Errorf("assertion %s: header %q: want %q, got %q", label, header, expected, actual)
}
Comment on lines +282 to +286

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header assertions use result.Header(header) which does a direct map lookup; this makes header matching case-sensitive even though HTTP header names are case-insensitive. Consider canonicalizing the asserted header name (e.g., via http.CanonicalHeaderKey) before lookup, or updating Result.Header to do case-insensitive matching.

Copilot uses AI. Check for mistakes.
}
return
}

Expand Down
43 changes: 43 additions & 0 deletions wftest/yaml_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,49 @@ func TestYAMLRunner_StatefulTestData(t *testing.T) {
wftest.RunYAMLTests(t, "testdata/stateful_test.yaml")
}

func TestYAMLRunner_ResponseJSON(t *testing.T) {
tmpDir := t.TempDir()
writeFile(t, tmpDir+"/json_test.yaml", `
yaml: |
modules:
- name: router
type: http.router
pipelines:
hello:
trigger:
type: http
config:
path: /hello
method: GET
steps:
- name: respond
type: step.json_response
config:
status: 200
body:
message: "hello"
data:
id: "abc123"
tests:
json-path-check:
trigger:
type: http
path: /hello
Comment on lines +249 to +251

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test YAML sets trigger.method: GET, but the YAML runner’s fireTrigger path for type: http currently ignores method and always issues a GET. Consider removing method here to avoid implying it’s honored (or switch to type: http.get/get if the intent is to test GET semantics).

Copilot uses AI. Check for mistakes.
assertions:
- response:
status: 200
json:
message: "hello"
data.id: "abc123"
json_not_empty:
- message
- data
headers:
Content-Type: "application/json"
`)
wftest.RunYAMLTests(t, tmpDir+"/json_test.yaml")
}

func TestRunYAMLTests_ScheduleTrigger(t *testing.T) {
tmpDir := t.TempDir()
writeFile(t, tmpDir+"/schedule_test.yaml", `
Expand Down
9 changes: 9 additions & 0 deletions wftest/yaml_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,13 @@ type ResponseAssert struct {
Status int `yaml:"status"`
// Body is a substring expected in the response body.
Body string `yaml:"body"`
// JSON maps dot-path keys to expected values for exact JSON path equality checks.
// Example: {"message": "ok", "data.id": "abc123"}
JSON map[string]any `yaml:"json"`
// JSONNotEmpty lists dot-paths that must be present and non-empty in the JSON body.
// Example: ["data", "meta"]
JSONNotEmpty []string `yaml:"json_not_empty"`
// Headers maps response header names to expected values.
// Example: {"Content-Type": "application/json"}
Headers map[string]string `yaml:"headers"`
}
Loading