| 1 | package common |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "maps" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "reflect" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | // TestCase represents a single migration test case |
| 15 | type TestCase struct { |
| 16 | Name string |
| 17 | InputConfig map[string]any |
| 18 | Assertions []ConfigAssertion |
| 19 | } |
| 20 | |
| 21 | // ConfigAssertion represents an assertion about the migrated config |
| 22 | type ConfigAssertion struct { |
| 23 | Path string |
| 24 | Expected any |
| 25 | } |
| 26 | |
| 27 | // RunMigrationTest runs a migration test with the given test case |
| 28 | func RunMigrationTest(t *testing.T, migration Migration, tc TestCase) { |
| 29 | t.Helper() |
| 30 | |
| 31 | // Convert input to JSON |
| 32 | inputJSON, err := json.MarshalIndent(tc.InputConfig, "", " ") |
| 33 | if err != nil { |
| 34 | t.Fatalf("failed to marshal input config: %v", err) |
| 35 | } |
| 36 | |
| 37 | // Run the migration's convert function |
| 38 | var output bytes.Buffer |
| 39 | if baseMig, ok := migration.(*BaseMigration); ok { |
| 40 | err = baseMig.Convert(bytes.NewReader(inputJSON), &output) |
| 41 | if err != nil { |
| 42 | t.Fatalf("migration failed: %v", err) |
| 43 | } |
| 44 | } else { |
| 45 | t.Skip("migration is not a BaseMigration") |
| 46 | } |
| 47 | |
| 48 | // Parse output |
| 49 | var result map[string]any |
| 50 | err = json.Unmarshal(output.Bytes(), &result) |
| 51 | if err != nil { |
| 52 | t.Fatalf("failed to unmarshal output: %v", err) |
| 53 | } |
| 54 | |
| 55 | // Run assertions |
| 56 | for _, assertion := range tc.Assertions { |
| 57 | AssertConfigField(t, result, assertion.Path, assertion.Expected) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | // AssertConfigField asserts that a field in the config has the expected value |
| 62 | func AssertConfigField(t *testing.T, config map[string]any, path string, expected any) { |
| 63 | t.Helper() |
| 64 | |
| 65 | actual, exists := GetField(config, path) |
| 66 | if expected == nil { |
| 67 | if exists { |
| 68 | t.Errorf("expected field %s to not exist, but it has value: %v", path, actual) |
| 69 | } |
| 70 | return |
| 71 | } |
| 72 | |
| 73 | if !exists { |
| 74 | t.Errorf("expected field %s to exist with value %v, but it doesn't exist", path, expected) |
| 75 | return |
| 76 | } |
| 77 | |
| 78 | // Handle different types of comparisons |
| 79 | switch exp := expected.(type) { |
| 80 | case []string: |
| 81 | actualSlice, ok := actual.([]any) |
| 82 | if !ok { |
| 83 | t.Errorf("field %s: expected []string, got %T", path, actual) |
| 84 | return |
| 85 | } |
| 86 | if len(exp) != len(actualSlice) { |
| 87 | t.Errorf("field %s: expected slice of length %d, got %d", path, len(exp), len(actualSlice)) |
| 88 | return |
| 89 | } |
| 90 | for i, expVal := range exp { |
| 91 | if actualSlice[i] != expVal { |
| 92 | t.Errorf("field %s[%d]: expected %v, got %v", path, i, expVal, actualSlice[i]) |
| 93 | } |
| 94 | } |
| 95 | case map[string]string: |
| 96 | actualMap, ok := actual.(map[string]any) |
| 97 | if !ok { |
| 98 | t.Errorf("field %s: expected map, got %T", path, actual) |
| 99 | return |
| 100 | } |
| 101 | for k, v := range exp { |
| 102 | if actualMap[k] != v { |
| 103 | t.Errorf("field %s[%s]: expected %v, got %v", path, k, v, actualMap[k]) |
| 104 | } |
| 105 | } |
| 106 | default: |
| 107 | if actual != expected { |
| 108 | t.Errorf("field %s: expected %v, got %v", path, expected, actual) |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // GenerateTestConfig creates a basic test config with the given fields |
| 114 | func GenerateTestConfig(fields map[string]any) map[string]any { |
| 115 | // Start with a minimal valid config |
| 116 | config := map[string]any{ |
| 117 | "Identity": map[string]any{ |
| 118 | "PeerID": "QmTest", |
| 119 | }, |
| 120 | } |
| 121 | |
| 122 | // Merge in the provided fields |
| 123 | maps.Copy(config, fields) |
| 124 | |
| 125 | return config |
| 126 | } |
| 127 | |
| 128 | // CreateTestRepo creates a temporary test repository with the given version and config |
| 129 | func CreateTestRepo(t *testing.T, version int, config map[string]any) string { |
| 130 | t.Helper() |
| 131 | |
| 132 | tempDir := t.TempDir() |
| 133 | |
| 134 | // Write version file |
| 135 | versionPath := filepath.Join(tempDir, "version") |
| 136 | err := os.WriteFile(versionPath, fmt.Appendf(nil, "%d", version), 0644) |
| 137 | if err != nil { |
| 138 | t.Fatalf("failed to write version file: %v", err) |
| 139 | } |
| 140 | |
| 141 | // Write config file |
| 142 | configPath := filepath.Join(tempDir, "config") |
| 143 | configData, err := json.MarshalIndent(config, "", " ") |
| 144 | if err != nil { |
| 145 | t.Fatalf("failed to marshal config: %v", err) |
| 146 | } |
| 147 | err = os.WriteFile(configPath, configData, 0644) |
| 148 | if err != nil { |
| 149 | t.Fatalf("failed to write config file: %v", err) |
| 150 | } |
| 151 | |
| 152 | return tempDir |
| 153 | } |
| 154 | |
| 155 | // AssertMigrationSuccess runs a full migration and checks that it succeeds |
| 156 | func AssertMigrationSuccess(t *testing.T, migration Migration, fromVersion, toVersion int, inputConfig map[string]any) map[string]any { |
| 157 | t.Helper() |
| 158 | |
| 159 | // Create test repo |
| 160 | repoPath := CreateTestRepo(t, fromVersion, inputConfig) |
| 161 | |
| 162 | // Run migration |
| 163 | opts := Options{ |
| 164 | Path: repoPath, |
| 165 | Verbose: false, |
| 166 | } |
| 167 | |
| 168 | err := migration.Apply(opts) |
| 169 | if err != nil { |
| 170 | t.Fatalf("migration failed: %v", err) |
| 171 | } |
| 172 | |
| 173 | // Check version was updated |
| 174 | versionBytes, err := os.ReadFile(filepath.Join(repoPath, "version")) |
| 175 | if err != nil { |
| 176 | t.Fatalf("failed to read version file: %v", err) |
| 177 | } |
| 178 | actualVersion := string(versionBytes) |
| 179 | if actualVersion != fmt.Sprintf("%d", toVersion) { |
| 180 | t.Errorf("expected version %d, got %s", toVersion, actualVersion) |
| 181 | } |
| 182 | |
| 183 | // Read and return the migrated config |
| 184 | configBytes, err := os.ReadFile(filepath.Join(repoPath, "config")) |
| 185 | if err != nil { |
| 186 | t.Fatalf("failed to read config file: %v", err) |
| 187 | } |
| 188 | |
| 189 | var result map[string]any |
| 190 | err = json.Unmarshal(configBytes, &result) |
| 191 | if err != nil { |
| 192 | t.Fatalf("failed to unmarshal config: %v", err) |
| 193 | } |
| 194 | |
| 195 | return result |
| 196 | } |
| 197 | |
| 198 | // AssertMigrationReversible checks that a migration can be reverted |
| 199 | func AssertMigrationReversible(t *testing.T, migration Migration, fromVersion, toVersion int, inputConfig map[string]any) { |
| 200 | t.Helper() |
| 201 | |
| 202 | // Create test repo at target version |
| 203 | repoPath := CreateTestRepo(t, toVersion, inputConfig) |
| 204 | |
| 205 | // Create backup file (simulating a previous migration) |
| 206 | backupPath := filepath.Join(repoPath, fmt.Sprintf("config.%d-to-%d.bak", fromVersion, toVersion)) |
| 207 | originalConfig, err := json.MarshalIndent(inputConfig, "", " ") |
| 208 | if err != nil { |
| 209 | t.Fatalf("failed to marshal original config: %v", err) |
| 210 | } |
| 211 | |
| 212 | if err := os.WriteFile(backupPath, originalConfig, 0644); err != nil { |
| 213 | t.Fatalf("failed to write backup file: %v", err) |
| 214 | } |
| 215 | |
| 216 | // Run revert |
| 217 | if err := migration.Revert(Options{Path: repoPath}); err != nil { |
| 218 | t.Fatalf("revert failed: %v", err) |
| 219 | } |
| 220 | |
| 221 | // Verify version was reverted |
| 222 | versionBytes, err := os.ReadFile(filepath.Join(repoPath, "version")) |
| 223 | if err != nil { |
| 224 | t.Fatalf("failed to read version file: %v", err) |
| 225 | } |
| 226 | |
| 227 | if actualVersion := string(versionBytes); actualVersion != fmt.Sprintf("%d", fromVersion) { |
| 228 | t.Errorf("expected version %d after revert, got %s", fromVersion, actualVersion) |
| 229 | } |
| 230 | |
| 231 | // Verify config was reverted |
| 232 | configBytes, err := os.ReadFile(filepath.Join(repoPath, "config")) |
| 233 | if err != nil { |
| 234 | t.Fatalf("failed to read reverted config file: %v", err) |
| 235 | } |
| 236 | |
| 237 | var revertedConfig map[string]any |
| 238 | if err := json.Unmarshal(configBytes, &revertedConfig); err != nil { |
| 239 | t.Fatalf("failed to unmarshal reverted config: %v", err) |
| 240 | } |
| 241 | |
| 242 | // Compare reverted config with original |
| 243 | compareConfigs(t, inputConfig, revertedConfig, "") |
| 244 | } |
| 245 | |
| 246 | // compareConfigs recursively compares two config maps and reports differences |
| 247 | func compareConfigs(t *testing.T, expected, actual map[string]any, path string) { |
| 248 | t.Helper() |
| 249 | |
| 250 | // Build current path helper |
| 251 | buildPath := func(key string) string { |
| 252 | if path == "" { |
| 253 | return key |
| 254 | } |
| 255 | return path + "." + key |
| 256 | } |
| 257 | |
| 258 | // Check all expected fields exist and match |
| 259 | for key, expectedValue := range expected { |
| 260 | currentPath := buildPath(key) |
| 261 | |
| 262 | actualValue, exists := actual[key] |
| 263 | if !exists { |
| 264 | t.Errorf("reverted config missing field %s", currentPath) |
| 265 | continue |
| 266 | } |
| 267 | |
| 268 | switch exp := expectedValue.(type) { |
| 269 | case map[string]any: |
| 270 | act, ok := actualValue.(map[string]any) |
| 271 | if !ok { |
| 272 | t.Errorf("field %s: expected map, got %T", currentPath, actualValue) |
| 273 | continue |
| 274 | } |
| 275 | compareConfigs(t, exp, act, currentPath) |
| 276 | default: |
| 277 | if !reflect.DeepEqual(expectedValue, actualValue) { |
| 278 | t.Errorf("field %s: expected %v, got %v after revert", |
| 279 | currentPath, expectedValue, actualValue) |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | // Check for unexpected fields using maps.Keys (Go 1.23+) |
| 285 | for key := range actual { |
| 286 | if _, exists := expected[key]; !exists { |
| 287 | t.Errorf("reverted config has unexpected field %s", buildPath(key)) |
| 288 | } |
| 289 | } |
| 290 | } |