From 1847e83dba6a589213b9137af549a9d17b817fcd Mon Sep 17 00:00:00 2001 From: Patrick Dewey <57921252+ptdewey@users.noreply.github.com> Date: Sat, 22 Nov 2025 12:52:09 -0500 Subject: [PATCH] feat!: change name of snapshots to use title rather than test name refactor: api cleanup --- README.md | 109 ++-- ...ub.snap => combined_ignore_and_scrub.snap} | 0 __snapshots__/complex_nested_structure.snap | 78 +++ ...gnore.snap => custom_ignore_function.snap} | 0 ...rubber.snap => custom_regex_scrubber.snap} | 2 +- ...tom_scrubber.snap => custom_scrubber.snap} | 2 +- ...custom_type.snap => custom_type_test.snap} | 4 +- ...crubber.snap => exact_match_scrubber.snap} | 2 +- ...y_values.snap => ignore_empty_values.snap} | 2 +- ...e_in_arrays.snap => ignore_in_arrays.snap} | 2 +- ...y_pattern.snap => ignore_key_pattern.snap} | 2 +- ...snap => ignore_keys_matching_pattern.snap} | 2 +- ...re_keys.snap => ignore_multiple_keys.snap} | 2 +- ...ll_values.snap => ignore_null_values.snap} | 2 +- ..._value.snap => ignore_password_field.snap} | 2 +- ...e_keys.snap => ignore_sensitive_keys.snap} | 0 ...alues.snap => ignore_specific_values.snap} | 2 +- .../json_with_special_characters.snap | 16 + __snapshots__/large_json_structure.snap | 54 ++ .../multiple_complex_structures.snap | 56 ++ __snapshots__/multiple_scrubbers.snap | 27 + ...ultiple.snap => multiple_values_test.snap} | 0 ...terns.snap => nested_ignore_patterns.snap} | 0 __snapshots__/nested_maps_and_slices.snap | 47 ++ ...mple.snap => real_world_api_response.snap} | 0 ...nap_function.snap => scrub_with_snap.snap} | 8 +- ...a_p_i_keys.snap => scrubbed_api_keys.snap} | 2 +- ...ubbing.snap => scrubbed_credit_cards.snap} | 2 +- ...t_scrub_dates.snap => scrubbed_dates.snap} | 2 +- ...scrub_emails.snap => scrubbed_emails.snap} | 2 +- ...b_i_p_addresses.snap => scrubbed_ips.snap} | 2 +- ...t_scrub_j_w_ts.snap => scrubbed_jwts.snap} | 2 +- ...mestamps.snap => scrubbed_timestamps.snap} | 2 +- ...ing.snap => scrubbed_unix_timestamps.snap} | 2 +- ...crub_u_u_i_ds.snap => scrubbed_uuids.snap} | 2 +- ...nap => snapjson_complex_api_response.snap} | 0 ...d_types.snap => snapjson_mixed_types.snap} | 0 ...ects.snap => snapjson_nested_objects.snap} | 0 ....snap => snapjson_real_world_example.snap} | 0 .../structure_with_empty_values.snap | 34 ++ .../structure_with_interface_fields.snap | 76 +++ __snapshots__/structure_with_pointers.snap | 27 + .../test_complex_json_structure.snap | 84 --- .../test_complex_nested_structure.snap | 87 --- __snapshots__/test_deeply_nested_json.snap | 15 - .../test_go_struct_marshalled_to_json.snap | 23 - __snapshots__/test_json_array_of_objects.snap | 34 -- __snapshots__/test_json_numbers.snap | 24 - __snapshots__/test_json_object.snap | 26 - .../test_json_with_mixed_arrays.snap | 54 -- .../test_json_with_special_characters.snap | 16 - .../test_json_with_various_types.snap | 30 - __snapshots__/test_large_json.snap | 54 -- __snapshots__/test_map.snap | 10 - .../test_multiple_complex_structures.snap | 64 --- __snapshots__/test_multiple_scrubbers.snap | 13 - .../test_nested_maps_and_slices.snap | 51 -- .../test_snap_json_array_of_objects.snap | 29 - __snapshots__/test_snap_json_basic.snap | 12 - .../test_snap_json_compact_format.snap | 16 - .../test_snap_json_empty_structures.snap | 18 - ...test_snap_json_large_nested_structure.snap | 103 ---- .../test_snap_json_simple_array.snap | 12 - __snapshots__/test_snap_json_with_nulls.snap | 18 - .../test_snap_json_with_numbers.snap | 34 -- ...est_snap_json_with_special_characters.snap | 15 - __snapshots__/test_snap_string.snap | 7 - .../test_structure_with_empty_values.snap | 38 -- .../test_structure_with_interface.snap | 76 --- .../test_structure_with_pointers.snap | 27 - ignore.go | 151 +++-- ignore_test.go | 288 +++++----- .../files/__snapshots__/accept_title.snap | 7 + internal/files/__snapshots__/testaccept.snap | 7 + internal/files/files.go | 43 +- internal/files/files_test.go | 16 +- internal/review/review.go | 12 +- internal/snapshots/snapshot.go | 15 +- internal/snapshots/snapshot_test.go | 471 ++++++++++++++++ internal/transform/transform_test.go | 521 ++++++++++++++++++ scrubbers.go | 120 +++- scrubbers_test.go | 306 +++++----- shutter.go | 227 +++++--- shutter_test.go | 504 +---------------- 84 files changed, 2235 insertions(+), 2019 deletions(-) rename __snapshots__/{test_combined_ignore_and_scrub.snap => combined_ignore_and_scrub.snap} (100%) create mode 100644 __snapshots__/complex_nested_structure.snap rename __snapshots__/{test_custom_ignore.snap => custom_ignore_function.snap} (100%) rename __snapshots__/{test_regex_scrubber.snap => custom_regex_scrubber.snap} (77%) rename __snapshots__/{test_custom_scrubber.snap => custom_scrubber.snap} (64%) rename __snapshots__/{test_snap_custom_type.snap => custom_type_test.snap} (82%) rename __snapshots__/{test_exact_match_scrubber.snap => exact_match_scrubber.snap} (72%) rename __snapshots__/{test_ignore_empty_values.snap => ignore_empty_values.snap} (76%) rename __snapshots__/{test_ignore_in_arrays.snap => ignore_in_arrays.snap} (88%) rename __snapshots__/{test_ignore_key_pattern.snap => ignore_key_pattern.snap} (72%) rename __snapshots__/{test_ignore_keys_matching.snap => ignore_keys_matching_pattern.snap} (73%) rename __snapshots__/{test_ignore_keys.snap => ignore_multiple_keys.snap} (78%) rename __snapshots__/{test_ignore_null_values.snap => ignore_null_values.snap} (78%) rename __snapshots__/{test_ignore_key_value.snap => ignore_password_field.snap} (76%) rename __snapshots__/{test_ignore_sensitive_keys.snap => ignore_sensitive_keys.snap} (100%) rename __snapshots__/{test_ignore_values.snap => ignore_specific_values.snap} (71%) create mode 100644 __snapshots__/json_with_special_characters.snap create mode 100644 __snapshots__/large_json_structure.snap create mode 100644 __snapshots__/multiple_complex_structures.snap create mode 100644 __snapshots__/multiple_scrubbers.snap rename __snapshots__/{test_snap_multiple.snap => multiple_values_test.snap} (100%) rename __snapshots__/{test_nested_ignore_patterns.snap => nested_ignore_patterns.snap} (100%) create mode 100644 __snapshots__/nested_maps_and_slices.snap rename __snapshots__/{test_complex_real_world_example.snap => real_world_api_response.snap} (100%) rename __snapshots__/{test_scrub_with_snap_function.snap => scrub_with_snap.snap} (58%) rename __snapshots__/{test_scrub_a_p_i_keys.snap => scrubbed_api_keys.snap} (81%) rename __snapshots__/{test_credit_card_scrubbing.snap => scrubbed_credit_cards.snap} (81%) rename __snapshots__/{test_scrub_dates.snap => scrubbed_dates.snap} (80%) rename __snapshots__/{test_scrub_emails.snap => scrubbed_emails.snap} (78%) rename __snapshots__/{test_scrub_i_p_addresses.snap => scrubbed_ips.snap} (76%) rename __snapshots__/{test_scrub_j_w_ts.snap => scrubbed_jwts.snap} (75%) rename __snapshots__/{test_scrub_timestamps.snap => scrubbed_timestamps.snap} (81%) rename __snapshots__/{test_unix_timestamp_scrubbing.snap => scrubbed_unix_timestamps.snap} (78%) rename __snapshots__/{test_scrub_u_u_i_ds.snap => scrubbed_uuids.snap} (78%) rename __snapshots__/{test_snap_json_complex_a_p_i.snap => snapjson_complex_api_response.snap} (100%) rename __snapshots__/{test_snap_json_mixed_types.snap => snapjson_mixed_types.snap} (100%) rename __snapshots__/{test_snap_json_with_nested_objects.snap => snapjson_nested_objects.snap} (100%) rename __snapshots__/{test_snap_json_real_world_example.snap => snapjson_real_world_example.snap} (100%) create mode 100644 __snapshots__/structure_with_empty_values.snap create mode 100644 __snapshots__/structure_with_interface_fields.snap create mode 100644 __snapshots__/structure_with_pointers.snap delete mode 100644 __snapshots__/test_complex_json_structure.snap delete mode 100644 __snapshots__/test_complex_nested_structure.snap delete mode 100644 __snapshots__/test_deeply_nested_json.snap delete mode 100644 __snapshots__/test_go_struct_marshalled_to_json.snap delete mode 100644 __snapshots__/test_json_array_of_objects.snap delete mode 100644 __snapshots__/test_json_numbers.snap delete mode 100644 __snapshots__/test_json_object.snap delete mode 100644 __snapshots__/test_json_with_mixed_arrays.snap delete mode 100644 __snapshots__/test_json_with_special_characters.snap delete mode 100644 __snapshots__/test_json_with_various_types.snap delete mode 100644 __snapshots__/test_large_json.snap delete mode 100644 __snapshots__/test_map.snap delete mode 100644 __snapshots__/test_multiple_complex_structures.snap delete mode 100644 __snapshots__/test_multiple_scrubbers.snap delete mode 100644 __snapshots__/test_nested_maps_and_slices.snap delete mode 100644 __snapshots__/test_snap_json_array_of_objects.snap delete mode 100644 __snapshots__/test_snap_json_basic.snap delete mode 100644 __snapshots__/test_snap_json_compact_format.snap delete mode 100644 __snapshots__/test_snap_json_empty_structures.snap delete mode 100644 __snapshots__/test_snap_json_large_nested_structure.snap delete mode 100644 __snapshots__/test_snap_json_simple_array.snap delete mode 100644 __snapshots__/test_snap_json_with_nulls.snap delete mode 100644 __snapshots__/test_snap_json_with_numbers.snap delete mode 100644 __snapshots__/test_snap_json_with_special_characters.snap delete mode 100644 __snapshots__/test_snap_string.snap delete mode 100644 __snapshots__/test_structure_with_empty_values.snap delete mode 100644 __snapshots__/test_structure_with_interface.snap delete mode 100644 __snapshots__/test_structure_with_pointers.snap create mode 100644 internal/files/__snapshots__/accept_title.snap create mode 100644 internal/files/__snapshots__/testaccept.snap create mode 100644 internal/snapshots/snapshot_test.go create mode 100644 internal/transform/transform_test.go diff --git a/README.md b/README.md index 6ad35e1..8a315ad 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# shutter +# Shutter A [birdie](https://github.com/giacomocavalieri/birdie) and [insta](https://github.com/mitsuhiko/insta) inspired snapshot testing library for Go. @@ -25,6 +25,20 @@ func TestSomething(t *testing.T) { } ``` +### Snapshotting Multiple Values + +Use `SnapMany()` when you need to snapshot multiple related values together: + +```go +func TestMultipleValues(t *testing.T) { + request := buildRequest() + response := handleRequest(request) + + // Snapshot both request and response together + shutter.SnapMany(t, "request and response", []any{request, response}) +} +``` + ### Advanced Usage: Scrubbers and Ignore Patterns shutter supports data scrubbing and field filtering to handle dynamic or sensitive data in snapshots. @@ -39,35 +53,35 @@ func TestUserAPI(t *testing.T) { // Replace UUIDs and timestamps with placeholders shutter.Snap(t, "user", user, - shutter.ScrubUUIDs(), - shutter.ScrubTimestamps(), + shutter.ScrubUUID(), + shutter.ScrubTimestamp(), ) } ``` **Built-in Scrubbers:** -- `ScrubUUIDs()` - Replaces UUIDs with `` -- `ScrubTimestamps()` - Replaces ISO8601 timestamps with `` -- `ScrubEmails()` - Replaces email addresses with `` -- `ScrubIPAddresses()` - Replaces IPv4 addresses with `` -- `ScrubJWTs()` - Replaces JWT tokens with `` -- `ScrubCreditCards()` - Replaces credit card numbers with `` -- `ScrubAPIKeys()` - Replaces API keys with `` -- `ScrubDates()` - Replaces various date formats with `` -- `ScrubUnixTimestamps()` - Replaces Unix timestamps with `` +- `ScrubUUID()` - Replaces UUIDs with `` +- `ScrubTimestamp()` - Replaces ISO8601 timestamps with `` +- `ScrubEmail()` - Replaces email addresses with `` +- `ScrubIP()` - Replaces IPv4 addresses with `` +- `ScrubJWT()` - Replaces JWT tokens with `` +- `ScrubCreditCard()` - Replaces credit card numbers with `` +- `ScrubAPIKey()` - Replaces API keys with `` +- `ScrubDate()` - Replaces various date formats with `` +- `ScrubUnixTimestamp()` - Replaces Unix timestamps with `` **Custom Scrubbers:** ```go // Using regex patterns -shutter.RegexScrubber(`user-\d+`, "") +shutter.ScrubRegex(`user-\d+`, "") // Using exact string matching -shutter.ExactMatchScrubber("secret_value", "") +shutter.ScrubExact("secret_value", "") // Using custom functions -shutter.CustomScrubber(func(content string) string { +shutter.ScrubWith(func(content string) string { return strings.ReplaceAll(content, "localhost", "") }) ``` @@ -79,39 +93,40 @@ Ignore patterns remove specific fields from JSON structures before snapshotting: ```go func TestAPIResponse(t *testing.T) { response := api.GetData() + jsonBytes, _ := json.Marshal(response) // Ignore sensitive fields and null values - shutter.SnapJSON(t, "response", response, - shutter.IgnoreSensitiveKeys(), - shutter.IgnoreNullValues(), - shutter.IgnoreKeys("created_at", "updated_at"), + shutter.SnapJSON(t, "response", string(jsonBytes), + shutter.IgnoreSensitive(), + shutter.IgnoreNull(), + shutter.IgnoreKey("created_at", "updated_at"), ) } ``` **Built-in Ignore Patterns:** -- `IgnoreSensitiveKeys()` - Ignores common sensitive keys (password, token, api_key, etc.) -- `IgnoreEmptyValues()` - Ignores fields with empty string values -- `IgnoreNullValues()` - Ignores fields with null values +- `IgnoreSensitive()` - Ignores common sensitive keys (password, token, api_key, etc.) +- `IgnoreEmpty()` - Ignores fields with empty string values +- `IgnoreNull()` - Ignores fields with null values **Custom Ignore Patterns:** ```go // Ignore specific keys -shutter.IgnoreKeys("id", "timestamp", "version") +shutter.IgnoreKey("id", "timestamp", "version") // Ignore key-value pairs shutter.IgnoreKeyValue("status", "pending") // Ignore keys matching a regex pattern -shutter.IgnoreKeysMatching(`^_.*`) // Ignore all keys starting with underscore +shutter.IgnoreKeyMatching(`^_.*`) // Ignore all keys starting with underscore // Ignore specific values -shutter.IgnoreValues("null", "undefined", "") +shutter.IgnoreValue("null", "undefined", "") // Using custom functions -shutter.CustomIgnore(func(key, value string) bool { +shutter.IgnoreWith(func(key, value string) bool { return strings.HasPrefix(key, "temp_") }) ``` @@ -123,30 +138,36 @@ You can combine multiple scrubbers and ignore patterns: ```go func TestComplexData(t *testing.T) { data := generateTestData() - - shutter.Snap(t, "data", data, - // Scrubbers - shutter.ScrubUUIDs(), - shutter.ScrubTimestamps(), - shutter.ScrubEmails(), - - // Ignore patterns - shutter.IgnoreSensitiveKeys(), - shutter.IgnoreKeys("debug_info"), - shutter.IgnoreNullValues(), + jsonBytes, _ := json.Marshal(data) + + shutter.SnapJSON(t, "data", string(jsonBytes), + // First, remove unwanted fields + shutter.IgnoreSensitive(), + shutter.IgnoreKey("debug_info"), + shutter.IgnoreNull(), + + // Then, scrub dynamic values in remaining fields + shutter.ScrubUUID(), + shutter.ScrubTimestamp(), + shutter.ScrubEmail(), ) } ``` +**Note:** Ignore patterns only work with `SnapJSON()`. Use scrubbers with `Snap()`, `SnapMany()`, or `SnapString()`. + #### API Reference -All snapshot functions support options as variadic parameters: +**Snapshot Functions:** ```go -// For general values (structs, maps, slices, etc.) +// For single values (structs, maps, slices, etc.) shutter.Snap(t, "title", value, options...) -// For JSON strings +// For multiple related values +shutter.SnapMany(t, "title", []any{value1, value2, value3}, options...) + +// For JSON strings (supports both scrubbers and ignore patterns) shutter.SnapJSON(t, "title", jsonString, options...) // For plain strings @@ -161,7 +182,7 @@ To review a set of snapshots, run: go run github.com/ptdewey/shutter/cmd/shutter review ``` -shutter can also be used programmatically: +Shutter can also be used programmatically: ```go // Example: tools/shutter/main.go @@ -181,7 +202,7 @@ Which can then be run with: go run tools/shutter/main.go ``` -shutter also includes (in a separate Go module) a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). +Shutter also includes (in a separate Go module) a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). (The TUI is shipped in a separate module to make the added dependencies optional) ### TUI Usage @@ -210,10 +231,6 @@ go run github.com/ptdewey/shutter/cmd/tui accept-all go run github.com/ptdewey/shutter/cmd/tui reject-all ``` -## Disclaimer - -- This package was largely vibe coded, your mileage may vary (but this library provides more of what I want than the ones below). - ## Other Libraries - [go-snaps](https://github.com/gkampitakis/go-snaps) diff --git a/__snapshots__/test_combined_ignore_and_scrub.snap b/__snapshots__/combined_ignore_and_scrub.snap similarity index 100% rename from __snapshots__/test_combined_ignore_and_scrub.snap rename to __snapshots__/combined_ignore_and_scrub.snap diff --git a/__snapshots__/complex_nested_structure.snap b/__snapshots__/complex_nested_structure.snap new file mode 100644 index 0000000..9e987b6 --- /dev/null +++ b/__snapshots__/complex_nested_structure.snap @@ -0,0 +1,78 @@ +--- +title: Complex Nested Structure +test_name: TestComplexNestedStructure +file_name: shutter_test.go +version: 0.1.0 +--- +shutter_test.Post{ + ID: 100, + Title: "Introduction to Go Snapshot Testing", + Content: "This is a comprehensive guide to snapshot testing in Go...", + Author: shutter_test.User{ + ID: 1, + Username: "john_doe", + Email: "john@example.com", + Active: true, + CreatedAt: time.Time{ + wall: 0x0, + ext: 63809375400, + loc: (*time.Location)(nil), + }, + Roles: []string{"admin", "moderator", "user"}, + Metadata: map[string]interface{}{ + "language": "en", + "notifications": true, + "preferences": map[string]interface{}{ + "email_frequency": "weekly", + "notifications": true, + }, + "theme": "dark", + }, + }, + Tags: []string{"go", "testing", "snapshots", "best-practices"}, + Comments: []shutter_test.Comment{ + { + ID: 1, + Author: "alice", + Content: "Great post!", + CreatedAt: time.Time{ + wall: 0x0, + ext: 63810858120, + loc: (*time.Location)(nil), + }, + Replies: []shutter_test.Comment{ + { + ID: 2, + Author: "bob", + Content: "I agree!", + CreatedAt: time.Time{ + wall: 0x0, + ext: 63810863100, + loc: (*time.Location)(nil), + }, + Replies: []shutter_test.Comment{ + }, + }, + }, + }, + { + ID: 3, + Author: "charlie", + Content: "Thanks for sharing!", + CreatedAt: time.Time{ + wall: 0x0, + ext: 63810927000, + loc: (*time.Location)(nil), + }, + Replies: []shutter_test.Comment{ + }, + }, + }, + Likes: 42, + Published: true, + CreatedAt: time.Time{ + wall: 0x0, + ext: 63809802000, + loc: (*time.Location)(nil), + }, +} diff --git a/__snapshots__/test_custom_ignore.snap b/__snapshots__/custom_ignore_function.snap similarity index 100% rename from __snapshots__/test_custom_ignore.snap rename to __snapshots__/custom_ignore_function.snap diff --git a/__snapshots__/test_regex_scrubber.snap b/__snapshots__/custom_regex_scrubber.snap similarity index 77% rename from __snapshots__/test_regex_scrubber.snap rename to __snapshots__/custom_regex_scrubber.snap index 7c742b0..e1cde1f 100644 --- a/__snapshots__/test_regex_scrubber.snap +++ b/__snapshots__/custom_regex_scrubber.snap @@ -1,6 +1,6 @@ --- title: Custom Regex Scrubber -test_name: TestRegexScrubber +test_name: TestCustomScrubbers/regex_scrubber file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_custom_scrubber.snap b/__snapshots__/custom_scrubber.snap similarity index 64% rename from __snapshots__/test_custom_scrubber.snap rename to __snapshots__/custom_scrubber.snap index a781505..0cb2c55 100644 --- a/__snapshots__/test_custom_scrubber.snap +++ b/__snapshots__/custom_scrubber.snap @@ -1,6 +1,6 @@ --- title: Custom Scrubber -test_name: TestCustomScrubber +test_name: TestCustomScrubbers/custom_function_scrubber file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_snap_custom_type.snap b/__snapshots__/custom_type_test.snap similarity index 82% rename from __snapshots__/test_snap_custom_type.snap rename to __snapshots__/custom_type_test.snap index 379d393..74b1774 100644 --- a/__snapshots__/test_snap_custom_type.snap +++ b/__snapshots__/custom_type_test.snap @@ -5,6 +5,6 @@ file_name: shutter_test.go version: 0.1.0 --- shutter_test.CustomStruct{ - Name: "Alice", - Age: 30, + Name: "Alice", + Age: 30, } diff --git a/__snapshots__/test_exact_match_scrubber.snap b/__snapshots__/exact_match_scrubber.snap similarity index 72% rename from __snapshots__/test_exact_match_scrubber.snap rename to __snapshots__/exact_match_scrubber.snap index b205731..288c262 100644 --- a/__snapshots__/test_exact_match_scrubber.snap +++ b/__snapshots__/exact_match_scrubber.snap @@ -1,6 +1,6 @@ --- title: Exact Match Scrubber -test_name: TestExactMatchScrubber +test_name: TestCustomScrubbers/exact_match_scrubber file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_ignore_empty_values.snap b/__snapshots__/ignore_empty_values.snap similarity index 76% rename from __snapshots__/test_ignore_empty_values.snap rename to __snapshots__/ignore_empty_values.snap index ea1a9c9..ec59716 100644 --- a/__snapshots__/test_ignore_empty_values.snap +++ b/__snapshots__/ignore_empty_values.snap @@ -1,6 +1,6 @@ --- title: Ignore Empty Values -test_name: TestIgnoreEmptyValues +test_name: TestIgnoreValues/empty_values file_name: ignore_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_ignore_in_arrays.snap b/__snapshots__/ignore_in_arrays.snap similarity index 88% rename from __snapshots__/test_ignore_in_arrays.snap rename to __snapshots__/ignore_in_arrays.snap index d9e0d49..963399e 100644 --- a/__snapshots__/test_ignore_in_arrays.snap +++ b/__snapshots__/ignore_in_arrays.snap @@ -1,6 +1,6 @@ --- title: Ignore in Arrays -test_name: TestIgnoreInArrays +test_name: TestIgnoreKeys/arrays file_name: ignore_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_ignore_key_pattern.snap b/__snapshots__/ignore_key_pattern.snap similarity index 72% rename from __snapshots__/test_ignore_key_pattern.snap rename to __snapshots__/ignore_key_pattern.snap index 029fac8..dd7235e 100644 --- a/__snapshots__/test_ignore_key_pattern.snap +++ b/__snapshots__/ignore_key_pattern.snap @@ -1,6 +1,6 @@ --- title: Ignore Key Pattern -test_name: TestIgnoreKeyPattern +test_name: TestIgnoreKeyPatterns/contains_pattern file_name: ignore_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_ignore_keys_matching.snap b/__snapshots__/ignore_keys_matching_pattern.snap similarity index 73% rename from __snapshots__/test_ignore_keys_matching.snap rename to __snapshots__/ignore_keys_matching_pattern.snap index 2119770..9cfcff5 100644 --- a/__snapshots__/test_ignore_keys_matching.snap +++ b/__snapshots__/ignore_keys_matching_pattern.snap @@ -1,6 +1,6 @@ --- title: Ignore Keys Matching Pattern -test_name: TestIgnoreKeysMatching +test_name: TestIgnoreKeyPatterns/prefix_pattern file_name: ignore_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_ignore_keys.snap b/__snapshots__/ignore_multiple_keys.snap similarity index 78% rename from __snapshots__/test_ignore_keys.snap rename to __snapshots__/ignore_multiple_keys.snap index 8f0d2d2..65b7e94 100644 --- a/__snapshots__/test_ignore_keys.snap +++ b/__snapshots__/ignore_multiple_keys.snap @@ -1,6 +1,6 @@ --- title: Ignore Multiple Keys -test_name: TestIgnoreKeys +test_name: TestIgnoreKeys/multiple_keys file_name: ignore_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_ignore_null_values.snap b/__snapshots__/ignore_null_values.snap similarity index 78% rename from __snapshots__/test_ignore_null_values.snap rename to __snapshots__/ignore_null_values.snap index c2052dc..f1b4356 100644 --- a/__snapshots__/test_ignore_null_values.snap +++ b/__snapshots__/ignore_null_values.snap @@ -1,6 +1,6 @@ --- title: Ignore Null Values -test_name: TestIgnoreNullValues +test_name: TestIgnoreValues/null_values file_name: ignore_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_ignore_key_value.snap b/__snapshots__/ignore_password_field.snap similarity index 76% rename from __snapshots__/test_ignore_key_value.snap rename to __snapshots__/ignore_password_field.snap index 46f21b6..dcf47ce 100644 --- a/__snapshots__/test_ignore_key_value.snap +++ b/__snapshots__/ignore_password_field.snap @@ -1,6 +1,6 @@ --- title: Ignore Password Field -test_name: TestIgnoreKeyValue +test_name: TestIgnoreKeys/key_value_pairs file_name: ignore_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_ignore_sensitive_keys.snap b/__snapshots__/ignore_sensitive_keys.snap similarity index 100% rename from __snapshots__/test_ignore_sensitive_keys.snap rename to __snapshots__/ignore_sensitive_keys.snap diff --git a/__snapshots__/test_ignore_values.snap b/__snapshots__/ignore_specific_values.snap similarity index 71% rename from __snapshots__/test_ignore_values.snap rename to __snapshots__/ignore_specific_values.snap index 2b23741..595b8f2 100644 --- a/__snapshots__/test_ignore_values.snap +++ b/__snapshots__/ignore_specific_values.snap @@ -1,6 +1,6 @@ --- title: Ignore Specific Values -test_name: TestIgnoreValues +test_name: TestIgnoreValues/specific_values file_name: ignore_test.go version: 0.1.0 --- diff --git a/__snapshots__/json_with_special_characters.snap b/__snapshots__/json_with_special_characters.snap new file mode 100644 index 0000000..81b2d05 --- /dev/null +++ b/__snapshots__/json_with_special_characters.snap @@ -0,0 +1,16 @@ +--- +title: JSON with Special Characters +test_name: TestJsonWithSpecialCharacters +file_name: shutter_test.go +version: 0.1.0 +--- +map[string]interface{}{ + "backslash": "path\\to\\file", + "emoji": "πŸ˜€ πŸ˜ƒ πŸ˜„ 😁 πŸ˜†", + "english": "Hello, World!", + "escaped": "quotes: \"double\" and 'single'", + "newlines": "line1\nline2\rline3\r\nline4", + "special_chars": "!@#$%^&*()_+-=[]{}|;:,.<>?", + "tabs": "col1\tcol2\tcol3", + "unicode": "こんにけは δΈ–η•Œ 🌍", +} diff --git a/__snapshots__/large_json_structure.snap b/__snapshots__/large_json_structure.snap new file mode 100644 index 0000000..1792ff4 --- /dev/null +++ b/__snapshots__/large_json_structure.snap @@ -0,0 +1,54 @@ +--- +title: Large JSON Structure +test_name: TestLargeJson +file_name: shutter_test.go +version: 0.1.0 +--- +map[string]interface{}{ + "created_at": "2023-01-28T14:30:00Z", + "customer_id": 42.0, + "delivered_at": nil, + "id": 1001.0, + "products": []interface{}{ + map[string]interface{}{ + "description": "High-performance laptop", + "id": 1.0, + "in_stock": true, + "name": "Laptop", + "price": 999.99, + "stock": 5.0, + "tags": []interface{}{ + "electronics", + "computers", + "laptops", + }, + }, + map[string]interface{}{ + "description": "Wireless mouse", + "id": 2.0, + "in_stock": true, + "name": "Mouse", + "price": 29.99, + "stock": 50.0, + "tags": []interface{}{ + "electronics", + "accessories", + }, + }, + map[string]interface{}{ + "description": "Mechanical keyboard", + "id": 3.0, + "in_stock": false, + "name": "Keyboard", + "price": 149.99, + "stock": 0.0, + "tags": []interface{}{ + "electronics", + "accessories", + }, + }, + }, + "shipped_at": "2023-02-01T10:00:00Z", + "status": "shipped", + "total": 1179.97, +} diff --git a/__snapshots__/multiple_complex_structures.snap b/__snapshots__/multiple_complex_structures.snap new file mode 100644 index 0000000..78bb61b --- /dev/null +++ b/__snapshots__/multiple_complex_structures.snap @@ -0,0 +1,56 @@ +--- +title: Multiple Complex Structures +test_name: TestMultipleComplexStructures +file_name: shutter_test.go +version: 0.1.0 +--- +[]shutter_test.User{ + { + ID: 1, + Username: "alice", + Email: "alice@example.com", + Active: true, + CreatedAt: time.Time{ + wall: 0x0, + ext: 0, + loc: (*time.Location)(nil), + }, + Roles: []string{"user", "moderator"}, + Metadata: map[string]interface{}{ + "badge": "verified", + "verified": true, + }, + }, + { + ID: 2, + Username: "bob", + Email: "bob@example.com", + Active: false, + CreatedAt: time.Time{ + wall: 0x0, + ext: 0, + loc: (*time.Location)(nil), + }, + Roles: []string{"user"}, + Metadata: map[string]interface{}{ + "avatar": "https://example.com/bob.jpg", + "verified": false, + }, + }, + { + ID: 3, + Username: "charlie", + Email: "charlie@example.com", + Active: true, + CreatedAt: time.Time{ + wall: 0x0, + ext: 0, + loc: (*time.Location)(nil), + }, + Roles: []string{"user", "admin"}, + Metadata: map[string]interface{}{ + "account_age_days": 365, + "verified": true, + }, + }, +} diff --git a/__snapshots__/multiple_scrubbers.snap b/__snapshots__/multiple_scrubbers.snap new file mode 100644 index 0000000..fb10ace --- /dev/null +++ b/__snapshots__/multiple_scrubbers.snap @@ -0,0 +1,27 @@ +--- +title: Multiple Scrubbers +test_name: TestBuiltInScrubbers +file_name: scrubbers_test.go +version: 0.1.0 +--- +{ + "api_key": "", + "backup_card": "", + "backup_email": "", + "birth_date": "", + "card_number": "", + "client_ip": "", + "created_at": "", + "email": "", + "jwt_token": "", + "message": "Connection from ", + "name": "John Doe", + "server_ip": "", + "session_id": "", + "stripe_key": "", + "unix_created": , + "unix_updated": , + "updated_at": "", + "us_format_date": "", + "user_id": "" +} \ No newline at end of file diff --git a/__snapshots__/test_snap_multiple.snap b/__snapshots__/multiple_values_test.snap similarity index 100% rename from __snapshots__/test_snap_multiple.snap rename to __snapshots__/multiple_values_test.snap diff --git a/__snapshots__/test_nested_ignore_patterns.snap b/__snapshots__/nested_ignore_patterns.snap similarity index 100% rename from __snapshots__/test_nested_ignore_patterns.snap rename to __snapshots__/nested_ignore_patterns.snap diff --git a/__snapshots__/nested_maps_and_slices.snap b/__snapshots__/nested_maps_and_slices.snap new file mode 100644 index 0000000..8a36c69 --- /dev/null +++ b/__snapshots__/nested_maps_and_slices.snap @@ -0,0 +1,47 @@ +--- +title: Nested Maps and Slices +test_name: TestNestedMapsAndSlices +file_name: shutter_test.go +version: 0.1.0 +--- +map[string]interface{}{ + "posts": map[string]interface{}{ + "categories": []string{"tech", "lifestyle", "news"}, + "drafts": 5, + "published": 42, + }, + "stats": map[string]interface{}{ + "daily": map[string]interface{}{ + "clicks": 320, + "conversions": map[string]interface{}{ + "by_source": map[string]int{ + "organic": 25, + "paid": 15, + "referral": 5, + }, + "total": 45, + }, + "views": 1500, + }, + }, + "users": map[string]interface{}{ + "active": []map[string]interface{}{ + { + "id": 1, + "name": "Alice", + "verified": true, + }, + { + "id": 2, + "name": "Bob", + "verified": false, + }, + }, + "inactive": []map[string]interface{}{ + { + "id": 3, + "name": "Charlie", + }, + }, + }, +} diff --git a/__snapshots__/test_complex_real_world_example.snap b/__snapshots__/real_world_api_response.snap similarity index 100% rename from __snapshots__/test_complex_real_world_example.snap rename to __snapshots__/real_world_api_response.snap diff --git a/__snapshots__/test_scrub_with_snap_function.snap b/__snapshots__/scrub_with_snap.snap similarity index 58% rename from __snapshots__/test_scrub_with_snap_function.snap rename to __snapshots__/scrub_with_snap.snap index af862e4..12c2baf 100644 --- a/__snapshots__/test_scrub_with_snap_function.snap +++ b/__snapshots__/scrub_with_snap.snap @@ -5,8 +5,8 @@ file_name: scrubbers_test.go version: 0.1.0 --- map[string]interface{}{ - "created_at": "", - "email": "", - "name": "John Doe", - "user_id": "", + "created_at": "", + "email": "", + "name": "John Doe", + "user_id": "", } diff --git a/__snapshots__/test_scrub_a_p_i_keys.snap b/__snapshots__/scrubbed_api_keys.snap similarity index 81% rename from __snapshots__/test_scrub_a_p_i_keys.snap rename to __snapshots__/scrubbed_api_keys.snap index 5291914..2f782f7 100644 --- a/__snapshots__/test_scrub_a_p_i_keys.snap +++ b/__snapshots__/scrubbed_api_keys.snap @@ -1,6 +1,6 @@ --- title: Scrubbed API Keys -test_name: TestScrubAPIKeys +test_name: TestIndividualScrubbers/api_keys file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_credit_card_scrubbing.snap b/__snapshots__/scrubbed_credit_cards.snap similarity index 81% rename from __snapshots__/test_credit_card_scrubbing.snap rename to __snapshots__/scrubbed_credit_cards.snap index 430cb49..1af7960 100644 --- a/__snapshots__/test_credit_card_scrubbing.snap +++ b/__snapshots__/scrubbed_credit_cards.snap @@ -1,6 +1,6 @@ --- title: Scrubbed Credit Cards -test_name: TestCreditCardScrubbing +test_name: TestIndividualScrubbers/credit_cards file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_scrub_dates.snap b/__snapshots__/scrubbed_dates.snap similarity index 80% rename from __snapshots__/test_scrub_dates.snap rename to __snapshots__/scrubbed_dates.snap index a426d57..65309a8 100644 --- a/__snapshots__/test_scrub_dates.snap +++ b/__snapshots__/scrubbed_dates.snap @@ -1,6 +1,6 @@ --- title: Scrubbed Dates -test_name: TestScrubDates +test_name: TestIndividualScrubbers/dates file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_scrub_emails.snap b/__snapshots__/scrubbed_emails.snap similarity index 78% rename from __snapshots__/test_scrub_emails.snap rename to __snapshots__/scrubbed_emails.snap index 2b5e805..615f455 100644 --- a/__snapshots__/test_scrub_emails.snap +++ b/__snapshots__/scrubbed_emails.snap @@ -1,6 +1,6 @@ --- title: Scrubbed Emails -test_name: TestScrubEmails +test_name: TestIndividualScrubbers/emails file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_scrub_i_p_addresses.snap b/__snapshots__/scrubbed_ips.snap similarity index 76% rename from __snapshots__/test_scrub_i_p_addresses.snap rename to __snapshots__/scrubbed_ips.snap index b9ed136..07201ea 100644 --- a/__snapshots__/test_scrub_i_p_addresses.snap +++ b/__snapshots__/scrubbed_ips.snap @@ -1,6 +1,6 @@ --- title: Scrubbed IPs -test_name: TestScrubIPAddresses +test_name: TestIndividualScrubbers/ip_addresses file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_scrub_j_w_ts.snap b/__snapshots__/scrubbed_jwts.snap similarity index 75% rename from __snapshots__/test_scrub_j_w_ts.snap rename to __snapshots__/scrubbed_jwts.snap index ebff2d1..9f1005a 100644 --- a/__snapshots__/test_scrub_j_w_ts.snap +++ b/__snapshots__/scrubbed_jwts.snap @@ -1,6 +1,6 @@ --- title: Scrubbed JWTs -test_name: TestScrubJWTs +test_name: TestIndividualScrubbers/jwts file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_scrub_timestamps.snap b/__snapshots__/scrubbed_timestamps.snap similarity index 81% rename from __snapshots__/test_scrub_timestamps.snap rename to __snapshots__/scrubbed_timestamps.snap index 12a3874..89e5ec8 100644 --- a/__snapshots__/test_scrub_timestamps.snap +++ b/__snapshots__/scrubbed_timestamps.snap @@ -1,6 +1,6 @@ --- title: Scrubbed Timestamps -test_name: TestScrubTimestamps +test_name: TestIndividualScrubbers/timestamps file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_unix_timestamp_scrubbing.snap b/__snapshots__/scrubbed_unix_timestamps.snap similarity index 78% rename from __snapshots__/test_unix_timestamp_scrubbing.snap rename to __snapshots__/scrubbed_unix_timestamps.snap index 9a35a43..9d09d27 100644 --- a/__snapshots__/test_unix_timestamp_scrubbing.snap +++ b/__snapshots__/scrubbed_unix_timestamps.snap @@ -1,6 +1,6 @@ --- title: Scrubbed Unix Timestamps -test_name: TestUnixTimestampScrubbing +test_name: TestIndividualScrubbers/unix_timestamps file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_scrub_u_u_i_ds.snap b/__snapshots__/scrubbed_uuids.snap similarity index 78% rename from __snapshots__/test_scrub_u_u_i_ds.snap rename to __snapshots__/scrubbed_uuids.snap index 4c94cf6..dd57c64 100644 --- a/__snapshots__/test_scrub_u_u_i_ds.snap +++ b/__snapshots__/scrubbed_uuids.snap @@ -1,6 +1,6 @@ --- title: Scrubbed UUIDs -test_name: TestScrubUUIDs +test_name: TestIndividualScrubbers/uuid file_name: scrubbers_test.go version: 0.1.0 --- diff --git a/__snapshots__/test_snap_json_complex_a_p_i.snap b/__snapshots__/snapjson_complex_api_response.snap similarity index 100% rename from __snapshots__/test_snap_json_complex_a_p_i.snap rename to __snapshots__/snapjson_complex_api_response.snap diff --git a/__snapshots__/test_snap_json_mixed_types.snap b/__snapshots__/snapjson_mixed_types.snap similarity index 100% rename from __snapshots__/test_snap_json_mixed_types.snap rename to __snapshots__/snapjson_mixed_types.snap diff --git a/__snapshots__/test_snap_json_with_nested_objects.snap b/__snapshots__/snapjson_nested_objects.snap similarity index 100% rename from __snapshots__/test_snap_json_with_nested_objects.snap rename to __snapshots__/snapjson_nested_objects.snap diff --git a/__snapshots__/test_snap_json_real_world_example.snap b/__snapshots__/snapjson_real_world_example.snap similarity index 100% rename from __snapshots__/test_snap_json_real_world_example.snap rename to __snapshots__/snapjson_real_world_example.snap diff --git a/__snapshots__/structure_with_empty_values.snap b/__snapshots__/structure_with_empty_values.snap new file mode 100644 index 0000000..073af35 --- /dev/null +++ b/__snapshots__/structure_with_empty_values.snap @@ -0,0 +1,34 @@ +--- +title: Structure with Empty Values +test_name: TestStructureWithEmptyValues +file_name: shutter_test.go +version: 0.1.0 +--- +[]shutter_test.Container{ + { + Items: []string{ + }, + Tags: map[string]string{ + }, + OptionalID: (*int)(nil), + Count: 0, + Active: false, + }, + { + Items: []string(nil), + Tags: map[string]string(nil), + OptionalID: (*int)(nil), + Count: 0, + Active: true, + }, + { + Items: []string{"a", "b", "c"}, + Tags: map[string]string{ + "env": "dev", + "type": "test", + }, + OptionalID: &int(42), + Count: 3, + Active: true, + }, +} diff --git a/__snapshots__/structure_with_interface_fields.snap b/__snapshots__/structure_with_interface_fields.snap new file mode 100644 index 0000000..16127b3 --- /dev/null +++ b/__snapshots__/structure_with_interface_fields.snap @@ -0,0 +1,76 @@ +--- +title: Structure with Interface Fields +test_name: TestStructureWithInterface +file_name: shutter_test.go +version: 0.1.0 +--- +[]shutter_test.Response{ + { + Status: "success", + Message: "User retrieved", + Data: shutter_test.User{ + ID: 1, + Username: "john", + Email: "john@example.com", + Active: true, + CreatedAt: time.Time{ + wall: 0x0, + ext: 0, + loc: (*time.Location)(nil), + }, + Roles: []string(nil), + Metadata: map[string]interface{}(nil), + }, + Meta: map[string]interface{}{ + "request_id": "req-123", + "timestamp": "2023-01-20T10:30:00Z", + }, + }, + { + Status: "error", + Message: "User not found", + Data: nil, + Meta: map[string]interface{}{ + "error_code": 404, + "error_type": "NOT_FOUND", + }, + }, + { + Status: "success", + Message: "Posts retrieved", + Data: []shutter_test.Post{ + { + ID: 1, + Title: "First Post", + Content: "", + Author: shutter_test.User{ + ID: 0, + Username: "", + Email: "", + Active: false, + CreatedAt: time.Time{ + wall: 0x0, + ext: 0, + loc: (*time.Location)(nil), + }, + Roles: []string(nil), + Metadata: map[string]interface{}(nil), + }, + Tags: []string(nil), + Comments: []shutter_test.Comment(nil), + Likes: 0, + Published: true, + CreatedAt: time.Time{ + wall: 0x0, + ext: 0, + loc: (*time.Location)(nil), + }, + }, + }, + Meta: map[string]interface{}{ + "page": 1, + "per_page": 20, + "total_count": 10, + }, + }, +} diff --git a/__snapshots__/structure_with_pointers.snap b/__snapshots__/structure_with_pointers.snap new file mode 100644 index 0000000..1c015de --- /dev/null +++ b/__snapshots__/structure_with_pointers.snap @@ -0,0 +1,27 @@ +--- +title: Structure with Pointers +test_name: TestStructureWithPointers +file_name: shutter_test.go +version: 0.1.0 +--- +shutter_test.Person{ + Name: "John", + Age: 35, + Address: &shutter_test.Address{ + Street: "123 Main St", + City: "Boston", + Zip: "02101", + }, + Manager: &shutter_test.Person{ + Name: "Jane", + Age: 30, + Address: (*shutter_test.Address)(), + Manager: (*shutter_test.Person)(nil), + Friends: []*shutter_test.Person(nil), + Email: &string("jane@example.com"), + }, + Friends: []*shutter_test.Person{ + (*shutter_test.Person)(), + }, + Email: (*string)(nil), +} diff --git a/__snapshots__/test_complex_json_structure.snap b/__snapshots__/test_complex_json_structure.snap deleted file mode 100644 index 4b68d82..0000000 --- a/__snapshots__/test_complex_json_structure.snap +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Complex JSON Structure -test_name: TestComplexJsonStructure -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "api": map[string]interface{}{ - "endpoints": []interface{}{ - map[string]interface{}{ - "auth_required": true, - "method": "GET", - "path": "/users", - "rate_limit": map[string]interface{}{ - "requests": 100.0, - "window": "1m", - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "Success", - "schema": map[string]interface{}{ - "items": map[string]interface{}{ - "properties": map[string]interface{}{ - "id": map[string]interface{}{ - "type": "integer", - }, - "name": map[string]interface{}{ - "type": "string", - }, - }, - "type": "object", - }, - "type": "array", - }, - }, - "401": map[string]interface{}{ - "description": "Unauthorized", - }, - }, - }, - map[string]interface{}{ - "auth_required": true, - "method": "POST", - "path": "/users/{id}", - "rate_limit": map[string]interface{}{ - "requests": 50.0, - "window": "1m", - }, - }, - }, - "models": map[string]interface{}{ - "User": map[string]interface{}{ - "properties": map[string]interface{}{ - "created_at": map[string]interface{}{ - "format": "date-time", - "type": "string", - }, - "email": map[string]interface{}{ - "format": "email", - "type": "string", - }, - "id": map[string]interface{}{ - "type": "integer", - }, - "roles": map[string]interface{}{ - "items": map[string]interface{}{ - "type": "string", - }, - "type": "array", - }, - "username": map[string]interface{}{ - "type": "string", - }, - }, - "required": []interface{}{ - "id", - "username", - "email", - }, - }, - }, - "version": "2.0", - }, -} diff --git a/__snapshots__/test_complex_nested_structure.snap b/__snapshots__/test_complex_nested_structure.snap deleted file mode 100644 index 1c5e4bf..0000000 --- a/__snapshots__/test_complex_nested_structure.snap +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Complex Nested Structure -test_name: TestComplexNestedStructure -file_name: shutter_test.go -version: 0.1.0 ---- -shutter_test.Post{ - ID: 100, - Title: "Introduction to Go Snapshot Testing", - Content: "This is a comprehensive guide to snapshot testing in Go...", - Author: shutter_test.User{ - ID: 1, - Username: "john_doe", - Email: "john@example.com", - Active: true, - CreatedAt: time.Time{ - wall: 0x0, - ext: 63809375400, - loc: (*time.Location)(nil), - }, - Roles: []string{ - "admin", - "moderator", - "user", - }, - Metadata: map[string]interface{}{ - "language": "en", - "notifications": true, - "preferences": map[string]interface{}{ - "email_frequency": "weekly", - "notifications": true, - }, - "theme": "dark", - }, - }, - Tags: []string{ - "go", - "testing", - "snapshots", - "best-practices", - }, - Comments: []shutter_test.Comment{ - { - ID: 1, - Author: "alice", - Content: "Great post!", - CreatedAt: time.Time{ - wall: 0x0, - ext: 63810858120, - loc: (*time.Location)(nil), - }, - Replies: []shutter_test.Comment{ - { - ID: 2, - Author: "bob", - Content: "I agree!", - CreatedAt: time.Time{ - wall: 0x0, - ext: 63810863100, - loc: (*time.Location)(nil), - }, - Replies: []shutter_test.Comment{ - }, - }, - }, - }, - { - ID: 3, - Author: "charlie", - Content: "Thanks for sharing!", - CreatedAt: time.Time{ - wall: 0x0, - ext: 63810927000, - loc: (*time.Location)(nil), - }, - Replies: []shutter_test.Comment{ - }, - }, - }, - Likes: 42, - Published: true, - CreatedAt: time.Time{ - wall: 0x0, - ext: 63809802000, - loc: (*time.Location)(nil), - }, -} diff --git a/__snapshots__/test_deeply_nested_json.snap b/__snapshots__/test_deeply_nested_json.snap deleted file mode 100644 index c97f438..0000000 --- a/__snapshots__/test_deeply_nested_json.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Deeply Nested JSON -test_name: TestDeeplyNestedJson -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "L2": map[string]interface{}{ - "L3": map[string]interface{}{ - "L4": map[string]interface{}{ - "Value": "deep value", - }, - }, - }, -} diff --git a/__snapshots__/test_go_struct_marshalled_to_json.snap b/__snapshots__/test_go_struct_marshalled_to_json.snap deleted file mode 100644 index 44bc091..0000000 --- a/__snapshots__/test_go_struct_marshalled_to_json.snap +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Go Struct Marshalled to JSON -test_name: TestGoStructMarshalledToJson -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "active": true, - "address": map[string]interface{}{ - "city": "San Francisco", - "street": "456 Oak Ave", - "zip": "94102", - }, - "created_at": "2023-06-15T14:30:00Z", - "email": "jane@example.com", - "name": "Jane Smith", - "phone": "+1-555-0123", - "tags": []interface{}{ - "vip", - "verified", - "premium", - }, -} diff --git a/__snapshots__/test_json_array_of_objects.snap b/__snapshots__/test_json_array_of_objects.snap deleted file mode 100644 index 09e5f1e..0000000 --- a/__snapshots__/test_json_array_of_objects.snap +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: JSON Array of Objects -test_name: TestJsonArrayOfObjects -file_name: shutter_test.go -version: 0.1.0 ---- -[]interface{}{ - map[string]interface{}{ - "data": map[string]interface{}{ - "name": "Alice", - "role": "admin", - }, - "id": 1.0, - "type": "user", - }, - map[string]interface{}{ - "data": map[string]interface{}{ - "author_id": 1.0, - "likes": 42.0, - "title": "First Post", - }, - "id": 100.0, - "type": "post", - }, - map[string]interface{}{ - "data": map[string]interface{}{ - "author_id": 2.0, - "content": "Great post!", - "post_id": 100.0, - }, - "id": 500.0, - "type": "comment", - }, -} diff --git a/__snapshots__/test_json_numbers.snap b/__snapshots__/test_json_numbers.snap deleted file mode 100644 index a14c213..0000000 --- a/__snapshots__/test_json_numbers.snap +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: JSON Numbers -test_name: TestJsonNumbers -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "edge_cases": map[string]interface{}{ - "minus_one": -1.0, - "one": 1.0, - }, - "floats": map[string]interface{}{ - "negative_float": -42.5, - "pi": 3.14159265359, - "scientific": 0.000123, - "small": 0.0001, - }, - "integers": map[string]interface{}{ - "large": 9.999999999999e+12.0, - "negative": -100.0, - "positive": 42.0, - "zero": 0.0, - }, -} diff --git a/__snapshots__/test_json_object.snap b/__snapshots__/test_json_object.snap deleted file mode 100644 index 3c1c7b3..0000000 --- a/__snapshots__/test_json_object.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: JSON Object -test_name: TestJsonObject -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "message": nil, - "status": "success", - "user": map[string]interface{}{ - "created_at": "2023-01-15T10:30:00Z", - "email": "john@example.com", - "id": 1.0, - "profile": map[string]interface{}{ - "bio": "Software engineer", - "first_name": "John", - "last_name": "Doe", - "verified": true, - }, - "roles": []interface{}{ - "user", - "admin", - }, - "username": "john_doe", - }, -} diff --git a/__snapshots__/test_json_with_mixed_arrays.snap b/__snapshots__/test_json_with_mixed_arrays.snap deleted file mode 100644 index 6989950..0000000 --- a/__snapshots__/test_json_with_mixed_arrays.snap +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: JSON with Mixed Arrays -test_name: TestJsonWithMixedArrays -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "heterogeneous_array": []interface{}{ - "string", - 42.0, - 3.14, - true, - nil, - map[string]interface{}{ - "object": "value", - }, - []interface{}{ - 1.0, - 2.0, - 3.0, - }, - }, - "matrix": []interface{}{ - []interface{}{ - 1.0, - 2.0, - 3.0, - }, - []interface{}{ - 4.0, - 5.0, - 6.0, - }, - []interface{}{ - 7.0, - 8.0, - 9.0, - }, - }, - "object_array": []interface{}{ - map[string]interface{}{ - "id": 1.0, - "name": "Item 1", - }, - map[string]interface{}{ - "id": 2.0, - "name": "Item 2", - }, - map[string]interface{}{ - "id": 3.0, - "name": "Item 3", - }, - }, -} diff --git a/__snapshots__/test_json_with_special_characters.snap b/__snapshots__/test_json_with_special_characters.snap deleted file mode 100644 index 5253642..0000000 --- a/__snapshots__/test_json_with_special_characters.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: JSON with Special Characters -test_name: TestJsonWithSpecialCharacters -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "backslash": "path\\to\\file", - "emoji": "πŸ˜€ πŸ˜ƒ πŸ˜„ 😁 πŸ˜†", - "english": "Hello, World!", - "escaped": "quotes: \"double\" and 'single'", - "newlines": "line1\nline2\rline3\r\nline4", - "special_chars": "!@#$%^&*()_+-=[]{}|;:,.<>?", - "tabs": "col1\tcol2\tcol3", - "unicode": "こんにけは δΈ–η•Œ 🌍", -} diff --git a/__snapshots__/test_json_with_various_types.snap b/__snapshots__/test_json_with_various_types.snap deleted file mode 100644 index b1185d4..0000000 --- a/__snapshots__/test_json_with_various_types.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -title: JSON with Various Types -test_name: TestJsonWithVariousTypes -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "array": []interface{}{ - 1.0, - 2.0, - 3.0, - "four", - 5.5, - }, - "boolean_false": false, - "boolean_true": true, - "empty_array": []interface{}{ - }, - "empty_object": map[string]interface{}{ - }, - "escaped_string": "line1\nline2\ttab", - "float": 3.14159, - "integer": 42.0, - "null_value": nil, - "object": map[string]interface{}{ - "count": 10.0, - "nested": "value", - }, - "string": "hello world", -} diff --git a/__snapshots__/test_large_json.snap b/__snapshots__/test_large_json.snap deleted file mode 100644 index 3140ee3..0000000 --- a/__snapshots__/test_large_json.snap +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Large JSON Structure -test_name: TestLargeJson -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "created_at": "2023-01-28T14:30:00Z", - "customer_id": 42.0, - "delivered_at": nil, - "id": 1001.0, - "products": []interface{}{ - map[string]interface{}{ - "description": "High-performance laptop", - "id": 1.0, - "in_stock": true, - "name": "Laptop", - "price": 999.99, - "stock": 5.0, - "tags": []interface{}{ - "electronics", - "computers", - "laptops", - }, - }, - map[string]interface{}{ - "description": "Wireless mouse", - "id": 2.0, - "in_stock": true, - "name": "Mouse", - "price": 29.99, - "stock": 50.0, - "tags": []interface{}{ - "electronics", - "accessories", - }, - }, - map[string]interface{}{ - "description": "Mechanical keyboard", - "id": 3.0, - "in_stock": false, - "name": "Keyboard", - "price": 149.99, - "stock": 0.0, - "tags": []interface{}{ - "electronics", - "accessories", - }, - }, - }, - "shipped_at": "2023-02-01T10:00:00Z", - "status": "shipped", - "total": 1179.97, -} diff --git a/__snapshots__/test_map.snap b/__snapshots__/test_map.snap deleted file mode 100644 index 5afde52..0000000 --- a/__snapshots__/test_map.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Map Test -test_name: TestMap -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "foo": "bar", - "wibble": "wobble", -} diff --git a/__snapshots__/test_multiple_complex_structures.snap b/__snapshots__/test_multiple_complex_structures.snap deleted file mode 100644 index 8074bea..0000000 --- a/__snapshots__/test_multiple_complex_structures.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Multiple Complex Structures -test_name: TestMultipleComplexStructures -file_name: shutter_test.go -version: 0.1.0 ---- -[]shutter_test.User{ - { - ID: 1, - Username: "alice", - Email: "alice@example.com", - Active: true, - CreatedAt: time.Time{ - wall: 0x0, - ext: 0, - loc: (*time.Location)(nil), - }, - Roles: []string{ - "user", - "moderator", - }, - Metadata: map[string]interface{}{ - "badge": "verified", - "verified": true, - }, - }, - { - ID: 2, - Username: "bob", - Email: "bob@example.com", - Active: false, - CreatedAt: time.Time{ - wall: 0x0, - ext: 0, - loc: (*time.Location)(nil), - }, - Roles: []string{ - "user", - }, - Metadata: map[string]interface{}{ - "avatar": "https://example.com/bob.jpg", - "verified": false, - }, - }, - { - ID: 3, - Username: "charlie", - Email: "charlie@example.com", - Active: true, - CreatedAt: time.Time{ - wall: 0x0, - ext: 0, - loc: (*time.Location)(nil), - }, - Roles: []string{ - "user", - "admin", - }, - Metadata: map[string]interface{}{ - "account_age_days": 365, - "verified": true, - }, - }, -} diff --git a/__snapshots__/test_multiple_scrubbers.snap b/__snapshots__/test_multiple_scrubbers.snap deleted file mode 100644 index ba1ab94..0000000 --- a/__snapshots__/test_multiple_scrubbers.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Multiple Scrubbers -test_name: TestMultipleScrubbers -file_name: scrubbers_test.go -version: 0.1.0 ---- -{ - "created_at": "", - "email": "", - "ip_address": "", - "name": "John Doe", - "user_id": "" -} \ No newline at end of file diff --git a/__snapshots__/test_nested_maps_and_slices.snap b/__snapshots__/test_nested_maps_and_slices.snap deleted file mode 100644 index cc71a27..0000000 --- a/__snapshots__/test_nested_maps_and_slices.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Nested Maps and Slices -test_name: TestNestedMapsAndSlices -file_name: shutter_test.go -version: 0.1.0 ---- -map[string]interface{}{ - "posts": map[string]interface{}{ - "categories": []string{ - "tech", - "lifestyle", - "news", - }, - "drafts": 5, - "published": 42, - }, - "stats": map[string]interface{}{ - "daily": map[string]interface{}{ - "clicks": 320, - "conversions": map[string]interface{}{ - "by_source": map[string]int{ - "organic": 25, - "paid": 15, - "referral": 5, - }, - "total": 45, - }, - "views": 1500, - }, - }, - "users": map[string]interface{}{ - "active": []map[string]interface{}{ - { - "id": 1, - "name": "Alice", - "verified": true, - }, - { - "id": 2, - "name": "Bob", - "verified": false, - }, - }, - "inactive": []map[string]interface{}{ - { - "id": 3, - "name": "Charlie", - }, - }, - }, -} diff --git a/__snapshots__/test_snap_json_array_of_objects.snap b/__snapshots__/test_snap_json_array_of_objects.snap deleted file mode 100644 index e52116d..0000000 --- a/__snapshots__/test_snap_json_array_of_objects.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: SnapJSON Array of Objects -test_name: TestSnapJsonArrayOfObjects -file_name: shutter_test.go -version: 0.1.0 ---- -[ - { - "id": 1, - "likes": 42, - "title": "First Post", - "type": "post", - "views": 150 - }, - { - "id": 2, - "likes": 75, - "title": "Second Post", - "type": "post", - "views": 280 - }, - { - "id": 3, - "likes": 120, - "title": "Third Post", - "type": "post", - "views": 450 - } -] \ No newline at end of file diff --git a/__snapshots__/test_snap_json_basic.snap b/__snapshots__/test_snap_json_basic.snap deleted file mode 100644 index 63e8442..0000000 --- a/__snapshots__/test_snap_json_basic.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: SnapJSON Basic Object -test_name: TestSnapJsonBasic -file_name: shutter_test.go -version: 0.1.0 ---- -{ - "age": 30, - "email": "john@example.com", - "name": "John Doe", - "verified": true -} \ No newline at end of file diff --git a/__snapshots__/test_snap_json_compact_format.snap b/__snapshots__/test_snap_json_compact_format.snap deleted file mode 100644 index 32c0e5d..0000000 --- a/__snapshots__/test_snap_json_compact_format.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: SnapJSON Compact Format -test_name: TestSnapJsonCompactFormat -file_name: shutter_test.go -version: 0.1.0 ---- -{ - "id": 1, - "in_stock": true, - "name": "Product", - "price": 99.99, - "tags": [ - "electronics", - "gadgets" - ] -} \ No newline at end of file diff --git a/__snapshots__/test_snap_json_empty_structures.snap b/__snapshots__/test_snap_json_empty_structures.snap deleted file mode 100644 index 2575950..0000000 --- a/__snapshots__/test_snap_json_empty_structures.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: SnapJSON Empty Structures -test_name: TestSnapJsonEmptyStructures -file_name: shutter_test.go -version: 0.1.0 ---- -{ - "empty_array": [], - "empty_object": {}, - "empty_string": "", - "false_value": false, - "nested": { - "also_empty": {}, - "empty": [] - }, - "null_value": null, - "zero": 0 -} \ No newline at end of file diff --git a/__snapshots__/test_snap_json_large_nested_structure.snap b/__snapshots__/test_snap_json_large_nested_structure.snap deleted file mode 100644 index 6cd201f..0000000 --- a/__snapshots__/test_snap_json_large_nested_structure.snap +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: SnapJSON Large Nested Structure -test_name: TestSnapJsonLargeNestedStructure -file_name: shutter_test.go -version: 0.1.0 ---- -{ - "organization": { - "departments": [ - { - "manager": "Alice", - "name": "Engineering", - "teams": [ - { - "lead": "John", - "members": [ - { - "id": 1, - "level": "senior", - "name": "John" - }, - { - "id": 2, - "level": "mid", - "name": "Jane" - } - ], - "name": "Backend", - "projects": [ - { - "id": "proj_1", - "name": "API Service", - "status": "active" - }, - { - "id": "proj_2", - "name": "Database Optimization", - "status": "planning" - } - ] - }, - { - "lead": "Bob", - "members": [ - { - "id": 3, - "level": "senior", - "name": "Bob" - }, - { - "id": 4, - "level": "junior", - "name": "Carol" - } - ], - "name": "Frontend", - "projects": [ - { - "id": "proj_3", - "name": "Web App", - "status": "active" - } - ] - } - ] - }, - { - "manager": "Charlie", - "name": "Sales", - "teams": [ - { - "lead": "Dave", - "members": [ - { - "id": 5, - "level": "senior", - "name": "Dave" - }, - { - "id": 6, - "level": "mid", - "name": "Eve" - } - ], - "name": "Enterprise", - "projects": [] - } - ] - } - ], - "id": "org_123", - "metadata": { - "employees": 150, - "founded": "2020", - "locations": [ - "USA", - "EU", - "APAC" - ] - }, - "name": "TechCorp" - } -} \ No newline at end of file diff --git a/__snapshots__/test_snap_json_simple_array.snap b/__snapshots__/test_snap_json_simple_array.snap deleted file mode 100644 index 814b55d..0000000 --- a/__snapshots__/test_snap_json_simple_array.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: SnapJSON Simple Array -test_name: TestSnapJsonSimpleArray -file_name: shutter_test.go -version: 0.1.0 ---- -[ - "apple", - "banana", - "orange", - "grape" -] \ No newline at end of file diff --git a/__snapshots__/test_snap_json_with_nulls.snap b/__snapshots__/test_snap_json_with_nulls.snap deleted file mode 100644 index 797efc2..0000000 --- a/__snapshots__/test_snap_json_with_nulls.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: SnapJSON With Nulls -test_name: TestSnapJsonWithNulls -file_name: shutter_test.go -version: 0.1.0 ---- -{ - "category": null, - "description": null, - "id": 1, - "metadata": { - "created": "2023-01-01", - "deleted": null, - "updated": null - }, - "name": "Item", - "tags": null -} \ No newline at end of file diff --git a/__snapshots__/test_snap_json_with_numbers.snap b/__snapshots__/test_snap_json_with_numbers.snap deleted file mode 100644 index f12b495..0000000 --- a/__snapshots__/test_snap_json_with_numbers.snap +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: SnapJSON With Numbers -test_name: TestSnapJsonWithNumbers -file_name: shutter_test.go -version: 0.1.0 ---- -{ - "financial": { - "expenses": 750000.75, - "profit_margin": 0.2499, - "revenue": 1000000.5 - }, - "floats": [ - 0, - 3.14, - -2.5, - 0.001, - 0.000123, - 56700000000 - ], - "integers": [ - 0, - 1, - -1, - 42, - -100, - 9999999 - ], - "measurements": { - "distance": 1000.25, - "temperature": -40.5, - "weight": 0.5 - } -} \ No newline at end of file diff --git a/__snapshots__/test_snap_json_with_special_characters.snap b/__snapshots__/test_snap_json_with_special_characters.snap deleted file mode 100644 index 671822c..0000000 --- a/__snapshots__/test_snap_json_with_special_characters.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: SnapJSON With Special Characters -test_name: TestSnapJsonWithSpecialCharacters -file_name: shutter_test.go -version: 0.1.0 ---- -{ - "escaped": "line1\nline2\ttab\rcarriage", - "html": "\u003cdiv class=\"container\"\u003eContent\u003c/div\u003e", - "paths": "C:\\Users\\name\\Documents\\file.txt", - "quotes": "He said \"hello\" and she said 'goodbye'", - "regex": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", - "special": "!@#$%^\u0026*()_+-=[]{}|;:',.\u003c\u003e?/", - "unicode": "Hello δΈ–η•Œ 🌍 Ω…Ψ±Ψ­Ψ¨Ψ§ ΠŸΡ€ΠΈΠ²Π΅Ρ‚" -} \ No newline at end of file diff --git a/__snapshots__/test_snap_string.snap b/__snapshots__/test_snap_string.snap deleted file mode 100644 index e68f37f..0000000 --- a/__snapshots__/test_snap_string.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Simple String Test -test_name: TestSnapString -file_name: shutter_test.go -version: 0.1.0 ---- -hello world \ No newline at end of file diff --git a/__snapshots__/test_structure_with_empty_values.snap b/__snapshots__/test_structure_with_empty_values.snap deleted file mode 100644 index bf24822..0000000 --- a/__snapshots__/test_structure_with_empty_values.snap +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Structure with Empty Values -test_name: TestStructureWithEmptyValues -file_name: shutter_test.go -version: 0.1.0 ---- -[]shutter_test.Container{ - { - Items: []string{ - }, - Tags: map[string]string{ - }, - OptionalID: (*int)(nil), - Count: 0, - Active: false, - }, - { - Items: []string(nil), - Tags: map[string]string(nil), - OptionalID: (*int)(nil), - Count: 0, - Active: true, - }, - { - Items: []string{ - "a", - "b", - "c", - }, - Tags: map[string]string{ - "env": "dev", - "type": "test", - }, - OptionalID: &int(42), - Count: 3, - Active: true, - }, -} diff --git a/__snapshots__/test_structure_with_interface.snap b/__snapshots__/test_structure_with_interface.snap deleted file mode 100644 index 4c055bd..0000000 --- a/__snapshots__/test_structure_with_interface.snap +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: Structure with Interface Fields -test_name: TestStructureWithInterface -file_name: shutter_test.go -version: 0.1.0 ---- -[]shutter_test.Response{ - { - Status: "success", - Message: "User retrieved", - Data: shutter_test.User{ - ID: 1, - Username: "john", - Email: "john@example.com", - Active: true, - CreatedAt: time.Time{ - wall: 0x0, - ext: 0, - loc: (*time.Location)(nil), - }, - Roles: []string(nil), - Metadata: map[string]interface{}(nil), - }, - Meta: map[string]interface{}{ - "request_id": "req-123", - "timestamp": "2023-01-20T10:30:00Z", - }, - }, - { - Status: "error", - Message: "User not found", - Data: nil, - Meta: map[string]interface{}{ - "error_code": 404, - "error_type": "NOT_FOUND", - }, - }, - { - Status: "success", - Message: "Posts retrieved", - Data: []shutter_test.Post{ - { - ID: 1, - Title: "First Post", - Content: "", - Author: shutter_test.User{ - ID: 0, - Username: "", - Email: "", - Active: false, - CreatedAt: time.Time{ - wall: 0x0, - ext: 0, - loc: (*time.Location)(nil), - }, - Roles: []string(nil), - Metadata: map[string]interface{}(nil), - }, - Tags: []string(nil), - Comments: []shutter_test.Comment(nil), - Likes: 0, - Published: true, - CreatedAt: time.Time{ - wall: 0x0, - ext: 0, - loc: (*time.Location)(nil), - }, - }, - }, - Meta: map[string]interface{}{ - "page": 1, - "per_page": 20, - "total_count": 10, - }, - }, -} diff --git a/__snapshots__/test_structure_with_pointers.snap b/__snapshots__/test_structure_with_pointers.snap deleted file mode 100644 index 5766ebe..0000000 --- a/__snapshots__/test_structure_with_pointers.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: Structure with Pointers -test_name: TestStructureWithPointers -file_name: shutter_test.go -version: 0.1.0 ---- -shutter_test.Person{ - Name: "John", - Age: 35, - Address: &shutter_test.Address{ - Street: "123 Main St", - City: "Boston", - Zip: "02101", - }, - Manager: &shutter_test.Person{ - Name: "Jane", - Age: 30, - Address: (*shutter_test.Address)(), - Manager: (*shutter_test.Person)(nil), - Friends: []*shutter_test.Person(nil), - Email: &string("jane@example.com"), - }, - Friends: []*shutter_test.Person{ - (*shutter_test.Person)(), - }, - Email: (*string)(nil), -} diff --git a/ignore.go b/ignore.go index d84343a..37c5099 100644 --- a/ignore.go +++ b/ignore.go @@ -12,18 +12,24 @@ type exactKeyValueIgnore struct { value string } +func (e *exactKeyValueIgnore) isOption() {} + func (e *exactKeyValueIgnore) ShouldIgnore(key, value string) bool { return e.key == key && (e.value == "*" || e.value == value) } -func (e *exactKeyValueIgnore) Apply(content string) string { - // Ignore patterns are applied during JSON transformation, not string scrubbing - return content -} - // IgnoreKeyValue creates an ignore pattern that matches exact key-value pairs. // Use "*" as the value to ignore any value for the given key. -func IgnoreKeyValue(key, value string) SnapshotOption { +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreKeyValue("password", "*"), +// shutter.IgnoreKeyValue("status", "pending"), +// ) +func IgnoreKeyValue(key, value string) IgnorePattern { return &exactKeyValueIgnore{ key: key, value: value, @@ -36,19 +42,26 @@ type regexKeyValueIgnore struct { valuePattern *regexp.Regexp } +func (r *regexKeyValueIgnore) isOption() {} + func (r *regexKeyValueIgnore) ShouldIgnore(key, value string) bool { keyMatch := r.keyPattern == nil || r.keyPattern.MatchString(key) valueMatch := r.valuePattern == nil || r.valuePattern.MatchString(value) return keyMatch && valueMatch } -func (r *regexKeyValueIgnore) Apply(content string) string { - return content -} - // IgnoreKeyPattern creates an ignore pattern using regex patterns for keys and values. // Pass empty string for keyPattern or valuePattern to match any key or value. -func IgnoreKeyPattern(keyPattern, valuePattern string) SnapshotOption { +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreKeyPattern(`.*password.*`, ""), +// shutter.IgnoreKeyPattern(`.*token.*`, ""), +// ) +func IgnoreKeyPattern(keyPattern, valuePattern string) IgnorePattern { var keyRe, valueRe *regexp.Regexp if keyPattern != "" { keyRe = regexp.MustCompile(keyPattern) @@ -67,17 +80,23 @@ type keyOnlyIgnore struct { keys []string } +func (k *keyOnlyIgnore) isOption() {} + func (k *keyOnlyIgnore) ShouldIgnore(key, value string) bool { return slices.Contains(k.keys, key) } -func (k *keyOnlyIgnore) Apply(content string) string { - return content -} - -// IgnoreKeys creates an ignore pattern that ignores the specified keys +// IgnoreKey creates an ignore pattern that ignores the specified keys // regardless of their values. -func IgnoreKeys(keys ...string) SnapshotOption { +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreKey("password", "secret", "token"), +// ) +func IgnoreKey(keys ...string) IgnorePattern { return &keyOnlyIgnore{ keys: keys, } @@ -88,17 +107,23 @@ type regexKeyIgnore struct { pattern *regexp.Regexp } +func (r *regexKeyIgnore) isOption() {} + func (r *regexKeyIgnore) ShouldIgnore(key, value string) bool { return r.pattern.MatchString(key) } -func (r *regexKeyIgnore) Apply(content string) string { - return content -} - -// IgnoreKeysMatching creates an ignore pattern that ignores keys matching +// IgnoreKeyMatching creates an ignore pattern that ignores keys matching // the given regex pattern. -func IgnoreKeysMatching(pattern string) SnapshotOption { +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreKeyMatching(`^user_`), +// ) +func IgnoreKeyMatching(pattern string) IgnorePattern { re := regexp.MustCompile(pattern) return ®exKeyIgnore{ pattern: re, @@ -112,8 +137,16 @@ var sensitiveKeys = []string{ "authorization", "auth", "credentials", "passwd", } -// IgnoreSensitiveKeys ignores common sensitive key names like password, token, etc. -func IgnoreSensitiveKeys() SnapshotOption { +// IgnoreSensitive ignores common sensitive key names like password, token, etc. +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreSensitive(), +// ) +func IgnoreSensitive() IgnorePattern { return &keyOnlyIgnore{ keys: sensitiveKeys, } @@ -124,17 +157,23 @@ type valueOnlyIgnore struct { values []string } +func (v *valueOnlyIgnore) isOption() {} + func (v *valueOnlyIgnore) ShouldIgnore(key, value string) bool { return slices.Contains(v.values, value) } -func (v *valueOnlyIgnore) Apply(content string) string { - return content -} - -// IgnoreValues creates an ignore pattern that ignores the specified values +// IgnoreValue creates an ignore pattern that ignores the specified values // regardless of their keys. -func IgnoreValues(values ...string) SnapshotOption { +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreValue("pending", "processing"), +// ) +func IgnoreValue(values ...string) IgnorePattern { return &valueOnlyIgnore{ values: values, } @@ -145,31 +184,57 @@ type customIgnore struct { ignoreFunc func(key, value string) bool } +func (c *customIgnore) isOption() {} + func (c *customIgnore) ShouldIgnore(key, value string) bool { return c.ignoreFunc(key, value) } -func (c *customIgnore) Apply(content string) string { - return content -} - -// CustomIgnore creates an ignore pattern using a custom function. -func CustomIgnore(ignoreFunc func(key, value string) bool) SnapshotOption { +// IgnoreWith creates an ignore pattern using a custom function. +// The function receives the key and value and should return true if the +// key-value pair should be ignored. +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreWith(func(key, value string) bool { +// return strings.HasPrefix(key, "temp_") +// }), +// ) +func IgnoreWith(ignoreFunc func(key, value string) bool) IgnorePattern { return &customIgnore{ ignoreFunc: ignoreFunc, } } -// IgnoreEmptyValues ignores fields with empty string values. -func IgnoreEmptyValues() SnapshotOption { - return CustomIgnore(func(key, value string) bool { +// IgnoreEmpty ignores fields with empty string values. +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreEmpty(), +// ) +func IgnoreEmpty() IgnorePattern { + return IgnoreWith(func(key, value string) bool { return strings.TrimSpace(value) == "" }) } -// IgnoreNullValues ignores fields with null/nil values (represented as "null" in JSON). -func IgnoreNullValues() SnapshotOption { - return CustomIgnore(func(key, value string) bool { +// IgnoreNull ignores fields with null/nil values (represented as "null" in JSON). +// +// This option only works with SnapJSON. +// +// Example: +// +// shutter.SnapJSON(t, "response", jsonStr, +// shutter.IgnoreNull(), +// ) +func IgnoreNull() IgnorePattern { + return IgnoreWith(func(key, value string) bool { return value == "null" || value == "" }) } diff --git a/ignore_test.go b/ignore_test.go index 0459bf9..8e7ee04 100644 --- a/ignore_test.go +++ b/ignore_test.go @@ -6,33 +6,68 @@ import ( "github.com/ptdewey/shutter" ) -func TestIgnoreKeyValue(t *testing.T) { - jsonStr := `{ - "username": "john_doe", - "password": "secret123", - "email": "john@example.com", - "api_key": "sk_live_abc123" - }` - - shutter.SnapJSON(t, "Ignore Password Field", jsonStr, - shutter.IgnoreKeyValue("password", "*"), - shutter.IgnoreKeyValue("api_key", "*"), - ) -} - func TestIgnoreKeys(t *testing.T) { - jsonStr := `{ - "id": 1, - "name": "John Doe", - "password": "secret", - "secret": "confidential", - "token": "abc123", - "email": "john@example.com" - }` + tests := []struct { + name string + json string + opts []shutter.Option + title string + }{ + { + name: "multiple_keys", + json: `{ + "id": 1, + "name": "John Doe", + "password": "secret", + "secret": "confidential", + "token": "abc123", + "email": "john@example.com" + }`, + opts: []shutter.Option{shutter.IgnoreKey("password", "secret", "token")}, + title: "Ignore Multiple Keys", + }, + { + name: "key_value_pairs", + json: `{ + "username": "john_doe", + "password": "secret123", + "email": "john@example.com", + "api_key": "sk_live_abc123" + }`, + opts: []shutter.Option{ + shutter.IgnoreKeyValue("password", "*"), + shutter.IgnoreKeyValue("api_key", "*"), + }, + title: "Ignore Password Field", + }, + { + name: "arrays", + json: `{ + "users": [ + { + "id": 1, + "name": "Alice", + "password": "secret1", + "email": "alice@example.com" + }, + { + "id": 2, + "name": "Bob", + "password": "secret2", + "email": "bob@example.com" + } + ] + }`, + opts: []shutter.Option{shutter.IgnoreKey("password")}, + title: "Ignore in Arrays", + }, + } - shutter.SnapJSON(t, "Ignore Multiple Keys", jsonStr, - shutter.IgnoreKeys("password", "secret", "token"), - ) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shutter.SnapJSON(t, tt.title, tt.json, tt.opts...) + }) + } } func TestIgnoreSensitiveKeys(t *testing.T) { @@ -47,78 +82,102 @@ func TestIgnoreSensitiveKeys(t *testing.T) { }` shutter.SnapJSON(t, "Ignore Sensitive Keys", jsonStr, - shutter.IgnoreSensitiveKeys(), - ) -} - -func TestIgnoreKeysMatching(t *testing.T) { - jsonStr := `{ - "user_id": 1, - "user_name": "john", - "user_email": "john@example.com", - "product_id": 100, - "product_name": "Widget" - }` - - shutter.SnapJSON(t, "Ignore Keys Matching Pattern", jsonStr, - shutter.IgnoreKeysMatching(`^user_`), + shutter.IgnoreSensitive(), ) } -func TestIgnoreKeyPattern(t *testing.T) { - jsonStr := `{ - "username": "john_doe", - "password": "secret", - "admin_password": "admin_secret", - "user_token": "token123", - "email": "john@example.com" - }` +func TestIgnoreKeyPatterns(t *testing.T) { + tests := []struct { + name string + json string + opts []shutter.Option + title string + }{ + { + name: "prefix_pattern", + json: `{ + "user_id": 1, + "user_name": "john", + "user_email": "john@example.com", + "product_id": 100, + "product_name": "Widget" + }`, + opts: []shutter.Option{shutter.IgnoreKeyMatching(`^user_`)}, + title: "Ignore Keys Matching Pattern", + }, + { + name: "contains_pattern", + json: `{ + "username": "john_doe", + "password": "secret", + "admin_password": "admin_secret", + "user_token": "token123", + "email": "john@example.com" + }`, + opts: []shutter.Option{ + shutter.IgnoreKeyPattern(`.*password.*`, ""), + shutter.IgnoreKeyPattern(`.*token.*`, ""), + }, + title: "Ignore Key Pattern", + }, + } - shutter.SnapJSON(t, "Ignore Key Pattern", jsonStr, - shutter.IgnoreKeyPattern(`.*password.*`, ""), - shutter.IgnoreKeyPattern(`.*token.*`, ""), - ) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shutter.SnapJSON(t, tt.title, tt.json, tt.opts...) + }) + } } func TestIgnoreValues(t *testing.T) { - jsonStr := `{ - "status": "pending", - "result": "pending", - "message": "Processing", - "state": "pending" - }` - - shutter.SnapJSON(t, "Ignore Specific Values", jsonStr, - shutter.IgnoreValues("pending"), - ) -} - -func TestIgnoreEmptyValues(t *testing.T) { - jsonStr := `{ - "name": "John Doe", - "middle_name": "", - "nickname": " ", - "email": "john@example.com", - "phone": "" - }` - - shutter.SnapJSON(t, "Ignore Empty Values", jsonStr, - shutter.IgnoreEmptyValues(), - ) -} - -func TestIgnoreNullValues(t *testing.T) { - jsonStr := `{ - "name": "John Doe", - "middle_name": null, - "email": "john@example.com", - "phone": null, - "age": 30 - }` + tests := []struct { + name string + json string + opts []shutter.Option + title string + }{ + { + name: "specific_values", + json: `{ + "status": "pending", + "result": "pending", + "message": "Processing", + "state": "pending" + }`, + opts: []shutter.Option{shutter.IgnoreValue("pending")}, + title: "Ignore Specific Values", + }, + { + name: "empty_values", + json: `{ + "name": "John Doe", + "middle_name": "", + "nickname": " ", + "email": "john@example.com", + "phone": "" + }`, + opts: []shutter.Option{shutter.IgnoreEmpty()}, + title: "Ignore Empty Values", + }, + { + name: "null_values", + json: `{ + "name": "John Doe", + "middle_name": null, + "email": "john@example.com", + "phone": null, + "age": 30 + }`, + opts: []shutter.Option{shutter.IgnoreNull()}, + title: "Ignore Null Values", + }, + } - shutter.SnapJSON(t, "Ignore Null Values", jsonStr, - shutter.IgnoreNullValues(), - ) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shutter.SnapJSON(t, tt.title, tt.json, tt.opts...) + }) + } } func TestCustomIgnore(t *testing.T) { @@ -131,7 +190,7 @@ func TestCustomIgnore(t *testing.T) { }` shutter.SnapJSON(t, "Custom Ignore Function", jsonStr, - shutter.CustomIgnore(func(key, value string) bool { + shutter.IgnoreWith(func(key, value string) bool { // Ignore numeric values return value == "1" || value == "25" || value == "95" }), @@ -158,7 +217,7 @@ func TestNestedIgnorePatterns(t *testing.T) { }` shutter.SnapJSON(t, "Nested Ignore Patterns", jsonStr, - shutter.IgnoreSensitiveKeys(), + shutter.IgnoreSensitive(), ) } @@ -175,35 +234,12 @@ func TestCombinedIgnoreAndScrub(t *testing.T) { shutter.SnapJSON(t, "Combined Ignore and Scrub", jsonStr, // Ignore sensitive keys entirely - shutter.IgnoreKeys("password", "api_key"), + shutter.IgnoreKey("password", "api_key"), // Scrub dynamic/identifiable data - shutter.ScrubUUIDs(), - shutter.ScrubEmails(), - shutter.ScrubTimestamps(), - shutter.ScrubIPAddresses(), - ) -} - -func TestIgnoreInArrays(t *testing.T) { - jsonStr := `{ - "users": [ - { - "id": 1, - "name": "Alice", - "password": "secret1", - "email": "alice@example.com" - }, - { - "id": 2, - "name": "Bob", - "password": "secret2", - "email": "bob@example.com" - } - ] - }` - - shutter.SnapJSON(t, "Ignore in Arrays", jsonStr, - shutter.IgnoreKeys("password"), + shutter.ScrubUUID(), + shutter.ScrubEmail(), + shutter.ScrubTimestamp(), + shutter.ScrubIP(), ) } @@ -236,13 +272,13 @@ func TestComplexRealWorldExample(t *testing.T) { shutter.SnapJSON(t, "Real World API Response", jsonStr, // Ignore sensitive fields - shutter.IgnoreSensitiveKeys(), - shutter.IgnoreKeys("card_number"), + shutter.IgnoreSensitive(), + shutter.IgnoreKey("card_number"), // Scrub dynamic/identifiable data - shutter.ScrubUUIDs(), - shutter.ScrubEmails(), - shutter.ScrubTimestamps(), - shutter.ScrubIPAddresses(), - shutter.ScrubJWTs(), + shutter.ScrubUUID(), + shutter.ScrubEmail(), + shutter.ScrubTimestamp(), + shutter.ScrubIP(), + shutter.ScrubJWT(), ) } diff --git a/internal/files/__snapshots__/accept_title.snap b/internal/files/__snapshots__/accept_title.snap new file mode 100644 index 0000000..70382e8 --- /dev/null +++ b/internal/files/__snapshots__/accept_title.snap @@ -0,0 +1,7 @@ +--- +title: Accept Title +test_name: TestAccept +file_name: +version: +--- +new content to accept \ No newline at end of file diff --git a/internal/files/__snapshots__/testaccept.snap b/internal/files/__snapshots__/testaccept.snap new file mode 100644 index 0000000..70382e8 --- /dev/null +++ b/internal/files/__snapshots__/testaccept.snap @@ -0,0 +1,7 @@ +--- +title: Accept Title +test_name: TestAccept +file_name: +version: +--- +new content to accept \ No newline at end of file diff --git a/internal/files/files.go b/internal/files/files.go index 1f431ef..0cc9d5e 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strings" ) @@ -75,24 +74,14 @@ func getSnapshotDir() (string, error) { return snapshotDir, nil } -func SnapshotFileName(testName string) string { - var result strings.Builder - for i, r := range testName { - if i > 0 && r >= 'A' && r <= 'Z' { - result.WriteRune('_') - } - result.WriteRune(r) - } - s := result.String() - s = strings.ToLower(s) - s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "_") - s = strings.Trim(s, "_") - return s +// TODO: make this use the snapshot title rather than the test name +func SnapshotFileName(snapTitle string) string { + return strings.ReplaceAll(strings.ToLower(snapTitle), " ", "_") } // getSnapshotFileName returns the filename for a snapshot based on test name and state -func getSnapshotFileName(testName string, state string) string { - baseName := SnapshotFileName(testName) +func getSnapshotFileName(snapTitle string, state string) string { + baseName := SnapshotFileName(snapTitle) switch state { case "accepted": return baseName + ".snap" @@ -109,19 +98,19 @@ func SaveSnapshot(snap *Snapshot, state string) error { return err } - fileName := getSnapshotFileName(snap.Test, state) + fileName := getSnapshotFileName(snap.Title, state) filePath := filepath.Join(snapshotDir, fileName) return os.WriteFile(filePath, []byte(snap.Serialize()), 0644) } -func ReadSnapshot(testName string, state string) (*Snapshot, error) { +func ReadSnapshot(snapTitle string, state string) (*Snapshot, error) { snapshotDir, err := getSnapshotDir() if err != nil { return nil, err } - fileName := getSnapshotFileName(testName, state) + fileName := getSnapshotFileName(snapTitle, state) filePath := filepath.Join(snapshotDir, fileName) data, err := os.ReadFile(filePath) @@ -132,12 +121,12 @@ func ReadSnapshot(testName string, state string) (*Snapshot, error) { return Deserialize(string(data)) } -func ReadAccepted(testName string) (*Snapshot, error) { - return ReadSnapshot(testName, "snap") +func ReadAccepted(snapTitle string) (*Snapshot, error) { + return ReadSnapshot(snapTitle, "snap") } -func ReadNew(testName string) (*Snapshot, error) { - return ReadSnapshot(testName, "new") +func ReadNew(snapTitle string) (*Snapshot, error) { + return ReadSnapshot(snapTitle, "new") } func ListNewSnapshots() ([]string, error) { @@ -162,13 +151,13 @@ func ListNewSnapshots() ([]string, error) { return newSnapshots, nil } -func AcceptSnapshot(testName string) error { +func AcceptSnapshot(snapTitle string) error { snapshotDir, err := getSnapshotDir() if err != nil { return err } - fileName := SnapshotFileName(testName) + fileName := SnapshotFileName(snapTitle) newPath := filepath.Join(snapshotDir, fileName+".snap.new") acceptedPath := filepath.Join(snapshotDir, fileName+".snap") @@ -184,13 +173,13 @@ func AcceptSnapshot(testName string) error { return os.Remove(newPath) } -func RejectSnapshot(testName string) error { +func RejectSnapshot(snapTitle string) error { snapshotDir, err := getSnapshotDir() if err != nil { return err } - fileName := SnapshotFileName(testName) + ".snap.new" + fileName := SnapshotFileName(snapTitle) + ".snap.new" filePath := filepath.Join(snapshotDir, fileName) return os.Remove(filePath) diff --git a/internal/files/files_test.go b/internal/files/files_test.go index d8137d8..84b633b 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -13,13 +13,13 @@ func TestSnapshotFileName(t *testing.T) { input string expected string }{ - {"TestMyFunction", "test_my_function"}, + {"Test My Function", "test_my_function"}, {"test_another_one", "test_another_one"}, - {"TestCamelCase", "test_camel_case"}, - {"TestWithNumbers123", "test_with_numbers123"}, - {"TestABC", "test_a_b_c"}, + {"Test Camel Case", "test_camel_case"}, + {"Test With Numbers123", "test_with_numbers123"}, + {"Test ABC", "test_abc"}, {"test", "test"}, - {"TEST", "t_e_s_t"}, + {"TEST", "test"}, } for _, tt := range tests { @@ -187,7 +187,7 @@ func TestAcceptSnapshot(t *testing.T) { t.Fatalf("SaveSnapshot failed: %v", err) } - if err := files.AcceptSnapshot("TestAccept"); err != nil { + if err := files.AcceptSnapshot("Accept Title"); err != nil { t.Fatalf("AcceptSnapshot failed: %v", err) } @@ -219,11 +219,11 @@ func TestRejectSnapshot(t *testing.T) { t.Fatalf("SaveSnapshot failed: %v", err) } - if err := files.RejectSnapshot("TestReject"); err != nil { + if err := files.RejectSnapshot("Reject Title"); err != nil { t.Fatalf("RejectSnapshot failed: %v", err) } - _, err := files.ReadSnapshot("TestReject", "new") + _, err := files.ReadSnapshot("Reject Title", "new") if err == nil { t.Error("expected error: .new file should be deleted after reject") } diff --git a/internal/review/review.go b/internal/review/review.go index f68c743..487620d 100644 --- a/internal/review/review.go +++ b/internal/review/review.go @@ -47,16 +47,16 @@ func Review() error { func reviewLoop(snapshots []string) error { reader := bufio.NewReader(os.Stdin) - for i, testName := range snapshots { - fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(testName)) + for i, snapTitle := range snapshots { + fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(snapTitle)) - newSnap, err := files.ReadSnapshot(testName, "new") + newSnap, err := files.ReadSnapshot(snapTitle, "new") if err != nil { fmt.Println(pretty.Error("βœ— Failed to read new snapshot: " + err.Error())) continue } - accepted, acceptErr := files.ReadSnapshot(testName, "accepted") + accepted, acceptErr := files.ReadSnapshot(snapTitle, "accepted") if acceptErr == nil { diffLines := computeDiffLines(accepted, newSnap) @@ -73,13 +73,13 @@ func reviewLoop(snapshots []string) error { switch choice { case Accept: - if err := files.AcceptSnapshot(testName); err != nil { + if err := files.AcceptSnapshot(snapTitle); err != nil { fmt.Println(pretty.Error("βœ— Failed to accept snapshot: " + err.Error())) } else { fmt.Println(pretty.Success("βœ“ Snapshot accepted")) } case Reject: - if err := files.RejectSnapshot(testName); err != nil { + if err := files.RejectSnapshot(snapTitle); err != nil { fmt.Println(pretty.Error("βœ— Failed to reject snapshot: " + err.Error())) } else { fmt.Println(pretty.Warning("⊘ Snapshot rejected")) diff --git a/internal/snapshots/snapshot.go b/internal/snapshots/snapshot.go index 3777185..c944b4c 100644 --- a/internal/snapshots/snapshot.go +++ b/internal/snapshots/snapshot.go @@ -5,7 +5,6 @@ import ( "path/filepath" "runtime" - "github.com/kortschak/utter" "github.com/ptdewey/shutter/internal/diff" "github.com/ptdewey/shutter/internal/files" "github.com/ptdewey/shutter/internal/pretty" @@ -56,7 +55,7 @@ func SnapWithTitle(t T, title, testName, fileName, version, content string) { Version: version, } - accepted, err := files.ReadAccepted(testName) + accepted, err := files.ReadAccepted(title) if err == nil { if accepted.Content == content { return @@ -81,15 +80,3 @@ func SnapWithTitle(t T, title, testName, fileName, version, content string) { fmt.Println(pretty.NewSnapshotBox(snapshot)) t.Error("new snapshot created - run 'shutter review' to accept") } - -func FormatValues(values ...any) string { - var result string - for _, v := range values { - result += FormatValue(v) - } - return result -} - -func FormatValue(v any) string { - return utter.Sdump(v) -} diff --git a/internal/snapshots/snapshot_test.go b/internal/snapshots/snapshot_test.go new file mode 100644 index 0000000..7f99839 --- /dev/null +++ b/internal/snapshots/snapshot_test.go @@ -0,0 +1,471 @@ +package snapshots + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ptdewey/shutter/internal/files" +) + +// mockT is a test implementation that captures test state +type mockT struct { + helperCalled bool + skipCalled bool + skipArgs []any + skipfCalled bool + skipfFormat string + skipfArgs []any + skipNowCalled bool + name string + errors []string + logs []string + cleanupFuncs []func() +} + +func (m *mockT) Helper() { + m.helperCalled = true +} + +func (m *mockT) Skip(args ...any) { + m.skipCalled = true + m.skipArgs = args +} + +func (m *mockT) Skipf(format string, args ...any) { + m.skipfCalled = true + m.skipfFormat = format + m.skipfArgs = args +} + +func (m *mockT) SkipNow() { + m.skipNowCalled = true +} + +func (m *mockT) Name() string { + return m.name +} + +func (m *mockT) Error(args ...any) { + var sb strings.Builder + for _, arg := range args { + if s, ok := arg.(string); ok { + sb.WriteString(s) + } else { + sb.WriteString(fmt.Sprintf("%v", arg)) + } + } + m.errors = append(m.errors, sb.String()) +} + +func (m *mockT) Log(args ...any) { + var sb strings.Builder + for _, arg := range args { + if s, ok := arg.(string); ok { + sb.WriteString(s) + } + } + m.logs = append(m.logs, sb.String()) +} + +func (m *mockT) Cleanup(f func()) { + m.cleanupFuncs = append(m.cleanupFuncs, f) +} + +func (m *mockT) runCleanups() { + for _, f := range m.cleanupFuncs { + f() + } +} + +// Helper to create a temporary snapshot directory for testing +func setupTestDir(t *testing.T) string { + tmpDir, err := os.MkdirTemp("", "shutter-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + // Change to temp directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp dir: %v", err) + } + + // Cleanup function + t.Cleanup(func() { + os.Chdir(originalDir) + os.RemoveAll(tmpDir) + }) + + return tmpDir +} + +func TestSnap_NewSnapshot(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestExample"} + Snap(mt, "test_snap", "v1", "content here") + + // Should create a new snapshot + if len(mt.errors) != 1 { + t.Errorf("expected 1 error, got %d", len(mt.errors)) + } + + if !strings.Contains(mt.errors[0], "new snapshot created") { + t.Errorf("expected 'new snapshot created' error, got: %s", mt.errors[0]) + } + + // Verify snapshot file was created + snapPath := filepath.Join("__snapshots__", "test_snap.snap.new") + if _, err := os.Stat(snapPath); os.IsNotExist(err) { + t.Error("expected snapshot file to be created") + } +} + +func TestSnap_MatchingSnapshot(t *testing.T) { + setupTestDir(t) + + // Create an accepted snapshot first + accepted := &files.Snapshot{ + Title: "matching_test", + Test: "TestExample", + FileName: "test.go", + Content: "expected content", + Version: "v1", + } + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { + t.Fatalf("failed to save accepted snapshot: %v", err) + } + + mt := &mockT{name: "TestExample"} + Snap(mt, "matching_test", "v1", "expected content") + + // Should not report any errors (snapshot matches) + if len(mt.errors) != 0 { + t.Errorf("expected no errors, got %d: %v", len(mt.errors), mt.errors) + } +} + +func TestSnap_MismatchedSnapshot(t *testing.T) { + setupTestDir(t) + + // Create an accepted snapshot + accepted := &files.Snapshot{ + Title: "mismatched_test", + Test: "TestExample", + FileName: "test.go", + Content: "old content", + Version: "v1", + } + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { + t.Fatalf("failed to save accepted snapshot: %v", err) + } + + mt := &mockT{name: "TestExample"} + Snap(mt, "mismatched_test", "v1", "new content") + + // Should report a mismatch error + if len(mt.errors) != 1 { + t.Errorf("expected 1 error, got %d", len(mt.errors)) + } + + if !strings.Contains(mt.errors[0], "snapshot mismatch") { + t.Errorf("expected 'snapshot mismatch' error, got: %s", mt.errors[0]) + } + + // Verify new snapshot file was created + snapPath := filepath.Join("__snapshots__", "mismatched_test.snap.new") + if _, err := os.Stat(snapPath); os.IsNotExist(err) { + t.Error("expected new snapshot file to be created") + } +} + +func TestSnap_CallerDetection(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestCallerDetection"} + Snap(mt, "caller_test", "v1", "test content") + + // Read the created snapshot + snap, err := files.ReadSnapshot("caller_test", "new") + if err != nil { + t.Fatalf("failed to read snapshot: %v", err) + } + + // Should detect the caller filename (this test file) + if snap.FileName == "unknown" { + t.Error("expected caller filename to be detected") + } + + // Should not be shutter.go + if snap.FileName == "shutter.go" { + t.Error("caller should not be shutter.go") + } +} + +func TestSnapWithTitle_CreatesCorrectSnapshot(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestExample"} + SnapWithTitle(mt, "custom_title", "TestExample", "test.go", "v1", "custom content") + + // Read the snapshot + snap, err := files.ReadSnapshot("custom_title", "new") + if err != nil { + t.Fatalf("failed to read snapshot: %v", err) + } + + if snap.Title != "custom_title" { + t.Errorf("expected title 'custom_title', got %q", snap.Title) + } + if snap.Test != "TestExample" { + t.Errorf("expected test name 'TestExample', got %q", snap.Test) + } + if snap.FileName != "test.go" { + t.Errorf("expected filename 'test.go', got %q", snap.FileName) + } + if snap.Version != "v1" { + t.Errorf("expected version 'v1', got %q", snap.Version) + } + if snap.Content != "custom content" { + t.Errorf("expected content 'custom content', got %q", snap.Content) + } +} + +func TestSnapWithTitle_MatchingContent(t *testing.T) { + setupTestDir(t) + + // Create accepted snapshot + accepted := &files.Snapshot{ + Title: "match_title", + Test: "TestMatch", + FileName: "test.go", + Content: "same content", + Version: "v1", + } + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { + t.Fatalf("failed to save accepted snapshot: %v", err) + } + + mt := &mockT{name: "TestMatch"} + SnapWithTitle(mt, "match_title", "TestMatch", "test.go", "v1", "same content") + + // Should not error (content matches) + if len(mt.errors) != 0 { + t.Errorf("expected no errors for matching content, got: %v", mt.errors) + } +} + +func TestSnapWithTitle_MismatchedContent(t *testing.T) { + setupTestDir(t) + + // Create accepted snapshot + accepted := &files.Snapshot{ + Title: "mismatch_title", + Test: "TestMismatch", + FileName: "test.go", + Content: "old content", + Version: "v1", + } + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { + t.Fatalf("failed to save accepted snapshot: %v", err) + } + + mt := &mockT{name: "TestMismatch"} + SnapWithTitle(mt, "mismatch_title", "TestMismatch", "test.go", "v1", "new content") + + // Should error about mismatch + if len(mt.errors) != 1 { + t.Errorf("expected 1 error, got %d", len(mt.errors)) + } + if !strings.Contains(mt.errors[0], "snapshot mismatch") { + t.Errorf("expected mismatch error, got: %s", mt.errors[0]) + } + + // Should create new snapshot file + newSnap, err := files.ReadSnapshot("mismatch_title", "new") + if err != nil { + t.Fatalf("failed to read new snapshot: %v", err) + } + if newSnap.Content != "new content" { + t.Errorf("expected new content in .snap.new file") + } +} + +func TestSnap_HelperCalled(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestHelper"} + Snap(mt, "helper_test", "v1", "content") + + if !mt.helperCalled { + t.Error("expected Helper() to be called") + } +} + +func TestSnapWithTitle_HelperCalled(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestHelper"} + SnapWithTitle(mt, "helper_test", "TestHelper", "test.go", "v1", "content") + + if !mt.helperCalled { + t.Error("expected Helper() to be called") + } +} + +func TestSnap_MultipleDifferentSnapshots(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestMultiple"} + + // Create multiple snapshots + Snap(mt, "snap_one", "v1", "content one") + Snap(mt, "snap_two", "v1", "content two") + Snap(mt, "snap_three", "v1", "content three") + + // All should be created as new snapshots + if len(mt.errors) != 3 { + t.Errorf("expected 3 errors (new snapshots), got %d", len(mt.errors)) + } + + // Verify all files exist + for _, title := range []string{"snap_one", "snap_two", "snap_three"} { + snapPath := filepath.Join("__snapshots__", title+".snap.new") + if _, err := os.Stat(snapPath); os.IsNotExist(err) { + t.Errorf("expected snapshot %s to exist", title) + } + } +} + +func TestSnap_EmptyContent(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestEmpty"} + Snap(mt, "empty_test", "v1", "") + + // Should create snapshot with empty content + snap, err := files.ReadSnapshot("empty_test", "new") + if err != nil { + t.Fatalf("failed to read snapshot: %v", err) + } + if snap.Content != "" { + t.Errorf("expected empty content, got %q", snap.Content) + } +} + +func TestSnap_MultilineContent(t *testing.T) { + setupTestDir(t) + + content := `line one +line two +line three` + + mt := &mockT{name: "TestMultiline"} + Snap(mt, "multiline_test", "v1", content) + + snap, err := files.ReadSnapshot("multiline_test", "new") + if err != nil { + t.Fatalf("failed to read snapshot: %v", err) + } + if snap.Content != content { + t.Errorf("expected multiline content to match") + } +} + +func TestSnap_SpecialCharacters(t *testing.T) { + setupTestDir(t) + + content := `{"key": "value with \"quotes\"", "emoji": "πŸŽ‰"}` + + mt := &mockT{name: "TestSpecial"} + Snap(mt, "special_test", "v1", content) + + snap, err := files.ReadSnapshot("special_test", "new") + if err != nil { + t.Fatalf("failed to read snapshot: %v", err) + } + if snap.Content != content { + t.Errorf("expected special characters to be preserved") + } +} + +func TestSnap_VersionTracking(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestVersion"} + Snap(mt, "version_test", "v2", "content") + + snap, err := files.ReadSnapshot("version_test", "new") + if err != nil { + t.Fatalf("failed to read snapshot: %v", err) + } + if snap.Version != "v2" { + t.Errorf("expected version 'v2', got %q", snap.Version) + } +} + +func TestSnap_UpdateAcceptedSnapshot(t *testing.T) { + setupTestDir(t) + + // Create initial accepted snapshot + accepted := &files.Snapshot{ + Title: "update_test", + Test: "TestUpdate", + FileName: "test.go", + Content: "old version", + Version: "v1", + } + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { + t.Fatalf("failed to save accepted snapshot: %v", err) + } + + // Create new snapshot with different content + mt := &mockT{name: "TestUpdate"} + Snap(mt, "update_test", "v2", "new version") + + // Verify new snapshot was created + newSnap, err := files.ReadSnapshot("update_test", "new") + if err != nil { + t.Fatalf("failed to read new snapshot: %v", err) + } + + if newSnap.Content != "new version" { + t.Errorf("expected new content") + } + if newSnap.Version != "v2" { + t.Errorf("expected version to be updated") + } + + // Accepted snapshot should remain unchanged + acceptedSnap, err := files.ReadSnapshot("update_test", "snap") + if err != nil { + t.Fatalf("failed to read accepted snapshot: %v", err) + } + if acceptedSnap.Content != "old version" { + t.Errorf("accepted snapshot should not change") + } +} + +func TestSnap_WithSpacesInTitle(t *testing.T) { + setupTestDir(t) + + mt := &mockT{name: "TestSpaces"} + Snap(mt, "test with spaces", "v1", "content") + + // Should normalize title to filename + snap, err := files.ReadSnapshot("test with spaces", "new") + if err != nil { + t.Fatalf("failed to read snapshot: %v", err) + } + + if snap.Title != "test with spaces" { + t.Errorf("expected title to preserve spaces, got %q", snap.Title) + } +} diff --git a/internal/transform/transform_test.go b/internal/transform/transform_test.go new file mode 100644 index 0000000..8645f0a --- /dev/null +++ b/internal/transform/transform_test.go @@ -0,0 +1,521 @@ +package transform + +import ( + "regexp" + "strings" + "testing" +) + +// Mock implementations for testing + +type mockScrubber struct { + fn func(string) string +} + +func (m *mockScrubber) Scrub(content string) string { + return m.fn(content) +} + +type mockIgnorePattern struct { + fn func(string, string) bool +} + +func (m *mockIgnorePattern) ShouldIgnore(key, value string) bool { + return m.fn(key, value) +} + +// Tests for ApplyScrubbers + +func TestApplyScrubbers_NoScrubbers(t *testing.T) { + input := "hello world" + result := ApplyScrubbers(input, nil) + + if result != input { + t.Errorf("expected %q, got %q", input, result) + } +} + +func TestApplyScrubbers_SingleScrubber(t *testing.T) { + scrubber := &mockScrubber{ + fn: func(s string) string { + return strings.ReplaceAll(s, "secret", "") + }, + } + + input := "my secret password" + expected := "my password" + result := ApplyScrubbers(input, []Scrubber{scrubber}) + + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestApplyScrubbers_MultipleScrubbers(t *testing.T) { + scrubber1 := &mockScrubber{ + fn: func(s string) string { + return strings.ReplaceAll(s, "foo", "FOO") + }, + } + scrubber2 := &mockScrubber{ + fn: func(s string) string { + return strings.ReplaceAll(s, "bar", "BAR") + }, + } + + input := "foo and bar" + expected := "FOO and BAR" + result := ApplyScrubbers(input, []Scrubber{scrubber1, scrubber2}) + + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestApplyScrubbers_OrderMatters(t *testing.T) { + // Test that scrubbers are applied in order + scrubber1 := &mockScrubber{ + fn: func(s string) string { + return strings.ReplaceAll(s, "test", "TEST") + }, + } + scrubber2 := &mockScrubber{ + fn: func(s string) string { + return strings.ReplaceAll(s, "TEST", "FINAL") + }, + } + + input := "test value" + expected := "FINAL value" + result := ApplyScrubbers(input, []Scrubber{scrubber1, scrubber2}) + + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +// Tests for TransformJSON + +func TestTransformJSON_InvalidJSON(t *testing.T) { + config := &Config{} + _, err := TransformJSON("not valid json", config) + + if err == nil { + t.Error("expected error for invalid JSON") + } + if err != nil && !strings.Contains(err.Error(), "failed to unmarshal JSON") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestTransformJSON_EmptyConfig(t *testing.T) { + config := &Config{} + input := `{"name":"John","age":30}` + + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should return pretty-printed JSON + expected := "{\n \"age\": 30,\n \"name\": \"John\"\n}" + if result != expected { + t.Errorf("expected:\n%s\ngot:\n%s", expected, result) + } +} + +func TestTransformJSON_WithScrubbers(t *testing.T) { + scrubber := &mockScrubber{ + fn: func(s string) string { + return strings.ReplaceAll(s, "John", "") + }, + } + + config := &Config{ + Scrubbers: []Scrubber{scrubber}, + } + + input := `{"name":"John","age":30}` + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(result, "") { + t.Errorf("expected scrubber to be applied, got: %s", result) + } + if strings.Contains(result, "John") { + t.Errorf("scrubber failed to replace 'John', got: %s", result) + } +} + +func TestTransformJSON_WithIgnorePatterns(t *testing.T) { + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return key == "password" + }, + } + + config := &Config{ + Ignore: []IgnorePattern{ignorePattern}, + } + + input := `{"username":"john","password":"secret"}` + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(result, "password") { + t.Errorf("expected 'password' to be ignored, got: %s", result) + } + if !strings.Contains(result, "username") { + t.Errorf("expected 'username' to be present, got: %s", result) + } +} + +func TestTransformJSON_ComplexNested(t *testing.T) { + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return key == "secret" + }, + } + + config := &Config{ + Ignore: []IgnorePattern{ignorePattern}, + } + + input := `{ + "user": { + "name": "John", + "secret": "hidden", + "nested": { + "secret": "also_hidden", + "public": "visible" + } + } + }` + + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(result, "hidden") || strings.Contains(result, "also_hidden") { + t.Errorf("expected nested secrets to be ignored, got: %s", result) + } + if !strings.Contains(result, "visible") { + t.Errorf("expected 'visible' to be present, got: %s", result) + } +} + +func TestTransformJSON_WithArrays(t *testing.T) { + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return key == "id" + }, + } + + config := &Config{ + Ignore: []IgnorePattern{ignorePattern}, + } + + input := `{ + "users": [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"} + ] + }` + + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should remove id fields from all array elements + if strings.Contains(result, "\"id\"") { + t.Errorf("expected 'id' fields to be ignored in arrays, got: %s", result) + } + if !strings.Contains(result, "Alice") || !strings.Contains(result, "Bob") { + t.Errorf("expected names to be present, got: %s", result) + } +} + +func TestTransformJSON_ScrubbersAndIgnoreCombined(t *testing.T) { + scrubber := &mockScrubber{ + fn: func(s string) string { + re := regexp.MustCompile(`\d{3}-\d{3}-\d{4}`) + return re.ReplaceAllString(s, "") + }, + } + + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return key == "ssn" + }, + } + + config := &Config{ + Scrubbers: []Scrubber{scrubber}, + Ignore: []IgnorePattern{ignorePattern}, + } + + input := `{"name":"John","phone":"555-123-4567","ssn":"123-45-6789"}` + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // SSN field should be completely removed + if strings.Contains(result, "ssn") { + t.Errorf("expected 'ssn' field to be ignored, got: %s", result) + } + + // Phone should be scrubbed + if !strings.Contains(result, "") { + t.Errorf("expected phone to be scrubbed, got: %s", result) + } + if strings.Contains(result, "555-123-4567") { + t.Errorf("expected phone number to be replaced, got: %s", result) + } +} + +// Tests for helper functions + +func TestValueToString_String(t *testing.T) { + result := valueToString("hello") + if result != "hello" { + t.Errorf("expected 'hello', got %q", result) + } +} + +func TestValueToString_Nil(t *testing.T) { + result := valueToString(nil) + if result != "null" { + t.Errorf("expected 'null', got %q", result) + } +} + +func TestValueToString_BoolTrue(t *testing.T) { + result := valueToString(true) + if result != "true" { + t.Errorf("expected 'true', got %q", result) + } +} + +func TestValueToString_BoolFalse(t *testing.T) { + result := valueToString(false) + if result != "false" { + t.Errorf("expected 'false', got %q", result) + } +} + +func TestValueToString_Float64(t *testing.T) { + result := valueToString(42.5) + if result != "42.5" { + t.Errorf("expected '42.5', got %q", result) + } +} + +func TestValueToString_Int(t *testing.T) { + result := valueToString(42) + if result != "42" { + t.Errorf("expected '42', got %q", result) + } +} + +func TestValueToString_ComplexType(t *testing.T) { + // Map should be marshalled to JSON + m := map[string]any{"key": "value"} + result := valueToString(m) + if result != `{"key":"value"}` { + t.Errorf("expected JSON string, got %q", result) + } +} + +func TestFilterMap_RemovesMatchingKeys(t *testing.T) { + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return key == "remove_me" + }, + } + + input := map[string]any{ + "keep": "value1", + "remove_me": "value2", + "also_keep": "value3", + } + + result := filterMap(input, []IgnorePattern{ignorePattern}) + + if _, exists := result["remove_me"]; exists { + t.Error("expected 'remove_me' to be filtered out") + } + if result["keep"] != "value1" { + t.Error("expected 'keep' to remain") + } + if result["also_keep"] != "value3" { + t.Error("expected 'also_keep' to remain") + } +} + +func TestFilterMap_NestedStructures(t *testing.T) { + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return key == "secret" + }, + } + + input := map[string]any{ + "public": "data", + "nested": map[string]any{ + "secret": "hidden", + "public": "visible", + }, + } + + result := filterMap(input, []IgnorePattern{ignorePattern}) + + nested, ok := result["nested"].(map[string]any) + if !ok { + t.Fatal("expected nested map") + } + + if _, exists := nested["secret"]; exists { + t.Error("expected nested 'secret' to be filtered out") + } + if nested["public"] != "visible" { + t.Error("expected nested 'public' to remain") + } +} + +func TestFilterSlice_ProcessesAllElements(t *testing.T) { + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return key == "id" + }, + } + + input := []any{ + map[string]any{"id": "1", "name": "Alice"}, + map[string]any{"id": "2", "name": "Bob"}, + } + + result := filterSlice(input, []IgnorePattern{ignorePattern}) + + if len(result) != 2 { + t.Fatalf("expected 2 elements, got %d", len(result)) + } + + for i, item := range result { + m, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected map at index %d", i) + } + if _, exists := m["id"]; exists { + t.Errorf("expected 'id' to be filtered at index %d", i) + } + if m["name"] == "" { + t.Errorf("expected 'name' to remain at index %d", i) + } + } +} + +func TestWalkAndFilter_HandlesAllTypes(t *testing.T) { + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return false // Don't ignore anything + }, + } + + tests := []struct { + name string + input any + expected any + }{ + { + name: "string passthrough", + input: "hello", + expected: "hello", + }, + { + name: "number passthrough", + input: 42, + expected: 42, + }, + { + name: "bool passthrough", + input: true, + expected: true, + }, + { + name: "nil passthrough", + input: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := walkAndFilter(tt.input, []IgnorePattern{ignorePattern}) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestTransformJSON_EmptyObject(t *testing.T) { + config := &Config{} + input := `{}` + + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "{}" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestTransformJSON_EmptyArray(t *testing.T) { + config := &Config{} + input := `[]` + + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "[]" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestTransformJSON_IgnoreByValue(t *testing.T) { + ignorePattern := &mockIgnorePattern{ + fn: func(key, value string) bool { + return value == "ignore_this" + }, + } + + config := &Config{ + Ignore: []IgnorePattern{ignorePattern}, + } + + input := `{"field1":"keep","field2":"ignore_this","field3":"also_keep"}` + result, err := TransformJSON(input, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(result, "field2") { + t.Errorf("expected field with ignored value to be removed, got: %s", result) + } + if !strings.Contains(result, "field1") || !strings.Contains(result, "field3") { + t.Errorf("expected other fields to remain, got: %s", result) + } +} diff --git a/scrubbers.go b/scrubbers.go index e1d0c8b..2ffa871 100644 --- a/scrubbers.go +++ b/scrubbers.go @@ -11,13 +11,19 @@ type regexScrubber struct { replacement string } -func (r *regexScrubber) Apply(content string) string { +func (r *regexScrubber) isOption() {} + +func (r *regexScrubber) Scrub(content string) string { return r.pattern.ReplaceAllString(content, r.replacement) } -// RegexScrubber creates a scrubber that replaces all matches of the given +// ScrubRegex creates a scrubber that replaces all matches of the given // regex pattern with the replacement string. -func RegexScrubber(pattern string, replacement string) SnapshotOption { +// +// Example: +// +// shutter.ScrubRegex(`user-\d+`, "") +func ScrubRegex(pattern string, replacement string) Scrubber { re := regexp.MustCompile(pattern) return ®exScrubber{ pattern: re, @@ -31,12 +37,18 @@ type exactMatchScrubber struct { replacement string } -func (e *exactMatchScrubber) Apply(content string) string { +func (e *exactMatchScrubber) isOption() {} + +func (e *exactMatchScrubber) Scrub(content string) string { return strings.ReplaceAll(content, e.match, e.replacement) } -// ExactMatchScrubber creates a scrubber that replaces exact string matches. -func ExactMatchScrubber(match string, replacement string) SnapshotOption { +// ScrubExact creates a scrubber that replaces exact string matches. +// +// Example: +// +// shutter.ScrubExact("secret_value", "") +func ScrubExact(match string, replacement string) Scrubber { return &exactMatchScrubber{ match: match, replacement: replacement, @@ -51,96 +63,150 @@ var ( // Unix timestamp pattern - matches 10-13 digit numbers (Unix timestamps in seconds or milliseconds) // Note: This is aggressive and may match other numbers. Use with caution or customize. unixTsPattern = regexp.MustCompile(`\b\d{10,13}\b`) - // IPv4 pattern with basic range validation (not perfect, but better) + // IPv4 pattern with basic range validation ipv4Pattern = regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) // Credit card pattern - matches 16 digit numbers with optional separators creditCardPattern = regexp.MustCompile(`\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b`) jwtPattern = regexp.MustCompile(`eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*`) + // Date patterns + datePattern = regexp.MustCompile(`\b\d{4}[-/]\d{2}[-/]\d{2}\b|\b\d{2}[-/]\d{2}[-/]\d{4}\b`) + // API key pattern - matches patterns like: sk_live_..., pk_test_..., api_key_... + apiKeyPattern = regexp.MustCompile(`\b(sk|pk|api[_-]?key)[_-](live|test|prod|dev)[_-][a-zA-Z0-9]+\b`) ) -// ScrubUUIDs replaces all UUIDs with "". -func ScrubUUIDs() SnapshotOption { +// ScrubUUID replaces all UUIDs with "". +// +// Example: +// +// shutter.Snap(t, "user", user, shutter.ScrubUUID()) +func ScrubUUID() Scrubber { return ®exScrubber{ pattern: uuidPattern, replacement: "", } } -// ScrubTimestamps replaces ISO8601 timestamps with "". -func ScrubTimestamps() SnapshotOption { +// ScrubTimestamp replaces ISO8601 timestamps with "". +// +// Example: +// +// shutter.Snap(t, "event", event, shutter.ScrubTimestamp()) +func ScrubTimestamp() Scrubber { return ®exScrubber{ pattern: iso8601Pattern, replacement: "", } } -// ScrubEmails replaces email addresses with "". -func ScrubEmails() SnapshotOption { +// ScrubEmail replaces email addresses with "". +// +// Example: +// +// shutter.Snap(t, "user", user, shutter.ScrubEmail()) +func ScrubEmail() Scrubber { return ®exScrubber{ pattern: emailPattern, replacement: "", } } -// ScrubUnixTimestamps replaces Unix timestamps (10-13 digits) with "". +// ScrubUnixTimestamp replaces Unix timestamps (10-13 digits) with "". // Note: This is aggressive and may match other long numbers. For more conservative -// scrubbing with context keywords, use a custom regex. -func ScrubUnixTimestamps() SnapshotOption { +// scrubbing with context keywords, use ScrubRegex with a custom pattern. +// +// Example: +// +// shutter.Snap(t, "data", data, shutter.ScrubUnixTimestamp()) +func ScrubUnixTimestamp() Scrubber { return ®exScrubber{ pattern: unixTsPattern, replacement: "", } } -// ScrubIPAddresses replaces IPv4 addresses with "". -func ScrubIPAddresses() SnapshotOption { +// ScrubIP replaces IPv4 addresses with "". +// +// Example: +// +// shutter.Snap(t, "request", request, shutter.ScrubIP()) +func ScrubIP() Scrubber { return ®exScrubber{ pattern: ipv4Pattern, replacement: "", } } -func ScrubCreditCards() SnapshotOption { +// ScrubCreditCard replaces credit card numbers with "". +// +// Example: +// +// shutter.Snap(t, "payment", payment, shutter.ScrubCreditCard()) +func ScrubCreditCard() Scrubber { return ®exScrubber{ pattern: creditCardPattern, replacement: "", } } -func ScrubJWTs() SnapshotOption { +// ScrubJWT replaces JWT tokens with "". +// +// Example: +// +// shutter.Snap(t, "auth", authData, shutter.ScrubJWT()) +func ScrubJWT() Scrubber { return ®exScrubber{ pattern: jwtPattern, replacement: "", } } -func ScrubDates() SnapshotOption { - datePattern := regexp.MustCompile(`\b\d{4}[-/]\d{2}[-/]\d{2}\b|\b\d{2}[-/]\d{2}[-/]\d{4}\b`) +// ScrubDate replaces various date formats with "". +// +// Example: +// +// shutter.Snap(t, "data", data, shutter.ScrubDate()) +func ScrubDate() Scrubber { return ®exScrubber{ pattern: datePattern, replacement: "", } } -// ScrubAPIKeys replaces common API key patterns with "". +// ScrubAPIKey replaces common API key patterns with "". // Matches patterns like: sk_live_..., pk_test_..., api_key_... -func ScrubAPIKeys() SnapshotOption { - apiKeyPattern := regexp.MustCompile(`\b(sk|pk|api[_-]?key)[_-](live|test|prod|dev)[_-][a-zA-Z0-9]+\b`) +// +// Example: +// +// shutter.Snap(t, "config", config, shutter.ScrubAPIKey()) +func ScrubAPIKey() Scrubber { return ®exScrubber{ pattern: apiKeyPattern, replacement: "", } } +// customScrubber allows users to provide a custom scrubbing function. type customScrubber struct { scrubFunc func(string) string } -func (c *customScrubber) Apply(content string) string { +func (c *customScrubber) isOption() {} + +func (c *customScrubber) Scrub(content string) string { return c.scrubFunc(content) } -func CustomScrubber(scrubFunc func(string) string) SnapshotOption { +// ScrubWith creates a scrubber using a custom function. +// The function receives the snapshot content and should return the scrubbed content. +// +// Example: +// +// shutter.Snap(t, "data", data, +// shutter.ScrubWith(func(content string) string { +// return strings.ReplaceAll(content, "localhost", "") +// }), +// ) +func ScrubWith(scrubFunc func(string) string) Scrubber { return &customScrubber{ scrubFunc: scrubFunc, } diff --git a/scrubbers_test.go b/scrubbers_test.go index 7dfed4b..08e3c66 100644 --- a/scrubbers_test.go +++ b/scrubbers_test.go @@ -7,137 +7,183 @@ import ( "github.com/ptdewey/shutter" ) -func TestScrubUUIDs(t *testing.T) { +func TestBuiltInScrubbers(t *testing.T) { + // Test all built-in scrubbers in one comprehensive test jsonStr := `{ "user_id": "550e8400-e29b-41d4-a716-446655440000", "session_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", - "name": "John Doe" - }` - - shutter.SnapJSON(t, "Scrubbed UUIDs", jsonStr, - shutter.ScrubUUIDs(), - ) -} - -func TestScrubTimestamps(t *testing.T) { - jsonStr := `{ - "created_at": "2023-01-15T10:30:00Z", - "updated_at": "2023-11-20T15:45:30.123Z", - "deleted_at": "2023-12-01T08:00:00+05:00", - "name": "Test Event" - }` - - shutter.SnapJSON(t, "Scrubbed Timestamps", jsonStr, - shutter.ScrubTimestamps(), - ) -} - -func TestScrubEmails(t *testing.T) { - jsonStr := `{ "email": "user@example.com", "backup_email": "backup.user+tag@subdomain.example.co.uk", - "name": "John Doe" - }` - - shutter.SnapJSON(t, "Scrubbed Emails", jsonStr, - shutter.ScrubEmails(), - ) -} - -func TestScrubIPAddresses(t *testing.T) { - jsonStr := `{ + "created_at": "2023-01-15T10:30:00Z", + "updated_at": "2023-11-20T15:45:30.123Z", + "birth_date": "1990-05-15", + "us_format_date": "12/25/2023", + "unix_created": 1699999999, + "unix_updated": 1700000000000, "client_ip": "192.168.1.1", "server_ip": "10.0.0.5", + "jwt_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + "stripe_key": "sk_live_51HqZ2bKl4FGBMFpLxO0123", + "api_key": "api_key_prod_abc123def456", + "card_number": "4532-1234-5678-9010", + "backup_card": "4532 1234 5678 9010", + "name": "John Doe", "message": "Connection from 172.16.0.100" }` - shutter.SnapJSON(t, "Scrubbed IPs", jsonStr, - shutter.ScrubIPAddresses(), - ) -} - -func TestScrubJWTs(t *testing.T) { - jsonStr := `{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", - "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" - }` - - shutter.SnapJSON(t, "Scrubbed JWTs", jsonStr, - shutter.ScrubJWTs(), - ) -} - -func TestMultipleScrubbers(t *testing.T) { - jsonStr := `{ - "user_id": "550e8400-e29b-41d4-a716-446655440000", - "email": "user@example.com", - "created_at": "2023-01-15T10:30:00Z", - "ip_address": "192.168.1.1", - "name": "John Doe" - }` - shutter.SnapJSON(t, "Multiple Scrubbers", jsonStr, - shutter.ScrubUUIDs(), - shutter.ScrubEmails(), - shutter.ScrubTimestamps(), - shutter.ScrubIPAddresses(), - ) -} - -func TestRegexScrubber(t *testing.T) { - jsonStr := `{ - "api_key": "sk_live_abc123def456", - "secret_key": "sk_test_xyz789uvw012", - "name": "Test User" - }` - - shutter.SnapJSON(t, "Custom Regex Scrubber", jsonStr, - shutter.RegexScrubber(`sk_(live|test)_[a-zA-Z0-9]+`, ""), - ) -} - -func TestExactMatchScrubber(t *testing.T) { - content := "The secret password is 'p@ssw0rd123' and should be hidden." - - shutter.SnapString(t, "Exact Match Scrubber", content, - shutter.ExactMatchScrubber("p@ssw0rd123", ""), - ) -} - -func TestCustomScrubber(t *testing.T) { - content := "Hello World! This is a TEST." - - shutter.SnapString(t, "Custom Scrubber", content, - shutter.CustomScrubber(func(s string) string { - return strings.ToLower(s) - }), + shutter.ScrubUUID(), + shutter.ScrubEmail(), + shutter.ScrubTimestamp(), + shutter.ScrubDate(), + shutter.ScrubUnixTimestamp(), + shutter.ScrubIP(), + shutter.ScrubJWT(), + shutter.ScrubAPIKey(), + shutter.ScrubCreditCard(), ) } -func TestScrubDates(t *testing.T) { - jsonStr := `{ - "birth_date": "1990-05-15", - "hire_date": "2020-01-01", - "us_format": "12/25/2023", - "name": "John Doe" - }` +func TestIndividualScrubbers(t *testing.T) { + tests := []struct { + name string + json string + scrubber shutter.Option + title string + }{ + { + name: "uuid", + json: `{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "session_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "name": "John Doe" + }`, + scrubber: shutter.ScrubUUID(), + title: "Scrubbed UUIDs", + }, + { + name: "timestamps", + json: `{ + "created_at": "2023-01-15T10:30:00Z", + "updated_at": "2023-11-20T15:45:30.123Z", + "deleted_at": "2023-12-01T08:00:00+05:00", + "name": "Test Event" + }`, + scrubber: shutter.ScrubTimestamp(), + title: "Scrubbed Timestamps", + }, + { + name: "emails", + json: `{ + "email": "user@example.com", + "backup_email": "backup.user+tag@subdomain.example.co.uk", + "name": "John Doe" + }`, + scrubber: shutter.ScrubEmail(), + title: "Scrubbed Emails", + }, + { + name: "ip_addresses", + json: `{ + "client_ip": "192.168.1.1", + "server_ip": "10.0.0.5", + "message": "Connection from 172.16.0.100" + }`, + scrubber: shutter.ScrubIP(), + title: "Scrubbed IPs", + }, + { + name: "jwts", + json: `{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }`, + scrubber: shutter.ScrubJWT(), + title: "Scrubbed JWTs", + }, + { + name: "dates", + json: `{ + "birth_date": "1990-05-15", + "hire_date": "2020-01-01", + "us_format": "12/25/2023", + "name": "John Doe" + }`, + scrubber: shutter.ScrubDate(), + title: "Scrubbed Dates", + }, + { + name: "api_keys", + json: `{ + "stripe_key": "sk_live_51HqZ2bKl4FGBMFpLxO0123", + "test_key": "pk_test_51HqZ2bKl4FGBMFpLxO0456", + "api_key_prod": "api_key_prod_abc123def456", + "name": "Test Config" + }`, + scrubber: shutter.ScrubAPIKey(), + title: "Scrubbed API Keys", + }, + { + name: "credit_cards", + json: `{ + "card_number": "4532-1234-5678-9010", + "backup_card": "4532 1234 5678 9010", + "another_card": "4532123456789010", + "name": "John Doe" + }`, + scrubber: shutter.ScrubCreditCard(), + title: "Scrubbed Credit Cards", + }, + { + name: "unix_timestamps", + json: `{ + "created": 1699999999, + "updated": 1700000000000, + "deleted": 1700000000, + "name": "Test Event" + }`, + scrubber: shutter.ScrubUnixTimestamp(), + title: "Scrubbed Unix Timestamps", + }, + } - shutter.SnapJSON(t, "Scrubbed Dates", jsonStr, - shutter.ScrubDates(), - ) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shutter.SnapJSON(t, tt.title, tt.json, tt.scrubber) + }) + } } -func TestScrubAPIKeys(t *testing.T) { - jsonStr := `{ - "stripe_key": "sk_live_51HqZ2bKl4FGBMFpLxO0123", - "test_key": "pk_test_51HqZ2bKl4FGBMFpLxO0456", - "api_key_prod": "api_key_prod_abc123def456", - "name": "Test Config" - }` - - shutter.SnapJSON(t, "Scrubbed API Keys", jsonStr, - shutter.ScrubAPIKeys(), - ) +func TestCustomScrubbers(t *testing.T) { + t.Run("regex_scrubber", func(t *testing.T) { + jsonStr := `{ + "api_key": "sk_live_abc123def456", + "secret_key": "sk_test_xyz789uvw012", + "name": "Test User" + }` + + shutter.SnapJSON(t, "Custom Regex Scrubber", jsonStr, + shutter.ScrubRegex(`sk_(live|test)_[a-zA-Z0-9]+`, ""), + ) + }) + + t.Run("exact_match_scrubber", func(t *testing.T) { + content := "The secret password is 'p@ssw0rd123' and should be hidden." + + shutter.SnapString(t, "Exact Match Scrubber", content, + shutter.ScrubExact("p@ssw0rd123", ""), + ) + }) + + t.Run("custom_function_scrubber", func(t *testing.T) { + content := "Hello World! This is a TEST." + + shutter.SnapString(t, "Custom Scrubber", content, + shutter.ScrubWith(func(s string) string { + return strings.ToLower(s) + }), + ) + }) } func TestScrubWithSnapFunction(t *testing.T) { @@ -149,34 +195,8 @@ func TestScrubWithSnapFunction(t *testing.T) { } shutter.Snap(t, "Scrub With Snap", data, - shutter.ScrubUUIDs(), - shutter.ScrubEmails(), - shutter.ScrubTimestamps(), - ) -} - -func TestCreditCardScrubbing(t *testing.T) { - jsonStr := `{ - "card_number": "4532-1234-5678-9010", - "backup_card": "4532 1234 5678 9010", - "another_card": "4532123456789010", - "name": "John Doe" - }` - - shutter.SnapJSON(t, "Scrubbed Credit Cards", jsonStr, - shutter.ScrubCreditCards(), - ) -} - -func TestUnixTimestampScrubbing(t *testing.T) { - jsonStr := `{ - "created": 1699999999, - "updated": 1700000000000, - "deleted": 1700000000, - "name": "Test Event" - }` - - shutter.SnapJSON(t, "Scrubbed Unix Timestamps", jsonStr, - shutter.ScrubUnixTimestamps(), + shutter.ScrubUUID(), + shutter.ScrubEmail(), + shutter.ScrubTimestamp(), ) } diff --git a/shutter.go b/shutter.go index 0bbead4..acc97d8 100644 --- a/shutter.go +++ b/shutter.go @@ -1,70 +1,166 @@ package shutter import ( + "fmt" + "github.com/kortschak/utter" "github.com/ptdewey/shutter/internal/review" "github.com/ptdewey/shutter/internal/snapshots" "github.com/ptdewey/shutter/internal/transform" ) -const version = "0.1.0" +// snapshotFormatVersion indicates the snapshot format version used by this library. +// This is automatically included in snapshot metadata for compatibility checking +// when the snapshot format changes in future versions. +const snapshotFormatVersion = "0.1.0" + +// utterConfig is a configured instance of utter for consistent formatting. +// This avoids modifying global state and ensures snapshot formatting is isolated. +var utterConfig = &utter.ConfigState{ + Indent: " ", + ElideType: true, + SortKeys: true, +} -func init() { - utter.Config.ElideType = true - utter.Config.SortKeys = true +// Option is a marker interface for all snapshot options. +// This allows compile-time type safety while supporting different option types. +type Option interface { + isOption() } -// Snap takes any values, formats them, and creates a snapshot with the given title. -// For complex types, values are formatted using a pretty-printer. -// The last parameters can be SnapshotOptions to apply scrubbers before snapshotting. +// Scrubber transforms content before snapshotting, typically to replace +// dynamic or sensitive data with stable placeholders. // -// shutter.Snap(t, "title", any(value1), any(value2), shutter.ScrubUUIDs()) +// Scrubbers are applied in the order they are provided. Later scrubbers +// can transform the output of earlier scrubbers. +// +// Example: +// +// shutter.Snap(t, "user data", user, +// shutter.ScrubUUID(), // First: UUIDs -> +// shutter.ScrubEmail(), // Second: emails -> +// ) +type Scrubber interface { + Option + Scrub(content string) string +} + +// IgnorePattern determines whether a key-value pair should be excluded +// from JSON snapshots. This is useful for removing fields that change +// frequently or contain sensitive data. +// +// IgnorePatterns only work with SnapJSON. Using them with Snap or SnapString +// will result in an error. +type IgnorePattern interface { + Option + ShouldIgnore(key, value string) bool +} + +// Snap takes a single value, formats it, and creates a snapshot with the given title. +// Complex types are formatted using a pretty-printer for readability. // -// REFACTOR: should this take in _one_ value, and then allow options as additional inputs? -func Snap(t snapshots.T, title string, values ...any) { +// Options can be provided to scrub sensitive or dynamic data before snapshotting. +// Only Scrubber options are supported; IgnorePattern options will cause an error. +// +// Example: +// +// user := User{ID: "123", Email: "user@example.com"} +// shutter.Snap(t, "user data", user, +// shutter.ScrubUUID(), +// shutter.ScrubEmail(), +// ) +func Snap(t snapshots.T, title string, value any, opts ...Option) { t.Helper() - // Separate options from values - var opts []SnapshotOption - var actualValues []any + scrubbers, ignores := separateOptions(opts) - for _, v := range values { - if opt, ok := v.(SnapshotOption); ok { - opts = append(opts, opt) - } else { - actualValues = append(actualValues, v) - } + if len(ignores) > 0 { + t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with Snap; use SnapJSON instead", title)) + return } - content := snapshots.FormatValues(actualValues...) + content := formatValue(value) + scrubbedContent := applyScrubbers(content, scrubbers) + + snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent) +} + +// SnapMany takes multiple values, formats them, and creates a snapshot with the given title. +// This is useful when you want to snapshot multiple related values together. +// +// Options can be provided to scrub sensitive or dynamic data before snapshotting. +// Only Scrubber options are supported; IgnorePattern options will cause an error. +// +// Example: +// +// shutter.SnapMany(t, "request and response", +// []any{request, response}, +// shutter.ScrubUUID(), +// shutter.ScrubTimestamp(), +// ) +func SnapMany(t snapshots.T, title string, values []any, opts ...Option) { + t.Helper() + + scrubbers, ignores := separateOptions(opts) + + if len(ignores) > 0 { + t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with SnapMany; use SnapJSON instead", title)) + return + } - // Apply scrubber options directly to the formatted content - scrubbers, _ := extractOptions(opts) - scrubbedContent := applyOptions(content, scrubbers) + content := formatValues(values...) + scrubbedContent := applyScrubbers(content, scrubbers) - snapshots.Snap(t, title, version, scrubbedContent) + snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent) } // SnapString takes a string value and creates a snapshot with the given title. -// Options can be provided to apply scrubbers before snapshotting. -func SnapString(t snapshots.T, title string, content string, opts ...SnapshotOption) { +// This is useful for snapshotting generated text, logs, or other string content. +// +// Options can be provided to scrub sensitive or dynamic data before snapshotting. +// Only Scrubber options are supported; IgnorePattern options will cause an error. +// +// Example: +// +// output := generateReport() +// shutter.SnapString(t, "report output", output, +// shutter.ScrubTimestamp(), +// ) +func SnapString(t snapshots.T, title string, content string, opts ...Option) { t.Helper() - // Apply scrubber options directly to the content - scrubbers, _ := extractOptions(opts) - scrubbedContent := applyOptions(content, scrubbers) + scrubbers, ignores := separateOptions(opts) - snapshots.Snap(t, title, version, scrubbedContent) + if len(ignores) > 0 { + t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with SnapString; use SnapJSON instead", title)) + return + } + + scrubbedContent := applyScrubbers(content, scrubbers) + + snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent) } // SnapJSON takes a JSON string, validates it, and pretty-prints it with // consistent formatting before snapshotting. This preserves the raw JSON // format while ensuring valid JSON structure. -// Options can be provided to apply scrubbers and ignore patterns. -func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...SnapshotOption) { +// +// Options can be provided to apply both Scrubbers and IgnorePatterns. +// IgnorePatterns remove fields from the JSON structure before scrubbing. +// Scrubbers then transform the remaining content. +// +// Example: +// +// jsonStr := `{"id": "550e8400-...", "email": "user@example.com", "password": "secret"}` +// shutter.SnapJSON(t, "user response", jsonStr, +// shutter.IgnoreKey("password"), // First: remove password field +// shutter.ScrubUUID(), // Second: scrub remaining UUIDs +// shutter.ScrubEmail(), // Third: scrub emails +// ) +func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...Option) { t.Helper() - scrubbers, ignores := extractOptions(opts) + scrubbers, ignores := separateOptions(opts) // Transform the JSON with ignore patterns and scrubbers transformConfig := &transform.Config{ @@ -74,11 +170,11 @@ func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...SnapshotOptio transformedJSON, err := transform.TransformJSON(jsonStr, transformConfig) if err != nil { - t.Error("failed to transform JSON:", err) + t.Error(fmt.Sprintf("snapshot %q: failed to transform JSON: %v", title, err)) return } - snapshots.Snap(t, title, version, transformedJSON) + snapshots.Snap(t, title, snapshotFormatVersion, transformedJSON) } // Review launches an interactive review session to accept or reject snapshot changes. @@ -96,64 +192,71 @@ func RejectAll() error { return review.RejectAll() } -// SnapshotOption represents a transformation that can be applied to snapshot content. -// Options are applied in the order they are provided. -type SnapshotOption interface { - Apply(content string) string +// formatValue formats a single value using the configured utter instance. +func formatValue(v any) string { + return utterConfig.Sdump(v) } -// IgnoreOption represents a pattern for ignoring key-value pairs in JSON structures. -type IgnoreOption interface { - ShouldIgnore(key, value string) bool +// formatValues formats multiple values using the configured utter instance. +func formatValues(values ...any) string { + var result string + for _, v := range values { + result += formatValue(v) + } + return result } -// extractOptions separates scrubbers and ignore patterns from options. -func extractOptions(opts []SnapshotOption) (scrubbers []SnapshotOption, ignores []IgnoreOption) { +// separateOptions splits options into scrubbers and ignore patterns. +func separateOptions(opts []Option) (scrubbers []Scrubber, ignores []IgnorePattern) { for _, opt := range opts { - if ignore, ok := opt.(IgnoreOption); ok { - ignores = append(ignores, ignore) - } else { - scrubbers = append(scrubbers, opt) + switch o := opt.(type) { + case IgnorePattern: + ignores = append(ignores, o) + case Scrubber: + scrubbers = append(scrubbers, o) + default: + // This shouldn't happen if Option interface is properly implemented + panic(fmt.Sprintf("unknown option type: %T", opt)) } } return scrubbers, ignores } -// applyOptions applies all scrubber options to content in sequence. -func applyOptions(content string, opts []SnapshotOption) string { - for _, opt := range opts { - content = opt.Apply(content) +// applyScrubbers applies all scrubbers to content in sequence. +func applyScrubbers(content string, scrubbers []Scrubber) string { + for _, scrubber := range scrubbers { + content = scrubber.Scrub(content) } return content } -// scrubberAdapter adapts a SnapshotOption to the transform.Scrubber interface. +// scrubberAdapter adapts a Scrubber to the transform.Scrubber interface. type scrubberAdapter struct { - opt SnapshotOption + scrubber Scrubber } func (s *scrubberAdapter) Scrub(content string) string { - return s.opt.Apply(content) + return s.scrubber.Scrub(content) } -func toTransformScrubbers(opts []SnapshotOption) []transform.Scrubber { - result := make([]transform.Scrubber, len(opts)) - for i, opt := range opts { - result[i] = &scrubberAdapter{opt: opt} +func toTransformScrubbers(scrubbers []Scrubber) []transform.Scrubber { + result := make([]transform.Scrubber, len(scrubbers)) + for i, scrubber := range scrubbers { + result[i] = &scrubberAdapter{scrubber: scrubber} } return result } -// ignoreAdapter adapts an IgnoreOption to the transform.IgnorePattern interface. +// ignoreAdapter adapts an IgnorePattern to the transform.IgnorePattern interface. type ignoreAdapter struct { - ignore IgnoreOption + ignore IgnorePattern } func (i *ignoreAdapter) ShouldIgnore(key, value string) bool { return i.ignore.ShouldIgnore(key, value) } -func toTransformIgnorePatterns(ignores []IgnoreOption) []transform.IgnorePattern { +func toTransformIgnorePatterns(ignores []IgnorePattern) []transform.IgnorePattern { result := make([]transform.IgnorePattern, len(ignores)) for i, ignore := range ignores { result[i] = &ignoreAdapter{ignore: ignore} diff --git a/shutter_test.go b/shutter_test.go index 2fd0c24..69b308e 100644 --- a/shutter_test.go +++ b/shutter_test.go @@ -12,12 +12,8 @@ import ( "github.com/ptdewey/shutter" ) -func TestSnapString(t *testing.T) { - shutter.SnapString(t, "Simple String Test", "hello world") -} - func TestSnapMultiple(t *testing.T) { - shutter.Snap(t, "Multiple Values Test", "value1", "value2", 42, "foo", "bar", "baz", "wibble", "wobble", "tock", nil) + shutter.SnapMany(t, "Multiple Values Test", []any{"value1", "value2", 42, "foo", "bar", "baz", "wibble", "wobble", "tock", nil}) } type CustomStruct struct { @@ -37,13 +33,6 @@ func TestSnapCustomType(t *testing.T) { shutter.Snap(t, "Custom Type Test", cs) } -func TestMap(t *testing.T) { - shutter.Snap(t, "Map Test", map[string]any{ - "foo": "bar", - "wibble": "wobble", - }) -} - func contains(s, substr string) bool { return strings.Contains(s, substr) } @@ -372,196 +361,9 @@ func TestStructureWithEmptyValues(t *testing.T) { } // ============================================================================ -// JSON OBJECT TESTS +// JSON TESTS - Focus on edge cases and special handling // ============================================================================ -func TestJsonObject(t *testing.T) { - jsonStr := `{ - "user": { - "id": 1, - "username": "john_doe", - "email": "john@example.com", - "profile": { - "first_name": "John", - "last_name": "Doe", - "bio": "Software engineer", - "verified": true - }, - "roles": ["user", "admin"], - "created_at": "2023-01-15T10:30:00Z" - }, - "status": "success", - "message": null - }` - - var data any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - t.Fatalf("failed to unmarshal json: %v", err) - } - - shutter.Snap(t, "JSON Object", data) -} - -func TestComplexJsonStructure(t *testing.T) { - jsonStr := `{ - "api": { - "version": "2.0", - "endpoints": [ - { - "path": "/users", - "method": "GET", - "auth_required": true, - "rate_limit": { - "requests": 100, - "window": "1m" - }, - "responses": { - "200": { - "description": "Success", - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "integer"}, - "name": {"type": "string"} - } - } - } - }, - "401": { - "description": "Unauthorized" - } - } - }, - { - "path": "/users/{id}", - "method": "POST", - "auth_required": true, - "rate_limit": { - "requests": 50, - "window": "1m" - } - } - ], - "models": { - "User": { - "properties": { - "id": {"type": "integer"}, - "username": {"type": "string"}, - "email": {"type": "string", "format": "email"}, - "created_at": {"type": "string", "format": "date-time"}, - "roles": { - "type": "array", - "items": {"type": "string"} - } - }, - "required": ["id", "username", "email"] - } - } - } - }` - - var data any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - t.Fatalf("failed to unmarshal json: %v", err) - } - - shutter.Snap(t, "Complex JSON Structure", data) -} - -func TestJsonArrayOfObjects(t *testing.T) { - jsonStr := `[ - { - "type": "user", - "id": 1, - "data": { - "name": "Alice", - "role": "admin" - } - }, - { - "type": "post", - "id": 100, - "data": { - "title": "First Post", - "author_id": 1, - "likes": 42 - } - }, - { - "type": "comment", - "id": 500, - "data": { - "content": "Great post!", - "author_id": 2, - "post_id": 100 - } - } - ]` - - var data any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - t.Fatalf("failed to unmarshal json: %v", err) - } - - shutter.Snap(t, "JSON Array of Objects", data) -} - -func TestJsonWithVariousTypes(t *testing.T) { - jsonStr := `{ - "string": "hello world", - "integer": 42, - "float": 3.14159, - "boolean_true": true, - "boolean_false": false, - "null_value": null, - "array": [1, 2, 3, "four", 5.5], - "object": { - "nested": "value", - "count": 10 - }, - "empty_array": [], - "empty_object": {}, - "escaped_string": "line1\nline2\ttab" - }` - - var data any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - t.Fatalf("failed to unmarshal json: %v", err) - } - - shutter.Snap(t, "JSON with Various Types", data) -} - -func TestJsonNumbers(t *testing.T) { - jsonStr := `{ - "integers": { - "zero": 0, - "positive": 42, - "negative": -100, - "large": 9999999999999 - }, - "floats": { - "small": 0.0001, - "pi": 3.14159265359, - "scientific": 1.23e-4, - "negative_float": -42.5 - }, - "edge_cases": { - "one": 1, - "minus_one": -1 - } - }` - - var data any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - t.Fatalf("failed to unmarshal json: %v", err) - } - - shutter.Snap(t, "JSON Numbers", data) -} - func TestJsonWithSpecialCharacters(t *testing.T) { jsonStr := `{ "english": "Hello, World!", @@ -582,90 +384,6 @@ func TestJsonWithSpecialCharacters(t *testing.T) { shutter.Snap(t, "JSON with Special Characters", data) } -func TestGoStructMarshalledToJson(t *testing.T) { - type Address struct { - Street string `json:"street"` - City string `json:"city"` - Zip string `json:"zip"` - } - - type Contact struct { - Name string `json:"name"` - Email string `json:"email"` - Phone string `json:"phone"` - Address Address `json:"address"` - Tags []string `json:"tags"` - Active bool `json:"active"` - CreatedAt time.Time `json:"created_at"` - } - - contact := Contact{ - Name: "Jane Smith", - Email: "jane@example.com", - Phone: "+1-555-0123", - Address: Address{ - Street: "456 Oak Ave", - City: "San Francisco", - Zip: "94102", - }, - Tags: []string{"vip", "verified", "premium"}, - Active: true, - CreatedAt: time.Date(2023, 6, 15, 14, 30, 0, 0, time.UTC), - } - - jsonBytes, err := json.MarshalIndent(contact, "", " ") - if err != nil { - t.Fatalf("failed to marshal json: %v", err) - } - - var data any - if err := json.Unmarshal(jsonBytes, &data); err != nil { - t.Fatalf("failed to unmarshal json: %v", err) - } - - shutter.Snap(t, "Go Struct Marshalled to JSON", data) -} - -func TestDeeplyNestedJson(t *testing.T) { - type Level4 struct { - Value string - } - - type Level3 struct { - L4 Level4 - } - - type Level2 struct { - L3 Level3 - } - - type Level1 struct { - L2 Level2 - } - - l1 := Level1{ - L2: Level2{ - L3: Level3{ - L4: Level4{ - Value: "deep value", - }, - }, - }, - } - - jsonBytes, err := json.MarshalIndent(l1, "", " ") - if err != nil { - t.Fatalf("failed to marshal json: %v", err) - } - - var data any - if err := json.Unmarshal(jsonBytes, &data); err != nil { - t.Fatalf("failed to unmarshal json: %v", err) - } - - shutter.Snap(t, "Deeply Nested JSON", data) -} - func TestLargeJson(t *testing.T) { type Product struct { ID int `json:"id"` @@ -742,69 +460,10 @@ func TestLargeJson(t *testing.T) { shutter.Snap(t, "Large JSON Structure", data) } -func TestJsonWithMixedArrays(t *testing.T) { - jsonStr := `{ - "heterogeneous_array": [ - "string", - 42, - 3.14, - true, - null, - {"object": "value"}, - [1, 2, 3] - ], - "matrix": [ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9] - ], - "object_array": [ - {"id": 1, "name": "Item 1"}, - {"id": 2, "name": "Item 2"}, - {"id": 3, "name": "Item 3"} - ] - }` - - var data any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - t.Fatalf("failed to unmarshal json: %v", err) - } - - shutter.Snap(t, "JSON with Mixed Arrays", data) -} - // ============================================================================ -// SNAPJSON FUNCTION TESTS - Serialized JSON Strings +// SNAPJSON FUNCTION TESTS - Focus on edge cases and real-world examples // ============================================================================ -func TestSnapJsonBasic(t *testing.T) { - jsonStr := `{ - "name": "John Doe", - "email": "john@example.com", - "age": 30, - "verified": true - }` - - shutter.SnapJSON(t, "SnapJSON Basic Object", jsonStr) -} - -func TestSnapJsonSimpleArray(t *testing.T) { - jsonStr := `[ - "apple", - "banana", - "orange", - "grape" - ]` - - shutter.SnapJSON(t, "SnapJSON Simple Array", jsonStr) -} - -func TestSnapJsonCompactFormat(t *testing.T) { - jsonStr := `{"id":1,"name":"Product","price":99.99,"in_stock":true,"tags":["electronics","gadgets"]}` - - shutter.SnapJSON(t, "SnapJSON Compact Format", jsonStr) -} - func TestSnapJsonWithNestedObjects(t *testing.T) { jsonStr := `{ "user": { @@ -867,163 +526,6 @@ func TestSnapJsonComplexAPI(t *testing.T) { shutter.SnapJSON(t, "SnapJSON Complex API Response", jsonStr) } -func TestSnapJsonWithNulls(t *testing.T) { - jsonStr := `{ - "id": 1, - "name": "Item", - "description": null, - "category": null, - "tags": null, - "metadata": { - "created": "2023-01-01", - "updated": null, - "deleted": null - } - }` - - shutter.SnapJSON(t, "SnapJSON With Nulls", jsonStr) -} - -func TestSnapJsonArrayOfObjects(t *testing.T) { - jsonStr := `[ - { - "id": 1, - "type": "post", - "title": "First Post", - "views": 150, - "likes": 42 - }, - { - "id": 2, - "type": "post", - "title": "Second Post", - "views": 280, - "likes": 75 - }, - { - "id": 3, - "type": "post", - "title": "Third Post", - "views": 450, - "likes": 120 - } - ]` - - shutter.SnapJSON(t, "SnapJSON Array of Objects", jsonStr) -} - -func TestSnapJsonLargeNestedStructure(t *testing.T) { - jsonStr := `{ - "organization": { - "name": "TechCorp", - "id": "org_123", - "departments": [ - { - "name": "Engineering", - "manager": "Alice", - "teams": [ - { - "name": "Backend", - "lead": "John", - "members": [ - {"id": 1, "name": "John", "level": "senior"}, - {"id": 2, "name": "Jane", "level": "mid"} - ], - "projects": [ - {"id": "proj_1", "name": "API Service", "status": "active"}, - {"id": "proj_2", "name": "Database Optimization", "status": "planning"} - ] - }, - { - "name": "Frontend", - "lead": "Bob", - "members": [ - {"id": 3, "name": "Bob", "level": "senior"}, - {"id": 4, "name": "Carol", "level": "junior"} - ], - "projects": [ - {"id": "proj_3", "name": "Web App", "status": "active"} - ] - } - ] - }, - { - "name": "Sales", - "manager": "Charlie", - "teams": [ - { - "name": "Enterprise", - "lead": "Dave", - "members": [ - {"id": 5, "name": "Dave", "level": "senior"}, - {"id": 6, "name": "Eve", "level": "mid"} - ], - "projects": [] - } - ] - } - ], - "metadata": { - "founded": "2020", - "employees": 150, - "locations": ["USA", "EU", "APAC"] - } - } - }` - - shutter.SnapJSON(t, "SnapJSON Large Nested Structure", jsonStr) -} - -func TestSnapJsonWithNumbers(t *testing.T) { - jsonStr := `{ - "integers": [0, 1, -1, 42, -100, 9999999], - "floats": [0.0, 3.14, -2.5, 0.001, 1.23e-4, 5.67e10], - "financial": { - "revenue": 1000000.50, - "expenses": 750000.75, - "profit_margin": 0.2499 - }, - "measurements": { - "temperature": -40.5, - "distance": 1000.25, - "weight": 0.5 - } - }` - - shutter.SnapJSON(t, "SnapJSON With Numbers", jsonStr) -} - -func TestSnapJsonWithSpecialCharacters(t *testing.T) { - jsonStr := `{ - "special": "!@#$%^&*()_+-=[]{}|;:',.<>?/", - "escaped": "line1\nline2\ttab\rcarriage", - "quotes": "He said \"hello\" and she said 'goodbye'", - "unicode": "Hello δΈ–η•Œ 🌍 Ω…Ψ±Ψ­Ψ¨Ψ§ ΠŸΡ€ΠΈΠ²Π΅Ρ‚", - "paths": "C:\\Users\\name\\Documents\\file.txt", - "html": "
Content
", - "regex": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" - }` - - shutter.SnapJSON(t, "SnapJSON With Special Characters", jsonStr) -} - -func TestSnapJsonEmptyStructures(t *testing.T) { - jsonStr := `{ - "empty_array": [], - "empty_object": {}, - "empty_string": "", - "zero": 0, - "false_value": false, - "null_value": null, - "nested": { - "empty": [], - "also_empty": {} - } - }` - - shutter.SnapJSON(t, "SnapJSON Empty Structures", jsonStr) -} - func TestSnapJsonMixedTypes(t *testing.T) { jsonStr := `{ "mixed_array": [ -- 2.51.2