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 }