Skip to content

Commit 59590f0

Browse files
committed
no-mistakes(test): Add live coverage for the map-VO enum-symbol and unvalidated-write fixes
Drives the real generated C# server (Roslyn-compiled generator output hosted on Kestrel over Testcontainers Postgres) rather than reading source: - An_enum_valued_map_component_persists_its_symbol_not_its_ordinal — proves the MapJsonb JsonStringEnumConverter fix (85f87c1) actually persists a field.map @objectref VO's enum members as their symbol, by reading the raw jsonb column back with a direct SQL query independent of the app's own reader. - A_map_valued_address_missing_its_required_street_is_silently_accepted_on_create — confirms live the exact claim docs/features/field-types.md now carries: a nested VO value inside a field.map is unvalidated on the C# create path (201, not 400). Both passed when run against a live Postgres container in this validation round, alongside the pre-existing MapNullColumnGeneratedServerTest (NULL-column read/list/PATCH regression, also verified live).
1 parent 4d536b9 commit 59590f0

1 file changed

Lines changed: 316 additions & 0 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
// MapValueObjectWriteBehaviorGeneratedServerTest — two live behaviors of a field.map
2+
// @objectRef's nested value-object values, driven against the REAL generated HTTP API
3+
// over real Postgres:
4+
//
5+
// 1. Enum members persist as their SYMBOL, not their ordinal. The MapJsonb helper
6+
// serializes a map's value object with System.Text.Json, which writes enums as
7+
// numbers by default. DbContextGenerator now registers a JsonStringEnumConverter on
8+
// that helper's shared JsonSerializerOptions (matching the symbol convention the
9+
// sibling field.object ToJson path already uses) so a string-backed enum (kind) and
10+
// an int-backed one (tier, @intValueMap) both write their member SYMBOL into the
11+
// jsonb document — never the ordinal, and never the @intValueMap-mapped int, because
12+
// there is no column inside a JSON document to apply that mapping to.
13+
//
14+
// 2. Nested VO values are UNVALIDATED on create (docs/features/field-types.md). An
15+
// Address missing its @required "street" is silently ACCEPTED, not rejected, because
16+
// RoutesGenerator's create-time VO validation walk admits only field.object, never a
17+
// field.map's value.
18+
//
19+
// Both drive the actual compiled generator output over Kestrel + Testcontainers
20+
// Postgres (the same JsonbGeneratedServerFactory-style pattern as
21+
// MapNullColumnGeneratedServerTest). The enum case reads the RAW jsonb text back with a
22+
// direct SQL query — bypassing the app's own JSON deserializer entirely — so the
23+
// assertion is on what actually landed in the database, not on what the reading code
24+
// chooses to show back.
25+
//
26+
// Run on-demand (Docker required):
27+
// dotnet test server/csharp/MetaObjects.IntegrationTests/MetaObjects.IntegrationTests.csproj \
28+
// --filter "FullyQualifiedName~MapValueObjectWriteBehavior"
29+
30+
using System.Reflection;
31+
using System.Text;
32+
using System.Text.Json;
33+
using System.Text.Json.Nodes;
34+
using Microsoft.AspNetCore.Builder;
35+
using Microsoft.AspNetCore.Hosting;
36+
using Microsoft.CodeAnalysis;
37+
using Microsoft.CodeAnalysis.CSharp;
38+
using Microsoft.EntityFrameworkCore;
39+
using Microsoft.Extensions.DependencyInjection;
40+
using Microsoft.Extensions.Logging;
41+
using MetaObjects.Codegen;
42+
using MetaObjects.Codegen.Generators;
43+
using MetaObjects.IntegrationTests.Runner;
44+
using MetaObjects.Loader;
45+
using Npgsql;
46+
using Xunit;
47+
using Xunit.Sdk;
48+
49+
namespace MetaObjects.IntegrationTests.Api;
50+
51+
public sealed class MapValueObjectWriteBehaviorGeneratedServerTest
52+
{
53+
private const string GeneratedNamespace = "MetaObjects.IntegrationTests.MapEnumGenerated";
54+
55+
// Address carries a string-backed enum (kind) and an int-backed one (tier,
56+
// @intValueMap A=1,B=2) — the same shape DbContextCompileTests uses to prove the
57+
// converter's delegates resolve; this test proves what those delegates ACTUALLY
58+
// persist through a real write.
59+
private const string ModelTemplate = """
60+
{ "metadata.root": { "package": "acme", "children": [
61+
{ "object.value": { "name": "Address", "children": [
62+
{ "field.string": { "name": "street", "@required": true } },
63+
{ "field.enum": { "name": "kind", "@values": ["HOME", "WORK"] } },
64+
{ "field.enum": { "name": "tier", "@values": ["A", "B"], "@intValueMap": { "A": 1, "B": 2 } } }
65+
]}},
66+
{ "object.entity": { "name": "Customer", "children": [
67+
{ "source.rdb": { "@table": "customers" } },
68+
{ "field.long": { "name": "id" } },
69+
{ "field.string": { "name": "name", "@required": true } },
70+
{ "field.map": { "name": "sites", "@objectRef": "Address" } },
71+
{ "identity.primary": { "@fields": "id" } }
72+
]}}
73+
]}}
74+
""";
75+
76+
private const string SchemaDdl = """
77+
CREATE TABLE "customers" (
78+
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
79+
"name" VARCHAR(200) NOT NULL,
80+
"sites" jsonb,
81+
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
82+
);
83+
""";
84+
85+
[Fact]
86+
public async Task An_enum_valued_map_component_persists_its_symbol_not_its_ordinal()
87+
{
88+
await using var pg = await PostgresContainer.StartAsync();
89+
await ProvisionAsync(pg.ConnectionString);
90+
91+
var (baseUrl, app) = await StartGeneratedServerAsync(pg);
92+
await using (app)
93+
{
94+
using var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
95+
96+
var body = new JsonObject
97+
{
98+
["name"] = "acme",
99+
["sites"] = new JsonObject
100+
{
101+
["hq"] = new JsonObject
102+
{
103+
["street"] = "1 Main",
104+
["kind"] = "HOME",
105+
["tier"] = "A",
106+
},
107+
},
108+
};
109+
using var response = await client.PostAsync("/api/customers",
110+
new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
111+
var text = await response.Content.ReadAsStringAsync();
112+
if (!response.IsSuccessStatusCode)
113+
throw new XunitException($"POST /api/customers -> {(int)response.StatusCode}: {text}");
114+
115+
// Read the RAW jsonb text directly from Postgres — not through the app's own
116+
// reader, so this proves what was actually WRITTEN, independent of whatever
117+
// the read path might tolerate or coerce.
118+
await using var conn = new NpgsqlConnection(pg.ConnectionString);
119+
await conn.OpenAsync();
120+
await using var cmd = conn.CreateCommand();
121+
cmd.CommandText = """SELECT "sites"::text FROM "customers" WHERE "name" = 'acme'""";
122+
var raw = (string?)await cmd.ExecuteScalarAsync();
123+
Assert.NotNull(raw);
124+
125+
// Parse with a plain JSON reader — independent of the app's own JSON
126+
// deserializer/enum handling — so this proves what the DATABASE holds, not
127+
// what the reading code is willing to tolerate.
128+
var hq = (JsonNode.Parse(raw!) as JsonObject)?["hq"] as JsonObject;
129+
Assert.NotNull(hq);
130+
Assert.Equal("HOME", (string?)hq!["kind"]);
131+
Assert.Equal("A", (string?)hq!["tier"]);
132+
Assert.NotEqual(JsonValueKind.Number, hq["kind"]!.GetValueKind());
133+
Assert.NotEqual(JsonValueKind.Number, hq["tier"]!.GetValueKind());
134+
}
135+
}
136+
137+
// Confirms, live, the exact claim docs/features/field-types.md now carries: a
138+
// field.map @objectRef's nested value-object values are UNVALIDATED on the C# create
139+
// path — an Address missing its @required "street" is silently ACCEPTED (201), not
140+
// rejected (400), because RoutesGenerator.ValueObjectFields (the source AppendCreateVoValidation
141+
// walks) admits only field.object, so a map never reaches the recursively-validating
142+
// VO arms. This is the failure mode the docs warn about — acceptance, not an error —
143+
// proven against the real generated create handler rather than inferred from source.
144+
[Fact]
145+
public async Task A_map_valued_address_missing_its_required_street_is_silently_accepted_on_create()
146+
{
147+
await using var pg = await PostgresContainer.StartAsync();
148+
await ProvisionAsync(pg.ConnectionString);
149+
150+
var (baseUrl, app) = await StartGeneratedServerAsync(pg);
151+
await using (app)
152+
{
153+
using var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
154+
155+
var body = new JsonObject
156+
{
157+
["name"] = "acme-invalid",
158+
["sites"] = new JsonObject
159+
{
160+
// "street" (@required on Address) is deliberately omitted.
161+
["hq"] = new JsonObject { ["kind"] = "HOME" },
162+
},
163+
};
164+
using var response = await client.PostAsync("/api/customers",
165+
new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
166+
var text = await response.Content.ReadAsStringAsync();
167+
168+
Assert.Equal(System.Net.HttpStatusCode.Created, response.StatusCode);
169+
Assert.Contains("acme-invalid", text);
170+
}
171+
}
172+
173+
private static async Task ProvisionAsync(string connString)
174+
{
175+
await using var c = new NpgsqlConnection(connString);
176+
await c.OpenAsync();
177+
await using var cmd = c.CreateCommand();
178+
cmd.CommandText = SchemaDdl;
179+
await cmd.ExecuteNonQueryAsync();
180+
}
181+
182+
private static async Task<(string BaseUrl, WebApplication App)> StartGeneratedServerAsync(PostgresContainer pg)
183+
{
184+
var (assembly, routeEntities) = CompileGeneratedServer();
185+
var dbContextType = assembly.GetType($"{GeneratedNamespace}.AppDbContext")
186+
?? throw new InvalidOperationException("generated AppDbContext type not found");
187+
188+
int port = PickFreePort();
189+
string baseUrl = $"http://127.0.0.1:{port}";
190+
191+
var builder = WebApplication.CreateBuilder();
192+
builder.WebHost.UseUrls(baseUrl);
193+
builder.Logging.ClearProviders();
194+
// The wire contract for an enum field is its member SYMBOL (a real app registers
195+
// this at the host level, e.g. via ConfigureHttpJsonOptions — see
196+
// TphGeneratedServerFactory). Without it, System.Text.Json's default enum handling
197+
// (numeric) rejects the incoming "HOME"/"A" strings before the request ever reaches
198+
// the DTO's own map-jsonb persistence path this test is about.
199+
builder.Services.ConfigureHttpJsonOptions(o =>
200+
o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()));
201+
RegisterGeneratedDbContext(builder.Services, dbContextType, pg.ConnectionString);
202+
203+
var app = builder.Build();
204+
foreach (var entity in routeEntities)
205+
{
206+
var routesType = assembly.GetType($"{GeneratedNamespace}.{entity}Routes");
207+
if (routesType is null) continue;
208+
var mapMethod = routesType.GetMethod($"Map{entity}Routes", BindingFlags.Public | BindingFlags.Static);
209+
mapMethod?.Invoke(null, new object[] { app, "/api" });
210+
}
211+
212+
await app.StartAsync();
213+
return (baseUrl, app);
214+
}
215+
216+
private static (Assembly Assembly, IReadOnlyList<string> RouteEntities) CompileGeneratedServer()
217+
{
218+
var loadResult = new MetaDataLoader().Load([new InMemoryStringSource(ModelTemplate, id: "map-enum.json")]);
219+
if (loadResult.Errors.Count != 0)
220+
throw new InvalidOperationException(
221+
"map-enum model failed to load: " + string.Join("; ", loadResult.Errors.Select(e => e.ToString())));
222+
223+
var routeEntities = loadResult.Root.Objects()
224+
.Where(o => o.IsEntity())
225+
.Select(o => CSharpNaming.Pascal(o.Name))
226+
.ToList();
227+
228+
var ctx = new GenContext
229+
{
230+
Entities = loadResult.Root.Objects(),
231+
Root = loadResult.Root,
232+
Config = new GenConfig
233+
{
234+
OutDir = "/unused",
235+
Namespace = GeneratedNamespace,
236+
ColumnNamingStrategy = ColumnNamingStrategy.Literal,
237+
EmitAbstractShapes = false,
238+
},
239+
};
240+
241+
var files = new EntityGenerator().Generate(ctx)
242+
.Concat(new DbContextGenerator().Generate(ctx))
243+
.Concat(new FilterAllowlistGenerator().Generate(ctx))
244+
.Concat(new RoutesGenerator().Generate(ctx))
245+
.ToList();
246+
247+
var trees = files
248+
.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12)))
249+
.ToArray();
250+
251+
var comp = CSharpCompilation.Create(
252+
"mapenum_generated_" + Guid.NewGuid().ToString("N"),
253+
trees, BuildReferenceSet(),
254+
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
255+
256+
using var ms = new MemoryStream();
257+
var emit = comp.Emit(ms);
258+
if (!emit.Success)
259+
{
260+
var errors = emit.Diagnostics
261+
.Where(d => d.Severity == DiagnosticSeverity.Error)
262+
.Select(d => $"{d.Id}: {d.GetMessage()}")
263+
.ToList();
264+
throw new InvalidOperationException(
265+
"generated map-enum server failed to compile:\n " + string.Join("\n ", errors));
266+
}
267+
268+
ms.Seek(0, SeekOrigin.Begin);
269+
return (Assembly.Load(ms.ToArray()), routeEntities);
270+
}
271+
272+
private static List<MetadataReference> BuildReferenceSet()
273+
{
274+
var byFileName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
275+
var tpa = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? "";
276+
foreach (var path in tpa.Split(Path.PathSeparator))
277+
if (path.Length > 0 && path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
278+
byFileName[Path.GetFileName(path)] = path;
279+
280+
var aspNetDir = Path.GetDirectoryName(typeof(WebApplication).Assembly.Location);
281+
if (aspNetDir is not null && Directory.Exists(aspNetDir))
282+
foreach (var dll in Directory.EnumerateFiles(aspNetDir, "*.dll"))
283+
byFileName[Path.GetFileName(dll)] = dll;
284+
285+
return byFileName.Values
286+
.Select(loc => (MetadataReference)MetadataReference.CreateFromFile(loc))
287+
.ToList();
288+
}
289+
290+
private static void RegisterGeneratedDbContext(
291+
IServiceCollection services, Type dbContextType, string connString)
292+
{
293+
var addDbContext = typeof(EntityFrameworkServiceCollectionExtensions)
294+
.GetMethods(BindingFlags.Public | BindingFlags.Static)
295+
.First(m => m.Name == "AddDbContext"
296+
&& m.IsGenericMethodDefinition
297+
&& m.GetGenericArguments().Length == 1
298+
&& m.GetParameters().Length == 4)
299+
.MakeGenericMethod(dbContextType);
300+
301+
Action<DbContextOptionsBuilder> configure = opts => opts.UseNpgsql(connString);
302+
addDbContext.Invoke(null, new object?[]
303+
{
304+
services, configure, ServiceLifetime.Scoped, ServiceLifetime.Scoped,
305+
});
306+
}
307+
308+
private static int PickFreePort()
309+
{
310+
var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
311+
l.Start();
312+
int port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
313+
l.Stop();
314+
return port;
315+
}
316+
}

0 commit comments

Comments
 (0)