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 <noreply@anthropic.com>master
| @@ -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(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 '/<h1>/,/Add to my list/p' /tmp/product.html)", | "Bash(sed -n '/<h1>/,/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 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": [ | "additionalDirectories": [ | ||||
| "G:\\development\\C Sharp AI\\CartWise" | "G:\\development\\C Sharp AI\\CartWise" | ||||
| @@ -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 | - Older observations are not overwritten by newer ones | ||||
| **Tasks** | **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 | ### `CW-STORY-05.4` Record household purchases | ||||
| **Release:** MVP | **Release:** MVP | ||||
| @@ -0,0 +1,89 @@ | |||||
| using CartWise.Domain.Enums; | |||||
| namespace CartWise.Domain.Entities; | |||||
| /// <summary> | |||||
| /// 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. | |||||
| /// </summary> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -41,6 +41,8 @@ public class CartWiseDbContext : IdentityDbContext<ApplicationUser>, IApplicatio | |||||
| public DbSet<PurchaseItem> PurchaseItems => Set<PurchaseItem>(); | public DbSet<PurchaseItem> PurchaseItems => Set<PurchaseItem>(); | ||||
| public DbSet<PriceObservation> PriceObservations => Set<PriceObservation>(); | |||||
| protected override void OnModelCreating(ModelBuilder builder) | protected override void OnModelCreating(ModelBuilder builder) | ||||
| { | { | ||||
| base.OnModelCreating(builder); | base.OnModelCreating(builder); | ||||
| @@ -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<PriceObservation> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<PriceObservation> builder) | |||||
| { | |||||
| builder.HasKey(o => o.PriceObservationId); | |||||
| builder.Property(o => o.CurrencyCode) | |||||
| .HasMaxLength(3) | |||||
| .IsRequired(); | |||||
| builder.Property(o => o.SourceReference) | |||||
| .HasMaxLength(200); | |||||
| builder.HasOne<Household>() | |||||
| .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<Product>() | |||||
| .WithMany() | |||||
| .HasForeignKey(o => o.ProductId) | |||||
| .OnDelete(DeleteBehavior.Restrict); | |||||
| builder.HasOne<StoreLocation>() | |||||
| .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 }); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,83 @@ | |||||
| using System; | |||||
| using Microsoft.EntityFrameworkCore.Migrations; | |||||
| #nullable disable | |||||
| namespace CartWise.Infrastructure.Data.Migrations | |||||
| { | |||||
| /// <inheritdoc /> | |||||
| public partial class AddPriceObservations : Migration | |||||
| { | |||||
| /// <inheritdoc /> | |||||
| protected override void Up(MigrationBuilder migrationBuilder) | |||||
| { | |||||
| migrationBuilder.CreateTable( | |||||
| name: "PriceObservations", | |||||
| columns: table => new | |||||
| { | |||||
| PriceObservationId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| HouseholdId = table.Column<Guid>(type: "TEXT", nullable: true), | |||||
| ProductId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| StoreLocationId = table.Column<Guid>(type: "TEXT", nullable: true), | |||||
| Price = table.Column<decimal>(type: "TEXT", nullable: false), | |||||
| SalePrice = table.Column<decimal>(type: "TEXT", nullable: true), | |||||
| UnitPrice = table.Column<decimal>(type: "TEXT", nullable: true), | |||||
| CurrencyCode = table.Column<string>(type: "TEXT", maxLength: 3, nullable: false), | |||||
| ObservedUtc = table.Column<DateTime>(type: "TEXT", nullable: false), | |||||
| SourceType = table.Column<int>(type: "INTEGER", nullable: false), | |||||
| SourceReference = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true), | |||||
| ConfidenceScore = table.Column<decimal>(type: "TEXT", nullable: false), | |||||
| CreatedUtc = table.Column<DateTime>(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"); | |||||
| } | |||||
| /// <inheritdoc /> | |||||
| protected override void Down(MigrationBuilder migrationBuilder) | |||||
| { | |||||
| migrationBuilder.DropTable( | |||||
| name: "PriceObservations"); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -159,6 +159,64 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| b.ToTable("HouseholdProductPreferences"); | b.ToTable("HouseholdProductPreferences"); | ||||
| }); | }); | ||||
| modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b => | |||||
| { | |||||
| b.Property<Guid>("PriceObservationId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal>("ConfidenceScore") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("CreatedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("CurrencyCode") | |||||
| .IsRequired() | |||||
| .HasMaxLength(3) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("HouseholdId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("ObservedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal>("Price") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("ProductId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal?>("SalePrice") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("SourceReference") | |||||
| .HasMaxLength(200) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("SourceType") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<Guid?>("StoreLocationId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal?>("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 => | modelBuilder.Entity("CartWise.Domain.Entities.Product", b => | ||||
| { | { | ||||
| b.Property<Guid>("ProductId") | b.Property<Guid>("ProductId") | ||||
| @@ -799,6 +857,25 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| .OnDelete(DeleteBehavior.SetNull); | .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 => | modelBuilder.Entity("CartWise.Domain.Entities.Product", b => | ||||
| { | { | ||||
| b.HasOne("CartWise.Domain.Entities.Brand", null) | b.HasOne("CartWise.Domain.Entities.Brand", null) | ||||
| @@ -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<ArgumentException>(() => new PriceObservation(Guid.Empty, 3.99m, Now, PriceObservationSourceType.UserEntered, 1m, Now)); | |||||
| } | |||||
| [Fact] | |||||
| public void Constructor_ThrowsWhenPriceIsNegative() | |||||
| { | |||||
| Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => new PriceObservation(Guid.NewGuid(), 3.99m, Now, PriceObservationSourceType.UserEntered, confidence, Now)); | |||||
| } | |||||
| [Fact] | |||||
| public void Constructor_ThrowsWhenCurrencyCodeIsNotThreeLetters() | |||||
| { | |||||
| Assert.Throws<ArgumentException>(() => 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); | |||||
| } | |||||
| } | |||||
Powered by TurnKey Linux.