Approval-based snapshot testing library for Go (mirror)

feat!: change name of snapshots to use title rather than test name

refactor: api cleanup

+2236 -2020
+61 -44
README.md
··· 1 - # shutter 1 + # Shutter 2 2 3 3 A [birdie](https://github.com/giacomocavalieri/birdie) and [insta](https://github.com/mitsuhiko/insta) inspired snapshot testing library for Go. 4 4 ··· 25 25 } 26 26 ``` 27 27 28 + ### Snapshotting Multiple Values 29 + 30 + Use `SnapMany()` when you need to snapshot multiple related values together: 31 + 32 + ```go 33 + func TestMultipleValues(t *testing.T) { 34 + request := buildRequest() 35 + response := handleRequest(request) 36 + 37 + // Snapshot both request and response together 38 + shutter.SnapMany(t, "request and response", []any{request, response}) 39 + } 40 + ``` 41 + 28 42 ### Advanced Usage: Scrubbers and Ignore Patterns 29 43 30 44 shutter supports data scrubbing and field filtering to handle dynamic or sensitive data in snapshots. ··· 39 53 40 54 // Replace UUIDs and timestamps with placeholders 41 55 shutter.Snap(t, "user", user, 42 - shutter.ScrubUUIDs(), 43 - shutter.ScrubTimestamps(), 56 + shutter.ScrubUUID(), 57 + shutter.ScrubTimestamp(), 44 58 ) 45 59 } 46 60 ``` 47 61 48 62 **Built-in Scrubbers:** 49 63 50 - - `ScrubUUIDs()` - Replaces UUIDs with `<UUID>` 51 - - `ScrubTimestamps()` - Replaces ISO8601 timestamps with `<TIMESTAMP>` 52 - - `ScrubEmails()` - Replaces email addresses with `<EMAIL>` 53 - - `ScrubIPAddresses()` - Replaces IPv4 addresses with `<IP>` 54 - - `ScrubJWTs()` - Replaces JWT tokens with `<JWT>` 55 - - `ScrubCreditCards()` - Replaces credit card numbers with `<CREDIT_CARD>` 56 - - `ScrubAPIKeys()` - Replaces API keys with `<API_KEY>` 57 - - `ScrubDates()` - Replaces various date formats with `<DATE>` 58 - - `ScrubUnixTimestamps()` - Replaces Unix timestamps with `<UNIX_TS>` 64 + - `ScrubUUID()` - Replaces UUIDs with `<UUID>` 65 + - `ScrubTimestamp()` - Replaces ISO8601 timestamps with `<TIMESTAMP>` 66 + - `ScrubEmail()` - Replaces email addresses with `<EMAIL>` 67 + - `ScrubIP()` - Replaces IPv4 addresses with `<IP>` 68 + - `ScrubJWT()` - Replaces JWT tokens with `<JWT>` 69 + - `ScrubCreditCard()` - Replaces credit card numbers with `<CREDIT_CARD>` 70 + - `ScrubAPIKey()` - Replaces API keys with `<API_KEY>` 71 + - `ScrubDate()` - Replaces various date formats with `<DATE>` 72 + - `ScrubUnixTimestamp()` - Replaces Unix timestamps with `<UNIX_TS>` 59 73 60 74 **Custom Scrubbers:** 61 75 62 76 ```go 63 77 // Using regex patterns 64 - shutter.RegexScrubber(`user-\d+`, "<USER_ID>") 78 + shutter.ScrubRegex(`user-\d+`, "<USER_ID>") 65 79 66 80 // Using exact string matching 67 - shutter.ExactMatchScrubber("secret_value", "<REDACTED>") 81 + shutter.ScrubExact("secret_value", "<REDACTED>") 68 82 69 83 // Using custom functions 70 - shutter.CustomScrubber(func(content string) string { 84 + shutter.ScrubWith(func(content string) string { 71 85 return strings.ReplaceAll(content, "localhost", "<HOST>") 72 86 }) 73 87 ``` ··· 79 93 ```go 80 94 func TestAPIResponse(t *testing.T) { 81 95 response := api.GetData() 96 + jsonBytes, _ := json.Marshal(response) 82 97 83 98 // Ignore sensitive fields and null values 84 - shutter.SnapJSON(t, "response", response, 85 - shutter.IgnoreSensitiveKeys(), 86 - shutter.IgnoreNullValues(), 87 - shutter.IgnoreKeys("created_at", "updated_at"), 99 + shutter.SnapJSON(t, "response", string(jsonBytes), 100 + shutter.IgnoreSensitive(), 101 + shutter.IgnoreNull(), 102 + shutter.IgnoreKey("created_at", "updated_at"), 88 103 ) 89 104 } 90 105 ``` 91 106 92 107 **Built-in Ignore Patterns:** 93 108 94 - - `IgnoreSensitiveKeys()` - Ignores common sensitive keys (password, token, api_key, etc.) 95 - - `IgnoreEmptyValues()` - Ignores fields with empty string values 96 - - `IgnoreNullValues()` - Ignores fields with null values 109 + - `IgnoreSensitive()` - Ignores common sensitive keys (password, token, api_key, etc.) 110 + - `IgnoreEmpty()` - Ignores fields with empty string values 111 + - `IgnoreNull()` - Ignores fields with null values 97 112 98 113 **Custom Ignore Patterns:** 99 114 100 115 ```go 101 116 // Ignore specific keys 102 - shutter.IgnoreKeys("id", "timestamp", "version") 117 + shutter.IgnoreKey("id", "timestamp", "version") 103 118 104 119 // Ignore key-value pairs 105 120 shutter.IgnoreKeyValue("status", "pending") 106 121 107 122 // Ignore keys matching a regex pattern 108 - shutter.IgnoreKeysMatching(`^_.*`) // Ignore all keys starting with underscore 123 + shutter.IgnoreKeyMatching(`^_.*`) // Ignore all keys starting with underscore 109 124 110 125 // Ignore specific values 111 - shutter.IgnoreValues("null", "undefined", "") 126 + shutter.IgnoreValue("null", "undefined", "") 112 127 113 128 // Using custom functions 114 - shutter.CustomIgnore(func(key, value string) bool { 129 + shutter.IgnoreWith(func(key, value string) bool { 115 130 return strings.HasPrefix(key, "temp_") 116 131 }) 117 132 ``` ··· 123 138 ```go 124 139 func TestComplexData(t *testing.T) { 125 140 data := generateTestData() 141 + jsonBytes, _ := json.Marshal(data) 126 142 127 - shutter.Snap(t, "data", data, 128 - // Scrubbers 129 - shutter.ScrubUUIDs(), 130 - shutter.ScrubTimestamps(), 131 - shutter.ScrubEmails(), 143 + shutter.SnapJSON(t, "data", string(jsonBytes), 144 + // First, remove unwanted fields 145 + shutter.IgnoreSensitive(), 146 + shutter.IgnoreKey("debug_info"), 147 + shutter.IgnoreNull(), 132 148 133 - // Ignore patterns 134 - shutter.IgnoreSensitiveKeys(), 135 - shutter.IgnoreKeys("debug_info"), 136 - shutter.IgnoreNullValues(), 149 + // Then, scrub dynamic values in remaining fields 150 + shutter.ScrubUUID(), 151 + shutter.ScrubTimestamp(), 152 + shutter.ScrubEmail(), 137 153 ) 138 154 } 139 155 ``` 140 156 157 + **Note:** Ignore patterns only work with `SnapJSON()`. Use scrubbers with `Snap()`, `SnapMany()`, or `SnapString()`. 158 + 141 159 #### API Reference 142 160 143 - All snapshot functions support options as variadic parameters: 161 + **Snapshot Functions:** 144 162 145 163 ```go 146 - // For general values (structs, maps, slices, etc.) 164 + // For single values (structs, maps, slices, etc.) 147 165 shutter.Snap(t, "title", value, options...) 148 166 149 - // For JSON strings 167 + // For multiple related values 168 + shutter.SnapMany(t, "title", []any{value1, value2, value3}, options...) 169 + 170 + // For JSON strings (supports both scrubbers and ignore patterns) 150 171 shutter.SnapJSON(t, "title", jsonString, options...) 151 172 152 173 // For plain strings ··· 161 182 go run github.com/ptdewey/shutter/cmd/shutter review 162 183 ``` 163 184 164 - shutter can also be used programmatically: 185 + Shutter can also be used programmatically: 165 186 166 187 ```go 167 188 // Example: tools/shutter/main.go ··· 181 202 go run tools/shutter/main.go 182 203 ``` 183 204 184 - 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). 205 + 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). 185 206 (The TUI is shipped in a separate module to make the added dependencies optional) 186 207 187 208 ### TUI Usage ··· 209 230 # Reject all new snapshots without review 210 231 go run github.com/ptdewey/shutter/cmd/tui reject-all 211 232 ``` 212 - 213 - ## Disclaimer 214 - 215 - - This package was largely vibe coded, your mileage may vary (but this library provides more of what I want than the ones below). 216 233 217 234 ## Other Libraries 218 235
+78
__snapshots__/complex_nested_structure.snap
··· 1 + --- 2 + title: Complex Nested Structure 3 + test_name: TestComplexNestedStructure 4 + file_name: shutter_test.go 5 + version: 0.1.0 6 + --- 7 + shutter_test.Post{ 8 + ID: 100, 9 + Title: "Introduction to Go Snapshot Testing", 10 + Content: "This is a comprehensive guide to snapshot testing in Go...", 11 + Author: shutter_test.User{ 12 + ID: 1, 13 + Username: "john_doe", 14 + Email: "john@example.com", 15 + Active: true, 16 + CreatedAt: time.Time{ 17 + wall: 0x0, 18 + ext: 63809375400, 19 + loc: (*time.Location)(nil), 20 + }, 21 + Roles: []string{"admin", "moderator", "user"}, 22 + Metadata: map[string]interface{}{ 23 + "language": "en", 24 + "notifications": true, 25 + "preferences": map[string]interface{}{ 26 + "email_frequency": "weekly", 27 + "notifications": true, 28 + }, 29 + "theme": "dark", 30 + }, 31 + }, 32 + Tags: []string{"go", "testing", "snapshots", "best-practices"}, 33 + Comments: []shutter_test.Comment{ 34 + { 35 + ID: 1, 36 + Author: "alice", 37 + Content: "Great post!", 38 + CreatedAt: time.Time{ 39 + wall: 0x0, 40 + ext: 63810858120, 41 + loc: (*time.Location)(nil), 42 + }, 43 + Replies: []shutter_test.Comment{ 44 + { 45 + ID: 2, 46 + Author: "bob", 47 + Content: "I agree!", 48 + CreatedAt: time.Time{ 49 + wall: 0x0, 50 + ext: 63810863100, 51 + loc: (*time.Location)(nil), 52 + }, 53 + Replies: []shutter_test.Comment{ 54 + }, 55 + }, 56 + }, 57 + }, 58 + { 59 + ID: 3, 60 + Author: "charlie", 61 + Content: "Thanks for sharing!", 62 + CreatedAt: time.Time{ 63 + wall: 0x0, 64 + ext: 63810927000, 65 + loc: (*time.Location)(nil), 66 + }, 67 + Replies: []shutter_test.Comment{ 68 + }, 69 + }, 70 + }, 71 + Likes: 42, 72 + Published: true, 73 + CreatedAt: time.Time{ 74 + wall: 0x0, 75 + ext: 63809802000, 76 + loc: (*time.Location)(nil), 77 + }, 78 + }
+16
__snapshots__/json_with_special_characters.snap
··· 1 + --- 2 + title: JSON with Special Characters 3 + test_name: TestJsonWithSpecialCharacters 4 + file_name: shutter_test.go 5 + version: 0.1.0 6 + --- 7 + map[string]interface{}{ 8 + "backslash": "path\\to\\file", 9 + "emoji": "😀 😃 😄 😁 😆", 10 + "english": "Hello, World!", 11 + "escaped": "quotes: \"double\" and 'single'", 12 + "newlines": "line1\nline2\rline3\r\nline4", 13 + "special_chars": "!@#$%^&*()_+-=[]{}|;:,.<>?", 14 + "tabs": "col1\tcol2\tcol3", 15 + "unicode": "こんにちは 世界 🌍", 16 + }
+54
__snapshots__/large_json_structure.snap
··· 1 + --- 2 + title: Large JSON Structure 3 + test_name: TestLargeJson 4 + file_name: shutter_test.go 5 + version: 0.1.0 6 + --- 7 + map[string]interface{}{ 8 + "created_at": "2023-01-28T14:30:00Z", 9 + "customer_id": 42.0, 10 + "delivered_at": nil, 11 + "id": 1001.0, 12 + "products": []interface{}{ 13 + map[string]interface{}{ 14 + "description": "High-performance laptop", 15 + "id": 1.0, 16 + "in_stock": true, 17 + "name": "Laptop", 18 + "price": 999.99, 19 + "stock": 5.0, 20 + "tags": []interface{}{ 21 + "electronics", 22 + "computers", 23 + "laptops", 24 + }, 25 + }, 26 + map[string]interface{}{ 27 + "description": "Wireless mouse", 28 + "id": 2.0, 29 + "in_stock": true, 30 + "name": "Mouse", 31 + "price": 29.99, 32 + "stock": 50.0, 33 + "tags": []interface{}{ 34 + "electronics", 35 + "accessories", 36 + }, 37 + }, 38 + map[string]interface{}{ 39 + "description": "Mechanical keyboard", 40 + "id": 3.0, 41 + "in_stock": false, 42 + "name": "Keyboard", 43 + "price": 149.99, 44 + "stock": 0.0, 45 + "tags": []interface{}{ 46 + "electronics", 47 + "accessories", 48 + }, 49 + }, 50 + }, 51 + "shipped_at": "2023-02-01T10:00:00Z", 52 + "status": "shipped", 53 + "total": 1179.97, 54 + }
+56
__snapshots__/multiple_complex_structures.snap
··· 1 + --- 2 + title: Multiple Complex Structures 3 + test_name: TestMultipleComplexStructures 4 + file_name: shutter_test.go 5 + version: 0.1.0 6 + --- 7 + []shutter_test.User{ 8 + { 9 + ID: 1, 10 + Username: "alice", 11 + Email: "alice@example.com", 12 + Active: true, 13 + CreatedAt: time.Time{ 14 + wall: 0x0, 15 + ext: 0, 16 + loc: (*time.Location)(nil), 17 + }, 18 + Roles: []string{"user", "moderator"}, 19 + Metadata: map[string]interface{}{ 20 + "badge": "verified", 21 + "verified": true, 22 + }, 23 + }, 24 + { 25 + ID: 2, 26 + Username: "bob", 27 + Email: "bob@example.com", 28 + Active: false, 29 + CreatedAt: time.Time{ 30 + wall: 0x0, 31 + ext: 0, 32 + loc: (*time.Location)(nil), 33 + }, 34 + Roles: []string{"user"}, 35 + Metadata: map[string]interface{}{ 36 + "avatar": "https://example.com/bob.jpg", 37 + "verified": false, 38 + }, 39 + }, 40 + { 41 + ID: 3, 42 + Username: "charlie", 43 + Email: "charlie@example.com", 44 + Active: true, 45 + CreatedAt: time.Time{ 46 + wall: 0x0, 47 + ext: 0, 48 + loc: (*time.Location)(nil), 49 + }, 50 + Roles: []string{"user", "admin"}, 51 + Metadata: map[string]interface{}{ 52 + "account_age_days": 365, 53 + "verified": true, 54 + }, 55 + }, 56 + }
+27
__snapshots__/multiple_scrubbers.snap
··· 1 + --- 2 + title: Multiple Scrubbers 3 + test_name: TestBuiltInScrubbers 4 + file_name: scrubbers_test.go 5 + version: 0.1.0 6 + --- 7 + { 8 + "api_key": "<API_KEY>", 9 + "backup_card": "<CREDIT_CARD>", 10 + "backup_email": "<EMAIL>", 11 + "birth_date": "<DATE>", 12 + "card_number": "<CREDIT_CARD>", 13 + "client_ip": "<IP>", 14 + "created_at": "<TIMESTAMP>", 15 + "email": "<EMAIL>", 16 + "jwt_token": "<JWT>", 17 + "message": "Connection from <IP>", 18 + "name": "John Doe", 19 + "server_ip": "<IP>", 20 + "session_id": "<UUID>", 21 + "stripe_key": "<API_KEY>", 22 + "unix_created": <UNIX_TS>, 23 + "unix_updated": <UNIX_TS>, 24 + "updated_at": "<TIMESTAMP>", 25 + "us_format_date": "<DATE>", 26 + "user_id": "<UUID>" 27 + }
+47
__snapshots__/nested_maps_and_slices.snap
··· 1 + --- 2 + title: Nested Maps and Slices 3 + test_name: TestNestedMapsAndSlices 4 + file_name: shutter_test.go 5 + version: 0.1.0 6 + --- 7 + map[string]interface{}{ 8 + "posts": map[string]interface{}{ 9 + "categories": []string{"tech", "lifestyle", "news"}, 10 + "drafts": 5, 11 + "published": 42, 12 + }, 13 + "stats": map[string]interface{}{ 14 + "daily": map[string]interface{}{ 15 + "clicks": 320, 16 + "conversions": map[string]interface{}{ 17 + "by_source": map[string]int{ 18 + "organic": 25, 19 + "paid": 15, 20 + "referral": 5, 21 + }, 22 + "total": 45, 23 + }, 24 + "views": 1500, 25 + }, 26 + }, 27 + "users": map[string]interface{}{ 28 + "active": []map[string]interface{}{ 29 + { 30 + "id": 1, 31 + "name": "Alice", 32 + "verified": true, 33 + }, 34 + { 35 + "id": 2, 36 + "name": "Bob", 37 + "verified": false, 38 + }, 39 + }, 40 + "inactive": []map[string]interface{}{ 41 + { 42 + "id": 3, 43 + "name": "Charlie", 44 + }, 45 + }, 46 + }, 47 + }
+12
__snapshots__/scrub_with_snap.snap
··· 1 + --- 2 + title: Scrub With Snap 3 + test_name: TestScrubWithSnapFunction 4 + file_name: scrubbers_test.go 5 + version: 0.1.0 6 + --- 7 + map[string]interface{}{ 8 + "created_at": "<TIMESTAMP>", 9 + "email": "<EMAIL>", 10 + "name": "John Doe", 11 + "user_id": "<UUID>", 12 + }
+34
__snapshots__/structure_with_empty_values.snap
··· 1 + --- 2 + title: Structure with Empty Values 3 + test_name: TestStructureWithEmptyValues 4 + file_name: shutter_test.go 5 + version: 0.1.0 6 + --- 7 + []shutter_test.Container{ 8 + { 9 + Items: []string{ 10 + }, 11 + Tags: map[string]string{ 12 + }, 13 + OptionalID: (*int)(nil), 14 + Count: 0, 15 + Active: false, 16 + }, 17 + { 18 + Items: []string(nil), 19 + Tags: map[string]string(nil), 20 + OptionalID: (*int)(nil), 21 + Count: 0, 22 + Active: true, 23 + }, 24 + { 25 + Items: []string{"a", "b", "c"}, 26 + Tags: map[string]string{ 27 + "env": "dev", 28 + "type": "test", 29 + }, 30 + OptionalID: &int(42), 31 + Count: 3, 32 + Active: true, 33 + }, 34 + }
+76
__snapshots__/structure_with_interface_fields.snap
··· 1 + --- 2 + title: Structure with Interface Fields 3 + test_name: TestStructureWithInterface 4 + file_name: shutter_test.go 5 + version: 0.1.0 6 + --- 7 + []shutter_test.Response{ 8 + { 9 + Status: "success", 10 + Message: "User retrieved", 11 + Data: shutter_test.User{ 12 + ID: 1, 13 + Username: "john", 14 + Email: "john@example.com", 15 + Active: true, 16 + CreatedAt: time.Time{ 17 + wall: 0x0, 18 + ext: 0, 19 + loc: (*time.Location)(nil), 20 + }, 21 + Roles: []string(nil), 22 + Metadata: map[string]interface{}(nil), 23 + }, 24 + Meta: map[string]interface{}{ 25 + "request_id": "req-123", 26 + "timestamp": "2023-01-20T10:30:00Z", 27 + }, 28 + }, 29 + { 30 + Status: "error", 31 + Message: "User not found", 32 + Data: nil, 33 + Meta: map[string]interface{}{ 34 + "error_code": 404, 35 + "error_type": "NOT_FOUND", 36 + }, 37 + }, 38 + { 39 + Status: "success", 40 + Message: "Posts retrieved", 41 + Data: []shutter_test.Post{ 42 + { 43 + ID: 1, 44 + Title: "First Post", 45 + Content: "", 46 + Author: shutter_test.User{ 47 + ID: 0, 48 + Username: "", 49 + Email: "", 50 + Active: false, 51 + CreatedAt: time.Time{ 52 + wall: 0x0, 53 + ext: 0, 54 + loc: (*time.Location)(nil), 55 + }, 56 + Roles: []string(nil), 57 + Metadata: map[string]interface{}(nil), 58 + }, 59 + Tags: []string(nil), 60 + Comments: []shutter_test.Comment(nil), 61 + Likes: 0, 62 + Published: true, 63 + CreatedAt: time.Time{ 64 + wall: 0x0, 65 + ext: 0, 66 + loc: (*time.Location)(nil), 67 + }, 68 + }, 69 + }, 70 + Meta: map[string]interface{}{ 71 + "page": 1, 72 + "per_page": 20, 73 + "total_count": 10, 74 + }, 75 + }, 76 + }
+27
__snapshots__/structure_with_pointers.snap
··· 1 + --- 2 + title: Structure with Pointers 3 + test_name: TestStructureWithPointers 4 + file_name: shutter_test.go 5 + version: 0.1.0 6 + --- 7 + shutter_test.Person{ 8 + Name: "John", 9 + Age: 35, 10 + Address: &shutter_test.Address{ 11 + Street: "123 Main St", 12 + City: "Boston", 13 + Zip: "02101", 14 + }, 15 + Manager: &shutter_test.Person{ 16 + Name: "Jane", 17 + Age: 30, 18 + Address: (*shutter_test.Address)(<already shown>), 19 + Manager: (*shutter_test.Person)(nil), 20 + Friends: []*shutter_test.Person(nil), 21 + Email: &string("jane@example.com"), 22 + }, 23 + Friends: []*shutter_test.Person{ 24 + (*shutter_test.Person)(<already shown>), 25 + }, 26 + Email: (*string)(nil), 27 + }
__snapshots__/test_combined_ignore_and_scrub.snap __snapshots__/combined_ignore_and_scrub.snap
-84
__snapshots__/test_complex_json_structure.snap
··· 1 - --- 2 - title: Complex JSON Structure 3 - test_name: TestComplexJsonStructure 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "api": map[string]interface{}{ 9 - "endpoints": []interface{}{ 10 - map[string]interface{}{ 11 - "auth_required": true, 12 - "method": "GET", 13 - "path": "/users", 14 - "rate_limit": map[string]interface{}{ 15 - "requests": 100.0, 16 - "window": "1m", 17 - }, 18 - "responses": map[string]interface{}{ 19 - "200": map[string]interface{}{ 20 - "description": "Success", 21 - "schema": map[string]interface{}{ 22 - "items": map[string]interface{}{ 23 - "properties": map[string]interface{}{ 24 - "id": map[string]interface{}{ 25 - "type": "integer", 26 - }, 27 - "name": map[string]interface{}{ 28 - "type": "string", 29 - }, 30 - }, 31 - "type": "object", 32 - }, 33 - "type": "array", 34 - }, 35 - }, 36 - "401": map[string]interface{}{ 37 - "description": "Unauthorized", 38 - }, 39 - }, 40 - }, 41 - map[string]interface{}{ 42 - "auth_required": true, 43 - "method": "POST", 44 - "path": "/users/{id}", 45 - "rate_limit": map[string]interface{}{ 46 - "requests": 50.0, 47 - "window": "1m", 48 - }, 49 - }, 50 - }, 51 - "models": map[string]interface{}{ 52 - "User": map[string]interface{}{ 53 - "properties": map[string]interface{}{ 54 - "created_at": map[string]interface{}{ 55 - "format": "date-time", 56 - "type": "string", 57 - }, 58 - "email": map[string]interface{}{ 59 - "format": "email", 60 - "type": "string", 61 - }, 62 - "id": map[string]interface{}{ 63 - "type": "integer", 64 - }, 65 - "roles": map[string]interface{}{ 66 - "items": map[string]interface{}{ 67 - "type": "string", 68 - }, 69 - "type": "array", 70 - }, 71 - "username": map[string]interface{}{ 72 - "type": "string", 73 - }, 74 - }, 75 - "required": []interface{}{ 76 - "id", 77 - "username", 78 - "email", 79 - }, 80 - }, 81 - }, 82 - "version": "2.0", 83 - }, 84 - }
-87
__snapshots__/test_complex_nested_structure.snap
··· 1 - --- 2 - title: Complex Nested Structure 3 - test_name: TestComplexNestedStructure 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - shutter_test.Post{ 8 - ID: 100, 9 - Title: "Introduction to Go Snapshot Testing", 10 - Content: "This is a comprehensive guide to snapshot testing in Go...", 11 - Author: shutter_test.User{ 12 - ID: 1, 13 - Username: "john_doe", 14 - Email: "john@example.com", 15 - Active: true, 16 - CreatedAt: time.Time{ 17 - wall: 0x0, 18 - ext: 63809375400, 19 - loc: (*time.Location)(nil), 20 - }, 21 - Roles: []string{ 22 - "admin", 23 - "moderator", 24 - "user", 25 - }, 26 - Metadata: map[string]interface{}{ 27 - "language": "en", 28 - "notifications": true, 29 - "preferences": map[string]interface{}{ 30 - "email_frequency": "weekly", 31 - "notifications": true, 32 - }, 33 - "theme": "dark", 34 - }, 35 - }, 36 - Tags: []string{ 37 - "go", 38 - "testing", 39 - "snapshots", 40 - "best-practices", 41 - }, 42 - Comments: []shutter_test.Comment{ 43 - { 44 - ID: 1, 45 - Author: "alice", 46 - Content: "Great post!", 47 - CreatedAt: time.Time{ 48 - wall: 0x0, 49 - ext: 63810858120, 50 - loc: (*time.Location)(nil), 51 - }, 52 - Replies: []shutter_test.Comment{ 53 - { 54 - ID: 2, 55 - Author: "bob", 56 - Content: "I agree!", 57 - CreatedAt: time.Time{ 58 - wall: 0x0, 59 - ext: 63810863100, 60 - loc: (*time.Location)(nil), 61 - }, 62 - Replies: []shutter_test.Comment{ 63 - }, 64 - }, 65 - }, 66 - }, 67 - { 68 - ID: 3, 69 - Author: "charlie", 70 - Content: "Thanks for sharing!", 71 - CreatedAt: time.Time{ 72 - wall: 0x0, 73 - ext: 63810927000, 74 - loc: (*time.Location)(nil), 75 - }, 76 - Replies: []shutter_test.Comment{ 77 - }, 78 - }, 79 - }, 80 - Likes: 42, 81 - Published: true, 82 - CreatedAt: time.Time{ 83 - wall: 0x0, 84 - ext: 63809802000, 85 - loc: (*time.Location)(nil), 86 - }, 87 - }
__snapshots__/test_complex_real_world_example.snap __snapshots__/real_world_api_response.snap
+1 -1
__snapshots__/test_credit_card_scrubbing.snap __snapshots__/scrubbed_credit_cards.snap
··· 1 1 --- 2 2 title: Scrubbed Credit Cards 3 - test_name: TestCreditCardScrubbing 3 + test_name: TestIndividualScrubbers/credit_cards 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
__snapshots__/test_custom_ignore.snap __snapshots__/custom_ignore_function.snap
+1 -1
__snapshots__/test_custom_scrubber.snap __snapshots__/custom_scrubber.snap
··· 1 1 --- 2 2 title: Custom Scrubber 3 - test_name: TestCustomScrubber 3 + test_name: TestCustomScrubbers/custom_function_scrubber 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
-15
__snapshots__/test_deeply_nested_json.snap
··· 1 - --- 2 - title: Deeply Nested JSON 3 - test_name: TestDeeplyNestedJson 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "L2": map[string]interface{}{ 9 - "L3": map[string]interface{}{ 10 - "L4": map[string]interface{}{ 11 - "Value": "deep value", 12 - }, 13 - }, 14 - }, 15 - }
+1 -1
__snapshots__/test_exact_match_scrubber.snap __snapshots__/exact_match_scrubber.snap
··· 1 1 --- 2 2 title: Exact Match Scrubber 3 - test_name: TestExactMatchScrubber 3 + test_name: TestCustomScrubbers/exact_match_scrubber 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
-23
__snapshots__/test_go_struct_marshalled_to_json.snap
··· 1 - --- 2 - title: Go Struct Marshalled to JSON 3 - test_name: TestGoStructMarshalledToJson 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "active": true, 9 - "address": map[string]interface{}{ 10 - "city": "San Francisco", 11 - "street": "456 Oak Ave", 12 - "zip": "94102", 13 - }, 14 - "created_at": "2023-06-15T14:30:00Z", 15 - "email": "jane@example.com", 16 - "name": "Jane Smith", 17 - "phone": "+1-555-0123", 18 - "tags": []interface{}{ 19 - "vip", 20 - "verified", 21 - "premium", 22 - }, 23 - }
+1 -1
__snapshots__/test_ignore_empty_values.snap __snapshots__/ignore_empty_values.snap
··· 1 1 --- 2 2 title: Ignore Empty Values 3 - test_name: TestIgnoreEmptyValues 3 + test_name: TestIgnoreValues/empty_values 4 4 file_name: ignore_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_ignore_in_arrays.snap __snapshots__/ignore_in_arrays.snap
··· 1 1 --- 2 2 title: Ignore in Arrays 3 - test_name: TestIgnoreInArrays 3 + test_name: TestIgnoreKeys/arrays 4 4 file_name: ignore_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_ignore_key_pattern.snap __snapshots__/ignore_key_pattern.snap
··· 1 1 --- 2 2 title: Ignore Key Pattern 3 - test_name: TestIgnoreKeyPattern 3 + test_name: TestIgnoreKeyPatterns/contains_pattern 4 4 file_name: ignore_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_ignore_key_value.snap __snapshots__/ignore_password_field.snap
··· 1 1 --- 2 2 title: Ignore Password Field 3 - test_name: TestIgnoreKeyValue 3 + test_name: TestIgnoreKeys/key_value_pairs 4 4 file_name: ignore_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_ignore_keys.snap __snapshots__/ignore_multiple_keys.snap
··· 1 1 --- 2 2 title: Ignore Multiple Keys 3 - test_name: TestIgnoreKeys 3 + test_name: TestIgnoreKeys/multiple_keys 4 4 file_name: ignore_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_ignore_keys_matching.snap __snapshots__/ignore_keys_matching_pattern.snap
··· 1 1 --- 2 2 title: Ignore Keys Matching Pattern 3 - test_name: TestIgnoreKeysMatching 3 + test_name: TestIgnoreKeyPatterns/prefix_pattern 4 4 file_name: ignore_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_ignore_null_values.snap __snapshots__/ignore_null_values.snap
··· 1 1 --- 2 2 title: Ignore Null Values 3 - test_name: TestIgnoreNullValues 3 + test_name: TestIgnoreValues/null_values 4 4 file_name: ignore_test.go 5 5 version: 0.1.0 6 6 ---
__snapshots__/test_ignore_sensitive_keys.snap __snapshots__/ignore_sensitive_keys.snap
+1 -1
__snapshots__/test_ignore_values.snap __snapshots__/ignore_specific_values.snap
··· 1 1 --- 2 2 title: Ignore Specific Values 3 - test_name: TestIgnoreValues 3 + test_name: TestIgnoreValues/specific_values 4 4 file_name: ignore_test.go 5 5 version: 0.1.0 6 6 ---
-34
__snapshots__/test_json_array_of_objects.snap
··· 1 - --- 2 - title: JSON Array of Objects 3 - test_name: TestJsonArrayOfObjects 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - []interface{}{ 8 - map[string]interface{}{ 9 - "data": map[string]interface{}{ 10 - "name": "Alice", 11 - "role": "admin", 12 - }, 13 - "id": 1.0, 14 - "type": "user", 15 - }, 16 - map[string]interface{}{ 17 - "data": map[string]interface{}{ 18 - "author_id": 1.0, 19 - "likes": 42.0, 20 - "title": "First Post", 21 - }, 22 - "id": 100.0, 23 - "type": "post", 24 - }, 25 - map[string]interface{}{ 26 - "data": map[string]interface{}{ 27 - "author_id": 2.0, 28 - "content": "Great post!", 29 - "post_id": 100.0, 30 - }, 31 - "id": 500.0, 32 - "type": "comment", 33 - }, 34 - }
-24
__snapshots__/test_json_numbers.snap
··· 1 - --- 2 - title: JSON Numbers 3 - test_name: TestJsonNumbers 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "edge_cases": map[string]interface{}{ 9 - "minus_one": -1.0, 10 - "one": 1.0, 11 - }, 12 - "floats": map[string]interface{}{ 13 - "negative_float": -42.5, 14 - "pi": 3.14159265359, 15 - "scientific": 0.000123, 16 - "small": 0.0001, 17 - }, 18 - "integers": map[string]interface{}{ 19 - "large": 9.999999999999e+12.0, 20 - "negative": -100.0, 21 - "positive": 42.0, 22 - "zero": 0.0, 23 - }, 24 - }
-26
__snapshots__/test_json_object.snap
··· 1 - --- 2 - title: JSON Object 3 - test_name: TestJsonObject 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "message": nil, 9 - "status": "success", 10 - "user": map[string]interface{}{ 11 - "created_at": "2023-01-15T10:30:00Z", 12 - "email": "john@example.com", 13 - "id": 1.0, 14 - "profile": map[string]interface{}{ 15 - "bio": "Software engineer", 16 - "first_name": "John", 17 - "last_name": "Doe", 18 - "verified": true, 19 - }, 20 - "roles": []interface{}{ 21 - "user", 22 - "admin", 23 - }, 24 - "username": "john_doe", 25 - }, 26 - }
-54
__snapshots__/test_json_with_mixed_arrays.snap
··· 1 - --- 2 - title: JSON with Mixed Arrays 3 - test_name: TestJsonWithMixedArrays 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "heterogeneous_array": []interface{}{ 9 - "string", 10 - 42.0, 11 - 3.14, 12 - true, 13 - nil, 14 - map[string]interface{}{ 15 - "object": "value", 16 - }, 17 - []interface{}{ 18 - 1.0, 19 - 2.0, 20 - 3.0, 21 - }, 22 - }, 23 - "matrix": []interface{}{ 24 - []interface{}{ 25 - 1.0, 26 - 2.0, 27 - 3.0, 28 - }, 29 - []interface{}{ 30 - 4.0, 31 - 5.0, 32 - 6.0, 33 - }, 34 - []interface{}{ 35 - 7.0, 36 - 8.0, 37 - 9.0, 38 - }, 39 - }, 40 - "object_array": []interface{}{ 41 - map[string]interface{}{ 42 - "id": 1.0, 43 - "name": "Item 1", 44 - }, 45 - map[string]interface{}{ 46 - "id": 2.0, 47 - "name": "Item 2", 48 - }, 49 - map[string]interface{}{ 50 - "id": 3.0, 51 - "name": "Item 3", 52 - }, 53 - }, 54 - }
-16
__snapshots__/test_json_with_special_characters.snap
··· 1 - --- 2 - title: JSON with Special Characters 3 - test_name: TestJsonWithSpecialCharacters 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "backslash": "path\\to\\file", 9 - "emoji": "😀 😃 😄 😁 😆", 10 - "english": "Hello, World!", 11 - "escaped": "quotes: \"double\" and 'single'", 12 - "newlines": "line1\nline2\rline3\r\nline4", 13 - "special_chars": "!@#$%^&*()_+-=[]{}|;:,.<>?", 14 - "tabs": "col1\tcol2\tcol3", 15 - "unicode": "こんにちは 世界 🌍", 16 - }
-30
__snapshots__/test_json_with_various_types.snap
··· 1 - --- 2 - title: JSON with Various Types 3 - test_name: TestJsonWithVariousTypes 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "array": []interface{}{ 9 - 1.0, 10 - 2.0, 11 - 3.0, 12 - "four", 13 - 5.5, 14 - }, 15 - "boolean_false": false, 16 - "boolean_true": true, 17 - "empty_array": []interface{}{ 18 - }, 19 - "empty_object": map[string]interface{}{ 20 - }, 21 - "escaped_string": "line1\nline2\ttab", 22 - "float": 3.14159, 23 - "integer": 42.0, 24 - "null_value": nil, 25 - "object": map[string]interface{}{ 26 - "count": 10.0, 27 - "nested": "value", 28 - }, 29 - "string": "hello world", 30 - }
-54
__snapshots__/test_large_json.snap
··· 1 - --- 2 - title: Large JSON Structure 3 - test_name: TestLargeJson 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "created_at": "2023-01-28T14:30:00Z", 9 - "customer_id": 42.0, 10 - "delivered_at": nil, 11 - "id": 1001.0, 12 - "products": []interface{}{ 13 - map[string]interface{}{ 14 - "description": "High-performance laptop", 15 - "id": 1.0, 16 - "in_stock": true, 17 - "name": "Laptop", 18 - "price": 999.99, 19 - "stock": 5.0, 20 - "tags": []interface{}{ 21 - "electronics", 22 - "computers", 23 - "laptops", 24 - }, 25 - }, 26 - map[string]interface{}{ 27 - "description": "Wireless mouse", 28 - "id": 2.0, 29 - "in_stock": true, 30 - "name": "Mouse", 31 - "price": 29.99, 32 - "stock": 50.0, 33 - "tags": []interface{}{ 34 - "electronics", 35 - "accessories", 36 - }, 37 - }, 38 - map[string]interface{}{ 39 - "description": "Mechanical keyboard", 40 - "id": 3.0, 41 - "in_stock": false, 42 - "name": "Keyboard", 43 - "price": 149.99, 44 - "stock": 0.0, 45 - "tags": []interface{}{ 46 - "electronics", 47 - "accessories", 48 - }, 49 - }, 50 - }, 51 - "shipped_at": "2023-02-01T10:00:00Z", 52 - "status": "shipped", 53 - "total": 1179.97, 54 - }
-10
__snapshots__/test_map.snap
··· 1 - --- 2 - title: Map Test 3 - test_name: TestMap 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "foo": "bar", 9 - "wibble": "wobble", 10 - }
-64
__snapshots__/test_multiple_complex_structures.snap
··· 1 - --- 2 - title: Multiple Complex Structures 3 - test_name: TestMultipleComplexStructures 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - []shutter_test.User{ 8 - { 9 - ID: 1, 10 - Username: "alice", 11 - Email: "alice@example.com", 12 - Active: true, 13 - CreatedAt: time.Time{ 14 - wall: 0x0, 15 - ext: 0, 16 - loc: (*time.Location)(nil), 17 - }, 18 - Roles: []string{ 19 - "user", 20 - "moderator", 21 - }, 22 - Metadata: map[string]interface{}{ 23 - "badge": "verified", 24 - "verified": true, 25 - }, 26 - }, 27 - { 28 - ID: 2, 29 - Username: "bob", 30 - Email: "bob@example.com", 31 - Active: false, 32 - CreatedAt: time.Time{ 33 - wall: 0x0, 34 - ext: 0, 35 - loc: (*time.Location)(nil), 36 - }, 37 - Roles: []string{ 38 - "user", 39 - }, 40 - Metadata: map[string]interface{}{ 41 - "avatar": "https://example.com/bob.jpg", 42 - "verified": false, 43 - }, 44 - }, 45 - { 46 - ID: 3, 47 - Username: "charlie", 48 - Email: "charlie@example.com", 49 - Active: true, 50 - CreatedAt: time.Time{ 51 - wall: 0x0, 52 - ext: 0, 53 - loc: (*time.Location)(nil), 54 - }, 55 - Roles: []string{ 56 - "user", 57 - "admin", 58 - }, 59 - Metadata: map[string]interface{}{ 60 - "account_age_days": 365, 61 - "verified": true, 62 - }, 63 - }, 64 - }
-13
__snapshots__/test_multiple_scrubbers.snap
··· 1 - --- 2 - title: Multiple Scrubbers 3 - test_name: TestMultipleScrubbers 4 - file_name: scrubbers_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "created_at": "<TIMESTAMP>", 9 - "email": "<EMAIL>", 10 - "ip_address": "<IP>", 11 - "name": "John Doe", 12 - "user_id": "<UUID>" 13 - }
__snapshots__/test_nested_ignore_patterns.snap __snapshots__/nested_ignore_patterns.snap
-51
__snapshots__/test_nested_maps_and_slices.snap
··· 1 - --- 2 - title: Nested Maps and Slices 3 - test_name: TestNestedMapsAndSlices 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "posts": map[string]interface{}{ 9 - "categories": []string{ 10 - "tech", 11 - "lifestyle", 12 - "news", 13 - }, 14 - "drafts": 5, 15 - "published": 42, 16 - }, 17 - "stats": map[string]interface{}{ 18 - "daily": map[string]interface{}{ 19 - "clicks": 320, 20 - "conversions": map[string]interface{}{ 21 - "by_source": map[string]int{ 22 - "organic": 25, 23 - "paid": 15, 24 - "referral": 5, 25 - }, 26 - "total": 45, 27 - }, 28 - "views": 1500, 29 - }, 30 - }, 31 - "users": map[string]interface{}{ 32 - "active": []map[string]interface{}{ 33 - { 34 - "id": 1, 35 - "name": "Alice", 36 - "verified": true, 37 - }, 38 - { 39 - "id": 2, 40 - "name": "Bob", 41 - "verified": false, 42 - }, 43 - }, 44 - "inactive": []map[string]interface{}{ 45 - { 46 - "id": 3, 47 - "name": "Charlie", 48 - }, 49 - }, 50 - }, 51 - }
+1 -1
__snapshots__/test_regex_scrubber.snap __snapshots__/custom_regex_scrubber.snap
··· 1 1 --- 2 2 title: Custom Regex Scrubber 3 - test_name: TestRegexScrubber 3 + test_name: TestCustomScrubbers/regex_scrubber 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_scrub_a_p_i_keys.snap __snapshots__/scrubbed_api_keys.snap
··· 1 1 --- 2 2 title: Scrubbed API Keys 3 - test_name: TestScrubAPIKeys 3 + test_name: TestIndividualScrubbers/api_keys 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_scrub_dates.snap __snapshots__/scrubbed_dates.snap
··· 1 1 --- 2 2 title: Scrubbed Dates 3 - test_name: TestScrubDates 3 + test_name: TestIndividualScrubbers/dates 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_scrub_emails.snap __snapshots__/scrubbed_emails.snap
··· 1 1 --- 2 2 title: Scrubbed Emails 3 - test_name: TestScrubEmails 3 + test_name: TestIndividualScrubbers/emails 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_scrub_i_p_addresses.snap __snapshots__/scrubbed_ips.snap
··· 1 1 --- 2 2 title: Scrubbed IPs 3 - test_name: TestScrubIPAddresses 3 + test_name: TestIndividualScrubbers/ip_addresses 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_scrub_j_w_ts.snap __snapshots__/scrubbed_jwts.snap
··· 1 1 --- 2 2 title: Scrubbed JWTs 3 - test_name: TestScrubJWTs 3 + test_name: TestIndividualScrubbers/jwts 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_scrub_timestamps.snap __snapshots__/scrubbed_timestamps.snap
··· 1 1 --- 2 2 title: Scrubbed Timestamps 3 - test_name: TestScrubTimestamps 3 + test_name: TestIndividualScrubbers/timestamps 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
+1 -1
__snapshots__/test_scrub_u_u_i_ds.snap __snapshots__/scrubbed_uuids.snap
··· 1 1 --- 2 2 title: Scrubbed UUIDs 3 - test_name: TestScrubUUIDs 3 + test_name: TestIndividualScrubbers/uuid 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
-12
__snapshots__/test_scrub_with_snap_function.snap
··· 1 - --- 2 - title: Scrub With Snap 3 - test_name: TestScrubWithSnapFunction 4 - file_name: scrubbers_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "created_at": "<TIMESTAMP>", 9 - "email": "<EMAIL>", 10 - "name": "John Doe", 11 - "user_id": "<UUID>", 12 - }
+2 -2
__snapshots__/test_snap_custom_type.snap __snapshots__/custom_type_test.snap
··· 5 5 version: 0.1.0 6 6 --- 7 7 shutter_test.CustomStruct{ 8 - Name: "Alice", 9 - Age: 30, 8 + Name: "Alice", 9 + Age: 30, 10 10 }
-29
__snapshots__/test_snap_json_array_of_objects.snap
··· 1 - --- 2 - title: SnapJSON Array of Objects 3 - test_name: TestSnapJsonArrayOfObjects 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - [ 8 - { 9 - "id": 1, 10 - "likes": 42, 11 - "title": "First Post", 12 - "type": "post", 13 - "views": 150 14 - }, 15 - { 16 - "id": 2, 17 - "likes": 75, 18 - "title": "Second Post", 19 - "type": "post", 20 - "views": 280 21 - }, 22 - { 23 - "id": 3, 24 - "likes": 120, 25 - "title": "Third Post", 26 - "type": "post", 27 - "views": 450 28 - } 29 - ]
-12
__snapshots__/test_snap_json_basic.snap
··· 1 - --- 2 - title: SnapJSON Basic Object 3 - test_name: TestSnapJsonBasic 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "age": 30, 9 - "email": "john@example.com", 10 - "name": "John Doe", 11 - "verified": true 12 - }
-16
__snapshots__/test_snap_json_compact_format.snap
··· 1 - --- 2 - title: SnapJSON Compact Format 3 - test_name: TestSnapJsonCompactFormat 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "id": 1, 9 - "in_stock": true, 10 - "name": "Product", 11 - "price": 99.99, 12 - "tags": [ 13 - "electronics", 14 - "gadgets" 15 - ] 16 - }
__snapshots__/test_snap_json_complex_a_p_i.snap __snapshots__/snapjson_complex_api_response.snap
-18
__snapshots__/test_snap_json_empty_structures.snap
··· 1 - --- 2 - title: SnapJSON Empty Structures 3 - test_name: TestSnapJsonEmptyStructures 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "empty_array": [], 9 - "empty_object": {}, 10 - "empty_string": "", 11 - "false_value": false, 12 - "nested": { 13 - "also_empty": {}, 14 - "empty": [] 15 - }, 16 - "null_value": null, 17 - "zero": 0 18 - }
-103
__snapshots__/test_snap_json_large_nested_structure.snap
··· 1 - --- 2 - title: SnapJSON Large Nested Structure 3 - test_name: TestSnapJsonLargeNestedStructure 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "organization": { 9 - "departments": [ 10 - { 11 - "manager": "Alice", 12 - "name": "Engineering", 13 - "teams": [ 14 - { 15 - "lead": "John", 16 - "members": [ 17 - { 18 - "id": 1, 19 - "level": "senior", 20 - "name": "John" 21 - }, 22 - { 23 - "id": 2, 24 - "level": "mid", 25 - "name": "Jane" 26 - } 27 - ], 28 - "name": "Backend", 29 - "projects": [ 30 - { 31 - "id": "proj_1", 32 - "name": "API Service", 33 - "status": "active" 34 - }, 35 - { 36 - "id": "proj_2", 37 - "name": "Database Optimization", 38 - "status": "planning" 39 - } 40 - ] 41 - }, 42 - { 43 - "lead": "Bob", 44 - "members": [ 45 - { 46 - "id": 3, 47 - "level": "senior", 48 - "name": "Bob" 49 - }, 50 - { 51 - "id": 4, 52 - "level": "junior", 53 - "name": "Carol" 54 - } 55 - ], 56 - "name": "Frontend", 57 - "projects": [ 58 - { 59 - "id": "proj_3", 60 - "name": "Web App", 61 - "status": "active" 62 - } 63 - ] 64 - } 65 - ] 66 - }, 67 - { 68 - "manager": "Charlie", 69 - "name": "Sales", 70 - "teams": [ 71 - { 72 - "lead": "Dave", 73 - "members": [ 74 - { 75 - "id": 5, 76 - "level": "senior", 77 - "name": "Dave" 78 - }, 79 - { 80 - "id": 6, 81 - "level": "mid", 82 - "name": "Eve" 83 - } 84 - ], 85 - "name": "Enterprise", 86 - "projects": [] 87 - } 88 - ] 89 - } 90 - ], 91 - "id": "org_123", 92 - "metadata": { 93 - "employees": 150, 94 - "founded": "2020", 95 - "locations": [ 96 - "USA", 97 - "EU", 98 - "APAC" 99 - ] 100 - }, 101 - "name": "TechCorp" 102 - } 103 - }
__snapshots__/test_snap_json_mixed_types.snap __snapshots__/snapjson_mixed_types.snap
__snapshots__/test_snap_json_real_world_example.snap __snapshots__/snapjson_real_world_example.snap
-12
__snapshots__/test_snap_json_simple_array.snap
··· 1 - --- 2 - title: SnapJSON Simple Array 3 - test_name: TestSnapJsonSimpleArray 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - [ 8 - "apple", 9 - "banana", 10 - "orange", 11 - "grape" 12 - ]
__snapshots__/test_snap_json_with_nested_objects.snap __snapshots__/snapjson_nested_objects.snap
-18
__snapshots__/test_snap_json_with_nulls.snap
··· 1 - --- 2 - title: SnapJSON With Nulls 3 - test_name: TestSnapJsonWithNulls 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "category": null, 9 - "description": null, 10 - "id": 1, 11 - "metadata": { 12 - "created": "2023-01-01", 13 - "deleted": null, 14 - "updated": null 15 - }, 16 - "name": "Item", 17 - "tags": null 18 - }
-34
__snapshots__/test_snap_json_with_numbers.snap
··· 1 - --- 2 - title: SnapJSON With Numbers 3 - test_name: TestSnapJsonWithNumbers 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "financial": { 9 - "expenses": 750000.75, 10 - "profit_margin": 0.2499, 11 - "revenue": 1000000.5 12 - }, 13 - "floats": [ 14 - 0, 15 - 3.14, 16 - -2.5, 17 - 0.001, 18 - 0.000123, 19 - 56700000000 20 - ], 21 - "integers": [ 22 - 0, 23 - 1, 24 - -1, 25 - 42, 26 - -100, 27 - 9999999 28 - ], 29 - "measurements": { 30 - "distance": 1000.25, 31 - "temperature": -40.5, 32 - "weight": 0.5 33 - } 34 - }
-15
__snapshots__/test_snap_json_with_special_characters.snap
··· 1 - --- 2 - title: SnapJSON With Special Characters 3 - test_name: TestSnapJsonWithSpecialCharacters 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "escaped": "line1\nline2\ttab\rcarriage", 9 - "html": "\u003cdiv class=\"container\"\u003eContent\u003c/div\u003e", 10 - "paths": "C:\\Users\\name\\Documents\\file.txt", 11 - "quotes": "He said \"hello\" and she said 'goodbye'", 12 - "regex": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", 13 - "special": "!@#$%^\u0026*()_+-=[]{}|;:',.\u003c\u003e?/", 14 - "unicode": "Hello 世界 🌍 مرحبا Привет" 15 - }
__snapshots__/test_snap_multiple.snap __snapshots__/multiple_values_test.snap
-7
__snapshots__/test_snap_string.snap
··· 1 - --- 2 - title: Simple String Test 3 - test_name: TestSnapString 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - hello world
-38
__snapshots__/test_structure_with_empty_values.snap
··· 1 - --- 2 - title: Structure with Empty Values 3 - test_name: TestStructureWithEmptyValues 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - []shutter_test.Container{ 8 - { 9 - Items: []string{ 10 - }, 11 - Tags: map[string]string{ 12 - }, 13 - OptionalID: (*int)(nil), 14 - Count: 0, 15 - Active: false, 16 - }, 17 - { 18 - Items: []string(nil), 19 - Tags: map[string]string(nil), 20 - OptionalID: (*int)(nil), 21 - Count: 0, 22 - Active: true, 23 - }, 24 - { 25 - Items: []string{ 26 - "a", 27 - "b", 28 - "c", 29 - }, 30 - Tags: map[string]string{ 31 - "env": "dev", 32 - "type": "test", 33 - }, 34 - OptionalID: &int(42), 35 - Count: 3, 36 - Active: true, 37 - }, 38 - }
-76
__snapshots__/test_structure_with_interface.snap
··· 1 - --- 2 - title: Structure with Interface Fields 3 - test_name: TestStructureWithInterface 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - []shutter_test.Response{ 8 - { 9 - Status: "success", 10 - Message: "User retrieved", 11 - Data: shutter_test.User{ 12 - ID: 1, 13 - Username: "john", 14 - Email: "john@example.com", 15 - Active: true, 16 - CreatedAt: time.Time{ 17 - wall: 0x0, 18 - ext: 0, 19 - loc: (*time.Location)(nil), 20 - }, 21 - Roles: []string(nil), 22 - Metadata: map[string]interface{}(nil), 23 - }, 24 - Meta: map[string]interface{}{ 25 - "request_id": "req-123", 26 - "timestamp": "2023-01-20T10:30:00Z", 27 - }, 28 - }, 29 - { 30 - Status: "error", 31 - Message: "User not found", 32 - Data: nil, 33 - Meta: map[string]interface{}{ 34 - "error_code": 404, 35 - "error_type": "NOT_FOUND", 36 - }, 37 - }, 38 - { 39 - Status: "success", 40 - Message: "Posts retrieved", 41 - Data: []shutter_test.Post{ 42 - { 43 - ID: 1, 44 - Title: "First Post", 45 - Content: "", 46 - Author: shutter_test.User{ 47 - ID: 0, 48 - Username: "", 49 - Email: "", 50 - Active: false, 51 - CreatedAt: time.Time{ 52 - wall: 0x0, 53 - ext: 0, 54 - loc: (*time.Location)(nil), 55 - }, 56 - Roles: []string(nil), 57 - Metadata: map[string]interface{}(nil), 58 - }, 59 - Tags: []string(nil), 60 - Comments: []shutter_test.Comment(nil), 61 - Likes: 0, 62 - Published: true, 63 - CreatedAt: time.Time{ 64 - wall: 0x0, 65 - ext: 0, 66 - loc: (*time.Location)(nil), 67 - }, 68 - }, 69 - }, 70 - Meta: map[string]interface{}{ 71 - "page": 1, 72 - "per_page": 20, 73 - "total_count": 10, 74 - }, 75 - }, 76 - }
-27
__snapshots__/test_structure_with_pointers.snap
··· 1 - --- 2 - title: Structure with Pointers 3 - test_name: TestStructureWithPointers 4 - file_name: shutter_test.go 5 - version: 0.1.0 6 - --- 7 - shutter_test.Person{ 8 - Name: "John", 9 - Age: 35, 10 - Address: &shutter_test.Address{ 11 - Street: "123 Main St", 12 - City: "Boston", 13 - Zip: "02101", 14 - }, 15 - Manager: &shutter_test.Person{ 16 - Name: "Jane", 17 - Age: 30, 18 - Address: (*shutter_test.Address)(<already shown>), 19 - Manager: (*shutter_test.Person)(nil), 20 - Friends: []*shutter_test.Person(nil), 21 - Email: &string("jane@example.com"), 22 - }, 23 - Friends: []*shutter_test.Person{ 24 - (*shutter_test.Person)(<already shown>), 25 - }, 26 - Email: (*string)(nil), 27 - }
+1 -1
__snapshots__/test_unix_timestamp_scrubbing.snap __snapshots__/scrubbed_unix_timestamps.snap
··· 1 1 --- 2 2 title: Scrubbed Unix Timestamps 3 - test_name: TestUnixTimestampScrubbing 3 + test_name: TestIndividualScrubbers/unix_timestamps 4 4 file_name: scrubbers_test.go 5 5 version: 0.1.0 6 6 ---
+108 -43
ignore.go
··· 12 12 value string 13 13 } 14 14 15 + func (e *exactKeyValueIgnore) isOption() {} 16 + 15 17 func (e *exactKeyValueIgnore) ShouldIgnore(key, value string) bool { 16 18 return e.key == key && (e.value == "*" || e.value == value) 17 19 } 18 20 19 - func (e *exactKeyValueIgnore) Apply(content string) string { 20 - // Ignore patterns are applied during JSON transformation, not string scrubbing 21 - return content 22 - } 23 - 24 21 // IgnoreKeyValue creates an ignore pattern that matches exact key-value pairs. 25 22 // Use "*" as the value to ignore any value for the given key. 26 - func IgnoreKeyValue(key, value string) SnapshotOption { 23 + // 24 + // This option only works with SnapJSON. 25 + // 26 + // Example: 27 + // 28 + // shutter.SnapJSON(t, "response", jsonStr, 29 + // shutter.IgnoreKeyValue("password", "*"), 30 + // shutter.IgnoreKeyValue("status", "pending"), 31 + // ) 32 + func IgnoreKeyValue(key, value string) IgnorePattern { 27 33 return &exactKeyValueIgnore{ 28 34 key: key, 29 35 value: value, ··· 36 42 valuePattern *regexp.Regexp 37 43 } 38 44 45 + func (r *regexKeyValueIgnore) isOption() {} 46 + 39 47 func (r *regexKeyValueIgnore) ShouldIgnore(key, value string) bool { 40 48 keyMatch := r.keyPattern == nil || r.keyPattern.MatchString(key) 41 49 valueMatch := r.valuePattern == nil || r.valuePattern.MatchString(value) 42 50 return keyMatch && valueMatch 43 - } 44 - 45 - func (r *regexKeyValueIgnore) Apply(content string) string { 46 - return content 47 51 } 48 52 49 53 // IgnoreKeyPattern creates an ignore pattern using regex patterns for keys and values. 50 54 // Pass empty string for keyPattern or valuePattern to match any key or value. 51 - func IgnoreKeyPattern(keyPattern, valuePattern string) SnapshotOption { 55 + // 56 + // This option only works with SnapJSON. 57 + // 58 + // Example: 59 + // 60 + // shutter.SnapJSON(t, "response", jsonStr, 61 + // shutter.IgnoreKeyPattern(`.*password.*`, ""), 62 + // shutter.IgnoreKeyPattern(`.*token.*`, ""), 63 + // ) 64 + func IgnoreKeyPattern(keyPattern, valuePattern string) IgnorePattern { 52 65 var keyRe, valueRe *regexp.Regexp 53 66 if keyPattern != "" { 54 67 keyRe = regexp.MustCompile(keyPattern) ··· 67 80 keys []string 68 81 } 69 82 83 + func (k *keyOnlyIgnore) isOption() {} 84 + 70 85 func (k *keyOnlyIgnore) ShouldIgnore(key, value string) bool { 71 86 return slices.Contains(k.keys, key) 72 87 } 73 88 74 - func (k *keyOnlyIgnore) Apply(content string) string { 75 - return content 76 - } 77 - 78 - // IgnoreKeys creates an ignore pattern that ignores the specified keys 89 + // IgnoreKey creates an ignore pattern that ignores the specified keys 79 90 // regardless of their values. 80 - func IgnoreKeys(keys ...string) SnapshotOption { 91 + // 92 + // This option only works with SnapJSON. 93 + // 94 + // Example: 95 + // 96 + // shutter.SnapJSON(t, "response", jsonStr, 97 + // shutter.IgnoreKey("password", "secret", "token"), 98 + // ) 99 + func IgnoreKey(keys ...string) IgnorePattern { 81 100 return &keyOnlyIgnore{ 82 101 keys: keys, 83 102 } ··· 88 107 pattern *regexp.Regexp 89 108 } 90 109 110 + func (r *regexKeyIgnore) isOption() {} 111 + 91 112 func (r *regexKeyIgnore) ShouldIgnore(key, value string) bool { 92 113 return r.pattern.MatchString(key) 93 114 } 94 115 95 - func (r *regexKeyIgnore) Apply(content string) string { 96 - return content 97 - } 98 - 99 - // IgnoreKeysMatching creates an ignore pattern that ignores keys matching 116 + // IgnoreKeyMatching creates an ignore pattern that ignores keys matching 100 117 // the given regex pattern. 101 - func IgnoreKeysMatching(pattern string) SnapshotOption { 118 + // 119 + // This option only works with SnapJSON. 120 + // 121 + // Example: 122 + // 123 + // shutter.SnapJSON(t, "response", jsonStr, 124 + // shutter.IgnoreKeyMatching(`^user_`), 125 + // ) 126 + func IgnoreKeyMatching(pattern string) IgnorePattern { 102 127 re := regexp.MustCompile(pattern) 103 128 return &regexKeyIgnore{ 104 129 pattern: re, ··· 112 137 "authorization", "auth", "credentials", "passwd", 113 138 } 114 139 115 - // IgnoreSensitiveKeys ignores common sensitive key names like password, token, etc. 116 - func IgnoreSensitiveKeys() SnapshotOption { 140 + // IgnoreSensitive ignores common sensitive key names like password, token, etc. 141 + // 142 + // This option only works with SnapJSON. 143 + // 144 + // Example: 145 + // 146 + // shutter.SnapJSON(t, "response", jsonStr, 147 + // shutter.IgnoreSensitive(), 148 + // ) 149 + func IgnoreSensitive() IgnorePattern { 117 150 return &keyOnlyIgnore{ 118 151 keys: sensitiveKeys, 119 152 } ··· 124 157 values []string 125 158 } 126 159 160 + func (v *valueOnlyIgnore) isOption() {} 161 + 127 162 func (v *valueOnlyIgnore) ShouldIgnore(key, value string) bool { 128 163 return slices.Contains(v.values, value) 129 164 } 130 165 131 - func (v *valueOnlyIgnore) Apply(content string) string { 132 - return content 133 - } 134 - 135 - // IgnoreValues creates an ignore pattern that ignores the specified values 166 + // IgnoreValue creates an ignore pattern that ignores the specified values 136 167 // regardless of their keys. 137 - func IgnoreValues(values ...string) SnapshotOption { 168 + // 169 + // This option only works with SnapJSON. 170 + // 171 + // Example: 172 + // 173 + // shutter.SnapJSON(t, "response", jsonStr, 174 + // shutter.IgnoreValue("pending", "processing"), 175 + // ) 176 + func IgnoreValue(values ...string) IgnorePattern { 138 177 return &valueOnlyIgnore{ 139 178 values: values, 140 179 } ··· 145 184 ignoreFunc func(key, value string) bool 146 185 } 147 186 187 + func (c *customIgnore) isOption() {} 188 + 148 189 func (c *customIgnore) ShouldIgnore(key, value string) bool { 149 190 return c.ignoreFunc(key, value) 150 191 } 151 192 152 - func (c *customIgnore) Apply(content string) string { 153 - return content 154 - } 155 - 156 - // CustomIgnore creates an ignore pattern using a custom function. 157 - func CustomIgnore(ignoreFunc func(key, value string) bool) SnapshotOption { 193 + // IgnoreWith creates an ignore pattern using a custom function. 194 + // The function receives the key and value and should return true if the 195 + // key-value pair should be ignored. 196 + // 197 + // This option only works with SnapJSON. 198 + // 199 + // Example: 200 + // 201 + // shutter.SnapJSON(t, "response", jsonStr, 202 + // shutter.IgnoreWith(func(key, value string) bool { 203 + // return strings.HasPrefix(key, "temp_") 204 + // }), 205 + // ) 206 + func IgnoreWith(ignoreFunc func(key, value string) bool) IgnorePattern { 158 207 return &customIgnore{ 159 208 ignoreFunc: ignoreFunc, 160 209 } 161 210 } 162 211 163 - // IgnoreEmptyValues ignores fields with empty string values. 164 - func IgnoreEmptyValues() SnapshotOption { 165 - return CustomIgnore(func(key, value string) bool { 212 + // IgnoreEmpty ignores fields with empty string values. 213 + // 214 + // This option only works with SnapJSON. 215 + // 216 + // Example: 217 + // 218 + // shutter.SnapJSON(t, "response", jsonStr, 219 + // shutter.IgnoreEmpty(), 220 + // ) 221 + func IgnoreEmpty() IgnorePattern { 222 + return IgnoreWith(func(key, value string) bool { 166 223 return strings.TrimSpace(value) == "" 167 224 }) 168 225 } 169 226 170 - // IgnoreNullValues ignores fields with null/nil values (represented as "null" in JSON). 171 - func IgnoreNullValues() SnapshotOption { 172 - return CustomIgnore(func(key, value string) bool { 227 + // IgnoreNull ignores fields with null/nil values (represented as "null" in JSON). 228 + // 229 + // This option only works with SnapJSON. 230 + // 231 + // Example: 232 + // 233 + // shutter.SnapJSON(t, "response", jsonStr, 234 + // shutter.IgnoreNull(), 235 + // ) 236 + func IgnoreNull() IgnorePattern { 237 + return IgnoreWith(func(key, value string) bool { 173 238 return value == "null" || value == "<nil>" 174 239 }) 175 240 }
+162 -126
ignore_test.go
··· 6 6 "github.com/ptdewey/shutter" 7 7 ) 8 8 9 - func TestIgnoreKeyValue(t *testing.T) { 10 - jsonStr := `{ 11 - "username": "john_doe", 12 - "password": "secret123", 13 - "email": "john@example.com", 14 - "api_key": "sk_live_abc123" 15 - }` 16 - 17 - shutter.SnapJSON(t, "Ignore Password Field", jsonStr, 18 - shutter.IgnoreKeyValue("password", "*"), 19 - shutter.IgnoreKeyValue("api_key", "*"), 20 - ) 21 - } 22 - 23 9 func TestIgnoreKeys(t *testing.T) { 24 - jsonStr := `{ 25 - "id": 1, 26 - "name": "John Doe", 27 - "password": "secret", 28 - "secret": "confidential", 29 - "token": "abc123", 30 - "email": "john@example.com" 31 - }` 10 + tests := []struct { 11 + name string 12 + json string 13 + opts []shutter.Option 14 + title string 15 + }{ 16 + { 17 + name: "multiple_keys", 18 + json: `{ 19 + "id": 1, 20 + "name": "John Doe", 21 + "password": "secret", 22 + "secret": "confidential", 23 + "token": "abc123", 24 + "email": "john@example.com" 25 + }`, 26 + opts: []shutter.Option{shutter.IgnoreKey("password", "secret", "token")}, 27 + title: "Ignore Multiple Keys", 28 + }, 29 + { 30 + name: "key_value_pairs", 31 + json: `{ 32 + "username": "john_doe", 33 + "password": "secret123", 34 + "email": "john@example.com", 35 + "api_key": "sk_live_abc123" 36 + }`, 37 + opts: []shutter.Option{ 38 + shutter.IgnoreKeyValue("password", "*"), 39 + shutter.IgnoreKeyValue("api_key", "*"), 40 + }, 41 + title: "Ignore Password Field", 42 + }, 43 + { 44 + name: "arrays", 45 + json: `{ 46 + "users": [ 47 + { 48 + "id": 1, 49 + "name": "Alice", 50 + "password": "secret1", 51 + "email": "alice@example.com" 52 + }, 53 + { 54 + "id": 2, 55 + "name": "Bob", 56 + "password": "secret2", 57 + "email": "bob@example.com" 58 + } 59 + ] 60 + }`, 61 + opts: []shutter.Option{shutter.IgnoreKey("password")}, 62 + title: "Ignore in Arrays", 63 + }, 64 + } 32 65 33 - shutter.SnapJSON(t, "Ignore Multiple Keys", jsonStr, 34 - shutter.IgnoreKeys("password", "secret", "token"), 35 - ) 66 + for _, tt := range tests { 67 + t.Run(tt.name, func(t *testing.T) { 68 + shutter.SnapJSON(t, tt.title, tt.json, tt.opts...) 69 + }) 70 + } 36 71 } 37 72 38 73 func TestIgnoreSensitiveKeys(t *testing.T) { ··· 47 82 }` 48 83 49 84 shutter.SnapJSON(t, "Ignore Sensitive Keys", jsonStr, 50 - shutter.IgnoreSensitiveKeys(), 51 - ) 52 - } 53 - 54 - func TestIgnoreKeysMatching(t *testing.T) { 55 - jsonStr := `{ 56 - "user_id": 1, 57 - "user_name": "john", 58 - "user_email": "john@example.com", 59 - "product_id": 100, 60 - "product_name": "Widget" 61 - }` 62 - 63 - shutter.SnapJSON(t, "Ignore Keys Matching Pattern", jsonStr, 64 - shutter.IgnoreKeysMatching(`^user_`), 85 + shutter.IgnoreSensitive(), 65 86 ) 66 87 } 67 88 68 - func TestIgnoreKeyPattern(t *testing.T) { 69 - jsonStr := `{ 70 - "username": "john_doe", 71 - "password": "secret", 72 - "admin_password": "admin_secret", 73 - "user_token": "token123", 74 - "email": "john@example.com" 75 - }` 89 + func TestIgnoreKeyPatterns(t *testing.T) { 90 + tests := []struct { 91 + name string 92 + json string 93 + opts []shutter.Option 94 + title string 95 + }{ 96 + { 97 + name: "prefix_pattern", 98 + json: `{ 99 + "user_id": 1, 100 + "user_name": "john", 101 + "user_email": "john@example.com", 102 + "product_id": 100, 103 + "product_name": "Widget" 104 + }`, 105 + opts: []shutter.Option{shutter.IgnoreKeyMatching(`^user_`)}, 106 + title: "Ignore Keys Matching Pattern", 107 + }, 108 + { 109 + name: "contains_pattern", 110 + json: `{ 111 + "username": "john_doe", 112 + "password": "secret", 113 + "admin_password": "admin_secret", 114 + "user_token": "token123", 115 + "email": "john@example.com" 116 + }`, 117 + opts: []shutter.Option{ 118 + shutter.IgnoreKeyPattern(`.*password.*`, ""), 119 + shutter.IgnoreKeyPattern(`.*token.*`, ""), 120 + }, 121 + title: "Ignore Key Pattern", 122 + }, 123 + } 76 124 77 - shutter.SnapJSON(t, "Ignore Key Pattern", jsonStr, 78 - shutter.IgnoreKeyPattern(`.*password.*`, ""), 79 - shutter.IgnoreKeyPattern(`.*token.*`, ""), 80 - ) 125 + for _, tt := range tests { 126 + t.Run(tt.name, func(t *testing.T) { 127 + shutter.SnapJSON(t, tt.title, tt.json, tt.opts...) 128 + }) 129 + } 81 130 } 82 131 83 132 func TestIgnoreValues(t *testing.T) { 84 - jsonStr := `{ 85 - "status": "pending", 86 - "result": "pending", 87 - "message": "Processing", 88 - "state": "pending" 89 - }` 90 - 91 - shutter.SnapJSON(t, "Ignore Specific Values", jsonStr, 92 - shutter.IgnoreValues("pending"), 93 - ) 94 - } 95 - 96 - func TestIgnoreEmptyValues(t *testing.T) { 97 - jsonStr := `{ 98 - "name": "John Doe", 99 - "middle_name": "", 100 - "nickname": " ", 101 - "email": "john@example.com", 102 - "phone": "" 103 - }` 104 - 105 - shutter.SnapJSON(t, "Ignore Empty Values", jsonStr, 106 - shutter.IgnoreEmptyValues(), 107 - ) 108 - } 109 - 110 - func TestIgnoreNullValues(t *testing.T) { 111 - jsonStr := `{ 112 - "name": "John Doe", 113 - "middle_name": null, 114 - "email": "john@example.com", 115 - "phone": null, 116 - "age": 30 117 - }` 133 + tests := []struct { 134 + name string 135 + json string 136 + opts []shutter.Option 137 + title string 138 + }{ 139 + { 140 + name: "specific_values", 141 + json: `{ 142 + "status": "pending", 143 + "result": "pending", 144 + "message": "Processing", 145 + "state": "pending" 146 + }`, 147 + opts: []shutter.Option{shutter.IgnoreValue("pending")}, 148 + title: "Ignore Specific Values", 149 + }, 150 + { 151 + name: "empty_values", 152 + json: `{ 153 + "name": "John Doe", 154 + "middle_name": "", 155 + "nickname": " ", 156 + "email": "john@example.com", 157 + "phone": "" 158 + }`, 159 + opts: []shutter.Option{shutter.IgnoreEmpty()}, 160 + title: "Ignore Empty Values", 161 + }, 162 + { 163 + name: "null_values", 164 + json: `{ 165 + "name": "John Doe", 166 + "middle_name": null, 167 + "email": "john@example.com", 168 + "phone": null, 169 + "age": 30 170 + }`, 171 + opts: []shutter.Option{shutter.IgnoreNull()}, 172 + title: "Ignore Null Values", 173 + }, 174 + } 118 175 119 - shutter.SnapJSON(t, "Ignore Null Values", jsonStr, 120 - shutter.IgnoreNullValues(), 121 - ) 176 + for _, tt := range tests { 177 + t.Run(tt.name, func(t *testing.T) { 178 + shutter.SnapJSON(t, tt.title, tt.json, tt.opts...) 179 + }) 180 + } 122 181 } 123 182 124 183 func TestCustomIgnore(t *testing.T) { ··· 131 190 }` 132 191 133 192 shutter.SnapJSON(t, "Custom Ignore Function", jsonStr, 134 - shutter.CustomIgnore(func(key, value string) bool { 193 + shutter.IgnoreWith(func(key, value string) bool { 135 194 // Ignore numeric values 136 195 return value == "1" || value == "25" || value == "95" 137 196 }), ··· 158 217 }` 159 218 160 219 shutter.SnapJSON(t, "Nested Ignore Patterns", jsonStr, 161 - shutter.IgnoreSensitiveKeys(), 220 + shutter.IgnoreSensitive(), 162 221 ) 163 222 } 164 223 ··· 175 234 176 235 shutter.SnapJSON(t, "Combined Ignore and Scrub", jsonStr, 177 236 // Ignore sensitive keys entirely 178 - shutter.IgnoreKeys("password", "api_key"), 237 + shutter.IgnoreKey("password", "api_key"), 179 238 // Scrub dynamic/identifiable data 180 - shutter.ScrubUUIDs(), 181 - shutter.ScrubEmails(), 182 - shutter.ScrubTimestamps(), 183 - shutter.ScrubIPAddresses(), 184 - ) 185 - } 186 - 187 - func TestIgnoreInArrays(t *testing.T) { 188 - jsonStr := `{ 189 - "users": [ 190 - { 191 - "id": 1, 192 - "name": "Alice", 193 - "password": "secret1", 194 - "email": "alice@example.com" 195 - }, 196 - { 197 - "id": 2, 198 - "name": "Bob", 199 - "password": "secret2", 200 - "email": "bob@example.com" 201 - } 202 - ] 203 - }` 204 - 205 - shutter.SnapJSON(t, "Ignore in Arrays", jsonStr, 206 - shutter.IgnoreKeys("password"), 239 + shutter.ScrubUUID(), 240 + shutter.ScrubEmail(), 241 + shutter.ScrubTimestamp(), 242 + shutter.ScrubIP(), 207 243 ) 208 244 } 209 245 ··· 236 272 237 273 shutter.SnapJSON(t, "Real World API Response", jsonStr, 238 274 // Ignore sensitive fields 239 - shutter.IgnoreSensitiveKeys(), 240 - shutter.IgnoreKeys("card_number"), 275 + shutter.IgnoreSensitive(), 276 + shutter.IgnoreKey("card_number"), 241 277 // Scrub dynamic/identifiable data 242 - shutter.ScrubUUIDs(), 243 - shutter.ScrubEmails(), 244 - shutter.ScrubTimestamps(), 245 - shutter.ScrubIPAddresses(), 246 - shutter.ScrubJWTs(), 278 + shutter.ScrubUUID(), 279 + shutter.ScrubEmail(), 280 + shutter.ScrubTimestamp(), 281 + shutter.ScrubIP(), 282 + shutter.ScrubJWT(), 247 283 ) 248 284 }
+7
internal/files/__snapshots__/accept_title.snap
··· 1 + --- 2 + title: Accept Title 3 + test_name: TestAccept 4 + file_name: 5 + version: 6 + --- 7 + new content to accept
+7
internal/files/__snapshots__/testaccept.snap
··· 1 + --- 2 + title: Accept Title 3 + test_name: TestAccept 4 + file_name: 5 + version: 6 + --- 7 + new content to accept
+16 -27
internal/files/files.go
··· 4 4 "fmt" 5 5 "os" 6 6 "path/filepath" 7 - "regexp" 8 7 "strings" 9 8 ) 10 9 ··· 75 74 return snapshotDir, nil 76 75 } 77 76 78 - func SnapshotFileName(testName string) string { 79 - var result strings.Builder 80 - for i, r := range testName { 81 - if i > 0 && r >= 'A' && r <= 'Z' { 82 - result.WriteRune('_') 83 - } 84 - result.WriteRune(r) 85 - } 86 - s := result.String() 87 - s = strings.ToLower(s) 88 - s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "_") 89 - s = strings.Trim(s, "_") 90 - return s 77 + // TODO: make this use the snapshot title rather than the test name 78 + func SnapshotFileName(snapTitle string) string { 79 + return strings.ReplaceAll(strings.ToLower(snapTitle), " ", "_") 91 80 } 92 81 93 82 // getSnapshotFileName returns the filename for a snapshot based on test name and state 94 - func getSnapshotFileName(testName string, state string) string { 95 - baseName := SnapshotFileName(testName) 83 + func getSnapshotFileName(snapTitle string, state string) string { 84 + baseName := SnapshotFileName(snapTitle) 96 85 switch state { 97 86 case "accepted": 98 87 return baseName + ".snap" ··· 109 98 return err 110 99 } 111 100 112 - fileName := getSnapshotFileName(snap.Test, state) 101 + fileName := getSnapshotFileName(snap.Title, state) 113 102 filePath := filepath.Join(snapshotDir, fileName) 114 103 115 104 return os.WriteFile(filePath, []byte(snap.Serialize()), 0644) 116 105 } 117 106 118 - func ReadSnapshot(testName string, state string) (*Snapshot, error) { 107 + func ReadSnapshot(snapTitle string, state string) (*Snapshot, error) { 119 108 snapshotDir, err := getSnapshotDir() 120 109 if err != nil { 121 110 return nil, err 122 111 } 123 112 124 - fileName := getSnapshotFileName(testName, state) 113 + fileName := getSnapshotFileName(snapTitle, state) 125 114 filePath := filepath.Join(snapshotDir, fileName) 126 115 127 116 data, err := os.ReadFile(filePath) ··· 132 121 return Deserialize(string(data)) 133 122 } 134 123 135 - func ReadAccepted(testName string) (*Snapshot, error) { 136 - return ReadSnapshot(testName, "snap") 124 + func ReadAccepted(snapTitle string) (*Snapshot, error) { 125 + return ReadSnapshot(snapTitle, "snap") 137 126 } 138 127 139 - func ReadNew(testName string) (*Snapshot, error) { 140 - return ReadSnapshot(testName, "new") 128 + func ReadNew(snapTitle string) (*Snapshot, error) { 129 + return ReadSnapshot(snapTitle, "new") 141 130 } 142 131 143 132 func ListNewSnapshots() ([]string, error) { ··· 162 151 return newSnapshots, nil 163 152 } 164 153 165 - func AcceptSnapshot(testName string) error { 154 + func AcceptSnapshot(snapTitle string) error { 166 155 snapshotDir, err := getSnapshotDir() 167 156 if err != nil { 168 157 return err 169 158 } 170 159 171 - fileName := SnapshotFileName(testName) 160 + fileName := SnapshotFileName(snapTitle) 172 161 newPath := filepath.Join(snapshotDir, fileName+".snap.new") 173 162 acceptedPath := filepath.Join(snapshotDir, fileName+".snap") 174 163 ··· 184 173 return os.Remove(newPath) 185 174 } 186 175 187 - func RejectSnapshot(testName string) error { 176 + func RejectSnapshot(snapTitle string) error { 188 177 snapshotDir, err := getSnapshotDir() 189 178 if err != nil { 190 179 return err 191 180 } 192 181 193 - fileName := SnapshotFileName(testName) + ".snap.new" 182 + fileName := SnapshotFileName(snapTitle) + ".snap.new" 194 183 filePath := filepath.Join(snapshotDir, fileName) 195 184 196 185 return os.Remove(filePath)
+8 -8
internal/files/files_test.go
··· 13 13 input string 14 14 expected string 15 15 }{ 16 - {"TestMyFunction", "test_my_function"}, 16 + {"Test My Function", "test_my_function"}, 17 17 {"test_another_one", "test_another_one"}, 18 - {"TestCamelCase", "test_camel_case"}, 19 - {"TestWithNumbers123", "test_with_numbers123"}, 20 - {"TestABC", "test_a_b_c"}, 18 + {"Test Camel Case", "test_camel_case"}, 19 + {"Test With Numbers123", "test_with_numbers123"}, 20 + {"Test ABC", "test_abc"}, 21 21 {"test", "test"}, 22 - {"TEST", "t_e_s_t"}, 22 + {"TEST", "test"}, 23 23 } 24 24 25 25 for _, tt := range tests { ··· 187 187 t.Fatalf("SaveSnapshot failed: %v", err) 188 188 } 189 189 190 - if err := files.AcceptSnapshot("TestAccept"); err != nil { 190 + if err := files.AcceptSnapshot("Accept Title"); err != nil { 191 191 t.Fatalf("AcceptSnapshot failed: %v", err) 192 192 } 193 193 ··· 219 219 t.Fatalf("SaveSnapshot failed: %v", err) 220 220 } 221 221 222 - if err := files.RejectSnapshot("TestReject"); err != nil { 222 + if err := files.RejectSnapshot("Reject Title"); err != nil { 223 223 t.Fatalf("RejectSnapshot failed: %v", err) 224 224 } 225 225 226 - _, err := files.ReadSnapshot("TestReject", "new") 226 + _, err := files.ReadSnapshot("Reject Title", "new") 227 227 if err == nil { 228 228 t.Error("expected error: .new file should be deleted after reject") 229 229 }
+6 -6
internal/review/review.go
··· 47 47 func reviewLoop(snapshots []string) error { 48 48 reader := bufio.NewReader(os.Stdin) 49 49 50 - for i, testName := range snapshots { 51 - fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(testName)) 50 + for i, snapTitle := range snapshots { 51 + fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(snapTitle)) 52 52 53 - newSnap, err := files.ReadSnapshot(testName, "new") 53 + newSnap, err := files.ReadSnapshot(snapTitle, "new") 54 54 if err != nil { 55 55 fmt.Println(pretty.Error("✗ Failed to read new snapshot: " + err.Error())) 56 56 continue 57 57 } 58 58 59 - accepted, acceptErr := files.ReadSnapshot(testName, "accepted") 59 + accepted, acceptErr := files.ReadSnapshot(snapTitle, "accepted") 60 60 61 61 if acceptErr == nil { 62 62 diffLines := computeDiffLines(accepted, newSnap) ··· 73 73 74 74 switch choice { 75 75 case Accept: 76 - if err := files.AcceptSnapshot(testName); err != nil { 76 + if err := files.AcceptSnapshot(snapTitle); err != nil { 77 77 fmt.Println(pretty.Error("✗ Failed to accept snapshot: " + err.Error())) 78 78 } else { 79 79 fmt.Println(pretty.Success("✓ Snapshot accepted")) 80 80 } 81 81 case Reject: 82 - if err := files.RejectSnapshot(testName); err != nil { 82 + if err := files.RejectSnapshot(snapTitle); err != nil { 83 83 fmt.Println(pretty.Error("✗ Failed to reject snapshot: " + err.Error())) 84 84 } else { 85 85 fmt.Println(pretty.Warning("⊘ Snapshot rejected"))
+1 -14
internal/snapshots/snapshot.go
··· 5 5 "path/filepath" 6 6 "runtime" 7 7 8 - "github.com/kortschak/utter" 9 8 "github.com/ptdewey/shutter/internal/diff" 10 9 "github.com/ptdewey/shutter/internal/files" 11 10 "github.com/ptdewey/shutter/internal/pretty" ··· 56 55 Version: version, 57 56 } 58 57 59 - accepted, err := files.ReadAccepted(testName) 58 + accepted, err := files.ReadAccepted(title) 60 59 if err == nil { 61 60 if accepted.Content == content { 62 61 return ··· 81 80 fmt.Println(pretty.NewSnapshotBox(snapshot)) 82 81 t.Error("new snapshot created - run 'shutter review' to accept") 83 82 } 84 - 85 - func FormatValues(values ...any) string { 86 - var result string 87 - for _, v := range values { 88 - result += FormatValue(v) 89 - } 90 - return result 91 - } 92 - 93 - func FormatValue(v any) string { 94 - return utter.Sdump(v) 95 - }
+471
internal/snapshots/snapshot_test.go
··· 1 + package snapshots 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/ptdewey/shutter/internal/files" 11 + ) 12 + 13 + // mockT is a test implementation that captures test state 14 + type mockT struct { 15 + helperCalled bool 16 + skipCalled bool 17 + skipArgs []any 18 + skipfCalled bool 19 + skipfFormat string 20 + skipfArgs []any 21 + skipNowCalled bool 22 + name string 23 + errors []string 24 + logs []string 25 + cleanupFuncs []func() 26 + } 27 + 28 + func (m *mockT) Helper() { 29 + m.helperCalled = true 30 + } 31 + 32 + func (m *mockT) Skip(args ...any) { 33 + m.skipCalled = true 34 + m.skipArgs = args 35 + } 36 + 37 + func (m *mockT) Skipf(format string, args ...any) { 38 + m.skipfCalled = true 39 + m.skipfFormat = format 40 + m.skipfArgs = args 41 + } 42 + 43 + func (m *mockT) SkipNow() { 44 + m.skipNowCalled = true 45 + } 46 + 47 + func (m *mockT) Name() string { 48 + return m.name 49 + } 50 + 51 + func (m *mockT) Error(args ...any) { 52 + var sb strings.Builder 53 + for _, arg := range args { 54 + if s, ok := arg.(string); ok { 55 + sb.WriteString(s) 56 + } else { 57 + sb.WriteString(fmt.Sprintf("%v", arg)) 58 + } 59 + } 60 + m.errors = append(m.errors, sb.String()) 61 + } 62 + 63 + func (m *mockT) Log(args ...any) { 64 + var sb strings.Builder 65 + for _, arg := range args { 66 + if s, ok := arg.(string); ok { 67 + sb.WriteString(s) 68 + } 69 + } 70 + m.logs = append(m.logs, sb.String()) 71 + } 72 + 73 + func (m *mockT) Cleanup(f func()) { 74 + m.cleanupFuncs = append(m.cleanupFuncs, f) 75 + } 76 + 77 + func (m *mockT) runCleanups() { 78 + for _, f := range m.cleanupFuncs { 79 + f() 80 + } 81 + } 82 + 83 + // Helper to create a temporary snapshot directory for testing 84 + func setupTestDir(t *testing.T) string { 85 + tmpDir, err := os.MkdirTemp("", "shutter-test-*") 86 + if err != nil { 87 + t.Fatalf("failed to create temp dir: %v", err) 88 + } 89 + 90 + // Change to temp directory 91 + originalDir, err := os.Getwd() 92 + if err != nil { 93 + t.Fatalf("failed to get working directory: %v", err) 94 + } 95 + 96 + if err := os.Chdir(tmpDir); err != nil { 97 + t.Fatalf("failed to change to temp dir: %v", err) 98 + } 99 + 100 + // Cleanup function 101 + t.Cleanup(func() { 102 + os.Chdir(originalDir) 103 + os.RemoveAll(tmpDir) 104 + }) 105 + 106 + return tmpDir 107 + } 108 + 109 + func TestSnap_NewSnapshot(t *testing.T) { 110 + setupTestDir(t) 111 + 112 + mt := &mockT{name: "TestExample"} 113 + Snap(mt, "test_snap", "v1", "content here") 114 + 115 + // Should create a new snapshot 116 + if len(mt.errors) != 1 { 117 + t.Errorf("expected 1 error, got %d", len(mt.errors)) 118 + } 119 + 120 + if !strings.Contains(mt.errors[0], "new snapshot created") { 121 + t.Errorf("expected 'new snapshot created' error, got: %s", mt.errors[0]) 122 + } 123 + 124 + // Verify snapshot file was created 125 + snapPath := filepath.Join("__snapshots__", "test_snap.snap.new") 126 + if _, err := os.Stat(snapPath); os.IsNotExist(err) { 127 + t.Error("expected snapshot file to be created") 128 + } 129 + } 130 + 131 + func TestSnap_MatchingSnapshot(t *testing.T) { 132 + setupTestDir(t) 133 + 134 + // Create an accepted snapshot first 135 + accepted := &files.Snapshot{ 136 + Title: "matching_test", 137 + Test: "TestExample", 138 + FileName: "test.go", 139 + Content: "expected content", 140 + Version: "v1", 141 + } 142 + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { 143 + t.Fatalf("failed to save accepted snapshot: %v", err) 144 + } 145 + 146 + mt := &mockT{name: "TestExample"} 147 + Snap(mt, "matching_test", "v1", "expected content") 148 + 149 + // Should not report any errors (snapshot matches) 150 + if len(mt.errors) != 0 { 151 + t.Errorf("expected no errors, got %d: %v", len(mt.errors), mt.errors) 152 + } 153 + } 154 + 155 + func TestSnap_MismatchedSnapshot(t *testing.T) { 156 + setupTestDir(t) 157 + 158 + // Create an accepted snapshot 159 + accepted := &files.Snapshot{ 160 + Title: "mismatched_test", 161 + Test: "TestExample", 162 + FileName: "test.go", 163 + Content: "old content", 164 + Version: "v1", 165 + } 166 + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { 167 + t.Fatalf("failed to save accepted snapshot: %v", err) 168 + } 169 + 170 + mt := &mockT{name: "TestExample"} 171 + Snap(mt, "mismatched_test", "v1", "new content") 172 + 173 + // Should report a mismatch error 174 + if len(mt.errors) != 1 { 175 + t.Errorf("expected 1 error, got %d", len(mt.errors)) 176 + } 177 + 178 + if !strings.Contains(mt.errors[0], "snapshot mismatch") { 179 + t.Errorf("expected 'snapshot mismatch' error, got: %s", mt.errors[0]) 180 + } 181 + 182 + // Verify new snapshot file was created 183 + snapPath := filepath.Join("__snapshots__", "mismatched_test.snap.new") 184 + if _, err := os.Stat(snapPath); os.IsNotExist(err) { 185 + t.Error("expected new snapshot file to be created") 186 + } 187 + } 188 + 189 + func TestSnap_CallerDetection(t *testing.T) { 190 + setupTestDir(t) 191 + 192 + mt := &mockT{name: "TestCallerDetection"} 193 + Snap(mt, "caller_test", "v1", "test content") 194 + 195 + // Read the created snapshot 196 + snap, err := files.ReadSnapshot("caller_test", "new") 197 + if err != nil { 198 + t.Fatalf("failed to read snapshot: %v", err) 199 + } 200 + 201 + // Should detect the caller filename (this test file) 202 + if snap.FileName == "unknown" { 203 + t.Error("expected caller filename to be detected") 204 + } 205 + 206 + // Should not be shutter.go 207 + if snap.FileName == "shutter.go" { 208 + t.Error("caller should not be shutter.go") 209 + } 210 + } 211 + 212 + func TestSnapWithTitle_CreatesCorrectSnapshot(t *testing.T) { 213 + setupTestDir(t) 214 + 215 + mt := &mockT{name: "TestExample"} 216 + SnapWithTitle(mt, "custom_title", "TestExample", "test.go", "v1", "custom content") 217 + 218 + // Read the snapshot 219 + snap, err := files.ReadSnapshot("custom_title", "new") 220 + if err != nil { 221 + t.Fatalf("failed to read snapshot: %v", err) 222 + } 223 + 224 + if snap.Title != "custom_title" { 225 + t.Errorf("expected title 'custom_title', got %q", snap.Title) 226 + } 227 + if snap.Test != "TestExample" { 228 + t.Errorf("expected test name 'TestExample', got %q", snap.Test) 229 + } 230 + if snap.FileName != "test.go" { 231 + t.Errorf("expected filename 'test.go', got %q", snap.FileName) 232 + } 233 + if snap.Version != "v1" { 234 + t.Errorf("expected version 'v1', got %q", snap.Version) 235 + } 236 + if snap.Content != "custom content" { 237 + t.Errorf("expected content 'custom content', got %q", snap.Content) 238 + } 239 + } 240 + 241 + func TestSnapWithTitle_MatchingContent(t *testing.T) { 242 + setupTestDir(t) 243 + 244 + // Create accepted snapshot 245 + accepted := &files.Snapshot{ 246 + Title: "match_title", 247 + Test: "TestMatch", 248 + FileName: "test.go", 249 + Content: "same content", 250 + Version: "v1", 251 + } 252 + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { 253 + t.Fatalf("failed to save accepted snapshot: %v", err) 254 + } 255 + 256 + mt := &mockT{name: "TestMatch"} 257 + SnapWithTitle(mt, "match_title", "TestMatch", "test.go", "v1", "same content") 258 + 259 + // Should not error (content matches) 260 + if len(mt.errors) != 0 { 261 + t.Errorf("expected no errors for matching content, got: %v", mt.errors) 262 + } 263 + } 264 + 265 + func TestSnapWithTitle_MismatchedContent(t *testing.T) { 266 + setupTestDir(t) 267 + 268 + // Create accepted snapshot 269 + accepted := &files.Snapshot{ 270 + Title: "mismatch_title", 271 + Test: "TestMismatch", 272 + FileName: "test.go", 273 + Content: "old content", 274 + Version: "v1", 275 + } 276 + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { 277 + t.Fatalf("failed to save accepted snapshot: %v", err) 278 + } 279 + 280 + mt := &mockT{name: "TestMismatch"} 281 + SnapWithTitle(mt, "mismatch_title", "TestMismatch", "test.go", "v1", "new content") 282 + 283 + // Should error about mismatch 284 + if len(mt.errors) != 1 { 285 + t.Errorf("expected 1 error, got %d", len(mt.errors)) 286 + } 287 + if !strings.Contains(mt.errors[0], "snapshot mismatch") { 288 + t.Errorf("expected mismatch error, got: %s", mt.errors[0]) 289 + } 290 + 291 + // Should create new snapshot file 292 + newSnap, err := files.ReadSnapshot("mismatch_title", "new") 293 + if err != nil { 294 + t.Fatalf("failed to read new snapshot: %v", err) 295 + } 296 + if newSnap.Content != "new content" { 297 + t.Errorf("expected new content in .snap.new file") 298 + } 299 + } 300 + 301 + func TestSnap_HelperCalled(t *testing.T) { 302 + setupTestDir(t) 303 + 304 + mt := &mockT{name: "TestHelper"} 305 + Snap(mt, "helper_test", "v1", "content") 306 + 307 + if !mt.helperCalled { 308 + t.Error("expected Helper() to be called") 309 + } 310 + } 311 + 312 + func TestSnapWithTitle_HelperCalled(t *testing.T) { 313 + setupTestDir(t) 314 + 315 + mt := &mockT{name: "TestHelper"} 316 + SnapWithTitle(mt, "helper_test", "TestHelper", "test.go", "v1", "content") 317 + 318 + if !mt.helperCalled { 319 + t.Error("expected Helper() to be called") 320 + } 321 + } 322 + 323 + func TestSnap_MultipleDifferentSnapshots(t *testing.T) { 324 + setupTestDir(t) 325 + 326 + mt := &mockT{name: "TestMultiple"} 327 + 328 + // Create multiple snapshots 329 + Snap(mt, "snap_one", "v1", "content one") 330 + Snap(mt, "snap_two", "v1", "content two") 331 + Snap(mt, "snap_three", "v1", "content three") 332 + 333 + // All should be created as new snapshots 334 + if len(mt.errors) != 3 { 335 + t.Errorf("expected 3 errors (new snapshots), got %d", len(mt.errors)) 336 + } 337 + 338 + // Verify all files exist 339 + for _, title := range []string{"snap_one", "snap_two", "snap_three"} { 340 + snapPath := filepath.Join("__snapshots__", title+".snap.new") 341 + if _, err := os.Stat(snapPath); os.IsNotExist(err) { 342 + t.Errorf("expected snapshot %s to exist", title) 343 + } 344 + } 345 + } 346 + 347 + func TestSnap_EmptyContent(t *testing.T) { 348 + setupTestDir(t) 349 + 350 + mt := &mockT{name: "TestEmpty"} 351 + Snap(mt, "empty_test", "v1", "") 352 + 353 + // Should create snapshot with empty content 354 + snap, err := files.ReadSnapshot("empty_test", "new") 355 + if err != nil { 356 + t.Fatalf("failed to read snapshot: %v", err) 357 + } 358 + if snap.Content != "" { 359 + t.Errorf("expected empty content, got %q", snap.Content) 360 + } 361 + } 362 + 363 + func TestSnap_MultilineContent(t *testing.T) { 364 + setupTestDir(t) 365 + 366 + content := `line one 367 + line two 368 + line three` 369 + 370 + mt := &mockT{name: "TestMultiline"} 371 + Snap(mt, "multiline_test", "v1", content) 372 + 373 + snap, err := files.ReadSnapshot("multiline_test", "new") 374 + if err != nil { 375 + t.Fatalf("failed to read snapshot: %v", err) 376 + } 377 + if snap.Content != content { 378 + t.Errorf("expected multiline content to match") 379 + } 380 + } 381 + 382 + func TestSnap_SpecialCharacters(t *testing.T) { 383 + setupTestDir(t) 384 + 385 + content := `{"key": "value with \"quotes\"", "emoji": "🎉"}` 386 + 387 + mt := &mockT{name: "TestSpecial"} 388 + Snap(mt, "special_test", "v1", content) 389 + 390 + snap, err := files.ReadSnapshot("special_test", "new") 391 + if err != nil { 392 + t.Fatalf("failed to read snapshot: %v", err) 393 + } 394 + if snap.Content != content { 395 + t.Errorf("expected special characters to be preserved") 396 + } 397 + } 398 + 399 + func TestSnap_VersionTracking(t *testing.T) { 400 + setupTestDir(t) 401 + 402 + mt := &mockT{name: "TestVersion"} 403 + Snap(mt, "version_test", "v2", "content") 404 + 405 + snap, err := files.ReadSnapshot("version_test", "new") 406 + if err != nil { 407 + t.Fatalf("failed to read snapshot: %v", err) 408 + } 409 + if snap.Version != "v2" { 410 + t.Errorf("expected version 'v2', got %q", snap.Version) 411 + } 412 + } 413 + 414 + func TestSnap_UpdateAcceptedSnapshot(t *testing.T) { 415 + setupTestDir(t) 416 + 417 + // Create initial accepted snapshot 418 + accepted := &files.Snapshot{ 419 + Title: "update_test", 420 + Test: "TestUpdate", 421 + FileName: "test.go", 422 + Content: "old version", 423 + Version: "v1", 424 + } 425 + if err := files.SaveSnapshot(accepted, "accepted"); err != nil { 426 + t.Fatalf("failed to save accepted snapshot: %v", err) 427 + } 428 + 429 + // Create new snapshot with different content 430 + mt := &mockT{name: "TestUpdate"} 431 + Snap(mt, "update_test", "v2", "new version") 432 + 433 + // Verify new snapshot was created 434 + newSnap, err := files.ReadSnapshot("update_test", "new") 435 + if err != nil { 436 + t.Fatalf("failed to read new snapshot: %v", err) 437 + } 438 + 439 + if newSnap.Content != "new version" { 440 + t.Errorf("expected new content") 441 + } 442 + if newSnap.Version != "v2" { 443 + t.Errorf("expected version to be updated") 444 + } 445 + 446 + // Accepted snapshot should remain unchanged 447 + acceptedSnap, err := files.ReadSnapshot("update_test", "snap") 448 + if err != nil { 449 + t.Fatalf("failed to read accepted snapshot: %v", err) 450 + } 451 + if acceptedSnap.Content != "old version" { 452 + t.Errorf("accepted snapshot should not change") 453 + } 454 + } 455 + 456 + func TestSnap_WithSpacesInTitle(t *testing.T) { 457 + setupTestDir(t) 458 + 459 + mt := &mockT{name: "TestSpaces"} 460 + Snap(mt, "test with spaces", "v1", "content") 461 + 462 + // Should normalize title to filename 463 + snap, err := files.ReadSnapshot("test with spaces", "new") 464 + if err != nil { 465 + t.Fatalf("failed to read snapshot: %v", err) 466 + } 467 + 468 + if snap.Title != "test with spaces" { 469 + t.Errorf("expected title to preserve spaces, got %q", snap.Title) 470 + } 471 + }
+521
internal/transform/transform_test.go
··· 1 + package transform 2 + 3 + import ( 4 + "regexp" 5 + "strings" 6 + "testing" 7 + ) 8 + 9 + // Mock implementations for testing 10 + 11 + type mockScrubber struct { 12 + fn func(string) string 13 + } 14 + 15 + func (m *mockScrubber) Scrub(content string) string { 16 + return m.fn(content) 17 + } 18 + 19 + type mockIgnorePattern struct { 20 + fn func(string, string) bool 21 + } 22 + 23 + func (m *mockIgnorePattern) ShouldIgnore(key, value string) bool { 24 + return m.fn(key, value) 25 + } 26 + 27 + // Tests for ApplyScrubbers 28 + 29 + func TestApplyScrubbers_NoScrubbers(t *testing.T) { 30 + input := "hello world" 31 + result := ApplyScrubbers(input, nil) 32 + 33 + if result != input { 34 + t.Errorf("expected %q, got %q", input, result) 35 + } 36 + } 37 + 38 + func TestApplyScrubbers_SingleScrubber(t *testing.T) { 39 + scrubber := &mockScrubber{ 40 + fn: func(s string) string { 41 + return strings.ReplaceAll(s, "secret", "<REDACTED>") 42 + }, 43 + } 44 + 45 + input := "my secret password" 46 + expected := "my <REDACTED> password" 47 + result := ApplyScrubbers(input, []Scrubber{scrubber}) 48 + 49 + if result != expected { 50 + t.Errorf("expected %q, got %q", expected, result) 51 + } 52 + } 53 + 54 + func TestApplyScrubbers_MultipleScrubbers(t *testing.T) { 55 + scrubber1 := &mockScrubber{ 56 + fn: func(s string) string { 57 + return strings.ReplaceAll(s, "foo", "FOO") 58 + }, 59 + } 60 + scrubber2 := &mockScrubber{ 61 + fn: func(s string) string { 62 + return strings.ReplaceAll(s, "bar", "BAR") 63 + }, 64 + } 65 + 66 + input := "foo and bar" 67 + expected := "FOO and BAR" 68 + result := ApplyScrubbers(input, []Scrubber{scrubber1, scrubber2}) 69 + 70 + if result != expected { 71 + t.Errorf("expected %q, got %q", expected, result) 72 + } 73 + } 74 + 75 + func TestApplyScrubbers_OrderMatters(t *testing.T) { 76 + // Test that scrubbers are applied in order 77 + scrubber1 := &mockScrubber{ 78 + fn: func(s string) string { 79 + return strings.ReplaceAll(s, "test", "TEST") 80 + }, 81 + } 82 + scrubber2 := &mockScrubber{ 83 + fn: func(s string) string { 84 + return strings.ReplaceAll(s, "TEST", "FINAL") 85 + }, 86 + } 87 + 88 + input := "test value" 89 + expected := "FINAL value" 90 + result := ApplyScrubbers(input, []Scrubber{scrubber1, scrubber2}) 91 + 92 + if result != expected { 93 + t.Errorf("expected %q, got %q", expected, result) 94 + } 95 + } 96 + 97 + // Tests for TransformJSON 98 + 99 + func TestTransformJSON_InvalidJSON(t *testing.T) { 100 + config := &Config{} 101 + _, err := TransformJSON("not valid json", config) 102 + 103 + if err == nil { 104 + t.Error("expected error for invalid JSON") 105 + } 106 + if err != nil && !strings.Contains(err.Error(), "failed to unmarshal JSON") { 107 + t.Errorf("unexpected error message: %v", err) 108 + } 109 + } 110 + 111 + func TestTransformJSON_EmptyConfig(t *testing.T) { 112 + config := &Config{} 113 + input := `{"name":"John","age":30}` 114 + 115 + result, err := TransformJSON(input, config) 116 + if err != nil { 117 + t.Fatalf("unexpected error: %v", err) 118 + } 119 + 120 + // Should return pretty-printed JSON 121 + expected := "{\n \"age\": 30,\n \"name\": \"John\"\n}" 122 + if result != expected { 123 + t.Errorf("expected:\n%s\ngot:\n%s", expected, result) 124 + } 125 + } 126 + 127 + func TestTransformJSON_WithScrubbers(t *testing.T) { 128 + scrubber := &mockScrubber{ 129 + fn: func(s string) string { 130 + return strings.ReplaceAll(s, "John", "<NAME>") 131 + }, 132 + } 133 + 134 + config := &Config{ 135 + Scrubbers: []Scrubber{scrubber}, 136 + } 137 + 138 + input := `{"name":"John","age":30}` 139 + result, err := TransformJSON(input, config) 140 + if err != nil { 141 + t.Fatalf("unexpected error: %v", err) 142 + } 143 + 144 + if !strings.Contains(result, "<NAME>") { 145 + t.Errorf("expected scrubber to be applied, got: %s", result) 146 + } 147 + if strings.Contains(result, "John") { 148 + t.Errorf("scrubber failed to replace 'John', got: %s", result) 149 + } 150 + } 151 + 152 + func TestTransformJSON_WithIgnorePatterns(t *testing.T) { 153 + ignorePattern := &mockIgnorePattern{ 154 + fn: func(key, value string) bool { 155 + return key == "password" 156 + }, 157 + } 158 + 159 + config := &Config{ 160 + Ignore: []IgnorePattern{ignorePattern}, 161 + } 162 + 163 + input := `{"username":"john","password":"secret"}` 164 + result, err := TransformJSON(input, config) 165 + if err != nil { 166 + t.Fatalf("unexpected error: %v", err) 167 + } 168 + 169 + if strings.Contains(result, "password") { 170 + t.Errorf("expected 'password' to be ignored, got: %s", result) 171 + } 172 + if !strings.Contains(result, "username") { 173 + t.Errorf("expected 'username' to be present, got: %s", result) 174 + } 175 + } 176 + 177 + func TestTransformJSON_ComplexNested(t *testing.T) { 178 + ignorePattern := &mockIgnorePattern{ 179 + fn: func(key, value string) bool { 180 + return key == "secret" 181 + }, 182 + } 183 + 184 + config := &Config{ 185 + Ignore: []IgnorePattern{ignorePattern}, 186 + } 187 + 188 + input := `{ 189 + "user": { 190 + "name": "John", 191 + "secret": "hidden", 192 + "nested": { 193 + "secret": "also_hidden", 194 + "public": "visible" 195 + } 196 + } 197 + }` 198 + 199 + result, err := TransformJSON(input, config) 200 + if err != nil { 201 + t.Fatalf("unexpected error: %v", err) 202 + } 203 + 204 + if strings.Contains(result, "hidden") || strings.Contains(result, "also_hidden") { 205 + t.Errorf("expected nested secrets to be ignored, got: %s", result) 206 + } 207 + if !strings.Contains(result, "visible") { 208 + t.Errorf("expected 'visible' to be present, got: %s", result) 209 + } 210 + } 211 + 212 + func TestTransformJSON_WithArrays(t *testing.T) { 213 + ignorePattern := &mockIgnorePattern{ 214 + fn: func(key, value string) bool { 215 + return key == "id" 216 + }, 217 + } 218 + 219 + config := &Config{ 220 + Ignore: []IgnorePattern{ignorePattern}, 221 + } 222 + 223 + input := `{ 224 + "users": [ 225 + {"id": 1, "name": "Alice"}, 226 + {"id": 2, "name": "Bob"} 227 + ] 228 + }` 229 + 230 + result, err := TransformJSON(input, config) 231 + if err != nil { 232 + t.Fatalf("unexpected error: %v", err) 233 + } 234 + 235 + // Should remove id fields from all array elements 236 + if strings.Contains(result, "\"id\"") { 237 + t.Errorf("expected 'id' fields to be ignored in arrays, got: %s", result) 238 + } 239 + if !strings.Contains(result, "Alice") || !strings.Contains(result, "Bob") { 240 + t.Errorf("expected names to be present, got: %s", result) 241 + } 242 + } 243 + 244 + func TestTransformJSON_ScrubbersAndIgnoreCombined(t *testing.T) { 245 + scrubber := &mockScrubber{ 246 + fn: func(s string) string { 247 + re := regexp.MustCompile(`\d{3}-\d{3}-\d{4}`) 248 + return re.ReplaceAllString(s, "<PHONE>") 249 + }, 250 + } 251 + 252 + ignorePattern := &mockIgnorePattern{ 253 + fn: func(key, value string) bool { 254 + return key == "ssn" 255 + }, 256 + } 257 + 258 + config := &Config{ 259 + Scrubbers: []Scrubber{scrubber}, 260 + Ignore: []IgnorePattern{ignorePattern}, 261 + } 262 + 263 + input := `{"name":"John","phone":"555-123-4567","ssn":"123-45-6789"}` 264 + result, err := TransformJSON(input, config) 265 + if err != nil { 266 + t.Fatalf("unexpected error: %v", err) 267 + } 268 + 269 + // SSN field should be completely removed 270 + if strings.Contains(result, "ssn") { 271 + t.Errorf("expected 'ssn' field to be ignored, got: %s", result) 272 + } 273 + 274 + // Phone should be scrubbed 275 + if !strings.Contains(result, "<PHONE>") { 276 + t.Errorf("expected phone to be scrubbed, got: %s", result) 277 + } 278 + if strings.Contains(result, "555-123-4567") { 279 + t.Errorf("expected phone number to be replaced, got: %s", result) 280 + } 281 + } 282 + 283 + // Tests for helper functions 284 + 285 + func TestValueToString_String(t *testing.T) { 286 + result := valueToString("hello") 287 + if result != "hello" { 288 + t.Errorf("expected 'hello', got %q", result) 289 + } 290 + } 291 + 292 + func TestValueToString_Nil(t *testing.T) { 293 + result := valueToString(nil) 294 + if result != "null" { 295 + t.Errorf("expected 'null', got %q", result) 296 + } 297 + } 298 + 299 + func TestValueToString_BoolTrue(t *testing.T) { 300 + result := valueToString(true) 301 + if result != "true" { 302 + t.Errorf("expected 'true', got %q", result) 303 + } 304 + } 305 + 306 + func TestValueToString_BoolFalse(t *testing.T) { 307 + result := valueToString(false) 308 + if result != "false" { 309 + t.Errorf("expected 'false', got %q", result) 310 + } 311 + } 312 + 313 + func TestValueToString_Float64(t *testing.T) { 314 + result := valueToString(42.5) 315 + if result != "42.5" { 316 + t.Errorf("expected '42.5', got %q", result) 317 + } 318 + } 319 + 320 + func TestValueToString_Int(t *testing.T) { 321 + result := valueToString(42) 322 + if result != "42" { 323 + t.Errorf("expected '42', got %q", result) 324 + } 325 + } 326 + 327 + func TestValueToString_ComplexType(t *testing.T) { 328 + // Map should be marshalled to JSON 329 + m := map[string]any{"key": "value"} 330 + result := valueToString(m) 331 + if result != `{"key":"value"}` { 332 + t.Errorf("expected JSON string, got %q", result) 333 + } 334 + } 335 + 336 + func TestFilterMap_RemovesMatchingKeys(t *testing.T) { 337 + ignorePattern := &mockIgnorePattern{ 338 + fn: func(key, value string) bool { 339 + return key == "remove_me" 340 + }, 341 + } 342 + 343 + input := map[string]any{ 344 + "keep": "value1", 345 + "remove_me": "value2", 346 + "also_keep": "value3", 347 + } 348 + 349 + result := filterMap(input, []IgnorePattern{ignorePattern}) 350 + 351 + if _, exists := result["remove_me"]; exists { 352 + t.Error("expected 'remove_me' to be filtered out") 353 + } 354 + if result["keep"] != "value1" { 355 + t.Error("expected 'keep' to remain") 356 + } 357 + if result["also_keep"] != "value3" { 358 + t.Error("expected 'also_keep' to remain") 359 + } 360 + } 361 + 362 + func TestFilterMap_NestedStructures(t *testing.T) { 363 + ignorePattern := &mockIgnorePattern{ 364 + fn: func(key, value string) bool { 365 + return key == "secret" 366 + }, 367 + } 368 + 369 + input := map[string]any{ 370 + "public": "data", 371 + "nested": map[string]any{ 372 + "secret": "hidden", 373 + "public": "visible", 374 + }, 375 + } 376 + 377 + result := filterMap(input, []IgnorePattern{ignorePattern}) 378 + 379 + nested, ok := result["nested"].(map[string]any) 380 + if !ok { 381 + t.Fatal("expected nested map") 382 + } 383 + 384 + if _, exists := nested["secret"]; exists { 385 + t.Error("expected nested 'secret' to be filtered out") 386 + } 387 + if nested["public"] != "visible" { 388 + t.Error("expected nested 'public' to remain") 389 + } 390 + } 391 + 392 + func TestFilterSlice_ProcessesAllElements(t *testing.T) { 393 + ignorePattern := &mockIgnorePattern{ 394 + fn: func(key, value string) bool { 395 + return key == "id" 396 + }, 397 + } 398 + 399 + input := []any{ 400 + map[string]any{"id": "1", "name": "Alice"}, 401 + map[string]any{"id": "2", "name": "Bob"}, 402 + } 403 + 404 + result := filterSlice(input, []IgnorePattern{ignorePattern}) 405 + 406 + if len(result) != 2 { 407 + t.Fatalf("expected 2 elements, got %d", len(result)) 408 + } 409 + 410 + for i, item := range result { 411 + m, ok := item.(map[string]any) 412 + if !ok { 413 + t.Fatalf("expected map at index %d", i) 414 + } 415 + if _, exists := m["id"]; exists { 416 + t.Errorf("expected 'id' to be filtered at index %d", i) 417 + } 418 + if m["name"] == "" { 419 + t.Errorf("expected 'name' to remain at index %d", i) 420 + } 421 + } 422 + } 423 + 424 + func TestWalkAndFilter_HandlesAllTypes(t *testing.T) { 425 + ignorePattern := &mockIgnorePattern{ 426 + fn: func(key, value string) bool { 427 + return false // Don't ignore anything 428 + }, 429 + } 430 + 431 + tests := []struct { 432 + name string 433 + input any 434 + expected any 435 + }{ 436 + { 437 + name: "string passthrough", 438 + input: "hello", 439 + expected: "hello", 440 + }, 441 + { 442 + name: "number passthrough", 443 + input: 42, 444 + expected: 42, 445 + }, 446 + { 447 + name: "bool passthrough", 448 + input: true, 449 + expected: true, 450 + }, 451 + { 452 + name: "nil passthrough", 453 + input: nil, 454 + expected: nil, 455 + }, 456 + } 457 + 458 + for _, tt := range tests { 459 + t.Run(tt.name, func(t *testing.T) { 460 + result := walkAndFilter(tt.input, []IgnorePattern{ignorePattern}) 461 + if result != tt.expected { 462 + t.Errorf("expected %v, got %v", tt.expected, result) 463 + } 464 + }) 465 + } 466 + } 467 + 468 + func TestTransformJSON_EmptyObject(t *testing.T) { 469 + config := &Config{} 470 + input := `{}` 471 + 472 + result, err := TransformJSON(input, config) 473 + if err != nil { 474 + t.Fatalf("unexpected error: %v", err) 475 + } 476 + 477 + expected := "{}" 478 + if result != expected { 479 + t.Errorf("expected %q, got %q", expected, result) 480 + } 481 + } 482 + 483 + func TestTransformJSON_EmptyArray(t *testing.T) { 484 + config := &Config{} 485 + input := `[]` 486 + 487 + result, err := TransformJSON(input, config) 488 + if err != nil { 489 + t.Fatalf("unexpected error: %v", err) 490 + } 491 + 492 + expected := "[]" 493 + if result != expected { 494 + t.Errorf("expected %q, got %q", expected, result) 495 + } 496 + } 497 + 498 + func TestTransformJSON_IgnoreByValue(t *testing.T) { 499 + ignorePattern := &mockIgnorePattern{ 500 + fn: func(key, value string) bool { 501 + return value == "ignore_this" 502 + }, 503 + } 504 + 505 + config := &Config{ 506 + Ignore: []IgnorePattern{ignorePattern}, 507 + } 508 + 509 + input := `{"field1":"keep","field2":"ignore_this","field3":"also_keep"}` 510 + result, err := TransformJSON(input, config) 511 + if err != nil { 512 + t.Fatalf("unexpected error: %v", err) 513 + } 514 + 515 + if strings.Contains(result, "field2") { 516 + t.Errorf("expected field with ignored value to be removed, got: %s", result) 517 + } 518 + if !strings.Contains(result, "field1") || !strings.Contains(result, "field3") { 519 + t.Errorf("expected other fields to remain, got: %s", result) 520 + } 521 + }
+93 -27
scrubbers.go
··· 11 11 replacement string 12 12 } 13 13 14 - func (r *regexScrubber) Apply(content string) string { 14 + func (r *regexScrubber) isOption() {} 15 + 16 + func (r *regexScrubber) Scrub(content string) string { 15 17 return r.pattern.ReplaceAllString(content, r.replacement) 16 18 } 17 19 18 - // RegexScrubber creates a scrubber that replaces all matches of the given 20 + // ScrubRegex creates a scrubber that replaces all matches of the given 19 21 // regex pattern with the replacement string. 20 - func RegexScrubber(pattern string, replacement string) SnapshotOption { 22 + // 23 + // Example: 24 + // 25 + // shutter.ScrubRegex(`user-\d+`, "<USER_ID>") 26 + func ScrubRegex(pattern string, replacement string) Scrubber { 21 27 re := regexp.MustCompile(pattern) 22 28 return &regexScrubber{ 23 29 pattern: re, ··· 31 37 replacement string 32 38 } 33 39 34 - func (e *exactMatchScrubber) Apply(content string) string { 40 + func (e *exactMatchScrubber) isOption() {} 41 + 42 + func (e *exactMatchScrubber) Scrub(content string) string { 35 43 return strings.ReplaceAll(content, e.match, e.replacement) 36 44 } 37 45 38 - // ExactMatchScrubber creates a scrubber that replaces exact string matches. 39 - func ExactMatchScrubber(match string, replacement string) SnapshotOption { 46 + // ScrubExact creates a scrubber that replaces exact string matches. 47 + // 48 + // Example: 49 + // 50 + // shutter.ScrubExact("secret_value", "<REDACTED>") 51 + func ScrubExact(match string, replacement string) Scrubber { 40 52 return &exactMatchScrubber{ 41 53 match: match, 42 54 replacement: replacement, ··· 51 63 // Unix timestamp pattern - matches 10-13 digit numbers (Unix timestamps in seconds or milliseconds) 52 64 // Note: This is aggressive and may match other numbers. Use with caution or customize. 53 65 unixTsPattern = regexp.MustCompile(`\b\d{10,13}\b`) 54 - // IPv4 pattern with basic range validation (not perfect, but better) 66 + // IPv4 pattern with basic range validation 55 67 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`) 56 68 // Credit card pattern - matches 16 digit numbers with optional separators 57 69 creditCardPattern = regexp.MustCompile(`\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b`) 58 70 jwtPattern = regexp.MustCompile(`eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*`) 71 + // Date patterns 72 + datePattern = regexp.MustCompile(`\b\d{4}[-/]\d{2}[-/]\d{2}\b|\b\d{2}[-/]\d{2}[-/]\d{4}\b`) 73 + // API key pattern - matches patterns like: sk_live_..., pk_test_..., api_key_... 74 + apiKeyPattern = regexp.MustCompile(`\b(sk|pk|api[_-]?key)[_-](live|test|prod|dev)[_-][a-zA-Z0-9]+\b`) 59 75 ) 60 76 61 - // ScrubUUIDs replaces all UUIDs with "<UUID>". 62 - func ScrubUUIDs() SnapshotOption { 77 + // ScrubUUID replaces all UUIDs with "<UUID>". 78 + // 79 + // Example: 80 + // 81 + // shutter.Snap(t, "user", user, shutter.ScrubUUID()) 82 + func ScrubUUID() Scrubber { 63 83 return &regexScrubber{ 64 84 pattern: uuidPattern, 65 85 replacement: "<UUID>", 66 86 } 67 87 } 68 88 69 - // ScrubTimestamps replaces ISO8601 timestamps with "<TIMESTAMP>". 70 - func ScrubTimestamps() SnapshotOption { 89 + // ScrubTimestamp replaces ISO8601 timestamps with "<TIMESTAMP>". 90 + // 91 + // Example: 92 + // 93 + // shutter.Snap(t, "event", event, shutter.ScrubTimestamp()) 94 + func ScrubTimestamp() Scrubber { 71 95 return &regexScrubber{ 72 96 pattern: iso8601Pattern, 73 97 replacement: "<TIMESTAMP>", 74 98 } 75 99 } 76 100 77 - // ScrubEmails replaces email addresses with "<EMAIL>". 78 - func ScrubEmails() SnapshotOption { 101 + // ScrubEmail replaces email addresses with "<EMAIL>". 102 + // 103 + // Example: 104 + // 105 + // shutter.Snap(t, "user", user, shutter.ScrubEmail()) 106 + func ScrubEmail() Scrubber { 79 107 return &regexScrubber{ 80 108 pattern: emailPattern, 81 109 replacement: "<EMAIL>", 82 110 } 83 111 } 84 112 85 - // ScrubUnixTimestamps replaces Unix timestamps (10-13 digits) with "<UNIX_TS>". 113 + // ScrubUnixTimestamp replaces Unix timestamps (10-13 digits) with "<UNIX_TS>". 86 114 // Note: This is aggressive and may match other long numbers. For more conservative 87 - // scrubbing with context keywords, use a custom regex. 88 - func ScrubUnixTimestamps() SnapshotOption { 115 + // scrubbing with context keywords, use ScrubRegex with a custom pattern. 116 + // 117 + // Example: 118 + // 119 + // shutter.Snap(t, "data", data, shutter.ScrubUnixTimestamp()) 120 + func ScrubUnixTimestamp() Scrubber { 89 121 return &regexScrubber{ 90 122 pattern: unixTsPattern, 91 123 replacement: "<UNIX_TS>", 92 124 } 93 125 } 94 126 95 - // ScrubIPAddresses replaces IPv4 addresses with "<IP>". 96 - func ScrubIPAddresses() SnapshotOption { 127 + // ScrubIP replaces IPv4 addresses with "<IP>". 128 + // 129 + // Example: 130 + // 131 + // shutter.Snap(t, "request", request, shutter.ScrubIP()) 132 + func ScrubIP() Scrubber { 97 133 return &regexScrubber{ 98 134 pattern: ipv4Pattern, 99 135 replacement: "<IP>", 100 136 } 101 137 } 102 138 103 - func ScrubCreditCards() SnapshotOption { 139 + // ScrubCreditCard replaces credit card numbers with "<CREDIT_CARD>". 140 + // 141 + // Example: 142 + // 143 + // shutter.Snap(t, "payment", payment, shutter.ScrubCreditCard()) 144 + func ScrubCreditCard() Scrubber { 104 145 return &regexScrubber{ 105 146 pattern: creditCardPattern, 106 147 replacement: "<CREDIT_CARD>", 107 148 } 108 149 } 109 150 110 - func ScrubJWTs() SnapshotOption { 151 + // ScrubJWT replaces JWT tokens with "<JWT>". 152 + // 153 + // Example: 154 + // 155 + // shutter.Snap(t, "auth", authData, shutter.ScrubJWT()) 156 + func ScrubJWT() Scrubber { 111 157 return &regexScrubber{ 112 158 pattern: jwtPattern, 113 159 replacement: "<JWT>", 114 160 } 115 161 } 116 162 117 - func ScrubDates() SnapshotOption { 118 - datePattern := regexp.MustCompile(`\b\d{4}[-/]\d{2}[-/]\d{2}\b|\b\d{2}[-/]\d{2}[-/]\d{4}\b`) 163 + // ScrubDate replaces various date formats with "<DATE>". 164 + // 165 + // Example: 166 + // 167 + // shutter.Snap(t, "data", data, shutter.ScrubDate()) 168 + func ScrubDate() Scrubber { 119 169 return &regexScrubber{ 120 170 pattern: datePattern, 121 171 replacement: "<DATE>", 122 172 } 123 173 } 124 174 125 - // ScrubAPIKeys replaces common API key patterns with "<API_KEY>". 175 + // ScrubAPIKey replaces common API key patterns with "<API_KEY>". 126 176 // Matches patterns like: sk_live_..., pk_test_..., api_key_... 127 - func ScrubAPIKeys() SnapshotOption { 128 - apiKeyPattern := regexp.MustCompile(`\b(sk|pk|api[_-]?key)[_-](live|test|prod|dev)[_-][a-zA-Z0-9]+\b`) 177 + // 178 + // Example: 179 + // 180 + // shutter.Snap(t, "config", config, shutter.ScrubAPIKey()) 181 + func ScrubAPIKey() Scrubber { 129 182 return &regexScrubber{ 130 183 pattern: apiKeyPattern, 131 184 replacement: "<API_KEY>", 132 185 } 133 186 } 134 187 188 + // customScrubber allows users to provide a custom scrubbing function. 135 189 type customScrubber struct { 136 190 scrubFunc func(string) string 137 191 } 138 192 139 - func (c *customScrubber) Apply(content string) string { 193 + func (c *customScrubber) isOption() {} 194 + 195 + func (c *customScrubber) Scrub(content string) string { 140 196 return c.scrubFunc(content) 141 197 } 142 198 143 - func CustomScrubber(scrubFunc func(string) string) SnapshotOption { 199 + // ScrubWith creates a scrubber using a custom function. 200 + // The function receives the snapshot content and should return the scrubbed content. 201 + // 202 + // Example: 203 + // 204 + // shutter.Snap(t, "data", data, 205 + // shutter.ScrubWith(func(content string) string { 206 + // return strings.ReplaceAll(content, "localhost", "<HOST>") 207 + // }), 208 + // ) 209 + func ScrubWith(scrubFunc func(string) string) Scrubber { 144 210 return &customScrubber{ 145 211 scrubFunc: scrubFunc, 146 212 }
+158 -138
scrubbers_test.go
··· 7 7 "github.com/ptdewey/shutter" 8 8 ) 9 9 10 - func TestScrubUUIDs(t *testing.T) { 10 + func TestBuiltInScrubbers(t *testing.T) { 11 + // Test all built-in scrubbers in one comprehensive test 11 12 jsonStr := `{ 12 13 "user_id": "550e8400-e29b-41d4-a716-446655440000", 13 14 "session_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", 14 - "name": "John Doe" 15 - }` 16 - 17 - shutter.SnapJSON(t, "Scrubbed UUIDs", jsonStr, 18 - shutter.ScrubUUIDs(), 19 - ) 20 - } 21 - 22 - func TestScrubTimestamps(t *testing.T) { 23 - jsonStr := `{ 24 - "created_at": "2023-01-15T10:30:00Z", 25 - "updated_at": "2023-11-20T15:45:30.123Z", 26 - "deleted_at": "2023-12-01T08:00:00+05:00", 27 - "name": "Test Event" 28 - }` 29 - 30 - shutter.SnapJSON(t, "Scrubbed Timestamps", jsonStr, 31 - shutter.ScrubTimestamps(), 32 - ) 33 - } 34 - 35 - func TestScrubEmails(t *testing.T) { 36 - jsonStr := `{ 37 15 "email": "user@example.com", 38 16 "backup_email": "backup.user+tag@subdomain.example.co.uk", 39 - "name": "John Doe" 40 - }` 41 - 42 - shutter.SnapJSON(t, "Scrubbed Emails", jsonStr, 43 - shutter.ScrubEmails(), 44 - ) 45 - } 46 - 47 - func TestScrubIPAddresses(t *testing.T) { 48 - jsonStr := `{ 17 + "created_at": "2023-01-15T10:30:00Z", 18 + "updated_at": "2023-11-20T15:45:30.123Z", 19 + "birth_date": "1990-05-15", 20 + "us_format_date": "12/25/2023", 21 + "unix_created": 1699999999, 22 + "unix_updated": 1700000000000, 49 23 "client_ip": "192.168.1.1", 50 24 "server_ip": "10.0.0.5", 25 + "jwt_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 26 + "stripe_key": "sk_live_51HqZ2bKl4FGBMFpLxO0123", 27 + "api_key": "api_key_prod_abc123def456", 28 + "card_number": "4532-1234-5678-9010", 29 + "backup_card": "4532 1234 5678 9010", 30 + "name": "John Doe", 51 31 "message": "Connection from 172.16.0.100" 52 32 }` 53 33 54 - shutter.SnapJSON(t, "Scrubbed IPs", jsonStr, 55 - shutter.ScrubIPAddresses(), 56 - ) 57 - } 58 - 59 - func TestScrubJWTs(t *testing.T) { 60 - jsonStr := `{ 61 - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 62 - "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" 63 - }` 64 - 65 - shutter.SnapJSON(t, "Scrubbed JWTs", jsonStr, 66 - shutter.ScrubJWTs(), 67 - ) 68 - } 69 - 70 - func TestMultipleScrubbers(t *testing.T) { 71 - jsonStr := `{ 72 - "user_id": "550e8400-e29b-41d4-a716-446655440000", 73 - "email": "user@example.com", 74 - "created_at": "2023-01-15T10:30:00Z", 75 - "ip_address": "192.168.1.1", 76 - "name": "John Doe" 77 - }` 78 - 79 34 shutter.SnapJSON(t, "Multiple Scrubbers", jsonStr, 80 - shutter.ScrubUUIDs(), 81 - shutter.ScrubEmails(), 82 - shutter.ScrubTimestamps(), 83 - shutter.ScrubIPAddresses(), 35 + shutter.ScrubUUID(), 36 + shutter.ScrubEmail(), 37 + shutter.ScrubTimestamp(), 38 + shutter.ScrubDate(), 39 + shutter.ScrubUnixTimestamp(), 40 + shutter.ScrubIP(), 41 + shutter.ScrubJWT(), 42 + shutter.ScrubAPIKey(), 43 + shutter.ScrubCreditCard(), 84 44 ) 85 45 } 86 46 87 - func TestRegexScrubber(t *testing.T) { 88 - jsonStr := `{ 89 - "api_key": "sk_live_abc123def456", 90 - "secret_key": "sk_test_xyz789uvw012", 91 - "name": "Test User" 92 - }` 93 - 94 - shutter.SnapJSON(t, "Custom Regex Scrubber", jsonStr, 95 - shutter.RegexScrubber(`sk_(live|test)_[a-zA-Z0-9]+`, "<API_KEY>"), 96 - ) 97 - } 98 - 99 - func TestExactMatchScrubber(t *testing.T) { 100 - content := "The secret password is 'p@ssw0rd123' and should be hidden." 47 + func TestIndividualScrubbers(t *testing.T) { 48 + tests := []struct { 49 + name string 50 + json string 51 + scrubber shutter.Option 52 + title string 53 + }{ 54 + { 55 + name: "uuid", 56 + json: `{ 57 + "user_id": "550e8400-e29b-41d4-a716-446655440000", 58 + "session_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", 59 + "name": "John Doe" 60 + }`, 61 + scrubber: shutter.ScrubUUID(), 62 + title: "Scrubbed UUIDs", 63 + }, 64 + { 65 + name: "timestamps", 66 + json: `{ 67 + "created_at": "2023-01-15T10:30:00Z", 68 + "updated_at": "2023-11-20T15:45:30.123Z", 69 + "deleted_at": "2023-12-01T08:00:00+05:00", 70 + "name": "Test Event" 71 + }`, 72 + scrubber: shutter.ScrubTimestamp(), 73 + title: "Scrubbed Timestamps", 74 + }, 75 + { 76 + name: "emails", 77 + json: `{ 78 + "email": "user@example.com", 79 + "backup_email": "backup.user+tag@subdomain.example.co.uk", 80 + "name": "John Doe" 81 + }`, 82 + scrubber: shutter.ScrubEmail(), 83 + title: "Scrubbed Emails", 84 + }, 85 + { 86 + name: "ip_addresses", 87 + json: `{ 88 + "client_ip": "192.168.1.1", 89 + "server_ip": "10.0.0.5", 90 + "message": "Connection from 172.16.0.100" 91 + }`, 92 + scrubber: shutter.ScrubIP(), 93 + title: "Scrubbed IPs", 94 + }, 95 + { 96 + name: "jwts", 97 + json: `{ 98 + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 99 + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" 100 + }`, 101 + scrubber: shutter.ScrubJWT(), 102 + title: "Scrubbed JWTs", 103 + }, 104 + { 105 + name: "dates", 106 + json: `{ 107 + "birth_date": "1990-05-15", 108 + "hire_date": "2020-01-01", 109 + "us_format": "12/25/2023", 110 + "name": "John Doe" 111 + }`, 112 + scrubber: shutter.ScrubDate(), 113 + title: "Scrubbed Dates", 114 + }, 115 + { 116 + name: "api_keys", 117 + json: `{ 118 + "stripe_key": "sk_live_51HqZ2bKl4FGBMFpLxO0123", 119 + "test_key": "pk_test_51HqZ2bKl4FGBMFpLxO0456", 120 + "api_key_prod": "api_key_prod_abc123def456", 121 + "name": "Test Config" 122 + }`, 123 + scrubber: shutter.ScrubAPIKey(), 124 + title: "Scrubbed API Keys", 125 + }, 126 + { 127 + name: "credit_cards", 128 + json: `{ 129 + "card_number": "4532-1234-5678-9010", 130 + "backup_card": "4532 1234 5678 9010", 131 + "another_card": "4532123456789010", 132 + "name": "John Doe" 133 + }`, 134 + scrubber: shutter.ScrubCreditCard(), 135 + title: "Scrubbed Credit Cards", 136 + }, 137 + { 138 + name: "unix_timestamps", 139 + json: `{ 140 + "created": 1699999999, 141 + "updated": 1700000000000, 142 + "deleted": 1700000000, 143 + "name": "Test Event" 144 + }`, 145 + scrubber: shutter.ScrubUnixTimestamp(), 146 + title: "Scrubbed Unix Timestamps", 147 + }, 148 + } 101 149 102 - shutter.SnapString(t, "Exact Match Scrubber", content, 103 - shutter.ExactMatchScrubber("p@ssw0rd123", "<PASSWORD>"), 104 - ) 150 + for _, tt := range tests { 151 + t.Run(tt.name, func(t *testing.T) { 152 + shutter.SnapJSON(t, tt.title, tt.json, tt.scrubber) 153 + }) 154 + } 105 155 } 106 156 107 - func TestCustomScrubber(t *testing.T) { 108 - content := "Hello World! This is a TEST." 157 + func TestCustomScrubbers(t *testing.T) { 158 + t.Run("regex_scrubber", func(t *testing.T) { 159 + jsonStr := `{ 160 + "api_key": "sk_live_abc123def456", 161 + "secret_key": "sk_test_xyz789uvw012", 162 + "name": "Test User" 163 + }` 109 164 110 - shutter.SnapString(t, "Custom Scrubber", content, 111 - shutter.CustomScrubber(func(s string) string { 112 - return strings.ToLower(s) 113 - }), 114 - ) 115 - } 165 + shutter.SnapJSON(t, "Custom Regex Scrubber", jsonStr, 166 + shutter.ScrubRegex(`sk_(live|test)_[a-zA-Z0-9]+`, "<API_KEY>"), 167 + ) 168 + }) 116 169 117 - func TestScrubDates(t *testing.T) { 118 - jsonStr := `{ 119 - "birth_date": "1990-05-15", 120 - "hire_date": "2020-01-01", 121 - "us_format": "12/25/2023", 122 - "name": "John Doe" 123 - }` 170 + t.Run("exact_match_scrubber", func(t *testing.T) { 171 + content := "The secret password is 'p@ssw0rd123' and should be hidden." 124 172 125 - shutter.SnapJSON(t, "Scrubbed Dates", jsonStr, 126 - shutter.ScrubDates(), 127 - ) 128 - } 173 + shutter.SnapString(t, "Exact Match Scrubber", content, 174 + shutter.ScrubExact("p@ssw0rd123", "<PASSWORD>"), 175 + ) 176 + }) 129 177 130 - func TestScrubAPIKeys(t *testing.T) { 131 - jsonStr := `{ 132 - "stripe_key": "sk_live_51HqZ2bKl4FGBMFpLxO0123", 133 - "test_key": "pk_test_51HqZ2bKl4FGBMFpLxO0456", 134 - "api_key_prod": "api_key_prod_abc123def456", 135 - "name": "Test Config" 136 - }` 178 + t.Run("custom_function_scrubber", func(t *testing.T) { 179 + content := "Hello World! This is a TEST." 137 180 138 - shutter.SnapJSON(t, "Scrubbed API Keys", jsonStr, 139 - shutter.ScrubAPIKeys(), 140 - ) 181 + shutter.SnapString(t, "Custom Scrubber", content, 182 + shutter.ScrubWith(func(s string) string { 183 + return strings.ToLower(s) 184 + }), 185 + ) 186 + }) 141 187 } 142 188 143 189 func TestScrubWithSnapFunction(t *testing.T) { ··· 149 195 } 150 196 151 197 shutter.Snap(t, "Scrub With Snap", data, 152 - shutter.ScrubUUIDs(), 153 - shutter.ScrubEmails(), 154 - shutter.ScrubTimestamps(), 155 - ) 156 - } 157 - 158 - func TestCreditCardScrubbing(t *testing.T) { 159 - jsonStr := `{ 160 - "card_number": "4532-1234-5678-9010", 161 - "backup_card": "4532 1234 5678 9010", 162 - "another_card": "4532123456789010", 163 - "name": "John Doe" 164 - }` 165 - 166 - shutter.SnapJSON(t, "Scrubbed Credit Cards", jsonStr, 167 - shutter.ScrubCreditCards(), 168 - ) 169 - } 170 - 171 - func TestUnixTimestampScrubbing(t *testing.T) { 172 - jsonStr := `{ 173 - "created": 1699999999, 174 - "updated": 1700000000000, 175 - "deleted": 1700000000, 176 - "name": "Test Event" 177 - }` 178 - 179 - shutter.SnapJSON(t, "Scrubbed Unix Timestamps", jsonStr, 180 - shutter.ScrubUnixTimestamps(), 198 + shutter.ScrubUUID(), 199 + shutter.ScrubEmail(), 200 + shutter.ScrubTimestamp(), 181 201 ) 182 202 }
+165 -62
shutter.go
··· 1 1 package shutter 2 2 3 3 import ( 4 + "fmt" 5 + 4 6 "github.com/kortschak/utter" 5 7 "github.com/ptdewey/shutter/internal/review" 6 8 "github.com/ptdewey/shutter/internal/snapshots" 7 9 "github.com/ptdewey/shutter/internal/transform" 8 10 ) 9 11 10 - const version = "0.1.0" 12 + // snapshotFormatVersion indicates the snapshot format version used by this library. 13 + // This is automatically included in snapshot metadata for compatibility checking 14 + // when the snapshot format changes in future versions. 15 + const snapshotFormatVersion = "0.1.0" 16 + 17 + // utterConfig is a configured instance of utter for consistent formatting. 18 + // This avoids modifying global state and ensures snapshot formatting is isolated. 19 + var utterConfig = &utter.ConfigState{ 20 + Indent: " ", 21 + ElideType: true, 22 + SortKeys: true, 23 + } 24 + 25 + // Option is a marker interface for all snapshot options. 26 + // This allows compile-time type safety while supporting different option types. 27 + type Option interface { 28 + isOption() 29 + } 11 30 12 - func init() { 13 - utter.Config.ElideType = true 14 - utter.Config.SortKeys = true 31 + // Scrubber transforms content before snapshotting, typically to replace 32 + // dynamic or sensitive data with stable placeholders. 33 + // 34 + // Scrubbers are applied in the order they are provided. Later scrubbers 35 + // can transform the output of earlier scrubbers. 36 + // 37 + // Example: 38 + // 39 + // shutter.Snap(t, "user data", user, 40 + // shutter.ScrubUUID(), // First: UUIDs -> <UUID> 41 + // shutter.ScrubEmail(), // Second: emails -> <EMAIL> 42 + // ) 43 + type Scrubber interface { 44 + Option 45 + Scrub(content string) string 15 46 } 16 47 17 - // Snap takes any values, formats them, and creates a snapshot with the given title. 18 - // For complex types, values are formatted using a pretty-printer. 19 - // The last parameters can be SnapshotOptions to apply scrubbers before snapshotting. 48 + // IgnorePattern determines whether a key-value pair should be excluded 49 + // from JSON snapshots. This is useful for removing fields that change 50 + // frequently or contain sensitive data. 20 51 // 21 - // shutter.Snap(t, "title", any(value1), any(value2), shutter.ScrubUUIDs()) 52 + // IgnorePatterns only work with SnapJSON. Using them with Snap or SnapString 53 + // will result in an error. 54 + type IgnorePattern interface { 55 + Option 56 + ShouldIgnore(key, value string) bool 57 + } 58 + 59 + // Snap takes a single value, formats it, and creates a snapshot with the given title. 60 + // Complex types are formatted using a pretty-printer for readability. 22 61 // 23 - // REFACTOR: should this take in _one_ value, and then allow options as additional inputs? 24 - func Snap(t snapshots.T, title string, values ...any) { 62 + // Options can be provided to scrub sensitive or dynamic data before snapshotting. 63 + // Only Scrubber options are supported; IgnorePattern options will cause an error. 64 + // 65 + // Example: 66 + // 67 + // user := User{ID: "123", Email: "user@example.com"} 68 + // shutter.Snap(t, "user data", user, 69 + // shutter.ScrubUUID(), 70 + // shutter.ScrubEmail(), 71 + // ) 72 + func Snap(t snapshots.T, title string, value any, opts ...Option) { 25 73 t.Helper() 26 74 27 - // Separate options from values 28 - var opts []SnapshotOption 29 - var actualValues []any 75 + scrubbers, ignores := separateOptions(opts) 30 76 31 - for _, v := range values { 32 - if opt, ok := v.(SnapshotOption); ok { 33 - opts = append(opts, opt) 34 - } else { 35 - actualValues = append(actualValues, v) 36 - } 77 + if len(ignores) > 0 { 78 + t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with Snap; use SnapJSON instead", title)) 79 + return 37 80 } 38 81 39 - content := snapshots.FormatValues(actualValues...) 82 + content := formatValue(value) 83 + scrubbedContent := applyScrubbers(content, scrubbers) 84 + 85 + snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent) 86 + } 40 87 41 - // Apply scrubber options directly to the formatted content 42 - scrubbers, _ := extractOptions(opts) 43 - scrubbedContent := applyOptions(content, scrubbers) 88 + // SnapMany takes multiple values, formats them, and creates a snapshot with the given title. 89 + // This is useful when you want to snapshot multiple related values together. 90 + // 91 + // Options can be provided to scrub sensitive or dynamic data before snapshotting. 92 + // Only Scrubber options are supported; IgnorePattern options will cause an error. 93 + // 94 + // Example: 95 + // 96 + // shutter.SnapMany(t, "request and response", 97 + // []any{request, response}, 98 + // shutter.ScrubUUID(), 99 + // shutter.ScrubTimestamp(), 100 + // ) 101 + func SnapMany(t snapshots.T, title string, values []any, opts ...Option) { 102 + t.Helper() 103 + 104 + scrubbers, ignores := separateOptions(opts) 105 + 106 + if len(ignores) > 0 { 107 + t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with SnapMany; use SnapJSON instead", title)) 108 + return 109 + } 110 + 111 + content := formatValues(values...) 112 + scrubbedContent := applyScrubbers(content, scrubbers) 44 113 45 - snapshots.Snap(t, title, version, scrubbedContent) 114 + snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent) 46 115 } 47 116 48 117 // SnapString takes a string value and creates a snapshot with the given title. 49 - // Options can be provided to apply scrubbers before snapshotting. 50 - func SnapString(t snapshots.T, title string, content string, opts ...SnapshotOption) { 118 + // This is useful for snapshotting generated text, logs, or other string content. 119 + // 120 + // Options can be provided to scrub sensitive or dynamic data before snapshotting. 121 + // Only Scrubber options are supported; IgnorePattern options will cause an error. 122 + // 123 + // Example: 124 + // 125 + // output := generateReport() 126 + // shutter.SnapString(t, "report output", output, 127 + // shutter.ScrubTimestamp(), 128 + // ) 129 + func SnapString(t snapshots.T, title string, content string, opts ...Option) { 51 130 t.Helper() 52 131 53 - // Apply scrubber options directly to the content 54 - scrubbers, _ := extractOptions(opts) 55 - scrubbedContent := applyOptions(content, scrubbers) 132 + scrubbers, ignores := separateOptions(opts) 56 133 57 - snapshots.Snap(t, title, version, scrubbedContent) 134 + if len(ignores) > 0 { 135 + t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with SnapString; use SnapJSON instead", title)) 136 + return 137 + } 138 + 139 + scrubbedContent := applyScrubbers(content, scrubbers) 140 + 141 + snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent) 58 142 } 59 143 60 144 // SnapJSON takes a JSON string, validates it, and pretty-prints it with 61 145 // consistent formatting before snapshotting. This preserves the raw JSON 62 146 // format while ensuring valid JSON structure. 63 - // Options can be provided to apply scrubbers and ignore patterns. 64 - func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...SnapshotOption) { 147 + // 148 + // Options can be provided to apply both Scrubbers and IgnorePatterns. 149 + // IgnorePatterns remove fields from the JSON structure before scrubbing. 150 + // Scrubbers then transform the remaining content. 151 + // 152 + // Example: 153 + // 154 + // jsonStr := `{"id": "550e8400-...", "email": "user@example.com", "password": "secret"}` 155 + // shutter.SnapJSON(t, "user response", jsonStr, 156 + // shutter.IgnoreKey("password"), // First: remove password field 157 + // shutter.ScrubUUID(), // Second: scrub remaining UUIDs 158 + // shutter.ScrubEmail(), // Third: scrub emails 159 + // ) 160 + func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...Option) { 65 161 t.Helper() 66 162 67 - scrubbers, ignores := extractOptions(opts) 163 + scrubbers, ignores := separateOptions(opts) 68 164 69 165 // Transform the JSON with ignore patterns and scrubbers 70 166 transformConfig := &transform.Config{ ··· 74 170 75 171 transformedJSON, err := transform.TransformJSON(jsonStr, transformConfig) 76 172 if err != nil { 77 - t.Error("failed to transform JSON:", err) 173 + t.Error(fmt.Sprintf("snapshot %q: failed to transform JSON: %v", title, err)) 78 174 return 79 175 } 80 176 81 - snapshots.Snap(t, title, version, transformedJSON) 177 + snapshots.Snap(t, title, snapshotFormatVersion, transformedJSON) 82 178 } 83 179 84 180 // Review launches an interactive review session to accept or reject snapshot changes. ··· 96 192 return review.RejectAll() 97 193 } 98 194 99 - // SnapshotOption represents a transformation that can be applied to snapshot content. 100 - // Options are applied in the order they are provided. 101 - type SnapshotOption interface { 102 - Apply(content string) string 195 + // formatValue formats a single value using the configured utter instance. 196 + func formatValue(v any) string { 197 + return utterConfig.Sdump(v) 103 198 } 104 199 105 - // IgnoreOption represents a pattern for ignoring key-value pairs in JSON structures. 106 - type IgnoreOption interface { 107 - ShouldIgnore(key, value string) bool 200 + // formatValues formats multiple values using the configured utter instance. 201 + func formatValues(values ...any) string { 202 + var result string 203 + for _, v := range values { 204 + result += formatValue(v) 205 + } 206 + return result 108 207 } 109 208 110 - // extractOptions separates scrubbers and ignore patterns from options. 111 - func extractOptions(opts []SnapshotOption) (scrubbers []SnapshotOption, ignores []IgnoreOption) { 209 + // separateOptions splits options into scrubbers and ignore patterns. 210 + func separateOptions(opts []Option) (scrubbers []Scrubber, ignores []IgnorePattern) { 112 211 for _, opt := range opts { 113 - if ignore, ok := opt.(IgnoreOption); ok { 114 - ignores = append(ignores, ignore) 115 - } else { 116 - scrubbers = append(scrubbers, opt) 212 + switch o := opt.(type) { 213 + case IgnorePattern: 214 + ignores = append(ignores, o) 215 + case Scrubber: 216 + scrubbers = append(scrubbers, o) 217 + default: 218 + // This shouldn't happen if Option interface is properly implemented 219 + panic(fmt.Sprintf("unknown option type: %T", opt)) 117 220 } 118 221 } 119 222 return scrubbers, ignores 120 223 } 121 224 122 - // applyOptions applies all scrubber options to content in sequence. 123 - func applyOptions(content string, opts []SnapshotOption) string { 124 - for _, opt := range opts { 125 - content = opt.Apply(content) 225 + // applyScrubbers applies all scrubbers to content in sequence. 226 + func applyScrubbers(content string, scrubbers []Scrubber) string { 227 + for _, scrubber := range scrubbers { 228 + content = scrubber.Scrub(content) 126 229 } 127 230 return content 128 231 } 129 232 130 - // scrubberAdapter adapts a SnapshotOption to the transform.Scrubber interface. 233 + // scrubberAdapter adapts a Scrubber to the transform.Scrubber interface. 131 234 type scrubberAdapter struct { 132 - opt SnapshotOption 235 + scrubber Scrubber 133 236 } 134 237 135 238 func (s *scrubberAdapter) Scrub(content string) string { 136 - return s.opt.Apply(content) 239 + return s.scrubber.Scrub(content) 137 240 } 138 241 139 - func toTransformScrubbers(opts []SnapshotOption) []transform.Scrubber { 140 - result := make([]transform.Scrubber, len(opts)) 141 - for i, opt := range opts { 142 - result[i] = &scrubberAdapter{opt: opt} 242 + func toTransformScrubbers(scrubbers []Scrubber) []transform.Scrubber { 243 + result := make([]transform.Scrubber, len(scrubbers)) 244 + for i, scrubber := range scrubbers { 245 + result[i] = &scrubberAdapter{scrubber: scrubber} 143 246 } 144 247 return result 145 248 } 146 249 147 - // ignoreAdapter adapts an IgnoreOption to the transform.IgnorePattern interface. 250 + // ignoreAdapter adapts an IgnorePattern to the transform.IgnorePattern interface. 148 251 type ignoreAdapter struct { 149 - ignore IgnoreOption 252 + ignore IgnorePattern 150 253 } 151 254 152 255 func (i *ignoreAdapter) ShouldIgnore(key, value string) bool { 153 256 return i.ignore.ShouldIgnore(key, value) 154 257 } 155 258 156 - func toTransformIgnorePatterns(ignores []IgnoreOption) []transform.IgnorePattern { 259 + func toTransformIgnorePatterns(ignores []IgnorePattern) []transform.IgnorePattern { 157 260 result := make([]transform.IgnorePattern, len(ignores)) 158 261 for i, ignore := range ignores { 159 262 result[i] = &ignoreAdapter{ignore: ignore}
+3 -501
shutter_test.go
··· 12 12 "github.com/ptdewey/shutter" 13 13 ) 14 14 15 - func TestSnapString(t *testing.T) { 16 - shutter.SnapString(t, "Simple String Test", "hello world") 17 - } 18 - 19 15 func TestSnapMultiple(t *testing.T) { 20 - shutter.Snap(t, "Multiple Values Test", "value1", "value2", 42, "foo", "bar", "baz", "wibble", "wobble", "tock", nil) 16 + shutter.SnapMany(t, "Multiple Values Test", []any{"value1", "value2", 42, "foo", "bar", "baz", "wibble", "wobble", "tock", nil}) 21 17 } 22 18 23 19 type CustomStruct struct { ··· 35 31 Age: 30, 36 32 } 37 33 shutter.Snap(t, "Custom Type Test", cs) 38 - } 39 - 40 - func TestMap(t *testing.T) { 41 - shutter.Snap(t, "Map Test", map[string]any{ 42 - "foo": "bar", 43 - "wibble": "wobble", 44 - }) 45 34 } 46 35 47 36 func contains(s, substr string) bool { ··· 372 361 } 373 362 374 363 // ============================================================================ 375 - // JSON OBJECT TESTS 364 + // JSON TESTS - Focus on edge cases and special handling 376 365 // ============================================================================ 377 366 378 - func TestJsonObject(t *testing.T) { 379 - jsonStr := `{ 380 - "user": { 381 - "id": 1, 382 - "username": "john_doe", 383 - "email": "john@example.com", 384 - "profile": { 385 - "first_name": "John", 386 - "last_name": "Doe", 387 - "bio": "Software engineer", 388 - "verified": true 389 - }, 390 - "roles": ["user", "admin"], 391 - "created_at": "2023-01-15T10:30:00Z" 392 - }, 393 - "status": "success", 394 - "message": null 395 - }` 396 - 397 - var data any 398 - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { 399 - t.Fatalf("failed to unmarshal json: %v", err) 400 - } 401 - 402 - shutter.Snap(t, "JSON Object", data) 403 - } 404 - 405 - func TestComplexJsonStructure(t *testing.T) { 406 - jsonStr := `{ 407 - "api": { 408 - "version": "2.0", 409 - "endpoints": [ 410 - { 411 - "path": "/users", 412 - "method": "GET", 413 - "auth_required": true, 414 - "rate_limit": { 415 - "requests": 100, 416 - "window": "1m" 417 - }, 418 - "responses": { 419 - "200": { 420 - "description": "Success", 421 - "schema": { 422 - "type": "array", 423 - "items": { 424 - "type": "object", 425 - "properties": { 426 - "id": {"type": "integer"}, 427 - "name": {"type": "string"} 428 - } 429 - } 430 - } 431 - }, 432 - "401": { 433 - "description": "Unauthorized" 434 - } 435 - } 436 - }, 437 - { 438 - "path": "/users/{id}", 439 - "method": "POST", 440 - "auth_required": true, 441 - "rate_limit": { 442 - "requests": 50, 443 - "window": "1m" 444 - } 445 - } 446 - ], 447 - "models": { 448 - "User": { 449 - "properties": { 450 - "id": {"type": "integer"}, 451 - "username": {"type": "string"}, 452 - "email": {"type": "string", "format": "email"}, 453 - "created_at": {"type": "string", "format": "date-time"}, 454 - "roles": { 455 - "type": "array", 456 - "items": {"type": "string"} 457 - } 458 - }, 459 - "required": ["id", "username", "email"] 460 - } 461 - } 462 - } 463 - }` 464 - 465 - var data any 466 - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { 467 - t.Fatalf("failed to unmarshal json: %v", err) 468 - } 469 - 470 - shutter.Snap(t, "Complex JSON Structure", data) 471 - } 472 - 473 - func TestJsonArrayOfObjects(t *testing.T) { 474 - jsonStr := `[ 475 - { 476 - "type": "user", 477 - "id": 1, 478 - "data": { 479 - "name": "Alice", 480 - "role": "admin" 481 - } 482 - }, 483 - { 484 - "type": "post", 485 - "id": 100, 486 - "data": { 487 - "title": "First Post", 488 - "author_id": 1, 489 - "likes": 42 490 - } 491 - }, 492 - { 493 - "type": "comment", 494 - "id": 500, 495 - "data": { 496 - "content": "Great post!", 497 - "author_id": 2, 498 - "post_id": 100 499 - } 500 - } 501 - ]` 502 - 503 - var data any 504 - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { 505 - t.Fatalf("failed to unmarshal json: %v", err) 506 - } 507 - 508 - shutter.Snap(t, "JSON Array of Objects", data) 509 - } 510 - 511 - func TestJsonWithVariousTypes(t *testing.T) { 512 - jsonStr := `{ 513 - "string": "hello world", 514 - "integer": 42, 515 - "float": 3.14159, 516 - "boolean_true": true, 517 - "boolean_false": false, 518 - "null_value": null, 519 - "array": [1, 2, 3, "four", 5.5], 520 - "object": { 521 - "nested": "value", 522 - "count": 10 523 - }, 524 - "empty_array": [], 525 - "empty_object": {}, 526 - "escaped_string": "line1\nline2\ttab" 527 - }` 528 - 529 - var data any 530 - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { 531 - t.Fatalf("failed to unmarshal json: %v", err) 532 - } 533 - 534 - shutter.Snap(t, "JSON with Various Types", data) 535 - } 536 - 537 - func TestJsonNumbers(t *testing.T) { 538 - jsonStr := `{ 539 - "integers": { 540 - "zero": 0, 541 - "positive": 42, 542 - "negative": -100, 543 - "large": 9999999999999 544 - }, 545 - "floats": { 546 - "small": 0.0001, 547 - "pi": 3.14159265359, 548 - "scientific": 1.23e-4, 549 - "negative_float": -42.5 550 - }, 551 - "edge_cases": { 552 - "one": 1, 553 - "minus_one": -1 554 - } 555 - }` 556 - 557 - var data any 558 - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { 559 - t.Fatalf("failed to unmarshal json: %v", err) 560 - } 561 - 562 - shutter.Snap(t, "JSON Numbers", data) 563 - } 564 - 565 367 func TestJsonWithSpecialCharacters(t *testing.T) { 566 368 jsonStr := `{ 567 369 "english": "Hello, World!", ··· 582 384 shutter.Snap(t, "JSON with Special Characters", data) 583 385 } 584 386 585 - func TestGoStructMarshalledToJson(t *testing.T) { 586 - type Address struct { 587 - Street string `json:"street"` 588 - City string `json:"city"` 589 - Zip string `json:"zip"` 590 - } 591 - 592 - type Contact struct { 593 - Name string `json:"name"` 594 - Email string `json:"email"` 595 - Phone string `json:"phone"` 596 - Address Address `json:"address"` 597 - Tags []string `json:"tags"` 598 - Active bool `json:"active"` 599 - CreatedAt time.Time `json:"created_at"` 600 - } 601 - 602 - contact := Contact{ 603 - Name: "Jane Smith", 604 - Email: "jane@example.com", 605 - Phone: "+1-555-0123", 606 - Address: Address{ 607 - Street: "456 Oak Ave", 608 - City: "San Francisco", 609 - Zip: "94102", 610 - }, 611 - Tags: []string{"vip", "verified", "premium"}, 612 - Active: true, 613 - CreatedAt: time.Date(2023, 6, 15, 14, 30, 0, 0, time.UTC), 614 - } 615 - 616 - jsonBytes, err := json.MarshalIndent(contact, "", " ") 617 - if err != nil { 618 - t.Fatalf("failed to marshal json: %v", err) 619 - } 620 - 621 - var data any 622 - if err := json.Unmarshal(jsonBytes, &data); err != nil { 623 - t.Fatalf("failed to unmarshal json: %v", err) 624 - } 625 - 626 - shutter.Snap(t, "Go Struct Marshalled to JSON", data) 627 - } 628 - 629 - func TestDeeplyNestedJson(t *testing.T) { 630 - type Level4 struct { 631 - Value string 632 - } 633 - 634 - type Level3 struct { 635 - L4 Level4 636 - } 637 - 638 - type Level2 struct { 639 - L3 Level3 640 - } 641 - 642 - type Level1 struct { 643 - L2 Level2 644 - } 645 - 646 - l1 := Level1{ 647 - L2: Level2{ 648 - L3: Level3{ 649 - L4: Level4{ 650 - Value: "deep value", 651 - }, 652 - }, 653 - }, 654 - } 655 - 656 - jsonBytes, err := json.MarshalIndent(l1, "", " ") 657 - if err != nil { 658 - t.Fatalf("failed to marshal json: %v", err) 659 - } 660 - 661 - var data any 662 - if err := json.Unmarshal(jsonBytes, &data); err != nil { 663 - t.Fatalf("failed to unmarshal json: %v", err) 664 - } 665 - 666 - shutter.Snap(t, "Deeply Nested JSON", data) 667 - } 668 - 669 387 func TestLargeJson(t *testing.T) { 670 388 type Product struct { 671 389 ID int `json:"id"` ··· 742 460 shutter.Snap(t, "Large JSON Structure", data) 743 461 } 744 462 745 - func TestJsonWithMixedArrays(t *testing.T) { 746 - jsonStr := `{ 747 - "heterogeneous_array": [ 748 - "string", 749 - 42, 750 - 3.14, 751 - true, 752 - null, 753 - {"object": "value"}, 754 - [1, 2, 3] 755 - ], 756 - "matrix": [ 757 - [1, 2, 3], 758 - [4, 5, 6], 759 - [7, 8, 9] 760 - ], 761 - "object_array": [ 762 - {"id": 1, "name": "Item 1"}, 763 - {"id": 2, "name": "Item 2"}, 764 - {"id": 3, "name": "Item 3"} 765 - ] 766 - }` 767 - 768 - var data any 769 - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { 770 - t.Fatalf("failed to unmarshal json: %v", err) 771 - } 772 - 773 - shutter.Snap(t, "JSON with Mixed Arrays", data) 774 - } 775 - 776 463 // ============================================================================ 777 - // SNAPJSON FUNCTION TESTS - Serialized JSON Strings 464 + // SNAPJSON FUNCTION TESTS - Focus on edge cases and real-world examples 778 465 // ============================================================================ 779 - 780 - func TestSnapJsonBasic(t *testing.T) { 781 - jsonStr := `{ 782 - "name": "John Doe", 783 - "email": "john@example.com", 784 - "age": 30, 785 - "verified": true 786 - }` 787 - 788 - shutter.SnapJSON(t, "SnapJSON Basic Object", jsonStr) 789 - } 790 - 791 - func TestSnapJsonSimpleArray(t *testing.T) { 792 - jsonStr := `[ 793 - "apple", 794 - "banana", 795 - "orange", 796 - "grape" 797 - ]` 798 - 799 - shutter.SnapJSON(t, "SnapJSON Simple Array", jsonStr) 800 - } 801 - 802 - func TestSnapJsonCompactFormat(t *testing.T) { 803 - jsonStr := `{"id":1,"name":"Product","price":99.99,"in_stock":true,"tags":["electronics","gadgets"]}` 804 - 805 - shutter.SnapJSON(t, "SnapJSON Compact Format", jsonStr) 806 - } 807 466 808 467 func TestSnapJsonWithNestedObjects(t *testing.T) { 809 468 jsonStr := `{ ··· 865 524 }` 866 525 867 526 shutter.SnapJSON(t, "SnapJSON Complex API Response", jsonStr) 868 - } 869 - 870 - func TestSnapJsonWithNulls(t *testing.T) { 871 - jsonStr := `{ 872 - "id": 1, 873 - "name": "Item", 874 - "description": null, 875 - "category": null, 876 - "tags": null, 877 - "metadata": { 878 - "created": "2023-01-01", 879 - "updated": null, 880 - "deleted": null 881 - } 882 - }` 883 - 884 - shutter.SnapJSON(t, "SnapJSON With Nulls", jsonStr) 885 - } 886 - 887 - func TestSnapJsonArrayOfObjects(t *testing.T) { 888 - jsonStr := `[ 889 - { 890 - "id": 1, 891 - "type": "post", 892 - "title": "First Post", 893 - "views": 150, 894 - "likes": 42 895 - }, 896 - { 897 - "id": 2, 898 - "type": "post", 899 - "title": "Second Post", 900 - "views": 280, 901 - "likes": 75 902 - }, 903 - { 904 - "id": 3, 905 - "type": "post", 906 - "title": "Third Post", 907 - "views": 450, 908 - "likes": 120 909 - } 910 - ]` 911 - 912 - shutter.SnapJSON(t, "SnapJSON Array of Objects", jsonStr) 913 - } 914 - 915 - func TestSnapJsonLargeNestedStructure(t *testing.T) { 916 - jsonStr := `{ 917 - "organization": { 918 - "name": "TechCorp", 919 - "id": "org_123", 920 - "departments": [ 921 - { 922 - "name": "Engineering", 923 - "manager": "Alice", 924 - "teams": [ 925 - { 926 - "name": "Backend", 927 - "lead": "John", 928 - "members": [ 929 - {"id": 1, "name": "John", "level": "senior"}, 930 - {"id": 2, "name": "Jane", "level": "mid"} 931 - ], 932 - "projects": [ 933 - {"id": "proj_1", "name": "API Service", "status": "active"}, 934 - {"id": "proj_2", "name": "Database Optimization", "status": "planning"} 935 - ] 936 - }, 937 - { 938 - "name": "Frontend", 939 - "lead": "Bob", 940 - "members": [ 941 - {"id": 3, "name": "Bob", "level": "senior"}, 942 - {"id": 4, "name": "Carol", "level": "junior"} 943 - ], 944 - "projects": [ 945 - {"id": "proj_3", "name": "Web App", "status": "active"} 946 - ] 947 - } 948 - ] 949 - }, 950 - { 951 - "name": "Sales", 952 - "manager": "Charlie", 953 - "teams": [ 954 - { 955 - "name": "Enterprise", 956 - "lead": "Dave", 957 - "members": [ 958 - {"id": 5, "name": "Dave", "level": "senior"}, 959 - {"id": 6, "name": "Eve", "level": "mid"} 960 - ], 961 - "projects": [] 962 - } 963 - ] 964 - } 965 - ], 966 - "metadata": { 967 - "founded": "2020", 968 - "employees": 150, 969 - "locations": ["USA", "EU", "APAC"] 970 - } 971 - } 972 - }` 973 - 974 - shutter.SnapJSON(t, "SnapJSON Large Nested Structure", jsonStr) 975 - } 976 - 977 - func TestSnapJsonWithNumbers(t *testing.T) { 978 - jsonStr := `{ 979 - "integers": [0, 1, -1, 42, -100, 9999999], 980 - "floats": [0.0, 3.14, -2.5, 0.001, 1.23e-4, 5.67e10], 981 - "financial": { 982 - "revenue": 1000000.50, 983 - "expenses": 750000.75, 984 - "profit_margin": 0.2499 985 - }, 986 - "measurements": { 987 - "temperature": -40.5, 988 - "distance": 1000.25, 989 - "weight": 0.5 990 - } 991 - }` 992 - 993 - shutter.SnapJSON(t, "SnapJSON With Numbers", jsonStr) 994 - } 995 - 996 - func TestSnapJsonWithSpecialCharacters(t *testing.T) { 997 - jsonStr := `{ 998 - "special": "!@#$%^&*()_+-=[]{}|;:',.<>?/", 999 - "escaped": "line1\nline2\ttab\rcarriage", 1000 - "quotes": "He said \"hello\" and she said 'goodbye'", 1001 - "unicode": "Hello 世界 🌍 مرحبا Привет", 1002 - "paths": "C:\\Users\\name\\Documents\\file.txt", 1003 - "html": "<div class=\"container\">Content</div>", 1004 - "regex": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" 1005 - }` 1006 - 1007 - shutter.SnapJSON(t, "SnapJSON With Special Characters", jsonStr) 1008 - } 1009 - 1010 - func TestSnapJsonEmptyStructures(t *testing.T) { 1011 - jsonStr := `{ 1012 - "empty_array": [], 1013 - "empty_object": {}, 1014 - "empty_string": "", 1015 - "zero": 0, 1016 - "false_value": false, 1017 - "null_value": null, 1018 - "nested": { 1019 - "empty": [], 1020 - "also_empty": {} 1021 - } 1022 - }` 1023 - 1024 - shutter.SnapJSON(t, "SnapJSON Empty Structures", jsonStr) 1025 527 } 1026 528 1027 529 func TestSnapJsonMixedTypes(t *testing.T) {