|
| 1 | +// ApiContractAssertions — assertion vocabulary for api-contract-conformance. |
| 2 | +// The recognized keys (equals / length / ids / names / row / hasId / envelope / |
| 3 | +// error / empty) are the cross-port contract — every per-port runner must |
| 4 | +// implement them identically. See |
| 5 | +// fixtures/api-contract-conformance/README.md. |
| 6 | + |
| 7 | +namespace MetaObjects.IntegrationTests.Api; |
| 8 | + |
| 9 | +internal static class ApiContractAssertions |
| 10 | +{ |
| 11 | + public static void AssertResponse(string scenarioName, ApiRequest request, int status, object? body) |
| 12 | + { |
| 13 | + if (status != request.ExpectStatus) |
| 14 | + throw new Xunit.Sdk.XunitException( |
| 15 | + $"{scenarioName} / {request.Id}: expected status {request.ExpectStatus}, got {status}; body: {Render(body)}"); |
| 16 | + |
| 17 | + var want = request.ExpectBody; |
| 18 | + if (want is null) return; |
| 19 | + |
| 20 | + if (want.TryGetValue("empty", out var emp) && emp is bool eb && eb) |
| 21 | + { |
| 22 | + if (!IsEmpty(body)) |
| 23 | + throw new Xunit.Sdk.XunitException( |
| 24 | + $"{scenarioName} / {request.Id}: expected empty body, got: {Render(body)}"); |
| 25 | + return; |
| 26 | + } |
| 27 | + if (want.TryGetValue("equals", out var eq) && eq is not null) |
| 28 | + { |
| 29 | + if (!StructuralEquals(body, eq)) |
| 30 | + throw new Xunit.Sdk.XunitException( |
| 31 | + $"{scenarioName} / {request.Id}: body mismatch\n expected: {Render(eq)}\n actual: {Render(body)}"); |
| 32 | + return; |
| 33 | + } |
| 34 | + if (want.TryGetValue("envelope", out var env) && env is bool envB && envB) |
| 35 | + { |
| 36 | + if (body is not Dictionary<string, object?> map) |
| 37 | + throw new Xunit.Sdk.XunitException( |
| 38 | + $"{scenarioName} / {request.Id}: expected envelope {{ rows, total }}, got: {Render(body)}"); |
| 39 | + if (!map.TryGetValue("rows", out var rowsObj) || rowsObj is not List<object?> rows) |
| 40 | + throw new Xunit.Sdk.XunitException( |
| 41 | + $"{scenarioName} / {request.Id}: envelope.rows missing or not a list; got: {Render(body)}"); |
| 42 | + if (!map.TryGetValue("total", out var totalObj) || totalObj is null) |
| 43 | + throw new Xunit.Sdk.XunitException( |
| 44 | + $"{scenarioName} / {request.Id}: envelope.total missing; got: {Render(body)}"); |
| 45 | + long total = Convert.ToInt64(totalObj); |
| 46 | + if (want.TryGetValue("rowsLength", out var wantLenObj) && wantLenObj is not null) |
| 47 | + { |
| 48 | + int wantLen = Convert.ToInt32(wantLenObj); |
| 49 | + if (rows.Count != wantLen) |
| 50 | + throw new Xunit.Sdk.XunitException( |
| 51 | + $"{scenarioName} / {request.Id}: expected rows.length={wantLen}, got {rows.Count}"); |
| 52 | + } |
| 53 | + if (want.TryGetValue("total", out var wantTotalObj) && wantTotalObj is not null) |
| 54 | + { |
| 55 | + long wantTotal = Convert.ToInt64(wantTotalObj); |
| 56 | + if (total != wantTotal) |
| 57 | + throw new Xunit.Sdk.XunitException( |
| 58 | + $"{scenarioName} / {request.Id}: expected total={wantTotal}, got {total}"); |
| 59 | + } |
| 60 | + return; |
| 61 | + } |
| 62 | + if (want.TryGetValue("error", out var errObj) && errObj is string wantErr) |
| 63 | + { |
| 64 | + string? actual = null; |
| 65 | + if (body is Dictionary<string, object?> bm |
| 66 | + && bm.TryGetValue("error", out var e) && e is string s) actual = s; |
| 67 | + if (!wantErr.Equals(actual)) |
| 68 | + throw new Xunit.Sdk.XunitException( |
| 69 | + $"{scenarioName} / {request.Id}: expected error=\"{wantErr}\", got: {Render(body)}"); |
| 70 | + return; |
| 71 | + } |
| 72 | + if (want.TryGetValue("length", out var lenObj) && lenObj is not null) |
| 73 | + { |
| 74 | + int wantLen = Convert.ToInt32(lenObj); |
| 75 | + if (body is not List<object?> list) |
| 76 | + throw new Xunit.Sdk.XunitException( |
| 77 | + $"{scenarioName} / {request.Id}: expected array, got: {Render(body)}"); |
| 78 | + if (list.Count != wantLen) |
| 79 | + throw new Xunit.Sdk.XunitException( |
| 80 | + $"{scenarioName} / {request.Id}: expected length={wantLen}, got {list.Count}"); |
| 81 | + } |
| 82 | + if (want.TryGetValue("ids", out var idsObj) && idsObj is List<object?> wantIds) |
| 83 | + { |
| 84 | + if (body is not List<object?> list) |
| 85 | + throw new Xunit.Sdk.XunitException( |
| 86 | + $"{scenarioName} / {request.Id}: expected array, got: {Render(body)}"); |
| 87 | + var actualIds = list.Select(it => |
| 88 | + it is Dictionary<string, object?> m && m.TryGetValue("id", out var idV) && idV is not null |
| 89 | + ? (long?)Convert.ToInt64(idV) |
| 90 | + : null).ToList(); |
| 91 | + var wantLongs = wantIds.Select(it => it is null ? (long?)null : (long?)Convert.ToInt64(it)).ToList(); |
| 92 | + if (!actualIds.SequenceEqual(wantLongs)) |
| 93 | + throw new Xunit.Sdk.XunitException( |
| 94 | + $"{scenarioName} / {request.Id}: expected ids [{string.Join(", ", wantLongs)}], got [{string.Join(", ", actualIds)}]"); |
| 95 | + } |
| 96 | + if (want.TryGetValue("names", out var namesObj) && namesObj is List<object?> wantNames) |
| 97 | + { |
| 98 | + if (body is not List<object?> list) |
| 99 | + throw new Xunit.Sdk.XunitException( |
| 100 | + $"{scenarioName} / {request.Id}: expected array, got: {Render(body)}"); |
| 101 | + var actualNames = list.Select(it => |
| 102 | + it is Dictionary<string, object?> m && m.TryGetValue("name", out var n) ? n?.ToString() : null).ToList(); |
| 103 | + var wantStrs = wantNames.Select(it => it?.ToString()).ToList(); |
| 104 | + if (!actualNames.SequenceEqual(wantStrs)) |
| 105 | + throw new Xunit.Sdk.XunitException( |
| 106 | + $"{scenarioName} / {request.Id}: expected names [{string.Join(", ", wantStrs)}], got [{string.Join(", ", actualNames)}]"); |
| 107 | + } |
| 108 | + if (want.TryGetValue("row", out var rowObj) && rowObj is Dictionary<string, object?> wantRow) |
| 109 | + { |
| 110 | + if (body is not Dictionary<string, object?> row) |
| 111 | + throw new Xunit.Sdk.XunitException( |
| 112 | + $"{scenarioName} / {request.Id}: expected object, got: {Render(body)}"); |
| 113 | + foreach (var kvp in wantRow) |
| 114 | + { |
| 115 | + row.TryGetValue(kvp.Key, out var actual); |
| 116 | + if (!StructuralEquals(actual, kvp.Value)) |
| 117 | + throw new Xunit.Sdk.XunitException( |
| 118 | + $"{scenarioName} / {request.Id}: expected row.{kvp.Key}={Render(kvp.Value)}, got {Render(actual)}"); |
| 119 | + } |
| 120 | + } |
| 121 | + if (want.TryGetValue("hasId", out var hasIdObj) && hasIdObj is bool hi && hi) |
| 122 | + { |
| 123 | + if (body is not Dictionary<string, object?> row) |
| 124 | + throw new Xunit.Sdk.XunitException( |
| 125 | + $"{scenarioName} / {request.Id}: expected object, got: {Render(body)}"); |
| 126 | + if (!row.TryGetValue("id", out var id) || id is null |
| 127 | + || (id is not int && id is not long && id is not double && id is not decimal)) |
| 128 | + throw new Xunit.Sdk.XunitException( |
| 129 | + $"{scenarioName} / {request.Id}: expected numeric id in body, got: {Render(body)}"); |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + /// <summary> |
| 134 | + /// Structural equality that normalizes numeric subtypes (int/long/etc are equal |
| 135 | + /// when their long value matches), recurses into maps + lists, and treats |
| 136 | + /// missing keys as null. |
| 137 | + /// </summary> |
| 138 | + private static bool StructuralEquals(object? a, object? b) |
| 139 | + { |
| 140 | + if (a is null && b is null) return true; |
| 141 | + if (a is null || b is null) return false; |
| 142 | + if (IsNumber(a) && IsNumber(b)) return Convert.ToInt64(a) == Convert.ToInt64(b); |
| 143 | + if (a is List<object?> al && b is List<object?> bl) |
| 144 | + { |
| 145 | + if (al.Count != bl.Count) return false; |
| 146 | + for (int i = 0; i < al.Count; i++) |
| 147 | + if (!StructuralEquals(al[i], bl[i])) return false; |
| 148 | + return true; |
| 149 | + } |
| 150 | + if (a is Dictionary<string, object?> am && b is Dictionary<string, object?> bm) |
| 151 | + { |
| 152 | + // Two-way key check. |
| 153 | + foreach (var k in am.Keys) if (!bm.ContainsKey(k)) return false; |
| 154 | + foreach (var k in bm.Keys) if (!am.ContainsKey(k)) return false; |
| 155 | + foreach (var k in am.Keys) if (!StructuralEquals(am[k], bm[k])) return false; |
| 156 | + return true; |
| 157 | + } |
| 158 | + return a.Equals(b); |
| 159 | + } |
| 160 | + |
| 161 | + private static bool IsNumber(object o) => |
| 162 | + o is sbyte or byte or short or ushort or int or uint or long or ulong; |
| 163 | + |
| 164 | + private static bool IsEmpty(object? body) => body switch |
| 165 | + { |
| 166 | + null => true, |
| 167 | + string s => s.Length == 0, |
| 168 | + Dictionary<string, object?> m => m.Count == 0, |
| 169 | + List<object?> l => l.Count == 0, |
| 170 | + _ => false, |
| 171 | + }; |
| 172 | + |
| 173 | + private static string Render(object? o) |
| 174 | + { |
| 175 | + if (o is null) return "null"; |
| 176 | + if (o is string s) return $"\"{s}\""; |
| 177 | + if (o is List<object?> l) return "[" + string.Join(", ", l.Select(Render)) + "]"; |
| 178 | + if (o is Dictionary<string, object?> m) |
| 179 | + return "{" + string.Join(", ", m.Select(kvp => $"{kvp.Key}={Render(kvp.Value)}")) + "}"; |
| 180 | + return o.ToString() ?? "null"; |
| 181 | + } |
| 182 | +} |
0 commit comments