|
| 1 | +// ApiContractJsonbConformanceTest — #98: the C# jsonb open-bag api-contract lanes. |
| 2 | +// |
| 3 | +// Drives the fixtures/api-contract-conformance/jsonb/ scenarios over HTTP against |
| 4 | +// BOTH lanes: |
| 5 | +// 1. the HAND-ROLLED reference server (JsonbReferenceServer), and |
| 6 | +// 2. the GENERATED routes artifact (JsonbGeneratedServerFactory) — the emitted |
| 7 | +// DocumentRoutes over the emitted AppDbContext, booted unmodified on Kestrel. |
| 8 | +// |
| 9 | +// The contract: a `field.string @dbColumnType:jsonb` open bag must accept a posted |
| 10 | +// JSON OBJECT and surface it back as a parsed OBJECT (not a double-encoded string). |
| 11 | +// The generated lane LOCKS the #105 codegen (payload property typed JsonDocument); a |
| 12 | +// regressed port that returned `"{\"k\":\"v\"}"` fails the body.row deep-equal. |
| 13 | +// |
| 14 | +// One Postgres testcontainer per scenario per lane (full isolation, mirrors the TPH + |
| 15 | +// m2m lanes). The two lanes share the corpus + scenario loader + assertion vocabulary. |
| 16 | +// |
| 17 | +// Run on-demand (Docker required): |
| 18 | +// dotnet test server/csharp/MetaObjects.IntegrationTests/MetaObjects.IntegrationTests.csproj \ |
| 19 | +// --filter "FullyQualifiedName~ApiContractJsonb" |
| 20 | + |
| 21 | +using System.Text; |
| 22 | +using System.Text.Json; |
| 23 | +using System.Text.Json.Nodes; |
| 24 | +using MetaObjects.IntegrationTests.Runner; |
| 25 | +using Xunit; |
| 26 | + |
| 27 | +namespace MetaObjects.IntegrationTests.Api; |
| 28 | + |
| 29 | +public sealed class ApiContractJsonbConformanceTest |
| 30 | +{ |
| 31 | + [Theory] |
| 32 | + [MemberData(nameof(Scenarios))] |
| 33 | + public async Task Api_contract_jsonb_reference(string scenarioPath) |
| 34 | + { |
| 35 | + var scenario = ApiContractScenarioLoader.LoadScenario(scenarioPath); |
| 36 | + await using var pg = await PostgresContainer.StartAsync(); |
| 37 | + await using var server = await JsonbReferenceServer.StartAsync(pg); |
| 38 | + await server.ApplySeedAsync(); |
| 39 | + await RunAsync(scenario, server.BaseUrl); |
| 40 | + } |
| 41 | + |
| 42 | + [Theory] |
| 43 | + [MemberData(nameof(Scenarios))] |
| 44 | + public async Task Api_contract_jsonb_generated(string scenarioPath) |
| 45 | + { |
| 46 | + var scenario = ApiContractScenarioLoader.LoadScenario(scenarioPath); |
| 47 | + await using var pg = await PostgresContainer.StartAsync(); |
| 48 | + await using var server = await JsonbGeneratedServerFactory.StartAsync(pg); |
| 49 | + await server.ApplySeedAsync(); |
| 50 | + await RunAsync(scenario, server.BaseUrl); |
| 51 | + } |
| 52 | + |
| 53 | + private static async Task RunAsync(ApiScenario scenario, string baseUrl) |
| 54 | + { |
| 55 | + using var client = new HttpClient { BaseAddress = new Uri(baseUrl) }; |
| 56 | + foreach (var req in scenario.Requests) |
| 57 | + { |
| 58 | + var request = new HttpRequestMessage(new HttpMethod(req.Method), req.Path); |
| 59 | + if (req.Body is not null) |
| 60 | + { |
| 61 | + string json = JsonSerializer.Serialize(req.Body, JsonOpts); |
| 62 | + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); |
| 63 | + } |
| 64 | + var response = await client.SendAsync(request); |
| 65 | + string bodyText = await response.Content.ReadAsStringAsync(); |
| 66 | + object? parsed = string.IsNullOrEmpty(bodyText) ? null : ToObject(JsonNode.Parse(bodyText)); |
| 67 | + ApiContractAssertions.AssertResponse(scenario.Name, req, (int)response.StatusCode, parsed); |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + public static IEnumerable<object[]> Scenarios() => |
| 72 | + Directory.EnumerateFiles(ApiContractCorpusPaths.JsonbScenariosDir, "*.yaml", SearchOption.TopDirectoryOnly) |
| 73 | + .OrderBy(p => p, StringComparer.Ordinal) |
| 74 | + .Select(p => new object[] { p }); |
| 75 | + |
| 76 | + private static readonly JsonSerializerOptions JsonOpts = new() |
| 77 | + { |
| 78 | + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, |
| 79 | + }; |
| 80 | + |
| 81 | + private static object? ToObject(JsonNode? node) |
| 82 | + { |
| 83 | + if (node is null) return null; |
| 84 | + if (node is JsonObject obj) |
| 85 | + { |
| 86 | + var d = new Dictionary<string, object?>(StringComparer.Ordinal); |
| 87 | + foreach (var kvp in obj) d[kvp.Key] = ToObject(kvp.Value); |
| 88 | + return d; |
| 89 | + } |
| 90 | + if (node is JsonArray arr) |
| 91 | + { |
| 92 | + var l = new List<object?>(arr.Count); |
| 93 | + foreach (var item in arr) l.Add(ToObject(item)); |
| 94 | + return l; |
| 95 | + } |
| 96 | + if (node is JsonValue jv) |
| 97 | + { |
| 98 | + if (jv.TryGetValue<bool>(out var b)) return b; |
| 99 | + if (jv.TryGetValue<long>(out var lv)) return lv; |
| 100 | + if (jv.TryGetValue<double>(out var dv)) return dv; |
| 101 | + if (jv.TryGetValue<string>(out var sv)) return sv; |
| 102 | + return jv.ToString(); |
| 103 | + } |
| 104 | + return null; |
| 105 | + } |
| 106 | +} |
0 commit comments