371 lines
14 KiB
C#
371 lines
14 KiB
C#
using AyCode.Core.Extensions;
|
|
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
|
|
|
|
namespace AyCode.Core.Tests.Serialization;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
[TestClass]
|
|
public class AcBinarySerializerNavigationPropertyTests
|
|
{
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
[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<ItemWithNavigationProperty>
|
|
{
|
|
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<ParentWithNavigatingItems>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test with multiple items, some with Product populated, some without.
|
|
/// This creates a mixed scenario where some items have navigation properties.
|
|
/// </summary>
|
|
[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<ParentWithNavigatingItems>();
|
|
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<List<ParentWithNavigatingItems>>();
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test deeply nested navigation properties.
|
|
/// Product has a Category, Category has a Parent, etc.
|
|
/// </summary>
|
|
[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<ItemWithNavigationProperty>
|
|
{
|
|
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<ParentWithNavigatingItems>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test with same Product instance referenced multiple times.
|
|
/// This tests the reference handling with navigation properties.
|
|
/// </summary>
|
|
[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<ItemWithNavigationProperty>
|
|
{
|
|
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<ParentWithNavigatingItems>();
|
|
|
|
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)}");
|
|
}
|
|
}
|