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(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 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" | |||
| @@ -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 | |||
| @@ -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<PriceObservation> PriceObservations => Set<PriceObservation>(); | |||
| protected override void OnModelCreating(ModelBuilder 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"); | |||
| }); | |||
| 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 => | |||
| { | |||
| b.Property<Guid>("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) | |||
| @@ -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.