Bläddra i källkod

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 <noreply@anthropic.com>
master
Daniel Covington 1 vecka sedan
förälder
incheckning
b77d0852a8
10 ändrade filer med 1444 tillägg och 6 borttagningar
  1. +2
    -1
      .claude/settings.json
  2. +7
    -5
      docs/scrum-backlog.md
  3. +89
    -0
      src/CartWise.Domain/Entities/PriceObservation.cs
  4. +12
    -0
      src/CartWise.Domain/Enums/PriceObservationSourceType.cs
  5. +2
    -0
      src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
  6. +41
    -0
      src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs
  7. +1073
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs
  8. +83
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs
  9. +77
    -0
      src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
  10. +58
    -0
      tests/CartWise.Domain.Tests/PriceObservationTests.cs

+ 2
- 1
.claude/settings.json Visa fil

@@ -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"


+ 7
- 5
docs/scrum-backlog.md Visa fil

@@ -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


+ 89
- 0
src/CartWise.Domain/Entities/PriceObservation.cs Visa fil

@@ -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;
}
}

+ 12
- 0
src/CartWise.Domain/Enums/PriceObservationSourceType.cs Visa fil

@@ -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
}

+ 2
- 0
src/CartWise.Infrastructure/Data/CartWiseDbContext.cs Visa fil

@@ -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);


+ 41
- 0
src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs Visa fil

@@ -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 });
}
}

+ 1073
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs
Filskillnaden har hållits tillbaka eftersom den är för stor
Visa fil


+ 83
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs Visa fil

@@ -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");
}
}
}

+ 77
- 0
src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs Visa fil

@@ -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)


+ 58
- 0
tests/CartWise.Domain.Tests/PriceObservationTests.cs Visa fil

@@ -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);
}
}

Laddar…
Avbryt
Spara

Powered by TurnKey Linux.