using AyCode.Core.Extensions; using static AyCode.Core.Tests.Serialization.AcSerializerModels; namespace AyCode.Core.Tests.Serialization; /// /// Tests for navigation property serialization issues. /// /// CRITICAL BUG REPRODUCTION: /// When a navigation property (like StockTakingItem.Product) is populated, /// the serializer writes properties of the navigation target (Product), /// but these property names were NOT registered in the metadata header! /// /// The bug pattern: /// 1. RegisterMetadataForType walks List<StockTakingItem> and registers StockTakingItem properties /// 2. StockTakingItem has a "Product" property of type Product - this property NAME is registered /// 3. BUT Product's own properties (Name, Description, Price, CategoryId) are NOT registered! /// 4. When Product is NOT NULL at runtime, WriteObject writes Product's property indices /// 5. GetPropertyNameIndex returns NEW indices that weren't in the header! /// 6. Deserializer reads property indices that don't exist in its table ? crash/type mismatch /// [TestClass] public class AcBinarySerializerNavigationPropertyTests { /// /// CRITICAL REGRESSION TEST: Navigation properties causing metadata mismatch. /// This is the EXACT production scenario: /// - StockTakingItem.Product is populated by the database query /// - Product's properties are serialized with wrong indices /// - Deserializer fails with type mismatch /// [TestMethod] public void Deserialize_NavigationPropertyPopulated_MetadataIncludesNestedType() { var parent = new ParentWithNavigatingItems { Id = 1, Name = "Parent", Creator = 6, // The exact value from production error Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc), Modified = DateTime.UtcNow, Items = new List { new() { Id = 10, ParentId = 1, ProductId = 100, IsMeasured = true, Quantity = 50, Created = DateTime.UtcNow.AddHours(-1), Modified = DateTime.UtcNow, // Navigation property IS populated - this is the key! Product = new ProductEntity { Id = 100, Name = "TestProduct", Description = "Product description with long text", Price = 99.99, CategoryId = 5, Created = DateTime.UtcNow.AddDays(-30) } } } }; var binary = parent.ToBinary(); var result = binary.BinaryTo(); Assert.IsNotNull(result); Assert.AreEqual(1, result.Id); Assert.AreEqual(6, result.Creator, "Creator should be 6"); Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks, $"Created mismatch. Expected: {parent.Created}, Got: {result.Created}"); Assert.IsNotNull(result.Items); Assert.AreEqual(1, result.Items.Count); var item = result.Items[0]; Assert.AreEqual(10, item.Id); Assert.AreEqual(100, item.ProductId); // Navigation property should be deserialized correctly Assert.IsNotNull(item.Product, "Product navigation property should not be null"); Assert.AreEqual(100, item.Product.Id); Assert.AreEqual("TestProduct", item.Product.Name); Assert.AreEqual(5, item.Product.CategoryId); } /// /// Test with multiple items, some with Product populated, some without. /// This creates a mixed scenario where some items have navigation properties. /// [TestMethod] public void Deserialize_MixedNavigationProperties_AllItemsCorrect() { var parent = new ParentWithNavigatingItems { Id = 1, Name = "Parent", Creator = 6, Created = DateTime.UtcNow, Modified = DateTime.UtcNow, Items = Enumerable.Range(1, 5).Select(i => new ItemWithNavigationProperty { Id = i * 10, ParentId = 1, ProductId = 100 + i, IsMeasured = i % 2 == 0, Quantity = i * 10, Created = DateTime.UtcNow.AddHours(-i), Modified = DateTime.UtcNow, // Only populate Product for even items Product = i % 2 == 0 ? new ProductEntity { Id = 100 + i, Name = $"Product_{i}", Description = $"Description for product {i}", Price = i * 10.5, CategoryId = i % 3, Created = DateTime.UtcNow.AddDays(-i) } : null }).ToList() }; var binary = parent.ToBinary(); var result = binary.BinaryTo(); Assert.IsNotNull(result); Assert.AreEqual(1, result.Id); Assert.AreEqual(6, result.Creator); Assert.IsNotNull(result.Items); Assert.AreEqual(5, result.Items.Count); for (int i = 1; i <= 5; i++) { var item = result.Items[i - 1]; Assert.AreEqual(i * 10, item.Id, $"Item {i} Id mismatch"); if (i % 2 == 0) { Assert.IsNotNull(item.Product, $"Item {i} should have Product"); Assert.AreEqual($"Product_{i}", item.Product.Name); } else { Assert.IsNull(item.Product, $"Item {i} should not have Product"); } } } /// /// Test with list of parents, each with items with navigation properties. /// This is the exact production scenario - multiple StockTaking entities /// each with StockTakingItems that have Product navigation properties. /// [TestMethod] public void Deserialize_ListOfParentsWithNavigationProperties_AllCorrect() { var parents = Enumerable.Range(1, 3).Select(p => new ParentWithNavigatingItems { Id = p, Name = $"Parent_{p}", Creator = p, Created = DateTime.UtcNow.AddDays(-p), Modified = DateTime.UtcNow, Items = Enumerable.Range(1, 2).Select(i => new ItemWithNavigationProperty { Id = p * 100 + i, ParentId = p, ProductId = 1000 + i, IsMeasured = true, Quantity = 10 * i, Created = DateTime.UtcNow.AddHours(-i), Modified = DateTime.UtcNow, Product = new ProductEntity { Id = 1000 + i, Name = $"Product_{p}_{i}", Description = $"Description {p}_{i}", Price = (p * 10) + (i * 1.5), CategoryId = i % 3, Created = DateTime.UtcNow.AddDays(-10) } }).ToList() }).ToList(); var binary = parents.ToBinary(); var result = binary.BinaryTo>(); Assert.IsNotNull(result); Assert.AreEqual(3, result.Count); for (int p = 0; p < 3; p++) { var parent = result[p]; Assert.AreEqual(p + 1, parent.Id, $"Parent[{p}].Id mismatch"); Assert.AreEqual(p + 1, parent.Creator, $"Parent[{p}].Creator mismatch"); Assert.IsNotNull(parent.Items); Assert.AreEqual(2, parent.Items.Count); for (int i = 0; i < 2; i++) { var item = parent.Items[i]; Assert.IsNotNull(item.Product, $"Parent[{p}].Items[{i}].Product should not be null"); Assert.AreEqual($"Product_{p + 1}_{i + 1}", item.Product.Name); } } } /// /// Test deeply nested navigation properties. /// Product has a Category, Category has a Parent, etc. /// [TestMethod] public void Deserialize_DeeplyNestedNavigationProperties_AllCorrect() { // This tests that the serializer correctly handles navigation properties // even when they are deeply nested (Product -> Category -> Parent) var parent = new ParentWithNavigatingItems { Id = 1, Name = "Parent", Creator = 6, Created = DateTime.UtcNow, Modified = DateTime.UtcNow, Items = new List { new() { Id = 10, ParentId = 1, ProductId = 100, IsMeasured = true, Quantity = 50, Created = DateTime.UtcNow, Modified = DateTime.UtcNow, Product = new ProductEntity { Id = 100, Name = "ProductWithDetails", Description = "Very long description that should be interned", Price = 123.45, CategoryId = 10, Created = DateTime.UtcNow.AddMonths(-6) } }, new() { Id = 20, ParentId = 1, ProductId = 200, IsMeasured = false, Quantity = 25, Created = DateTime.UtcNow, Modified = DateTime.UtcNow, Product = new ProductEntity { Id = 200, Name = "AnotherProduct", Description = "Another description", Price = 67.89, CategoryId = 20, Created = DateTime.UtcNow.AddMonths(-3) } } } }; var binary = parent.ToBinary(); // Log binary size for debugging Console.WriteLine($"Binary size: {binary.Length} bytes"); var result = binary.BinaryTo(); Assert.IsNotNull(result); Assert.AreEqual(1, result.Id); Assert.AreEqual(6, result.Creator); Assert.IsNotNull(result.Items); Assert.AreEqual(2, result.Items.Count); // First item Assert.AreEqual(10, result.Items[0].Id); Assert.IsNotNull(result.Items[0].Product); Assert.AreEqual("ProductWithDetails", result.Items[0].Product.Name); Assert.AreEqual(123.45, result.Items[0].Product.Price); // Second item Assert.AreEqual(20, result.Items[1].Id); Assert.IsNotNull(result.Items[1].Product); Assert.AreEqual("AnotherProduct", result.Items[1].Product.Name); Assert.AreEqual(67.89, result.Items[1].Product.Price); } /// /// Test with same Product instance referenced multiple times. /// This tests the reference handling with navigation properties. /// [TestMethod] public void Deserialize_SharedNavigationProperty_ReferencesPreserved() { // Create a shared Product that is referenced by multiple items var sharedProduct = new ProductEntity { Id = 999, Name = "SharedProduct", Description = "This product is shared across items", Price = 50.00, CategoryId = 1, Created = DateTime.UtcNow.AddYears(-1) }; var parent = new ParentWithNavigatingItems { Id = 1, Name = "Parent", Creator = 6, Created = DateTime.UtcNow, Modified = DateTime.UtcNow, Items = new List { new() { Id = 10, ParentId = 1, ProductId = 999, IsMeasured = true, Quantity = 50, Created = DateTime.UtcNow, Modified = DateTime.UtcNow, Product = sharedProduct // Same reference }, new() { Id = 20, ParentId = 1, ProductId = 999, IsMeasured = false, Quantity = 75, Created = DateTime.UtcNow, Modified = DateTime.UtcNow, Product = sharedProduct // Same reference } } }; var binary = parent.ToBinary(); var result = binary.BinaryTo(); Assert.IsNotNull(result); Assert.IsNotNull(result.Items); Assert.AreEqual(2, result.Items.Count); // Both items should have the same Product values Assert.IsNotNull(result.Items[0].Product); Assert.IsNotNull(result.Items[1].Product); Assert.AreEqual(999, result.Items[0].Product.Id); Assert.AreEqual(999, result.Items[1].Product.Id); Assert.AreEqual("SharedProduct", result.Items[0].Product.Name); Assert.AreEqual("SharedProduct", result.Items[1].Product.Name); // With reference handling, they should be the same instance // (This depends on UseReferenceHandling being enabled) Console.WriteLine($"Same instance: {ReferenceEquals(result.Items[0].Product, result.Items[1].Product)}"); } }