- Record/replay baselines plus mutated fixtures for nil vs [], dates, tri-state bools, envelopes, money and ids - Header, CSV/image byte, sidecar digest and two-flow continuation cases fail with path diagnostics - Manifest coverage still reports later flows after a comparison failure Co-authored-by: Cursor <cursoragent@cursor.com>
190 lines
9.0 KiB
Go
190 lines
9.0 KiB
Go
package tide
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestDiffContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
baseline := `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`
|
|
reordered := `{"meta":{"total":1,"per_page":15,"last_page":1,"current_page":1},"data":[{"deleted_at":null,"created_at":"2026-01-01T00:00:00+00:00","is_owner":true,"tracklist":[],"price":"1.5000","slug":"keep-me","id":9}]}`
|
|
|
|
spec := Flow{Version: 1, Name: "diff-contract", Steps: []Step{{
|
|
ID: "get",
|
|
Request: Request{Method: http.MethodGet, Path: "/item"},
|
|
}}}
|
|
orig := jsonServer(t, baseline)
|
|
recorded, err := RecordFlow(ctx, spec, RecordConfig{Target: orig.URL})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ReplayFlow(ctx, recorded, ReplayConfig{Target: orig.URL}); err != nil {
|
|
t.Fatalf("baseline must pass: %v", err)
|
|
}
|
|
if _, err := ReplayFlow(ctx, recorded, ReplayConfig{Target: jsonServer(t, reordered).URL}); err != nil {
|
|
t.Fatalf("object key reorder must pass: %v", err)
|
|
}
|
|
|
|
type mutation struct {
|
|
name string
|
|
live string
|
|
path string
|
|
}
|
|
cases := []mutation{
|
|
{"null vs array", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":null,"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "tracklist"},
|
|
{"empty object vs array", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":{},"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "tracklist"},
|
|
{"carbon Z vs +00:00", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00Z","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "created_at"},
|
|
{"non-date text", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"yesterday","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "created_at"},
|
|
{"date present-null vs absent", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00"}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "deleted_at"},
|
|
{"tri-state true vs false", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":false,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "is_owner"},
|
|
{"tri-state true vs null", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":null,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "is_owner"},
|
|
{"missing meta envelope", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}]}`, "meta"},
|
|
{"extra links envelope key", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1},"links":{}}`, "links"},
|
|
{"conditional reservation key", `{"data":[{"id":1,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null,"reservation":{"id":2}}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "reservation"},
|
|
{"money string vs number", `{"data":[{"id":1,"slug":"keep-me","price":1.5,"tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "price"},
|
|
{"integer id vs string", `{"data":[{"id":"1","slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "id"},
|
|
{"integer id vs fraction", `{"data":[{"id":1.5,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "id"},
|
|
{"exact slug mismatch", `{"data":[{"id":1,"slug":"other","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-01-01T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`, "slug"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
mut := cloneRecorded(t, recorded)
|
|
mut.Steps[0].Response.Body = Body(tc.live)
|
|
_, err := ReplayFlow(ctx, mut, ReplayConfig{Target: orig.URL})
|
|
if err == nil {
|
|
t.Fatal("mutated fixture must fail")
|
|
}
|
|
assertPathMismatch(t, err, tc.path)
|
|
})
|
|
}
|
|
|
|
t.Run("csv exact-byte mismatch", func(t *testing.T) {
|
|
want := csvServer(t, "a,b\n1,2\n", `attachment; filename="albums.csv"`)
|
|
got := csvServer(t, "a,b\n1,3\n", `attachment; filename="albums.csv"`)
|
|
spec := Flow{Version: 1, Name: "csv", Steps: []Step{{
|
|
ID: "export",
|
|
Request: Request{Method: http.MethodGet, Path: "/export"},
|
|
}}}
|
|
rec, err := RecordFlow(ctx, spec, RecordConfig{Target: want.URL})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ReplayFlow(ctx, rec, ReplayConfig{Target: want.URL}); err != nil {
|
|
t.Fatalf("csv baseline must pass: %v", err)
|
|
}
|
|
_, err = ReplayFlow(ctx, rec, ReplayConfig{Target: got.URL})
|
|
if err == nil {
|
|
t.Fatal("csv byte mismatch must fail")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "byte") && !strings.Contains(msg, "body[") {
|
|
t.Fatalf("csv mismatch missing byte diagnostic: %s", msg)
|
|
}
|
|
})
|
|
|
|
t.Run("image exact-byte mismatch", func(t *testing.T) {
|
|
want := binaryServer(t, "image/png", []byte{0x89, 'P', 'N', 'G', 1})
|
|
got := binaryServer(t, "image/png", []byte{0x89, 'P', 'N', 'G', 2})
|
|
spec := Flow{Version: 1, Name: "png", Steps: []Step{{
|
|
ID: "cover",
|
|
Request: Request{Method: http.MethodGet, Path: "/cover"},
|
|
}}}
|
|
rec, err := RecordFlow(ctx, spec, RecordConfig{Target: want.URL})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ReplayFlow(ctx, rec, ReplayConfig{Target: want.URL}); err != nil {
|
|
t.Fatalf("image baseline must pass: %v", err)
|
|
}
|
|
_, err = ReplayFlow(ctx, rec, ReplayConfig{Target: got.URL})
|
|
if err == nil {
|
|
t.Fatal("image byte mismatch must fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "byte") && !strings.Contains(err.Error(), "body[") {
|
|
t.Fatalf("image mismatch missing byte diagnostic: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("per-step normalization disable", func(t *testing.T) {
|
|
later := jsonServer(t, `{"data":[{"id":9,"slug":"keep-me","price":"1.5000","tracklist":[],"is_owner":true,"created_at":"2026-02-02T00:00:00+00:00","deleted_at":null}],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":1}}`)
|
|
if _, err := ReplayFlow(ctx, recorded, ReplayConfig{Target: later.URL}); err != nil {
|
|
t.Fatalf("masked dates/ids must pass: %v", err)
|
|
}
|
|
disabled := cloneRecorded(t, recorded)
|
|
disabled.Steps[0].Normalize = []NormalizeRule{{Path: "created_at", Disable: true}}
|
|
_, err := ReplayFlow(ctx, disabled, ReplayConfig{Target: later.URL})
|
|
if err == nil {
|
|
t.Fatal("disabled date mask must fail")
|
|
}
|
|
assertPathMismatch(t, err, "created_at")
|
|
})
|
|
}
|
|
|
|
func cloneRecorded(t *testing.T, flow Flow) Flow {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "clone.yaml")
|
|
if err := SaveFlow(path, flow); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cloned, err := LoadFlow(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func assertPathMismatch(t *testing.T, err error, path string) {
|
|
t.Helper()
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, path) {
|
|
t.Fatalf("want path %s in %s", path, msg)
|
|
}
|
|
if !strings.Contains(msg, "expected") || !strings.Contains(msg, "actual") {
|
|
t.Fatalf("want expected/actual diagnostic in %s", msg)
|
|
}
|
|
var mis *MismatchError
|
|
if !errors.As(err, &mis) {
|
|
return
|
|
}
|
|
found := false
|
|
for _, step := range mis.Result.Steps {
|
|
for _, d := range step.Diffs {
|
|
if strings.Contains(d.Path, path) && d.Expected != d.Actual {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("result diffs missing path %s: %+v", path, mis.Result.Steps)
|
|
}
|
|
}
|
|
|
|
func csvServer(t *testing.T, body, disposition string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/csv")
|
|
w.Header().Set("Content-Disposition", disposition)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func binaryServer(t *testing.T, ct string, body []byte) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", ct)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|