From b77d0852a8a29cf9f6109ffb652a1df947fa5a91 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 10 Aug 2026 15:19:53 -0400 Subject: [PATCH] CW-STORY-05.3: model append-only price observations PriceObservation has zero mutation methods, enforced by a reflection test. Fixed a real bug found while writing the EF config: Product -> PriceObservation was wired Cascade (copy-paste), which would let deleting a Product silently destroy its price history. Changed to Restrict. Co-Authored-By: Claude Sonnet 5 --- .claude/settings.json | 3 +- docs/scrum-backlog.md | 12 +- .../Entities/PriceObservation.cs | 89 ++ .../Enums/PriceObservationSourceType.cs | 12 + .../Data/CartWiseDbContext.cs | 2 + .../PriceObservationConfiguration.cs | 41 + ...810191825_AddPriceObservations.Designer.cs | 1073 +++++++++++++++++ .../20260810191825_AddPriceObservations.cs | 83 ++ .../CartWiseDbContextModelSnapshot.cs | 77 ++ .../PriceObservationTests.cs | 58 + 10 files changed, 1444 insertions(+), 6 deletions(-) create mode 100644 src/CartWise.Domain/Entities/PriceObservation.cs create mode 100644 src/CartWise.Domain/Enums/PriceObservationSourceType.cs create mode 100644 src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs create mode 100644 src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs create mode 100644 src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs create mode 100644 tests/CartWise.Domain.Tests/PriceObservationTests.cs diff --git a/.claude/settings.json b/.claude/settings.json index cdf216a..10b92ce 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -38,7 +38,8 @@ "Bash(curl -s -b /tmp/cw5.txt http://127.0.0.1:5187/products/0ce5df7a-6c8d-489d-ba9f-8d41699f0705 -o /tmp/product.html -w \"status=%{http_code}\\\\n\")", "Bash(sed -n '/

/,/Add to my list/p' /tmp/product.html)", "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify4.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify4.db\" 2>&1 | tail -10 && rm -f verify4.db*)", - "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify5.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify5.db\" 2>&1 | tail -10 && rm -f verify5.db*)" + "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify5.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify5.db\" 2>&1 | tail -10 && rm -f verify5.db*)", + "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify6.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify6.db\" 2>&1 | tail -10 && rm -f verify6.db*)" ], "additionalDirectories": [ "G:\\development\\C Sharp AI\\CartWise" diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index 05f13d6..b4c1504 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -594,11 +594,13 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food - Older observations are not overwritten by newer ones **Tasks** -- [ ] Create `PriceObservation` -- [ ] Add indexes for product, household, and store history -- [ ] Add EF configuration -- [ ] Create migration -- [ ] Add tests for append-only behavior +- [x] Create `PriceObservation` +- [x] Add indexes for product, household, and store history +- [x] Add EF configuration +- [x] Create migration +- [x] Add tests for append-only behavior + +**Status:** Done — `PriceObservation` has zero mutation methods (only a constructor); a dedicated reflection-based test (`Type_HasNoPublicMutationMethods`) asserts no public instance methods exist beyond property getters, so the append-only invariant can't silently regress. Real bug caught and fixed while writing the EF configuration: `Product → PriceObservation` was initially wired `DeleteBehavior.Cascade` (copy-paste from other configs), which would have let deleting a `Product` silently wipe out its entire price history — directly contradicting CLAUDE.md's "protect historical data from accidental cascade deletion" rule. Changed to `Restrict`. All three indexes from AGENTS.md §5.6 added (`ProductId`+`ObservedUtc`, `+StoreLocationId`, `+HouseholdId`). `ConfidenceScore` is constructor-validated to `[0, 1]`; `CurrencyCode` must be exactly 3 letters (defaults `"USD"`). `AddPriceObservations` migration verified applying cleanly. 7 domain tests. ### `CW-STORY-05.4` Record household purchases **Release:** MVP diff --git a/src/CartWise.Domain/Entities/PriceObservation.cs b/src/CartWise.Domain/Entities/PriceObservation.cs new file mode 100644 index 0000000..3cc51f6 --- /dev/null +++ b/src/CartWise.Domain/Entities/PriceObservation.cs @@ -0,0 +1,89 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Entities; + +/// +/// Append-only price history. There are intentionally no mutation methods on this type — +/// a new observation is always a new row, never an update to an existing one. +/// +public class PriceObservation +{ + public Guid PriceObservationId { get; private set; } + + public Guid? HouseholdId { get; private set; } + + public Guid ProductId { get; private set; } + + public Guid? StoreLocationId { get; private set; } + + public decimal Price { get; private set; } + + public decimal? SalePrice { get; private set; } + + public decimal? UnitPrice { get; private set; } + + public string CurrencyCode { get; private set; } = null!; + + public DateTime ObservedUtc { get; private set; } + + public PriceObservationSourceType SourceType { get; private set; } + + public string? SourceReference { get; private set; } + + public decimal ConfidenceScore { get; private set; } + + public DateTime CreatedUtc { get; private set; } + + private PriceObservation() + { + } + + public PriceObservation( + Guid productId, + decimal price, + DateTime observedUtc, + PriceObservationSourceType sourceType, + decimal confidenceScore, + DateTime createdUtc, + Guid? householdId = null, + Guid? storeLocationId = null, + decimal? salePrice = null, + decimal? unitPrice = null, + string currencyCode = "USD", + string? sourceReference = null) + { + if (productId == Guid.Empty) + { + throw new ArgumentException("ProductId is required.", nameof(productId)); + } + + if (price < 0) + { + throw new ArgumentOutOfRangeException(nameof(price), "Price cannot be negative."); + } + + if (confidenceScore is < 0 or > 1) + { + throw new ArgumentOutOfRangeException(nameof(confidenceScore), "ConfidenceScore must be between 0 and 1."); + } + + if (string.IsNullOrWhiteSpace(currencyCode) || currencyCode.Length != 3) + { + throw new ArgumentException("CurrencyCode must be a 3-letter code.", nameof(currencyCode)); + } + + PriceObservationId = Guid.NewGuid(); + ProductId = productId; + Price = price; + ObservedUtc = observedUtc; + SourceType = sourceType; + ConfidenceScore = confidenceScore; + HouseholdId = householdId; + StoreLocationId = storeLocationId; + SalePrice = salePrice; + UnitPrice = unitPrice; + CurrencyCode = currencyCode.Trim().ToUpperInvariant(); + SourceReference = sourceReference; + CreatedUtc = createdUtc; + } +} diff --git a/src/CartWise.Domain/Enums/PriceObservationSourceType.cs b/src/CartWise.Domain/Enums/PriceObservationSourceType.cs new file mode 100644 index 0000000..f465dac --- /dev/null +++ b/src/CartWise.Domain/Enums/PriceObservationSourceType.cs @@ -0,0 +1,12 @@ +namespace CartWise.Domain.Enums; + +public enum PriceObservationSourceType +{ + UserEntered = 0, + ShoppingMode = 1, + Receipt = 2, + OpenFoodFacts = 3, + RetailerApi = 4, + Instacart = 5, + ImportedPurchase = 6 +} diff --git a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs index 0bf6ece..e1eb9f6 100644 --- a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs +++ b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs @@ -41,6 +41,8 @@ public class CartWiseDbContext : IdentityDbContext, IApplicatio public DbSet PurchaseItems => Set(); + public DbSet PriceObservations => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs new file mode 100644 index 0000000..1a9f9c5 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs @@ -0,0 +1,41 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class PriceObservationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.PriceObservationId); + + builder.Property(o => o.CurrencyCode) + .HasMaxLength(3) + .IsRequired(); + + builder.Property(o => o.SourceReference) + .HasMaxLength(200); + + builder.HasOne() + .WithMany() + .HasForeignKey(o => o.HouseholdId) + .OnDelete(DeleteBehavior.SetNull); + + // Restrict, not Cascade: PriceObservation is append-only history and must never be + // silently destroyed by deleting the Product it references. + builder.HasOne() + .WithMany() + .HasForeignKey(o => o.ProductId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne() + .WithMany() + .HasForeignKey(o => o.StoreLocationId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(o => new { o.ProductId, o.ObservedUtc }); + builder.HasIndex(o => new { o.ProductId, o.StoreLocationId, o.ObservedUtc }); + builder.HasIndex(o => new { o.HouseholdId, o.ProductId, o.ObservedUtc }); + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs new file mode 100644 index 0000000..ac9d930 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs @@ -0,0 +1,1073 @@ +// +using System; +using CartWise.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CartWise.Infrastructure.Data.Migrations +{ + [DbContext(typeof(CartWiseDbContext))] + [Migration("20260810191825_AddPriceObservations")] + partial class AddPriceObservations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("CartWise.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("BrandId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Brands"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => + { + b.Property("GroceryConceptId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("GroceryConceptId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("GroceryConcepts"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Property("HouseholdId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("HouseholdId"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("Households"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => + { + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("JoinedUtc") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("HouseholdId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("HouseholdMembers"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b => + { + b.Property("HouseholdProductPreferenceId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("PreferredProductId") + .HasColumnType("TEXT"); + + b.Property("PreferredQuantity") + .HasColumnType("TEXT"); + + b.Property("PreferredUnit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SubstitutionLevel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("HouseholdProductPreferenceId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("PreferredProductId"); + + b.HasIndex("HouseholdId", "GroceryConceptId") + .IsUnique(); + + b.ToTable("HouseholdProductPreferences"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b => + { + b.Property("PriceObservationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConfidenceScore") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("ObservedUtc") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("SalePrice") + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("StoreLocationId") + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PriceObservationId"); + + b.HasIndex("StoreLocationId"); + + b.HasIndex("ProductId", "ObservedUtc"); + + b.HasIndex("HouseholdId", "ProductId", "ObservedUtc"); + + b.HasIndex("ProductId", "StoreLocationId", "ObservedUtc"); + + b.ToTable("PriceObservations"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BrandId") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("SizeUnit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SizeValue") + .HasColumnType("TEXT"); + + b.Property("SourceExternalId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("ProductId"); + + b.HasIndex("BrandId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => + { + b.Property("ProductCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("ParentCategoryId") + .HasColumnType("TEXT"); + + b.HasKey("ProductCategoryId"); + + b.HasIndex("ParentCategoryId"); + + b.ToTable("ProductCategories"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b => + { + b.Property("ProductIdentifierId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IdentifierType") + .HasColumnType("INTEGER"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("ProductIdentifierId"); + + b.HasIndex("ProductId"); + + b.HasIndex("IdentifierType", "NormalizedValue") + .IsUnique(); + + b.ToTable("ProductIdentifiers"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Purchase", b => + { + b.Property("PurchaseId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("PurchasedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PurchasedUtc") + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("StoreLocationId") + .HasColumnType("TEXT"); + + b.Property("Subtotal") + .HasColumnType("TEXT"); + + b.Property("Tax") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.HasKey("PurchaseId"); + + b.HasIndex("PurchasedByUserId"); + + b.HasIndex("StoreLocationId"); + + b.HasIndex("HouseholdId", "PurchasedUtc"); + + b.ToTable("Purchases"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.PurchaseItem", b => + { + b.Property("PurchaseItemId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("LinePrice") + .HasColumnType("TEXT"); + + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("PurchaseId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("ShoppingListItemId") + .HasColumnType("TEXT"); + + b.Property("Unit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PurchaseItemId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("ShoppingListItemId"); + + b.ToTable("PurchaseItems"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Retailer", b => + { + b.Property("RetailerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("WebsiteUrl") + .HasColumnType("TEXT"); + + b.HasKey("RetailerId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Retailers"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.Property("ShoppingListId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("ShoppingListId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("HouseholdId", "Status"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b => + { + b.Property("ShoppingListItemId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AddedUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchasedUtc") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ShoppingListId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Unit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("ShoppingListItemId"); + + b.HasIndex("AddedByUserId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("ShoppingListId", "Status"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.StoreLocation", b => + { + b.Property("StoreLocationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddressLine1") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AddressLine2") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("City") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ExternalStoreId") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("Region") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("RetailerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("StoreLocationId"); + + b.HasIndex("RetailerId"); + + b.ToTable("StoreLocations"); + }); + + modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => + { + b.HasOne("CartWise.Domain.Entities.ProductCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => + { + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany("Members") + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b => + { + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("PreferredProductId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b => + { + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.StoreLocation", null) + .WithMany() + .HasForeignKey("StoreLocationId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.HasOne("CartWise.Domain.Entities.Brand", null) + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => + { + b.HasOne("CartWise.Domain.Entities.ProductCategory", null) + .WithMany() + .HasForeignKey("ParentCategoryId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b => + { + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany("Identifiers") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Purchase", b => + { + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("PurchasedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.StoreLocation", null) + .WithMany() + .HasForeignKey("StoreLocationId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.PurchaseItem", b => + { + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.Purchase", null) + .WithMany("Items") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.ShoppingListItem", null) + .WithMany() + .HasForeignKey("ShoppingListItemId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("AddedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.ShoppingList", null) + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.StoreLocation", b => + { + b.HasOne("CartWise.Domain.Entities.Retailer", null) + .WithMany() + .HasForeignKey("RetailerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.Navigation("Identifiers"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Purchase", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs new file mode 100644 index 0000000..0f299f3 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CartWise.Infrastructure.Data.Migrations +{ + /// + public partial class AddPriceObservations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PriceObservations", + columns: table => new + { + PriceObservationId = table.Column(type: "TEXT", nullable: false), + HouseholdId = table.Column(type: "TEXT", nullable: true), + ProductId = table.Column(type: "TEXT", nullable: false), + StoreLocationId = table.Column(type: "TEXT", nullable: true), + Price = table.Column(type: "TEXT", nullable: false), + SalePrice = table.Column(type: "TEXT", nullable: true), + UnitPrice = table.Column(type: "TEXT", nullable: true), + CurrencyCode = table.Column(type: "TEXT", maxLength: 3, nullable: false), + ObservedUtc = table.Column(type: "TEXT", nullable: false), + SourceType = table.Column(type: "INTEGER", nullable: false), + SourceReference = table.Column(type: "TEXT", maxLength: 200, nullable: true), + ConfidenceScore = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PriceObservations", x => x.PriceObservationId); + table.ForeignKey( + name: "FK_PriceObservations_Households_HouseholdId", + column: x => x.HouseholdId, + principalTable: "Households", + principalColumn: "HouseholdId", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_PriceObservations_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "ProductId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PriceObservations_StoreLocations_StoreLocationId", + column: x => x.StoreLocationId, + principalTable: "StoreLocations", + principalColumn: "StoreLocationId", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_PriceObservations_HouseholdId_ProductId_ObservedUtc", + table: "PriceObservations", + columns: new[] { "HouseholdId", "ProductId", "ObservedUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_PriceObservations_ProductId_ObservedUtc", + table: "PriceObservations", + columns: new[] { "ProductId", "ObservedUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_PriceObservations_ProductId_StoreLocationId_ObservedUtc", + table: "PriceObservations", + columns: new[] { "ProductId", "StoreLocationId", "ObservedUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_PriceObservations_StoreLocationId", + table: "PriceObservations", + column: "StoreLocationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PriceObservations"); + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs index a5bfdc1..bedea99 100644 --- a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs +++ b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs @@ -159,6 +159,64 @@ namespace CartWise.Infrastructure.Data.Migrations b.ToTable("HouseholdProductPreferences"); }); + modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b => + { + b.Property("PriceObservationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConfidenceScore") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("ObservedUtc") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("SalePrice") + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("StoreLocationId") + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PriceObservationId"); + + b.HasIndex("StoreLocationId"); + + b.HasIndex("ProductId", "ObservedUtc"); + + b.HasIndex("HouseholdId", "ProductId", "ObservedUtc"); + + b.HasIndex("ProductId", "StoreLocationId", "ObservedUtc"); + + b.ToTable("PriceObservations"); + }); + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => { b.Property("ProductId") @@ -799,6 +857,25 @@ namespace CartWise.Infrastructure.Data.Migrations .OnDelete(DeleteBehavior.SetNull); }); + modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b => + { + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.StoreLocation", null) + .WithMany() + .HasForeignKey("StoreLocationId") + .OnDelete(DeleteBehavior.SetNull); + }); + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => { b.HasOne("CartWise.Domain.Entities.Brand", null) diff --git a/tests/CartWise.Domain.Tests/PriceObservationTests.cs b/tests/CartWise.Domain.Tests/PriceObservationTests.cs new file mode 100644 index 0000000..674b360 --- /dev/null +++ b/tests/CartWise.Domain.Tests/PriceObservationTests.cs @@ -0,0 +1,58 @@ +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; +using System.Reflection; + +namespace CartWise.Domain.Tests; + +public class PriceObservationTests +{ + private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Constructor_SetsDefaultCurrencyCode() + { + var observation = new PriceObservation(Guid.NewGuid(), 3.99m, Now, PriceObservationSourceType.UserEntered, 1m, Now); + + Assert.Equal("USD", observation.CurrencyCode); + } + + [Fact] + public void Constructor_ThrowsWhenProductIdIsEmpty() + { + Assert.Throws(() => new PriceObservation(Guid.Empty, 3.99m, Now, PriceObservationSourceType.UserEntered, 1m, Now)); + } + + [Fact] + public void Constructor_ThrowsWhenPriceIsNegative() + { + Assert.Throws(() => new PriceObservation(Guid.NewGuid(), -1m, Now, PriceObservationSourceType.UserEntered, 1m, Now)); + } + + [Theory] + [InlineData(-0.01)] + [InlineData(1.01)] + public void Constructor_ThrowsWhenConfidenceScoreIsOutOfRange(decimal confidence) + { + Assert.Throws(() => new PriceObservation(Guid.NewGuid(), 3.99m, Now, PriceObservationSourceType.UserEntered, confidence, Now)); + } + + [Fact] + public void Constructor_ThrowsWhenCurrencyCodeIsNotThreeLetters() + { + Assert.Throws(() => new PriceObservation(Guid.NewGuid(), 3.99m, Now, PriceObservationSourceType.UserEntered, 1m, Now, currencyCode: "US")); + } + + [Fact] + public void Type_HasNoPublicMutationMethods() + { + // Append-only enforcement: nothing beyond the constructor may change an existing + // observation's state. If this test ever needs updating, the append-only invariant + // has likely been broken. + var publicMethods = typeof(PriceObservation) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName) // exclude property getters + .ToList(); + + Assert.Empty(publicMethods); + } +}