profileShare

rasmusjy / splitapp-backend-microservices

Read-only snapshot

No repository description.

main default branch 501 files Expires Sep 13, 2026, 9:06 AM
CrossModuleNavigationTests.cs 3,666 bytes
1 using System.IO;
2 using System.Linq;
3 using System.Text.RegularExpressions;
4
5 namespace SplitApp.WebApp.IntegrationTests.Architecture;
6
7 /// <summary>
8 /// Cross-module entity navigations are allowed only when annotated <c>[NotMapped]</c>.
9 /// EF still treats every module's schema as isolated (cross-module navs are never
10 /// loaded via Include) — the WebApp facade hydrates them in-memory after fetching
11 /// from the appropriate module's DbContext. A plain (mapped) navigation across
12 /// modules would let EF cross schemas and is therefore forbidden.
13 /// </summary>
14 public class CrossModuleNavigationTests
15 {
16 private static string SolutionRoot()
17 {
18 var dir = new DirectoryInfo(AppContext.BaseDirectory);
19 while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SplitApp.sln")))
20 {
21 dir = dir.Parent;
22 }
23 Assert.NotNull(dir);
24 return dir!.FullName;
25 }
26
27 [Fact]
28 public void NoDomainEntityHasMappedNavigationToAnotherModulesEntityType()
29 {
30 var modulesDir = Path.Combine(SolutionRoot(), "src", "Modules");
31 var moduleNames = Directory.GetDirectories(modulesDir).Select(Path.GetFileName).ToList();
32
33 var entitiesByModule = moduleNames.ToDictionary(
34 m => m!,
35 m => Directory
36 .GetFiles(Path.Combine(modulesDir, m!, $"SplitApp.Modules.{m}.Domain", "Entities"),
37 "*.cs", SearchOption.TopDirectoryOnly)
38 .Select(Path.GetFileNameWithoutExtension)
39 .Where(n => n != null)
40 .Cast<string>()
41 .ToHashSet());
42
43 var failures = new List<string>();
44
45 foreach (var module in moduleNames)
46 {
47 var foreignEntities = entitiesByModule
48 .Where(kv => kv.Key != module)
49 .SelectMany(kv => kv.Value)
50 .ToHashSet();
51
52 var entityFiles = Directory.GetFiles(
53 Path.Combine(modulesDir, module!, $"SplitApp.Modules.{module}.Domain", "Entities"),
54 "*.cs",
55 SearchOption.TopDirectoryOnly);
56
57 foreach (var file in entityFiles)
58 {
59 var lines = File.ReadAllLines(file);
60 for (var i = 0; i < lines.Length; i++)
61 {
62 var line = Regex.Replace(lines[i], "//.*", "").TrimStart();
63 if (!line.StartsWith("public ")) continue;
64
65 foreach (var foreign in foreignEntities)
66 {
67 // Look for "public Currency? Foo {", "public ICollection<Currency>? Foo {", etc.
68 var pattern = $@"public\s+(?:I[A-Za-z]+<\s*)?{Regex.Escape(foreign)}\??\s*>?\s*\??\s+\w+\s*\{{";
69 if (!Regex.IsMatch(line, pattern)) continue;
70
71 // Allow if [NotMapped] is on this line OR on any of the previous (attribute) lines
72 var hasNotMapped = false;
73 for (var k = i; k >= 0 && k >= i - 3; k--)
74 {
75 if (lines[k].Contains("[NotMapped]")) { hasNotMapped = true; break; }
76 if (k < i && !lines[k].TrimStart().StartsWith("[") && lines[k].TrimStart().Length > 0) break;
77 }
78
79 if (!hasNotMapped)
80 {
81 failures.Add($"{Path.GetFileName(file)} (module {module}) line {i + 1}: mapped navigation to foreign entity '{foreign}'");
82 }
83 }
84 }
85 }
86 }
87
88 Assert.Empty(failures);
89 }
90 }
91