Implement checkpoint modes with docs/tests and reorganize project file layout
All checks were successful
NuGet Publish / build-and-pack (push) Successful in 46s
NuGet Publish / publish-to-gitea (push) Successful in 53s

This commit is contained in:
Joseph Doherty
2026-02-21 07:56:36 -05:00
parent 3ffd468c79
commit 4c6aaa5a3f
96 changed files with 744 additions and 249 deletions

View File

@@ -0,0 +1,159 @@
using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Shared;
using ZB.MOM.WW.CBDD.Shared.TestDbContext_TestDbContext_Mappers;
namespace ZB.MOM.WW.CBDD.Tests
{
public class AttributeTests
{
// Use full path for mapper until we are sure of the namespace
private ZB_MOM_WW_CBDD_Shared_AnnotatedUserMapper CreateMapper() => new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, ushort> _keyMap = new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<ushort, string> _keys = new();
/// <summary>
/// Initializes lookup maps used by attribute mapper tests.
/// </summary>
public AttributeTests()
{
ushort id = 1;
string[] keys = ["_id", "display_name", "age", "location", "0", "1"];
foreach (var key in keys)
{
_keyMap[key] = id;
_keys[id] = key;
id++;
}
}
/// <summary>
/// Verifies table attribute mapping resolves the expected collection name.
/// </summary>
[Fact]
public void Test_Table_Attribute_Mapping()
{
// Verify that the generated mapper has the correct collection name
var mapper = CreateMapper();
mapper.CollectionName.ShouldBe("test.custom_users");
}
/// <summary>
/// Verifies required attribute validation is enforced.
/// </summary>
[Fact]
public void Test_Required_Validation()
{
var mapper = CreateMapper();
var user = new AnnotatedUser { Name = "" }; // Required name is empty
var writer = new BsonSpanWriter(new byte[1024], _keyMap);
bool thrown = false;
try
{
mapper.Serialize(user, writer);
}
catch (ValidationException)
{
thrown = true;
}
thrown.ShouldBeTrue("Should throw ValidationException for empty Name.");
}
/// <summary>
/// Verifies string length attribute validation is enforced.
/// </summary>
[Fact]
public void Test_StringLength_Validation()
{
var mapper = CreateMapper();
var user = new AnnotatedUser { Name = "Jo" }; // Too short
var writer = new BsonSpanWriter(new byte[1024], _keyMap);
bool thrown = false;
try { mapper.Serialize(user, writer); } catch (ValidationException) { thrown = true; }
thrown.ShouldBeTrue("Should throw ValidationException for Name too short.");
user.Name = new string('A', 51); // Too long
thrown = false;
try { mapper.Serialize(user, writer); } catch (ValidationException) { thrown = true; }
thrown.ShouldBeTrue("Should throw ValidationException for Name too long.");
}
/// <summary>
/// Verifies range attribute validation is enforced.
/// </summary>
[Fact]
public void Test_Range_Validation()
{
var mapper = CreateMapper();
var user = new AnnotatedUser { Name = "John", Age = 200 }; // Out of range
var writer = new BsonSpanWriter(new byte[1024], _keyMap);
bool thrown = false;
try { mapper.Serialize(user, writer); } catch (ValidationException) { thrown = true; }
thrown.ShouldBeTrue("Should throw ValidationException for Age out of range.");
}
/// <summary>
/// Verifies column attribute maps to the expected BSON field name.
/// </summary>
[Fact]
public void Test_Column_Name_Mapping()
{
var mapper = CreateMapper();
var user = new AnnotatedUser { Name = "John", Age = 30 };
var buffer = new byte[1024];
var writer = new BsonSpanWriter(buffer, _keyMap);
mapper.Serialize(user, writer);
var reader = new BsonSpanReader(buffer, _keys);
reader.ReadDocumentSize();
bool foundDisplayName = false;
while (reader.Remaining > 0)
{
var type = reader.ReadBsonType();
if (type == BsonType.EndOfDocument) break;
var name = reader.ReadElementHeader();
if (name == "display_name") foundDisplayName = true;
reader.SkipValue(type);
}
foundDisplayName.ShouldBeTrue("BSON field name should be 'display_name' from [Column] attribute.");
}
/// <summary>
/// Verifies not-mapped attribute excludes properties from BSON serialization.
/// </summary>
[Fact]
public void Test_NotMapped_Attribute()
{
var mapper = CreateMapper();
var user = new AnnotatedUser { Name = "John", Age = 30 };
var buffer = new byte[1024];
var writer = new BsonSpanWriter(buffer, _keyMap);
mapper.Serialize(user, writer);
var reader = new BsonSpanReader(buffer, _keys);
reader.ReadDocumentSize();
bool foundComputed = false;
while (reader.Remaining > 0)
{
var type = reader.ReadBsonType();
if (type == BsonType.EndOfDocument) break;
var name = reader.ReadElementHeader();
if (name == "ComputedInfo") foundComputed = true;
reader.SkipValue(type);
}
foundComputed.ShouldBeFalse("ComputedInfo should not be mapped to BSON.");
}
}
}

View File

@@ -0,0 +1,431 @@
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Shared;
using ZB.MOM.WW.CBDD.Tests;
using Xunit;
namespace ZB.MOM.WW.CBDD.Tests;
/// <summary>
/// Tests for circular references and N-N relationships
/// Validates that the source generator handles:
/// 1. Self-referencing entities using ObjectId references (Employee → ManagerId, DirectReportIds)
/// 2. N-N via referencing with ObjectIds (CategoryRef/ProductRef) - BEST PRACTICE
///
/// Note: Bidirectional embedding (Category ↔ Product with full objects) is NOT supported
/// by the source generator and is an anti-pattern for document databases.
/// Use referencing (ObjectIds) instead for N-N relationships.
/// </summary>
public class CircularReferenceTests : IDisposable
{
private readonly string _dbPath;
private readonly Shared.TestDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="CircularReferenceTests"/> class.
/// </summary>
public CircularReferenceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"cbdd_circular_test_{Guid.NewGuid()}");
_context = new Shared.TestDbContext(_dbPath);
}
/// <summary>
/// Executes Dispose.
/// </summary>
public void Dispose()
{
_context?.Dispose();
if (Directory.Exists(_dbPath))
{
Directory.Delete(_dbPath, true);
}
}
// ========================================
// Self-Reference Tests (Employee hierarchy with ObjectId references)
// ========================================
/// <summary>
/// Executes SelfReference_InsertAndQuery_ShouldWork.
/// </summary>
[Fact]
public void SelfReference_InsertAndQuery_ShouldWork()
{
// Arrange: Create organizational hierarchy using ObjectId references
var ceoId = ObjectId.NewObjectId();
var manager1Id = ObjectId.NewObjectId();
var manager2Id = ObjectId.NewObjectId();
var developerId = ObjectId.NewObjectId();
var ceo = new Employee
{
Id = ceoId,
Name = "Alice CEO",
Department = "Executive",
ManagerId = null,
DirectReportIds = new List<ObjectId> { manager1Id, manager2Id }
};
var manager1 = new Employee
{
Id = manager1Id,
Name = "Bob Manager",
Department = "Engineering",
ManagerId = ceoId,
DirectReportIds = new List<ObjectId> { developerId }
};
var manager2 = new Employee
{
Id = manager2Id,
Name = "Carol Manager",
Department = "Sales",
ManagerId = ceoId,
DirectReportIds = new List<ObjectId>() // No direct reports
};
var developer = new Employee
{
Id = developerId,
Name = "Dave Developer",
Department = "Engineering",
ManagerId = manager1Id,
DirectReportIds = null // Leaf node
};
// Act: Insert all employees
_context.Employees.Insert(ceo);
_context.Employees.Insert(manager1);
_context.Employees.Insert(manager2);
_context.Employees.Insert(developer);
// Assert: Query and verify
var queriedCeo = _context.Employees.FindById(ceoId);
queriedCeo.ShouldNotBeNull();
queriedCeo.Name.ShouldBe("Alice CEO");
queriedCeo.DirectReportIds.ShouldNotBeNull();
queriedCeo.DirectReportIds.Count.ShouldBe(2);
queriedCeo.DirectReportIds.ShouldContain(manager1Id);
queriedCeo.DirectReportIds.ShouldContain(manager2Id);
// Query manager and verify direct reports
var queriedManager1 = _context.Employees.FindById(manager1Id);
queriedManager1.ShouldNotBeNull();
queriedManager1.ManagerId.ShouldBe(ceoId);
queriedManager1.DirectReportIds.ShouldNotBeNull();
queriedManager1.DirectReportIds.Count().ShouldBe(1);
queriedManager1.DirectReportIds.ShouldContain(developerId);
// Query developer and verify no direct reports
var queriedDeveloper = _context.Employees.FindById(developerId);
queriedDeveloper.ShouldNotBeNull();
queriedDeveloper.ManagerId.ShouldBe(manager1Id);
// Empty list is acceptable (same as null semantically - no direct reports)
(queriedDeveloper.DirectReportIds ?? new List<ObjectId>()).ShouldBeEmpty();
}
/// <summary>
/// Executes SelfReference_UpdateDirectReports_ShouldPersist.
/// </summary>
[Fact]
public void SelfReference_UpdateDirectReports_ShouldPersist()
{
// Arrange: Create manager with one direct report
var managerId = ObjectId.NewObjectId();
var employee1Id = ObjectId.NewObjectId();
var employee2Id = ObjectId.NewObjectId();
var manager = new Employee
{
Id = managerId,
Name = "Manager",
Department = "Engineering",
DirectReportIds = new List<ObjectId> { employee1Id }
};
var employee1 = new Employee
{
Id = employee1Id,
Name = "Employee 1",
Department = "Engineering",
ManagerId = managerId
};
var employee2 = new Employee
{
Id = employee2Id,
Name = "Employee 2",
Department = "Engineering",
ManagerId = managerId
};
_context.Employees.Insert(manager);
_context.Employees.Insert(employee1);
_context.Employees.Insert(employee2);
// Act: Add another direct report
manager.DirectReportIds?.Add(employee2Id);
_context.Employees.Update(manager);
// Assert: Verify update persisted
var queried = _context.Employees.FindById(managerId);
queried.ShouldNotBeNull();
queried.DirectReportIds.ShouldNotBeNull();
queried.DirectReportIds.Count.ShouldBe(2);
queried.DirectReportIds.ShouldContain(employee1Id);
queried.DirectReportIds.ShouldContain(employee2Id);
}
/// <summary>
/// Executes SelfReference_QueryByManagerId_ShouldWork.
/// </summary>
[Fact]
public void SelfReference_QueryByManagerId_ShouldWork()
{
// Arrange: Create hierarchy
var managerId = ObjectId.NewObjectId();
var manager = new Employee
{
Id = managerId,
Name = "Manager",
Department = "Engineering"
};
var employee1 = new Employee
{
Id = ObjectId.NewObjectId(),
Name = "Employee 1",
Department = "Engineering",
ManagerId = managerId
};
var employee2 = new Employee
{
Id = ObjectId.NewObjectId(),
Name = "Employee 2",
Department = "Engineering",
ManagerId = managerId
};
_context.Employees.Insert(manager);
_context.Employees.Insert(employee1);
_context.Employees.Insert(employee2);
// Act: Query all employees with specific manager
var subordinates = _context.Employees
.AsQueryable()
.Where(e => e.ManagerId == managerId)
.ToList();
// Assert: Should find both employees
subordinates.Count.ShouldBe(2);
subordinates.ShouldContain(e => e.Name == "Employee 1");
subordinates.ShouldContain(e => e.Name == "Employee 2");
}
// ========================================
// N-N Referencing Tests (CategoryRef/ProductRef)
// BEST PRACTICE for document databases
// ========================================
/// <summary>
/// Executes NtoNReferencing_InsertAndQuery_ShouldWork.
/// </summary>
[Fact]
public void NtoNReferencing_InsertAndQuery_ShouldWork()
{
// Arrange: Create categories and products with ObjectId references
var categoryId1 = ObjectId.NewObjectId();
var categoryId2 = ObjectId.NewObjectId();
var productId1 = ObjectId.NewObjectId();
var productId2 = ObjectId.NewObjectId();
var electronics = new CategoryRef
{
Id = categoryId1,
Name = "Electronics",
Description = "Electronic devices",
ProductIds = new List<ObjectId> { productId1, productId2 }
};
var computers = new CategoryRef
{
Id = categoryId2,
Name = "Computers",
Description = "Computing devices",
ProductIds = new List<ObjectId> { productId1 }
};
var laptop = new ProductRef
{
Id = productId1,
Name = "Laptop",
Price = 999.99m,
CategoryIds = new List<ObjectId> { categoryId1, categoryId2 }
};
var phone = new ProductRef
{
Id = productId2,
Name = "Phone",
Price = 599.99m,
CategoryIds = new List<ObjectId> { categoryId1 }
};
// Act: Insert all entities
_context.CategoryRefs.Insert(electronics);
_context.CategoryRefs.Insert(computers);
_context.ProductRefs.Insert(laptop);
_context.ProductRefs.Insert(phone);
// Assert: Query and verify references
var queriedCategory = _context.CategoryRefs.FindById(categoryId1);
queriedCategory.ShouldNotBeNull();
queriedCategory.Name.ShouldBe("Electronics");
queriedCategory.ProductIds.ShouldNotBeNull();
queriedCategory.ProductIds.Count.ShouldBe(2);
queriedCategory.ProductIds.ShouldContain(productId1);
queriedCategory.ProductIds.ShouldContain(productId2);
var queriedProduct = _context.ProductRefs.FindById(productId1);
queriedProduct.ShouldNotBeNull();
queriedProduct.Name.ShouldBe("Laptop");
queriedProduct.CategoryIds.ShouldNotBeNull();
queriedProduct.CategoryIds.Count.ShouldBe(2);
queriedProduct.CategoryIds.ShouldContain(categoryId1);
queriedProduct.CategoryIds.ShouldContain(categoryId2);
}
/// <summary>
/// Executes NtoNReferencing_UpdateRelationships_ShouldPersist.
/// </summary>
[Fact]
public void NtoNReferencing_UpdateRelationships_ShouldPersist()
{
// Arrange: Create category and product
var categoryId = ObjectId.NewObjectId();
var productId1 = ObjectId.NewObjectId();
var productId2 = ObjectId.NewObjectId();
var category = new CategoryRef
{
Id = categoryId,
Name = "Books",
Description = "Book category",
ProductIds = new List<ObjectId> { productId1 }
};
var product1 = new ProductRef
{
Id = productId1,
Name = "Book 1",
Price = 19.99m,
CategoryIds = new List<ObjectId> { categoryId }
};
var product2 = new ProductRef
{
Id = productId2,
Name = "Book 2",
Price = 29.99m,
CategoryIds = new List<ObjectId>()
};
_context.CategoryRefs.Insert(category);
_context.ProductRefs.Insert(product1);
_context.ProductRefs.Insert(product2);
// Act: Add product2 to category
category.ProductIds?.Add(productId2);
_context.CategoryRefs.Update(category);
product2.CategoryIds?.Add(categoryId);
_context.ProductRefs.Update(product2);
// Assert: Verify relationships updated
var queriedCategory = _context.CategoryRefs.FindById(categoryId);
queriedCategory.ShouldNotBeNull();
queriedCategory.ProductIds.ShouldNotBeNull();
queriedCategory.ProductIds.Count.ShouldBe(2);
queriedCategory.ProductIds.ShouldContain(productId2);
var queriedProduct2 = _context.ProductRefs.FindById(productId2);
queriedProduct2.ShouldNotBeNull();
queriedProduct2.CategoryIds.ShouldNotBeNull();
queriedProduct2.CategoryIds.Count().ShouldBe(1);
queriedProduct2.CategoryIds.ShouldContain(categoryId);
}
/// <summary>
/// Executes NtoNReferencing_DocumentSize_RemainSmall.
/// </summary>
[Fact]
public void NtoNReferencing_DocumentSize_RemainSmall()
{
// Arrange: Create category referencing 100 products (only IDs)
var categoryId = ObjectId.NewObjectId();
var productIds = Enumerable.Range(0, 100)
.Select(_ => ObjectId.NewObjectId())
.ToList();
var category = new CategoryRef
{
Id = categoryId,
Name = "Large Category",
Description = "Category with 100 products",
ProductIds = productIds
};
// Act: Insert and query
_context.CategoryRefs.Insert(category);
var queried = _context.CategoryRefs.FindById(categoryId);
// Assert: Document remains small (only ObjectIds, no embedding)
queried.ShouldNotBeNull();
queried.ProductIds?.Count.ShouldBe(100);
// Note: 100 ObjectIds = ~1.2KB (vs embedding full products = potentially hundreds of KBs)
// This demonstrates why referencing is preferred for large N-N relationships
}
/// <summary>
/// Executes NtoNReferencing_QueryByProductId_ShouldWork.
/// </summary>
[Fact]
public void NtoNReferencing_QueryByProductId_ShouldWork()
{
// Arrange: Create multiple categories referencing same product
var productId = ObjectId.NewObjectId();
var category1 = new CategoryRef
{
Id = ObjectId.NewObjectId(),
Name = "Category 1",
Description = "First category",
ProductIds = new List<ObjectId> { productId }
};
var category2 = new CategoryRef
{
Id = ObjectId.NewObjectId(),
Name = "Category 2",
Description = "Second category",
ProductIds = new List<ObjectId> { productId }
};
_context.CategoryRefs.Insert(category1);
_context.CategoryRefs.Insert(category2);
// Act: Query all categories containing the product
var categoriesWithProduct = _context.CategoryRefs
.AsQueryable()
.Where(c => c.ProductIds != null && c.ProductIds.Contains(productId))
.ToList();
// Assert: Should find both categories
categoriesWithProduct.Count.ShouldBe(2);
categoriesWithProduct.ShouldContain(c => c.Name == "Category 1");
categoriesWithProduct.ShouldContain(c => c.Name == "Category 2");
}
}

View File

@@ -0,0 +1,170 @@
using ZB.MOM.WW.CBDD.Shared;
namespace ZB.MOM.WW.CBDD.Tests
{
/// <summary>
/// Tests for entities with nullable string Id (like UuidEntity scenario from CleanCore)
/// This reproduces the bug where the generator incorrectly chose ObjectIdMapperBase
/// instead of StringMapperBase for inherited nullable string Id properties
/// </summary>
public class NullableStringIdTests : System.IDisposable
{
private const string DbPath = "nullable_string_id.db";
/// <summary>
/// Initializes a new instance of the <see cref="NullableStringIdTests"/> class.
/// </summary>
public NullableStringIdTests()
{
if (File.Exists(DbPath)) File.Delete(DbPath);
}
/// <summary>
/// Disposes test resources.
/// </summary>
public void Dispose()
{
if (File.Exists(DbPath)) File.Delete(DbPath);
}
/// <summary>
/// Verifies the mock counter collection is initialized.
/// </summary>
[Fact]
public void MockCounter_Collection_IsInitialized()
{
using var db = new Shared.TestDbContext(DbPath);
// Verify Collection is not null (initialized by generated method)
db.MockCounters.ShouldNotBeNull();
}
/// <summary>
/// Verifies insert and find-by-id operations work for string identifiers.
/// </summary>
[Fact]
public void MockCounter_Insert_And_FindById_Works()
{
using var db = new Shared.TestDbContext(DbPath);
var counter = new MockCounter("test-id-123")
{
Name = "TestCounter",
Value = 42
};
// Insert should work with string Id
db.MockCounters.Insert(counter);
// FindById should retrieve the entity
var stored = db.MockCounters.FindById("test-id-123");
stored.ShouldNotBeNull();
stored.Id.ShouldBe("test-id-123");
stored.Name.ShouldBe("TestCounter");
stored.Value.ShouldBe(42);
}
/// <summary>
/// Verifies update operations work for string identifiers.
/// </summary>
[Fact]
public void MockCounter_Update_Works()
{
using var db = new Shared.TestDbContext(DbPath);
var counter = new MockCounter("update-test")
{
Name = "Original",
Value = 10
};
db.MockCounters.Insert(counter);
// Update the entity
counter.Name = "Updated";
counter.Value = 20;
db.MockCounters.Update(counter);
// Verify update
var updated = db.MockCounters.FindById("update-test");
updated.ShouldNotBeNull();
updated.Name.ShouldBe("Updated");
updated.Value.ShouldBe(20);
}
/// <summary>
/// Verifies delete operations work for string identifiers.
/// </summary>
[Fact]
public void MockCounter_Delete_Works()
{
using var db = new Shared.TestDbContext(DbPath);
var counter = new MockCounter("delete-test")
{
Name = "ToDelete",
Value = 99
};
db.MockCounters.Insert(counter);
db.MockCounters.FindById("delete-test").ShouldNotBeNull();
// Delete the entity
db.MockCounters.Delete("delete-test");
// Verify deletion
var deleted = db.MockCounters.FindById("delete-test");
deleted.ShouldBeNull();
}
/// <summary>
/// Verifies query operations work for string identifiers.
/// </summary>
[Fact]
public void MockCounter_Query_Works()
{
using var db = new Shared.TestDbContext(DbPath);
db.MockCounters.Insert(new MockCounter("q1") { Name = "First", Value = 100 });
db.MockCounters.Insert(new MockCounter("q2") { Name = "Second", Value = 200 });
db.MockCounters.Insert(new MockCounter("q3") { Name = "Third", Value = 150 });
// Query all
var all = db.MockCounters.AsQueryable().ToList();
all.Count.ShouldBe(3);
// Query with condition
var highValues = db.MockCounters.AsQueryable()
.Where(c => c.Value > 150)
.ToList();
highValues.Count().ShouldBe(1);
highValues[0].Name.ShouldBe("Second");
}
/// <summary>
/// Verifies inherited string identifiers are stored and retrieved correctly.
/// </summary>
[Fact]
public void MockCounter_InheritedId_IsStoredCorrectly()
{
using var db = new Shared.TestDbContext(DbPath);
// Test that the inherited nullable string Id from MockBaseEntity works correctly
var counter = new MockCounter("inherited-id-test")
{
Name = "Inherited",
Value = 777
};
db.MockCounters.Insert(counter);
var stored = db.MockCounters.FindById("inherited-id-test");
stored.ShouldNotBeNull();
// Verify the Id is correctly stored and retrieved through inheritance
stored.Id.ShouldBe("inherited-id-test");
stored.Id.ShouldBeOfType<string>();
}
}
}

View File

@@ -0,0 +1,258 @@
using System;
using System.IO;
using System.Linq;
using Xunit;
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Bson.Schema;
using ZB.MOM.WW.CBDD.Core.Storage;
using ZB.MOM.WW.CBDD.Core.Collections;
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Shared.TestDbContext_TestDbContext_Mappers;
using ZB.MOM.WW.CBDD.Shared;
namespace ZB.MOM.WW.CBDD.Tests;
public class SchemaPersistenceTests : IDisposable
{
private readonly string _dbPath;
private readonly Shared.TestDbContext _db;
/// <summary>
/// Initializes a new instance of the <see cref="SchemaPersistenceTests"/> class.
/// </summary>
public SchemaPersistenceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"schema_test_{Guid.NewGuid()}.db");
_db = new Shared.TestDbContext(_dbPath);
}
/// <summary>
/// Disposes test resources and removes temporary files.
/// </summary>
public void Dispose()
{
_db.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
/// <summary>
/// Verifies BSON schema serialization and deserialization round-trips correctly.
/// </summary>
[Fact]
public void BsonSchema_Serialization_RoundTrip()
{
var schema = new BsonSchema
{
Title = "Person",
Fields =
{
new BsonField { Name = "id", Type = BsonType.ObjectId },
new BsonField { Name = "name", Type = BsonType.String, IsNullable = true },
new BsonField { Name = "age", Type = BsonType.Int32 },
new BsonField
{
Name = "address",
Type = BsonType.Document,
NestedSchema = new BsonSchema
{
Fields =
{
new BsonField { Name = "city", Type = BsonType.String }
}
}
}
}
};
var buffer = new byte[1024];
var keyMap = new System.Collections.Concurrent.ConcurrentDictionary<string, ushort>(StringComparer.OrdinalIgnoreCase);
var keys = new System.Collections.Concurrent.ConcurrentDictionary<ushort, string>();
// Manual registration for schema keys
ushort id = 1;
foreach (var k in new[] { "person", "id", "name", "age", "address", "city", "fields", "title", "type", "isnullable", "nestedschema", "t", "v", "f", "n", "b", "s", "a", "_v", "0", "1", "2", "3", "4", "5" })
{
keyMap[k] = id;
keys[id] = k;
id++;
}
var writer = new BsonSpanWriter(buffer, keyMap);
schema.ToBson(ref writer);
var reader = new BsonSpanReader(buffer.AsSpan(0, writer.Position), keys);
var roundTrip = BsonSchema.FromBson(ref reader);
roundTrip.Title.ShouldBe(schema.Title);
roundTrip.Fields.Count.ShouldBe(schema.Fields.Count);
roundTrip.Fields[0].Name.ShouldBe(schema.Fields[0].Name);
roundTrip.Fields[3].NestedSchema!.Fields[0].Name.ShouldBe(schema.Fields[3].NestedSchema!.Fields[0].Name);
schema.Equals(roundTrip).ShouldBeTrue();
}
/// <summary>
/// Verifies collection metadata is persisted and reloaded correctly.
/// </summary>
[Fact]
public void StorageEngine_Collections_Metadata_Persistence()
{
var meta = new CollectionMetadata
{
Name = "users",
PrimaryRootPageId = 10,
SchemaRootPageId = 20
};
meta.Indexes.Add(new IndexMetadata { Name = "age", IsUnique = false, Type = IndexType.BTree, PropertyPaths = ["Age"] });
_db.Storage.SaveCollectionMetadata(meta);
var loaded = _db.Storage.GetCollectionMetadata("users");
loaded.ShouldNotBeNull();
loaded.Name.ShouldBe(meta.Name);
loaded.PrimaryRootPageId.ShouldBe(meta.PrimaryRootPageId);
loaded.SchemaRootPageId.ShouldBe(meta.SchemaRootPageId);
loaded.Indexes.Count().ShouldBe(1);
loaded.Indexes[0].Name.ShouldBe("age");
}
/// <summary>
/// Verifies schema versioning appends new schema versions correctly.
/// </summary>
[Fact]
public void StorageEngine_Schema_Versioning()
{
var schema1 = new BsonSchema { Title = "V1", Fields = { new BsonField { Name = "f1", Type = BsonType.String } } };
var schema2 = new BsonSchema { Title = "V2", Fields = { new BsonField { Name = "f1", Type = BsonType.String }, new BsonField { Name = "f2", Type = BsonType.Int32 } } };
var rootId = _db.Storage.AppendSchema(0, schema1);
rootId.ShouldNotBe(0u);
var schemas = _db.Storage.GetSchemas(rootId);
schemas.Count().ShouldBe(1);
schemas[0].Title.ShouldBe("V1");
var updatedRoot = _db.Storage.AppendSchema(rootId, schema2);
updatedRoot.ShouldBe(rootId);
schemas = _db.Storage.GetSchemas(rootId);
schemas.Count.ShouldBe(2, $"Expected 2 schemas but found {schemas.Count}. Titles: {(schemas.Count > 0 ? string.Join(", ", schemas.Select(s => s.Title)) : "None")}");
schemas[0].Title.ShouldBe("V1");
schemas[1].Title.ShouldBe("V2");
}
/// <summary>
/// Verifies collection startup integrates schema versioning behavior.
/// </summary>
[Fact]
public void DocumentCollection_Integrates_Schema_Versioning_On_Startup()
{
// Use a dedicated database for this test to avoid schema pollution from _db
var testDbPath = Path.Combine(Path.GetTempPath(), $"schema_versioning_test_{Guid.NewGuid()}.db");
try
{
var mapper1 = new ZB_MOM_WW_CBDD_Shared_PersonMapper();
var schema1 = mapper1.GetSchema();
// 1. First startup - create DB and initialize Person collection
using (var db1 = new Shared.TestDbContext(testDbPath))
{
// Access only People collection to avoid initializing others
var coll = db1.People;
var meta = db1.Storage.GetCollectionMetadata("people_collection");
meta.ShouldNotBeNull();
var schemas = db1.Storage.GetSchemas(meta.SchemaRootPageId);
schemas.Count().ShouldBe(1);
schema1.Equals(schemas[0]).ShouldBeTrue("Persisted schema 1 should equal current schema 1");
coll.CurrentSchemaVersion.ShouldNotBeNull();
coll.CurrentSchemaVersion!.Value.Version.ShouldBe(1);
coll.CurrentSchemaVersion!.Value.Hash.ShouldBe(schema1.GetHash());
}
// 2. Restart with SAME schema (should NOT append)
using (var db2 = new Shared.TestDbContext(testDbPath))
{
var coll = db2.People;
var meta = db2.Storage.GetCollectionMetadata("people_collection");
var schemas = db2.Storage.GetSchemas(meta!.SchemaRootPageId);
schemas.Count().ShouldBe(1); // Still 1
coll.CurrentSchemaVersion!.Value.Version.ShouldBe(1);
coll.CurrentSchemaVersion!.Value.Hash.ShouldBe(schema1.GetHash());
}
// 3. Simulate schema evolution: Person with an additional field
// Since we can't change the actual Person class at runtime, this test verifies
// that the same schema doesn't get re-appended.
// A real-world scenario would involve deploying a new mapper version.
using (var db3 = new Shared.TestDbContext(testDbPath))
{
var coll = db3.People;
var meta = db3.Storage.GetCollectionMetadata("people_collection");
var schemas = db3.Storage.GetSchemas(meta!.SchemaRootPageId);
// Schema should still be 1 since we're using the same Person type
schemas.Count().ShouldBe(1);
schemas[0].Title.ShouldBe("Person");
coll.CurrentSchemaVersion!.Value.Version.ShouldBe(1);
}
}
finally
{
if (File.Exists(testDbPath)) File.Delete(testDbPath);
}
}
/// <summary>
/// Verifies persisted documents include the schema version field.
/// </summary>
[Fact]
public void Document_Contains_Schema_Version_Field()
{
var mapper = new ZB_MOM_WW_CBDD_Shared_PersonMapper();
using (var coll = _db.People)
{
var person = new Person { Name = "John" };
var id = coll.Insert(person);
_db.SaveChanges();
coll.Count().ShouldBe(1);
coll.CurrentSchemaVersion.ShouldNotBeNull();
coll.CurrentSchemaVersion!.Value.Version.ShouldBe(1);
// Verify that the document in storage contains _v
var meta = _db.Storage.GetCollectionMetadata("persons"); // person lowercase
// meta.ShouldNotBeNull();
// Get location from primary index (internal access enabled by InternalsVisibleTo)
var key = mapper.ToIndexKey(id);
coll._primaryIndex.TryFind(key, out var location, 0).ShouldBeTrue();
// Read raw bytes from page
var pageBuffer = new byte[_db.Storage.PageSize];
_db.Storage.ReadPage(location.PageId, 0, pageBuffer);
var slotOffset = SlottedPageHeader.Size + (location.SlotIndex * SlotEntry.Size);
var slot = SlotEntry.ReadFrom(pageBuffer.AsSpan(slotOffset));
var docData = pageBuffer.AsSpan(slot.Offset, slot.Length);
// Print some info if it fails (using Assert messages)
string hex = BitConverter.ToString(docData.ToArray()).Replace("-", "");
// Look for _v (BsonType.Int32 + 2-byte ID)
ushort vId = _db.Storage.GetKeyMap()["_v"];
string vIdHex = vId.ToString("X4");
// Reverse endian for hex string check (ushort is LE)
string vIdHexLE = vIdHex.Substring(2, 2) + vIdHex.Substring(0, 2);
string pattern = "10" + vIdHexLE;
bool found = hex.Contains(pattern);
found.ShouldBeTrue($"Document should contain _v field ({pattern}). Raw BSON: {hex}");
// Verify the value (1) follows the key
int index = hex.IndexOf(pattern);
string valueHex = hex.Substring(index + 6, 8); // 4 bytes = 8 hex chars (pattern is 6 hex chars: 10 + ID_LE)
valueHex.ShouldBe("01000000");
}
}
}

View File

@@ -0,0 +1,63 @@
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Core.Collections;
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Shared.TestDbContext_TestDbContext_Mappers;
using System.Text;
using Xunit;
namespace ZB.MOM.WW.CBDD.Tests;
public class SchemaTests
{
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ushort> _testKeyMap = new(StringComparer.OrdinalIgnoreCase);
static SchemaTests()
{
ushort id = 1;
foreach (var k in new[] { "_id", "name", "mainaddress", "otheraddresses", "tags", "secret", "street", "city" }) _testKeyMap[k] = id++;
}
/// <summary>
/// Executes UsedKeys_ShouldReturnAllKeys.
/// </summary>
[Fact]
public void UsedKeys_ShouldReturnAllKeys()
{
var mapper = new ZB_MOM_WW_CBDD_Shared_ComplexUserMapper();
var keys = mapper.UsedKeys.ToList();
keys.ShouldContain("_id");
keys.ShouldContain("name");
keys.ShouldContain("mainaddress");
keys.ShouldContain("otheraddresses");
keys.ShouldContain("tags");
keys.ShouldContain("secret");
keys.ShouldContain("street");
keys.ShouldContain("city");
}
/// <summary>
/// Executes GetSchema_ShouldReturnBsonSchema.
/// </summary>
[Fact]
public void GetSchema_ShouldReturnBsonSchema()
{
var mapper = new ZB_MOM_WW_CBDD_Shared_ComplexUserMapper();
var schema = mapper.GetSchema();
var idField = schema.Fields.FirstOrDefault(f => f.Name == "_id");
idField.ShouldNotBeNull();
idField.Type.ShouldBe(BsonType.ObjectId);
var nameField = schema.Fields.FirstOrDefault(f => f.Name == "name");
nameField.ShouldNotBeNull();
nameField.Type.ShouldBe(BsonType.String);
var addressField = schema.Fields.FirstOrDefault(f => f.Name == "mainaddress");
addressField.ShouldNotBeNull();
addressField.Type.ShouldBe(BsonType.Document);
addressField.NestedSchema.ShouldNotBeNull();
// Address in MockEntities has City (Nested)
addressField.NestedSchema.Fields.ShouldContain(f => f.Name == "city");
}
}

View File

@@ -0,0 +1,253 @@
using System;
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Shared;
using ZB.MOM.WW.CBDD.Tests;
using Xunit;
namespace ZB.MOM.WW.CBDD.Tests
{
public class TemporalTypesTests : IDisposable
{
private readonly Shared.TestDbContext _db;
private readonly string _dbPath;
/// <summary>
/// Initializes a new instance of the <see cref="TemporalTypesTests"/> class.
/// </summary>
public TemporalTypesTests()
{
_dbPath = $"temporal_test_{Guid.NewGuid()}.db";
_db = new Shared.TestDbContext(_dbPath);
}
/// <summary>
/// Releases test resources.
/// </summary>
public void Dispose()
{
_db?.Dispose();
if (File.Exists(_dbPath))
File.Delete(_dbPath);
}
/// <summary>
/// Verifies temporal entity collection initialization.
/// </summary>
[Fact]
public void TemporalEntity_Collection_IsInitialized()
{
_db.TemporalEntities.ShouldNotBeNull();
}
/// <summary>
/// Verifies temporal fields round-trip through insert and lookup.
/// </summary>
[Fact]
public void TemporalEntity_Insert_And_FindById_Works()
{
// Arrange
var now = DateTime.UtcNow;
var offset = DateTimeOffset.UtcNow;
var duration = TimeSpan.FromHours(5.5);
var birthDate = new DateOnly(1990, 5, 15);
var openingTime = new TimeOnly(9, 30, 0);
var entity = new TemporalEntity
{
Id = ObjectId.NewObjectId(),
Name = "Test Entity",
CreatedAt = now,
UpdatedAt = offset,
LastAccessedAt = offset.AddDays(1),
Duration = duration,
OptionalDuration = TimeSpan.FromMinutes(30),
BirthDate = birthDate,
Anniversary = new DateOnly(2020, 6, 10),
OpeningTime = openingTime,
ClosingTime = new TimeOnly(18, 0, 0)
};
// Act
_db.TemporalEntities.Insert(entity);
var retrieved = _db.TemporalEntities.FindById(entity.Id);
// Assert
retrieved.ShouldNotBeNull();
retrieved.Name.ShouldBe(entity.Name);
// DateTime comparison (allowing some millisecond precision loss)
(retrieved.CreatedAt.Ticks / 10000).ShouldBe(entity.CreatedAt.Ticks / 10000); // millisecond precision
// DateTimeOffset comparison
(retrieved.UpdatedAt.UtcDateTime.Ticks / 10000).ShouldBe(entity.UpdatedAt.UtcDateTime.Ticks / 10000);
retrieved.LastAccessedAt.ShouldNotBeNull();
(retrieved.LastAccessedAt!.Value.UtcDateTime.Ticks / 10000).ShouldBe(entity.LastAccessedAt!.Value.UtcDateTime.Ticks / 10000);
// TimeSpan comparison
retrieved.Duration.ShouldBe(entity.Duration);
retrieved.OptionalDuration.ShouldNotBeNull();
retrieved.OptionalDuration!.Value.ShouldBe(entity.OptionalDuration!.Value);
// DateOnly comparison
retrieved.BirthDate.ShouldBe(entity.BirthDate);
retrieved.Anniversary.ShouldNotBeNull();
retrieved.Anniversary!.Value.ShouldBe(entity.Anniversary!.Value);
// TimeOnly comparison
retrieved.OpeningTime.ShouldBe(entity.OpeningTime);
retrieved.ClosingTime.ShouldNotBeNull();
retrieved.ClosingTime!.Value.ShouldBe(entity.ClosingTime!.Value);
}
/// <summary>
/// Verifies insert behavior when optional temporal fields are null.
/// </summary>
[Fact]
public void TemporalEntity_Insert_WithNullOptionalFields_Works()
{
// Arrange
var entity = new TemporalEntity
{
Id = ObjectId.NewObjectId(),
Name = "Minimal Entity",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromHours(1),
BirthDate = new DateOnly(1985, 3, 20),
OpeningTime = new TimeOnly(8, 0, 0),
// Optional fields left null
LastAccessedAt = null,
OptionalDuration = null,
Anniversary = null,
ClosingTime = null
};
// Act
_db.TemporalEntities.Insert(entity);
var retrieved = _db.TemporalEntities.FindById(entity.Id);
// Assert
retrieved.ShouldNotBeNull();
retrieved.Name.ShouldBe(entity.Name);
retrieved.LastAccessedAt.ShouldBeNull();
retrieved.OptionalDuration.ShouldBeNull();
retrieved.Anniversary.ShouldBeNull();
retrieved.ClosingTime.ShouldBeNull();
}
/// <summary>
/// Verifies temporal entity updates persist correctly.
/// </summary>
[Fact]
public void TemporalEntity_Update_Works()
{
// Arrange
var entity = new TemporalEntity
{
Id = ObjectId.NewObjectId(),
Name = "Original",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromHours(1),
BirthDate = new DateOnly(1990, 1, 1),
OpeningTime = new TimeOnly(9, 0, 0)
};
_db.TemporalEntities.Insert(entity);
// Act - Update temporal fields
entity.Name = "Updated";
entity.UpdatedAt = DateTimeOffset.UtcNow.AddDays(1);
entity.Duration = TimeSpan.FromHours(2);
entity.BirthDate = new DateOnly(1991, 2, 2);
entity.OpeningTime = new TimeOnly(10, 0, 0);
_db.TemporalEntities.Update(entity);
var retrieved = _db.TemporalEntities.FindById(entity.Id);
// Assert
retrieved.ShouldNotBeNull();
retrieved.Name.ShouldBe("Updated");
retrieved.Duration.ShouldBe(entity.Duration);
retrieved.BirthDate.ShouldBe(entity.BirthDate);
retrieved.OpeningTime.ShouldBe(entity.OpeningTime);
}
/// <summary>
/// Verifies querying temporal entities by temporal fields.
/// </summary>
[Fact]
public void TemporalEntity_Query_Works()
{
// Arrange
var birthDate1 = new DateOnly(1990, 1, 1);
var birthDate2 = new DateOnly(1995, 6, 15);
var entity1 = new TemporalEntity
{
Id = ObjectId.NewObjectId(),
Name = "Person 1",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromHours(1),
BirthDate = birthDate1,
OpeningTime = new TimeOnly(9, 0, 0)
};
var entity2 = new TemporalEntity
{
Id = ObjectId.NewObjectId(),
Name = "Person 2",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromHours(2),
BirthDate = birthDate2,
OpeningTime = new TimeOnly(10, 0, 0)
};
_db.TemporalEntities.Insert(entity1);
_db.TemporalEntities.Insert(entity2);
// Act
var results = _db.TemporalEntities.AsQueryable()
.Where(e => e.BirthDate == birthDate1)
.ToList();
// Assert
results.Count().ShouldBe(1);
results[0].Name.ShouldBe("Person 1");
}
/// <summary>
/// Verifies edge-case TimeSpan values are persisted correctly.
/// </summary>
[Fact]
public void TimeSpan_EdgeCases_Work()
{
// Arrange - Test various TimeSpan values
var entity = new TemporalEntity
{
Id = ObjectId.NewObjectId(),
Name = "TimeSpan Test",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.Zero,
OptionalDuration = TimeSpan.MaxValue,
BirthDate = DateOnly.MinValue,
OpeningTime = TimeOnly.MinValue
};
// Act
_db.TemporalEntities.Insert(entity);
var retrieved = _db.TemporalEntities.FindById(entity.Id);
// Assert
retrieved.ShouldNotBeNull();
retrieved.Duration.ShouldBe(TimeSpan.Zero);
retrieved.OptionalDuration.ShouldNotBeNull();
retrieved.OptionalDuration!.Value.ShouldBe(TimeSpan.MaxValue);
retrieved.BirthDate.ShouldBe(DateOnly.MinValue);
retrieved.OpeningTime.ShouldBe(TimeOnly.MinValue);
}
}
}

View File

@@ -0,0 +1,63 @@
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Core.Collections;
using Xunit;
using System.Linq;
namespace ZB.MOM.WW.CBDD.Tests;
public class VisibilityTests
{
public class VisibilityEntity
{
// Should be included
/// <summary>
/// Gets or sets the normal prop.
/// </summary>
public int NormalProp { get; set; }
// Should be included (serialization usually writes it)
/// <summary>
/// Gets or sets the private set prop.
/// </summary>
public int PrivateSetProp { get; private set; }
// Should be included
/// <summary>
/// Gets or sets the init prop.
/// </summary>
public int InitProp { get; init; }
// Fields - typically included in BSON if public, but reflection need GetFields
public string PublicField = string.Empty;
// Should NOT be included
private int _privateField;
// Helper to set private
/// <summary>
/// Tests set private.
/// </summary>
/// <param name="val">Value assigned to the private field.</param>
public void SetPrivate(int val) => _privateField = val;
}
/// <summary>
/// Tests generate schema visibility checks.
/// </summary>
[Fact]
public void GenerateSchema_VisibilityChecks()
{
var schema = BsonSchemaGenerator.FromType<VisibilityEntity>();
schema.Fields.ShouldContain(f => f.Name == "normalprop");
schema.Fields.ShouldContain(f => f.Name == "privatesetprop");
schema.Fields.ShouldContain(f => f.Name == "initprop");
// Verify assumption about fields
// Current implementation uses GetProperties, so publicfield might be missing.
// We will assert current status and then fix if requested/failed.
schema.Fields.ShouldContain(f => f.Name == "publicfield"); // This will likely fail currently
schema.Fields.ShouldNotContain(f => f.Name == "_privatefield");
}
}