refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
189
modules/tide/diff_contract_test.go
Normal file
189
modules/tide/diff_contract_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user