using System.Text.Json; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using Xunit; namespace StellaOps.TestKit.Fixtures; /// /// Helpers for API contract testing using OpenAPI schema snapshots. /// public static class ContractTestHelper { /// /// Fetches and validates the OpenAPI schema against a snapshot. /// public static async Task ValidateOpenApiSchemaAsync( WebApplicationFactory factory, string expectedSnapshotPath, string swaggerEndpoint = "/swagger/v1/swagger.json") where TProgram : class { using var client = factory.CreateClient(); var response = await client.GetAsync(swaggerEndpoint); response.EnsureSuccessStatusCode(); var actualSchema = await response.Content.ReadAsStringAsync(); if (ShouldUpdateSnapshots()) { await UpdateSnapshotAsync(expectedSnapshotPath, actualSchema); return; } var expectedSchema = await File.ReadAllTextAsync(expectedSnapshotPath); // Normalize both for comparison var actualNormalized = NormalizeOpenApiSchema(actualSchema); var expectedNormalized = NormalizeOpenApiSchema(expectedSchema); actualNormalized.Should().Be(expectedNormalized, "OpenAPI schema should match snapshot. Set STELLAOPS_UPDATE_FIXTURES=true to update."); } /// /// Validates that the schema contains expected endpoints. /// public static async Task ValidateEndpointsExistAsync( WebApplicationFactory factory, IEnumerable expectedEndpoints, string swaggerEndpoint = "/swagger/v1/swagger.json") where TProgram : class { using var client = factory.CreateClient(); var response = await client.GetAsync(swaggerEndpoint); response.EnsureSuccessStatusCode(); var schemaJson = await response.Content.ReadAsStringAsync(); var schema = JsonDocument.Parse(schemaJson); var paths = schema.RootElement.GetProperty("paths"); foreach (var endpoint in expectedEndpoints) { paths.TryGetProperty(endpoint, out _).Should().BeTrue( $"Expected endpoint '{endpoint}' should exist in OpenAPI schema"); } } /// /// Detects breaking changes between current schema and snapshot. /// public static async Task DetectBreakingChangesAsync( WebApplicationFactory factory, string snapshotPath, string swaggerEndpoint = "/swagger/v1/swagger.json") where TProgram : class { using var client = factory.CreateClient(); var response = await client.GetAsync(swaggerEndpoint); response.EnsureSuccessStatusCode(); var actualSchema = await response.Content.ReadAsStringAsync(); if (!File.Exists(snapshotPath)) { return new SchemaBreakingChanges(new List { "No previous snapshot exists" }, new List()); } var expectedSchema = await File.ReadAllTextAsync(snapshotPath); return CompareSchemas(expectedSchema, actualSchema); } private static SchemaBreakingChanges CompareSchemas(string expected, string actual) { var breakingChanges = new List(); var nonBreakingChanges = new List(); try { var expectedDoc = JsonDocument.Parse(expected); var actualDoc = JsonDocument.Parse(actual); // Check for removed endpoints (breaking) if (expectedDoc.RootElement.TryGetProperty("paths", out var expectedPaths) && actualDoc.RootElement.TryGetProperty("paths", out var actualPaths)) { foreach (var path in expectedPaths.EnumerateObject()) { if (!actualPaths.TryGetProperty(path.Name, out _)) { breakingChanges.Add($"Endpoint removed: {path.Name}"); } else { // Check for removed methods foreach (var method in path.Value.EnumerateObject()) { if (!actualPaths.GetProperty(path.Name).TryGetProperty(method.Name, out _)) { breakingChanges.Add($"Method removed: {method.Name.ToUpper()} {path.Name}"); } } } } // Check for new endpoints (non-breaking) foreach (var path in actualPaths.EnumerateObject()) { if (!expectedPaths.TryGetProperty(path.Name, out _)) { nonBreakingChanges.Add($"Endpoint added: {path.Name}"); } } } // Check for removed schemas (breaking) if (expectedDoc.RootElement.TryGetProperty("components", out var expectedComponents) && expectedComponents.TryGetProperty("schemas", out var expectedSchemas) && actualDoc.RootElement.TryGetProperty("components", out var actualComponents) && actualComponents.TryGetProperty("schemas", out var actualSchemas)) { foreach (var schema in expectedSchemas.EnumerateObject()) { if (!actualSchemas.TryGetProperty(schema.Name, out _)) { breakingChanges.Add($"Schema removed: {schema.Name}"); } } } } catch (JsonException ex) { breakingChanges.Add($"Schema parse error: {ex.Message}"); } return new SchemaBreakingChanges(breakingChanges, nonBreakingChanges); } private static string NormalizeOpenApiSchema(string schema) { try { var doc = JsonDocument.Parse(schema); // Remove non-deterministic fields return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } catch { return schema; } } private static bool ShouldUpdateSnapshots() { return Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true"; } private static async Task UpdateSnapshotAsync(string path, string content) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); // Pretty-print for readability var doc = JsonDocument.Parse(content); var pretty = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(path, pretty); } } /// /// Result of schema breaking change detection. /// public sealed record SchemaBreakingChanges( IReadOnlyList BreakingChanges, IReadOnlyList NonBreakingChanges) { public bool HasBreakingChanges => BreakingChanges.Count > 0; }