From fb59d109f6329d603ae583df89ad803ec8e18362 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:42:54 +0530 Subject: [PATCH 01/10] feat(domain): add household, recipient and milk rate model Add the Household, Recipient and MilkRate aggregates with typed UUIDv7 ids, Money in whole paise, PricePerLitre with half-up rounding once per line, and RateSchedule for rate lookups by date. DomainException can name the input a rule is about. Every applicable invariant from the domain model has a test. --- .../Common/DomainException.cs | 12 + .../PureFreshMilk.Domain/Common/Identifier.cs | 13 + .../src/PureFreshMilk.Domain/Common/Money.cs | 40 +++ .../PureFreshMilk.Domain/Common/NameRules.cs | 29 +++ .../Households/Household.cs | 82 +++++++ .../Households/HouseholdId.cs | 17 ++ .../PureFreshMilk.Domain/Rates/MilkRate.cs | 60 +++++ .../PureFreshMilk.Domain/Rates/MilkRateId.cs | 17 ++ .../Rates/PricePerLitre.cs | 48 ++++ .../Rates/RateSchedule.cs | 57 +++++ .../Recipients/Recipient.cs | 124 ++++++++++ .../Recipients/RecipientId.cs | 17 ++ .../Common/MoneyTests.cs | 59 +++++ .../Common/TypedIdTests.cs | 55 +++++ .../Households/HouseholdTests.cs | 149 ++++++++++++ .../Rates/MilkRateTests.cs | 79 ++++++ .../Rates/PricePerLitreTests.cs | 99 ++++++++ .../Rates/RateScheduleTests.cs | 84 +++++++ .../Recipients/RecipientTests.cs | 229 ++++++++++++++++++ 19 files changed, 1270 insertions(+) create mode 100644 backend/src/PureFreshMilk.Domain/Common/Identifier.cs create mode 100644 backend/src/PureFreshMilk.Domain/Common/Money.cs create mode 100644 backend/src/PureFreshMilk.Domain/Common/NameRules.cs create mode 100644 backend/src/PureFreshMilk.Domain/Households/Household.cs create mode 100644 backend/src/PureFreshMilk.Domain/Households/HouseholdId.cs create mode 100644 backend/src/PureFreshMilk.Domain/Rates/MilkRate.cs create mode 100644 backend/src/PureFreshMilk.Domain/Rates/MilkRateId.cs create mode 100644 backend/src/PureFreshMilk.Domain/Rates/PricePerLitre.cs create mode 100644 backend/src/PureFreshMilk.Domain/Rates/RateSchedule.cs create mode 100644 backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs create mode 100644 backend/src/PureFreshMilk.Domain/Recipients/RecipientId.cs create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Common/MoneyTests.cs create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Common/TypedIdTests.cs create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Households/HouseholdTests.cs create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Rates/MilkRateTests.cs create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Rates/PricePerLitreTests.cs create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Rates/RateScheduleTests.cs create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Recipients/RecipientTests.cs diff --git a/backend/src/PureFreshMilk.Domain/Common/DomainException.cs b/backend/src/PureFreshMilk.Domain/Common/DomainException.cs index a0d13ef..5a39a4c 100644 --- a/backend/src/PureFreshMilk.Domain/Common/DomainException.cs +++ b/backend/src/PureFreshMilk.Domain/Common/DomainException.cs @@ -9,4 +9,16 @@ public DomainException(string message) : base(message) { } + + public DomainException(string message, string? field) + : base(message) + { + Field = field; + } + + /// + /// The single input the broken rule is about, named as in the API (for example expectedMl), + /// or null when the rule concerns several inputs or none. + /// + public string? Field { get; } } diff --git a/backend/src/PureFreshMilk.Domain/Common/Identifier.cs b/backend/src/PureFreshMilk.Domain/Common/Identifier.cs new file mode 100644 index 0000000..834ece8 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Common/Identifier.cs @@ -0,0 +1,13 @@ +namespace PureFreshMilk.Domain.Common; + +/// +/// Shared rules for the typed ids: UUIDv7 values (ordered by time, so inserts stay index-friendly and +/// ids cannot be guessed) that are never empty. +/// +internal static class Identifier +{ + public static Guid Create(DateTimeOffset now) => Guid.CreateVersion7(now); + + public static Guid Require(Guid value, string what) => + value == Guid.Empty ? throw new DomainException($"{what} cannot be empty.") : value; +} diff --git a/backend/src/PureFreshMilk.Domain/Common/Money.cs b/backend/src/PureFreshMilk.Domain/Common/Money.cs new file mode 100644 index 0000000..57e6450 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Common/Money.cs @@ -0,0 +1,40 @@ +using System.Globalization; + +namespace PureFreshMilk.Domain.Common; + +/// +/// An amount of Indian rupees held as a signed whole number of paise, so that sums are exact and never +/// suffer from floating-point rounding (see ADR 0004). INR is the only currency. Unlike +/// it may be negative, because adjustments can be. +/// +public readonly record struct Money : IComparable +{ + private Money(long paise) => Paise = paise; + + public long Paise { get; } + + public static Money Zero => default; + + public static Money FromPaise(long paise) => new(paise); + + public static Money operator +(Money left, Money right) => + new(checked(left.Paise + right.Paise)); + + public static Money operator -(Money left, Money right) => + new(checked(left.Paise - right.Paise)); + + public static Money operator -(Money value) => new(checked(-value.Paise)); + + public static bool operator <(Money left, Money right) => left.CompareTo(right) < 0; + + public static bool operator >(Money left, Money right) => left.CompareTo(right) > 0; + + public static bool operator <=(Money left, Money right) => left.CompareTo(right) <= 0; + + public static bool operator >=(Money left, Money right) => left.CompareTo(right) >= 0; + + public int CompareTo(Money other) => Paise.CompareTo(other.Paise); + + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"{Paise} paise"); +} diff --git a/backend/src/PureFreshMilk.Domain/Common/NameRules.cs b/backend/src/PureFreshMilk.Domain/Common/NameRules.cs new file mode 100644 index 0000000..be640fc --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Common/NameRules.cs @@ -0,0 +1,29 @@ +namespace PureFreshMilk.Domain.Common; + +/// The rule shared by every display name: trimmed, 1 to 80 characters, no control characters. +internal static class NameRules +{ + public const int MaxLength = 80; + + public static string Require(string? value, string field, string what) + { + var name = value?.Trim() ?? string.Empty; + + if (name.Length == 0) + { + throw new DomainException($"{what} is required.", field); + } + + if (name.Length > MaxLength) + { + throw new DomainException($"{what} can have at most {MaxLength} characters.", field); + } + + if (name.Any(char.IsControl)) + { + throw new DomainException($"{what} cannot contain control characters.", field); + } + + return name; + } +} diff --git a/backend/src/PureFreshMilk.Domain/Households/Household.cs b/backend/src/PureFreshMilk.Domain/Households/Household.cs new file mode 100644 index 0000000..9c56a89 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Households/Household.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Domain.Households; + +/// +/// A household that buys milk. It owns the notion of "today": dates in this application are calendar +/// dates in the household's own time zone (ADR 0004). +/// +public sealed partial class Household +{ + public const string DefaultTimeZoneId = "Asia/Kolkata"; + + // Persistence constructor. + private Household() + { + } + + public HouseholdId Id { get; private set; } + + public string Name { get; private set; } = string.Empty; + + /// An IANA time zone id such as Asia/Kolkata. + public string TimeZoneId { get; private set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public static Household Create(string? name, string? timeZoneId, DateTimeOffset now) + { + var household = new Household { Id = HouseholdId.New(now), CreatedAt = now }; + household.Apply(name, timeZoneId, now); + return household; + } + + public void Update(string? name, string? timeZoneId, DateTimeOffset now) => Apply(name, timeZoneId, now); + + /// The calendar date it is right now in the household's time zone. + public DateOnly Today(DateTimeOffset utcNow) + { + var zone = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId); + return DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(utcNow, zone).DateTime); + } + + private void Apply(string? name, string? timeZoneId, DateTimeOffset now) + { + // Check everything before changing anything, so a rejected update leaves the household as it was. + var validName = NameRules.Require(name, "name", "The household name"); + var validTimeZoneId = RequireTimeZone(timeZoneId); + + Name = validName; + TimeZoneId = validTimeZoneId; + UpdatedAt = now; + } + + private static string RequireTimeZone(string? timeZoneId) + { + var id = timeZoneId?.Trim() ?? string.Empty; + + // Only IANA ids are stored: Windows ids ("India Standard Time") would not resolve on Linux. + if (!IanaIdPattern().IsMatch(id) || id.Length > 64) + { + throw new DomainException( + "The time zone must be an IANA id such as Asia/Kolkata.", "timeZoneId"); + } + + try + { + _ = TimeZoneInfo.FindSystemTimeZoneById(id); + } + catch (Exception exception) when (exception is TimeZoneNotFoundException or InvalidTimeZoneException) + { + throw new DomainException($"The time zone '{id}' is not known.", "timeZoneId"); + } + + return id; + } + + [GeneratedRegex(@"^[A-Za-z_]+(/[A-Za-z0-9_+\-]+)*$", RegexOptions.CultureInvariant)] + private static partial Regex IanaIdPattern(); +} diff --git a/backend/src/PureFreshMilk.Domain/Households/HouseholdId.cs b/backend/src/PureFreshMilk.Domain/Households/HouseholdId.cs new file mode 100644 index 0000000..6d9162f --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Households/HouseholdId.cs @@ -0,0 +1,17 @@ +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Domain.Households; + +/// Identifies a household. A distinct type so it cannot be passed where a recipient id is expected. +public readonly record struct HouseholdId +{ + private HouseholdId(Guid value) => Value = value; + + public Guid Value { get; } + + public static HouseholdId New(DateTimeOffset now) => new(Identifier.Create(now)); + + public static HouseholdId From(Guid value) => new(Identifier.Require(value, "A household id")); + + public override string ToString() => Value.ToString(); +} diff --git a/backend/src/PureFreshMilk.Domain/Rates/MilkRate.cs b/backend/src/PureFreshMilk.Domain/Rates/MilkRate.cs new file mode 100644 index 0000000..8d587b5 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Rates/MilkRate.cs @@ -0,0 +1,60 @@ +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Domain.Rates; + +/// +/// The price of a litre of milk for a household from a given day. A delivery is priced at the rate with +/// the latest effective-from date on or before the delivery date (see ). +/// +public sealed class MilkRate +{ + // Persistence constructor. + private MilkRate() + { + } + + public MilkRateId Id { get; private set; } + + public HouseholdId HouseholdId { get; private set; } + + /// The first day this price applies. + public DateOnly EffectiveFrom { get; private set; } + + public PricePerLitre Price { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public static MilkRate Create( + HouseholdId householdId, DateOnly effectiveFrom, PricePerLitre price, DateTimeOffset now) + { + var rate = new MilkRate + { + Id = MilkRateId.New(now), + HouseholdId = householdId, + CreatedAt = now, + }; + rate.Apply(effectiveFrom, price, now); + return rate; + } + + public void Change(DateOnly effectiveFrom, PricePerLitre price, DateTimeOffset now) => + Apply(effectiveFrom, price, now); + + private void Apply(DateOnly effectiveFrom, PricePerLitre price, DateTimeOffset now) + { + if (effectiveFrom == default) + { + throw new DomainException("The date the price starts on is required.", "effectiveFrom"); + } + + // A default PricePerLitre (0 paise) bypasses FromPaise, so check the value again. + _ = PricePerLitre.FromPaise(price.PaisePerLitre); + + EffectiveFrom = effectiveFrom; + Price = price; + UpdatedAt = now; + } +} diff --git a/backend/src/PureFreshMilk.Domain/Rates/MilkRateId.cs b/backend/src/PureFreshMilk.Domain/Rates/MilkRateId.cs new file mode 100644 index 0000000..3810283 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Rates/MilkRateId.cs @@ -0,0 +1,17 @@ +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Domain.Rates; + +/// Identifies a milk rate. +public readonly record struct MilkRateId +{ + private MilkRateId(Guid value) => Value = value; + + public Guid Value { get; } + + public static MilkRateId New(DateTimeOffset now) => new(Identifier.Create(now)); + + public static MilkRateId From(Guid value) => new(Identifier.Require(value, "A milk rate id")); + + public override string ToString() => Value.ToString(); +} diff --git a/backend/src/PureFreshMilk.Domain/Rates/PricePerLitre.cs b/backend/src/PureFreshMilk.Domain/Rates/PricePerLitre.cs new file mode 100644 index 0000000..8d08d51 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Rates/PricePerLitre.cs @@ -0,0 +1,48 @@ +using System.Globalization; +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Domain.Rates; + +/// +/// A price for one litre of milk, in whole paise (ADR 0004). The upper bound keeps every amount +/// computed from it far inside the range of a . +/// +public readonly record struct PricePerLitre : IComparable +{ + public const long MinPaise = 1; + public const long MaxPaise = 1_000_000; + + private PricePerLitre(long paisePerLitre) => PaisePerLitre = paisePerLitre; + + public long PaisePerLitre { get; } + + public static PricePerLitre FromPaise(long paisePerLitre) + { + if (paisePerLitre is < MinPaise or > MaxPaise) + { + throw new DomainException( + $"A price must be between {MinPaise} and {MaxPaise} paise a litre (got {paisePerLitre.ToString(CultureInfo.InvariantCulture)}).", + "pricePerLitrePaise"); + } + + return new PricePerLitre(paisePerLitre); + } + + /// + /// The cost of at this price: millilitres × paise per litre ÷ 1000, + /// rounded half-up to a whole paisa. Round once per bill line, never per delivery, so pass the + /// line's total quantity (ADR 0004). + /// + public Money CostOf(Quantity quantity) + { + var thousandthsOfPaisa = checked((long)quantity.Millilitres * PaisePerLitre); + const long halfOfDivisor = Quantity.MillilitresPerLitre / 2; + + return Money.FromPaise(checked(thousandthsOfPaisa + halfOfDivisor) / Quantity.MillilitresPerLitre); + } + + public int CompareTo(PricePerLitre other) => PaisePerLitre.CompareTo(other.PaisePerLitre); + + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"{PaisePerLitre} paise a litre"); +} diff --git a/backend/src/PureFreshMilk.Domain/Rates/RateSchedule.cs b/backend/src/PureFreshMilk.Domain/Rates/RateSchedule.cs new file mode 100644 index 0000000..c777c2b --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Rates/RateSchedule.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Domain.Rates; + +/// +/// One household's rates, ordered by effective-from date. Answers "which price applies on this day" +/// without a database, so the rule is unit-testable. +/// +public sealed class RateSchedule +{ + private readonly MilkRate[] rates; + + public RateSchedule(IEnumerable rates) + { + this.rates = [.. rates.OrderBy(rate => rate.EffectiveFrom)]; + + for (var index = 1; index < this.rates.Length; index++) + { + if (this.rates[index].EffectiveFrom == this.rates[index - 1].EffectiveFrom) + { + throw new DomainException( + $"Two rates cannot start on the same date ({this.rates[index].EffectiveFrom.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}).", + "effectiveFrom"); + } + + if (this.rates[index].HouseholdId != this.rates[0].HouseholdId) + { + throw new DomainException("A rate schedule belongs to a single household."); + } + } + } + + /// The rates, earliest first. + public IReadOnlyList Rates => rates; + + /// + /// The rate in force on : the one with the latest effective-from date on or before it, + /// or null before the first rate (such a day has no price; it is never priced at zero). + /// + public MilkRate? RateOn(DateOnly date) + { + MilkRate? inForce = null; + + foreach (var rate in rates) + { + if (rate.EffectiveFrom > date) + { + break; + } + + inForce = rate; + } + + return inForce; + } +} diff --git a/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs new file mode 100644 index 0000000..8989677 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Domain.Recipients; + +/// +/// Someone in the household who receives a daily milk delivery. Never deleted: a recipient who stops +/// receiving milk is archived, so their history stays (ADR 0004). +/// +public sealed class Recipient +{ + public const int MinExpectedMillilitres = 1; + public const int MaxExpectedMillilitres = 20_000; + + // Persistence constructor. + private Recipient() + { + } + + public RecipientId Id { get; private set; } + + public HouseholdId HouseholdId { get; private set; } + + public string Name { get; private set; } = string.Empty; + + /// The usual daily quantity. Applied when a delivery is recorded without one. + public Quantity ExpectedQuantity { get; private set; } + + /// The first day a delivery is expected. + public DateOnly StartsOn { get; private set; } + + /// The first day on which no delivery is expected, or null while the recipient is not archived. + public DateOnly? ArchivedFrom { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public static Recipient Add( + HouseholdId householdId, string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now) + { + var recipient = new Recipient + { + Id = RecipientId.New(now), + HouseholdId = householdId, + CreatedAt = now, + }; + recipient.Apply(name, expectedQuantity, startsOn, now); + return recipient; + } + + public void Update(string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now) => + Apply(name, expectedQuantity, startsOn, now); + + /// Stops deliveries from onwards. The days before it keep their history. + public void Archive(DateOnly from, DateTimeOffset now) + { + if (ArchivedFrom is not null) + { + throw new DomainException("The recipient is already archived."); + } + + if (from <= StartsOn) + { + throw new DomainException( + $"A recipient must be active for at least one day, so the archive date must be after the start date ({FormatDate(StartsOn)}).", + "archivedFrom"); + } + + ArchivedFrom = from; + UpdatedAt = now; + } + + /// Brings an archived recipient back. Does nothing if the recipient is not archived. + public void Unarchive(DateTimeOffset now) + { + if (ArchivedFrom is null) + { + return; + } + + ArchivedFrom = null; + UpdatedAt = now; + } + + /// Whether a delivery is expected on : from the start date up to the day before archiving. + public bool IsActiveOn(DateOnly date) => + date >= StartsOn && (ArchivedFrom is null || date < ArchivedFrom); + + /// Whether the recipient has been archived as of (an archive date in the future still counts as active). + public bool IsArchivedOn(DateOnly today) => ArchivedFrom is { } from && from <= today; + + private void Apply(string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now) + { + // Check everything before changing anything, so a rejected update leaves the recipient as it was. + var validName = NameRules.Require(name, "name", "The recipient's name"); + + if (expectedQuantity.Millilitres is < MinExpectedMillilitres or > MaxExpectedMillilitres) + { + throw new DomainException( + $"The usual quantity must be between {MinExpectedMillilitres} ml and {MaxExpectedMillilitres} ml (got {expectedQuantity.Millilitres} ml).", + "expectedMl"); + } + + if (startsOn == default) + { + throw new DomainException("The start date is required.", "startsOn"); + } + + if (ArchivedFrom is { } archivedFrom && startsOn >= archivedFrom) + { + throw new DomainException( + $"The start date must be before the archive date ({FormatDate(archivedFrom)}).", "startsOn"); + } + + Name = validName; + ExpectedQuantity = expectedQuantity; + StartsOn = startsOn; + UpdatedAt = now; + } + + private static string FormatDate(DateOnly date) => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); +} diff --git a/backend/src/PureFreshMilk.Domain/Recipients/RecipientId.cs b/backend/src/PureFreshMilk.Domain/Recipients/RecipientId.cs new file mode 100644 index 0000000..bdb7002 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Recipients/RecipientId.cs @@ -0,0 +1,17 @@ +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Domain.Recipients; + +/// Identifies a recipient. A distinct type so it cannot be passed where a household id is expected. +public readonly record struct RecipientId +{ + private RecipientId(Guid value) => Value = value; + + public Guid Value { get; } + + public static RecipientId New(DateTimeOffset now) => new(Identifier.Create(now)); + + public static RecipientId From(Guid value) => new(Identifier.Require(value, "A recipient id")); + + public override string ToString() => Value.ToString(); +} diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Common/MoneyTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Common/MoneyTests.cs new file mode 100644 index 0000000..ceeb5fc --- /dev/null +++ b/backend/tests/PureFreshMilk.Domain.Tests/Common/MoneyTests.cs @@ -0,0 +1,59 @@ +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Domain.Tests.Common; + +public sealed class MoneyTests +{ + [Fact] + public void Default_money_is_zero() + { + Assert.Equal(0, default(Money).Paise); + Assert.Equal(Money.Zero, default); + } + + [Fact] + public void Money_adds_and_subtracts_exactly() + { + var total = Money.FromPaise(46_875) + Money.FromPaise(15) - Money.FromPaise(90); + + Assert.Equal(Money.FromPaise(46_800), total); + } + + [Fact] + public void Money_can_be_negative_because_adjustments_can_be() + { + Assert.Equal(-250, Money.FromPaise(-250).Paise); + Assert.Equal(Money.FromPaise(-250), -Money.FromPaise(250)); + Assert.Equal(Money.FromPaise(-100), Money.FromPaise(50) - Money.FromPaise(150)); + } + + [Fact] + [Trait("Invariant", "B-4")] + public void Arithmetic_that_overflows_throws() + { + var largest = Money.FromPaise(long.MaxValue); + var smallest = Money.FromPaise(long.MinValue); + + Assert.Throws(() => largest + Money.FromPaise(1)); + Assert.Throws(() => smallest - Money.FromPaise(1)); + Assert.Throws(() => -smallest); + } + + [Fact] + public void Money_compares_by_amount() + { + var small = Money.FromPaise(100); + var large = Money.FromPaise(200); + + Assert.True(small < large); + Assert.True(large > small); + Assert.True(small <= Money.FromPaise(100)); + Assert.True(large >= small); + } + + [Fact] + public void ToString_shows_paise() + { + Assert.Equal("6250 paise", Money.FromPaise(6250).ToString()); + } +} diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Common/TypedIdTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Common/TypedIdTests.cs new file mode 100644 index 0000000..cfa8953 --- /dev/null +++ b/backend/tests/PureFreshMilk.Domain.Tests/Common/TypedIdTests.cs @@ -0,0 +1,55 @@ +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Domain.Tests.Common; + +public sealed class TypedIdTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero); + + [Fact] + public void New_ids_are_non_empty_version_7_uuids() + { + Guid[] values = [HouseholdId.New(Now).Value, RecipientId.New(Now).Value, MilkRateId.New(Now).Value]; + + Assert.All(values, value => + { + Assert.NotEqual(Guid.Empty, value); + Assert.Equal(7, value.Version); + }); + } + + [Fact] + public void New_ids_are_unique() + { + Assert.NotEqual(RecipientId.New(Now), RecipientId.New(Now)); + } + + [Fact] + public void From_round_trips_a_value() + { + var value = Guid.CreateVersion7(Now); + + Assert.Equal(value, HouseholdId.From(value).Value); + Assert.Equal(value, RecipientId.From(value).Value); + Assert.Equal(value, MilkRateId.From(value).Value); + } + + [Fact] + public void From_rejects_an_empty_guid() + { + Assert.Throws(() => HouseholdId.From(Guid.Empty)); + Assert.Throws(() => RecipientId.From(Guid.Empty)); + Assert.Throws(() => MilkRateId.From(Guid.Empty)); + } + + [Fact] + public void ToString_is_the_guid() + { + var value = Guid.CreateVersion7(Now); + + Assert.Equal(value.ToString(), RecipientId.From(value).ToString()); + } +} diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Households/HouseholdTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Households/HouseholdTests.cs new file mode 100644 index 0000000..4ffc7b7 --- /dev/null +++ b/backend/tests/PureFreshMilk.Domain.Tests/Households/HouseholdTests.cs @@ -0,0 +1,149 @@ +using System.Globalization; +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Domain.Tests.Households; + +public sealed class HouseholdTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero); + + [Fact] + public void Create_stores_the_details_and_timestamps() + { + var household = Household.Create(" The Example Household ", "Asia/Kolkata", Now); + + Assert.NotEqual(Guid.Empty, household.Id.Value); + Assert.Equal("The Example Household", household.Name); + Assert.Equal("Asia/Kolkata", household.TimeZoneId); + Assert.Equal(Now, household.CreatedAt); + Assert.Equal(Now, household.UpdatedAt); + } + + [Theory] + [Trait("Invariant", "H-1")] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_rejects_a_missing_name(string? name) + { + var exception = Assert.Throws(() => Household.Create(name, "Asia/Kolkata", Now)); + + Assert.Equal("name", exception.Field); + } + + [Fact] + [Trait("Invariant", "H-1")] + public void Name_can_have_80_characters_but_not_81() + { + Assert.Equal(80, Household.Create(new string('a', 80), "Asia/Kolkata", Now).Name.Length); + + var exception = Assert.Throws(() => Household.Create(new string('a', 81), "Asia/Kolkata", Now)); + Assert.Equal("name", exception.Field); + } + + [Fact] + [Trait("Invariant", "H-1")] + public void Name_rejects_control_characters() + { + Assert.Throws(() => Household.Create("Line one\nLine two", "Asia/Kolkata", Now)); + } + + [Theory] + [Trait("Invariant", "H-2")] + [InlineData("Asia/Kolkata")] + [InlineData("Europe/London")] + [InlineData("America/Argentina/Buenos_Aires")] + [InlineData("UTC")] + public void Create_accepts_valid_iana_time_zones(string timeZoneId) + { + Assert.Equal(timeZoneId, Household.Create("Home", timeZoneId, Now).TimeZoneId); + } + + [Theory] + [Trait("Invariant", "H-2")] + [InlineData(null)] + [InlineData("")] + [InlineData("Mars/Olympus_Mons")] + [InlineData("India Standard Time")] + [InlineData("UTC-11")] + [InlineData("Asia/")] + [InlineData("../etc/passwd")] + public void Create_rejects_unknown_or_non_iana_time_zones(string? timeZoneId) + { + var exception = Assert.Throws(() => Household.Create("Home", timeZoneId, Now)); + + Assert.Equal("timeZoneId", exception.Field); + } + + [Fact] + [Trait("Invariant", "H-2")] + public void Time_zone_ids_longer_than_64_characters_are_rejected() + { + Assert.Throws(() => Household.Create("Home", "Asia/" + new string('a', 70), Now)); + } + + [Theory] + [Trait("Invariant", "H-3")] + [InlineData("2026-10-30T18:29:00Z", "2026-10-30")] + [InlineData("2026-10-30T18:30:00Z", "2026-10-31")] + [InlineData("2026-10-30T00:00:00Z", "2026-10-30")] + public void Today_follows_the_household_time_zone(string utcNow, string expectedDate) + { + var household = Household.Create("Home", "Asia/Kolkata", Now); + + var today = household.Today(DateTimeOffset.Parse(utcNow, CultureInfo.InvariantCulture)); + + Assert.Equal(DateOnly.Parse(expectedDate, CultureInfo.InvariantCulture), today); + } + + [Theory] + [Trait("Invariant", "H-3")] + [InlineData("2026-10-30T03:59:00Z", "2026-10-29")] + [InlineData("2026-10-30T04:00:00Z", "2026-10-30")] + public void Today_accounts_for_daylight_saving_time(string utcNow, string expectedDate) + { + // New York is still on daylight saving time (UTC-4) on 30 October 2026. + var household = Household.Create("Home", "America/New_York", Now); + + var today = household.Today(DateTimeOffset.Parse(utcNow, CultureInfo.InvariantCulture)); + + Assert.Equal(DateOnly.Parse(expectedDate, CultureInfo.InvariantCulture), today); + } + + [Fact] + [Trait("Invariant", "H-4")] + public void Asia_Kolkata_resolves_on_this_platform() + { + // Fails loudly where time zone data is missing, for example in a slim container image. + var zone = TimeZoneInfo.FindSystemTimeZoneById(Household.DefaultTimeZoneId); + + Assert.Equal(TimeSpan.FromHours(5.5), zone.BaseUtcOffset); + } + + [Fact] + public void Update_changes_the_details_and_the_timestamp() + { + var household = Household.Create("Home", "Asia/Kolkata", Now); + var later = Now.AddHours(1); + + household.Update("New name", "Europe/London", later); + + Assert.Equal("New name", household.Name); + Assert.Equal("Europe/London", household.TimeZoneId); + Assert.Equal(Now, household.CreatedAt); + Assert.Equal(later, household.UpdatedAt); + } + + [Fact] + public void A_rejected_update_leaves_the_household_unchanged() + { + var household = Household.Create("Home", "Asia/Kolkata", Now); + + Assert.Throws(() => household.Update("New name", "Mars/Olympus_Mons", Now.AddHours(1))); + + Assert.Equal("Home", household.Name); + Assert.Equal("Asia/Kolkata", household.TimeZoneId); + Assert.Equal(Now, household.UpdatedAt); + } +} diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Rates/MilkRateTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Rates/MilkRateTests.cs new file mode 100644 index 0000000..845344d --- /dev/null +++ b/backend/tests/PureFreshMilk.Domain.Tests/Rates/MilkRateTests.cs @@ -0,0 +1,79 @@ +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Domain.Tests.Rates; + +public sealed class MilkRateTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero); + private static readonly HouseholdId Household = HouseholdId.New(Now); + + [Fact] + public void Create_stores_the_details() + { + var rate = MilkRate.Create(Household, new DateOnly(2026, 10, 1), PricePerLitre.FromPaise(6250), Now); + + Assert.NotEqual(Guid.Empty, rate.Id.Value); + Assert.Equal(Household, rate.HouseholdId); + Assert.Equal(new DateOnly(2026, 10, 1), rate.EffectiveFrom); + Assert.Equal(6250, rate.Price.PaisePerLitre); + Assert.Equal(Now, rate.CreatedAt); + Assert.Equal(Now, rate.UpdatedAt); + } + + [Fact] + [Trait("Invariant", "T-1")] + public void The_effective_from_date_is_required() + { + var exception = Assert.Throws( + () => MilkRate.Create(Household, default, PricePerLitre.FromPaise(6250), Now)); + + Assert.Equal("effectiveFrom", exception.Field); + } + + [Fact] + [Trait("Invariant", "T-1")] + public void A_default_price_of_zero_is_rejected() + { + var exception = Assert.Throws( + () => MilkRate.Create(Household, new DateOnly(2026, 10, 1), default, Now)); + + Assert.Equal("pricePerLitrePaise", exception.Field); + } + + [Fact] + [Trait("Invariant", "T-4")] + public void A_rate_can_start_in_the_past() + { + var rate = MilkRate.Create(Household, new DateOnly(2020, 1, 1), PricePerLitre.FromPaise(4000), Now); + + Assert.Equal(new DateOnly(2020, 1, 1), rate.EffectiveFrom); + } + + [Fact] + public void Change_updates_the_date_price_and_timestamp() + { + var rate = MilkRate.Create(Household, new DateOnly(2026, 10, 1), PricePerLitre.FromPaise(6250), Now); + var later = Now.AddHours(1); + + rate.Change(new DateOnly(2026, 10, 5), PricePerLitre.FromPaise(6400), later); + + Assert.Equal(new DateOnly(2026, 10, 5), rate.EffectiveFrom); + Assert.Equal(6400, rate.Price.PaisePerLitre); + Assert.Equal(Now, rate.CreatedAt); + Assert.Equal(later, rate.UpdatedAt); + } + + [Fact] + public void A_rejected_change_leaves_the_rate_unchanged() + { + var rate = MilkRate.Create(Household, new DateOnly(2026, 10, 1), PricePerLitre.FromPaise(6250), Now); + + Assert.Throws(() => rate.Change(default, PricePerLitre.FromPaise(6400), Now.AddHours(1))); + + Assert.Equal(new DateOnly(2026, 10, 1), rate.EffectiveFrom); + Assert.Equal(6250, rate.Price.PaisePerLitre); + Assert.Equal(Now, rate.UpdatedAt); + } +} diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Rates/PricePerLitreTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Rates/PricePerLitreTests.cs new file mode 100644 index 0000000..396e2b8 --- /dev/null +++ b/backend/tests/PureFreshMilk.Domain.Tests/Rates/PricePerLitreTests.cs @@ -0,0 +1,99 @@ +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Domain.Tests.Rates; + +public sealed class PricePerLitreTests +{ + [Theory] + [Trait("Invariant", "T-1")] + [InlineData(1)] + [InlineData(6250)] + [InlineData(1_000_000)] + public void A_price_from_1_to_1000000_paise_a_litre_is_accepted(long paise) + { + Assert.Equal(paise, PricePerLitre.FromPaise(paise).PaisePerLitre); + } + + [Theory] + [Trait("Invariant", "T-1")] + [InlineData(0)] + [InlineData(-1)] + [InlineData(1_000_001)] + [InlineData(long.MaxValue)] + public void Zero_negative_and_oversized_prices_are_rejected(long paise) + { + var exception = Assert.Throws(() => PricePerLitre.FromPaise(paise)); + + Assert.Equal("pricePerLitrePaise", exception.Field); + } + + [Theory] + [Trait("Invariant", "B-3")] + [InlineData(250, 6250, 1563)] // 1562.5 paise: the exact midpoint rounds up + [InlineData(1000, 6250, 6250)] + [InlineData(500, 3, 2)] // 1.5 paise: the exact midpoint rounds up + [InlineData(499, 3, 1)] // 1.497 paise + [InlineData(333, 3, 1)] // 0.999 paise + [InlineData(1, 1, 0)] // 0.001 paise + [InlineData(0, 6250, 0)] + [InlineData(7500, 6250, 46_875)] + public void The_cost_is_millilitres_times_price_over_1000_rounded_half_up( + int millilitres, long pricePaise, long expectedPaise) + { + var cost = PricePerLitre.FromPaise(pricePaise).CostOf(Quantity.FromMillilitres(millilitres)); + + Assert.Equal(expectedPaise, cost.Paise); + } + + [Fact] + [Trait("Invariant", "B-3")] + public void Rounding_once_per_line_differs_from_rounding_every_delivery() + { + var price = PricePerLitre.FromPaise(6250); + + // 30 days of 250 ml: one line of 7,500 ml, as a calculator would give it. + var once = price.CostOf(Quantity.FromMillilitres(30 * 250)); + // The rejected alternative: rounding every delivery overstates the bill by 15 paise. + var perDelivery = Enumerable.Range(0, 30).Aggregate( + Money.Zero, (sum, _) => sum + price.CostOf(Quantity.FromMillilitres(250))); + + Assert.Equal(Money.FromPaise(46_875), once); + Assert.Equal(Money.FromPaise(46_890), perDelivery); + } + + [Fact] + [Trait("Invariant", "B-2")] + public void A_rate_change_splits_a_month_into_one_line_per_rate() + { + // 1 L a day: ₹60.00 until 14 October (14 days), ₹64.00 from 15 October (17 days). + var before = PricePerLitre.FromPaise(6000).CostOf(Quantity.FromMillilitres(14 * 1000)); + var after = PricePerLitre.FromPaise(6400).CostOf(Quantity.FromMillilitres(17 * 1000)); + + Assert.Equal(Money.FromPaise(84_000), before); + Assert.Equal(Money.FromPaise(108_800), after); + Assert.Equal(Money.FromPaise(192_800), before + after); + } + + [Fact] + [Trait("Invariant", "B-4")] + public void The_largest_quantity_at_the_largest_price_does_not_overflow() + { + var cost = PricePerLitre.FromPaise(PricePerLitre.MaxPaise).CostOf(Quantity.FromMillilitres(int.MaxValue)); + + Assert.Equal(2_147_483_647_000L, cost.Paise); + } + + [Fact] + public void Prices_compare_by_amount() + { + Assert.True(PricePerLitre.FromPaise(6000).CompareTo(PricePerLitre.FromPaise(6400)) < 0); + Assert.Equal(PricePerLitre.FromPaise(6000), PricePerLitre.FromPaise(6000)); + } + + [Fact] + public void ToString_shows_paise_a_litre() + { + Assert.Equal("6250 paise a litre", PricePerLitre.FromPaise(6250).ToString()); + } +} diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Rates/RateScheduleTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Rates/RateScheduleTests.cs new file mode 100644 index 0000000..962e5d9 --- /dev/null +++ b/backend/tests/PureFreshMilk.Domain.Tests/Rates/RateScheduleTests.cs @@ -0,0 +1,84 @@ +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Domain.Tests.Rates; + +public sealed class RateScheduleTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero); + private static readonly HouseholdId Household = HouseholdId.New(Now); + + private static MilkRate Rate(DateOnly from, long paise, HouseholdId? household = null) => + MilkRate.Create(household ?? Household, from, PricePerLitre.FromPaise(paise), Now); + + private static readonly MilkRate October = Rate(new DateOnly(2026, 10, 1), 6000); + private static readonly MilkRate Mid = Rate(new DateOnly(2026, 10, 15), 6400); + + [Fact] + [Trait("Invariant", "T-2")] + public void There_is_no_rate_before_the_first_one() + { + var schedule = new RateSchedule([October, Mid]); + + Assert.Null(schedule.RateOn(new DateOnly(2026, 9, 30))); + } + + [Fact] + [Trait("Invariant", "T-2")] + public void An_empty_schedule_has_no_rate_on_any_day() + { + Assert.Null(new RateSchedule([]).RateOn(new DateOnly(2026, 10, 30))); + } + + [Theory] + [Trait("Invariant", "T-2")] + [InlineData("2026-10-01", 6000)] // the first rate applies on its own effective-from day + [InlineData("2026-10-14", 6000)] // the old rate applies the day before the change + [InlineData("2026-10-15", 6400)] // the new rate applies on its effective-from day + [InlineData("2026-12-31", 6400)] // and keeps applying until a later rate starts + public void The_rate_in_force_is_the_latest_one_starting_on_or_before_the_day(string date, long expectedPaise) + { + var schedule = new RateSchedule([October, Mid]); + + var rate = schedule.RateOn(DateOnly.Parse(date, System.Globalization.CultureInfo.InvariantCulture)); + + Assert.NotNull(rate); + Assert.Equal(expectedPaise, rate.Price.PaisePerLitre); + } + + [Fact] + [Trait("Invariant", "T-2")] + public void The_order_rates_were_added_in_does_not_matter() + { + // A back-dated rate is added last but still governs the days it covers. + var backDated = Rate(new DateOnly(2026, 9, 1), 5500); + var inAddedOrder = new RateSchedule([October, Mid, backDated]); + var reversed = new RateSchedule([backDated, Mid, October]); + + Assert.Equal(5500, inAddedOrder.RateOn(new DateOnly(2026, 9, 15))?.Price.PaisePerLitre); + Assert.Equal(5500, reversed.RateOn(new DateOnly(2026, 9, 15))?.Price.PaisePerLitre); + Assert.Equal( + inAddedOrder.Rates.Select(rate => rate.EffectiveFrom), + reversed.Rates.Select(rate => rate.EffectiveFrom)); + Assert.Equal(new DateOnly(2026, 9, 1), inAddedOrder.Rates[0].EffectiveFrom); + } + + [Fact] + public void Two_rates_cannot_start_on_the_same_day() + { + var duplicate = Rate(new DateOnly(2026, 10, 1), 7000); + + var exception = Assert.Throws(() => new RateSchedule([October, duplicate])); + + Assert.Equal("effectiveFrom", exception.Field); + } + + [Fact] + public void A_schedule_cannot_mix_households() + { + var other = Rate(new DateOnly(2026, 11, 1), 6500, HouseholdId.New(Now)); + + Assert.Throws(() => new RateSchedule([October, other])); + } +} diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Recipients/RecipientTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Recipients/RecipientTests.cs new file mode 100644 index 0000000..d016ffa --- /dev/null +++ b/backend/tests/PureFreshMilk.Domain.Tests/Recipients/RecipientTests.cs @@ -0,0 +1,229 @@ +using System.Globalization; +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Domain.Tests.Recipients; + +public sealed class RecipientTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero); + private static readonly HouseholdId Household = HouseholdId.New(Now); + private static readonly DateOnly Start = new(2026, 10, 1); + + private static Recipient NewRecipient(string name = "Recipient A", int millilitres = 1000) => + Recipient.Add(Household, name, Quantity.FromMillilitres(millilitres), Start, Now); + + [Fact] + public void Add_stores_the_details_and_starts_active() + { + var recipient = Recipient.Add(Household, " Recipient A ", Quantity.FromMillilitres(1000), Start, Now); + + Assert.NotEqual(Guid.Empty, recipient.Id.Value); + Assert.Equal(Household, recipient.HouseholdId); + Assert.Equal("Recipient A", recipient.Name); + Assert.Equal(1000, recipient.ExpectedQuantity.Millilitres); + Assert.Equal(Start, recipient.StartsOn); + Assert.Null(recipient.ArchivedFrom); + Assert.Equal(Now, recipient.CreatedAt); + Assert.Equal(Now, recipient.UpdatedAt); + } + + [Theory] + [Trait("Invariant", "R-1")] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Add_rejects_a_missing_name(string? name) + { + var exception = Assert.Throws( + () => Recipient.Add(Household, name, Quantity.FromMillilitres(1000), Start, Now)); + + Assert.Equal("name", exception.Field); + } + + [Fact] + [Trait("Invariant", "R-1")] + public void Name_can_have_80_characters_but_not_81() + { + Assert.Equal(80, NewRecipient(new string('a', 80)).Name.Length); + + var exception = Assert.Throws(() => NewRecipient(new string('a', 81))); + Assert.Equal("name", exception.Field); + } + + [Theory] + [Trait("Invariant", "R-2")] + [InlineData(1)] + [InlineData(250)] + [InlineData(20_000)] + public void The_usual_quantity_can_be_between_1_and_20000_ml(int millilitres) + { + Assert.Equal(millilitres, NewRecipient(millilitres: millilitres).ExpectedQuantity.Millilitres); + } + + [Theory] + [Trait("Invariant", "R-2")] + [InlineData(0)] + [InlineData(20_001)] + [InlineData(int.MaxValue)] + public void The_usual_quantity_outside_1_to_20000_ml_is_rejected(int millilitres) + { + var exception = Assert.Throws(() => NewRecipient(millilitres: millilitres)); + + Assert.Equal("expectedMl", exception.Field); + } + + [Fact] + [Trait("Invariant", "R-3")] + public void The_start_date_is_required() + { + var exception = Assert.Throws( + () => Recipient.Add(Household, "Recipient A", Quantity.FromMillilitres(1000), default, Now)); + + Assert.Equal("startsOn", exception.Field); + } + + [Theory] + [Trait("Invariant", "R-4")] + [InlineData("2026-09-30", false)] + [InlineData("2026-10-01", true)] + [InlineData("2026-10-14", true)] + [InlineData("2026-10-15", false)] + [InlineData("2026-10-16", false)] + public void A_recipient_is_active_from_the_start_date_until_the_day_before_archiving(string date, bool expected) + { + var recipient = NewRecipient(); + recipient.Archive(new DateOnly(2026, 10, 15), Now); + + Assert.Equal(expected, recipient.IsActiveOn(DateOnly.Parse(date, CultureInfo.InvariantCulture))); + } + + [Theory] + [Trait("Invariant", "R-4")] + [InlineData("2026-09-30", false)] + [InlineData("2026-10-01", true)] + [InlineData("2035-01-01", true)] + public void A_recipient_who_is_not_archived_stays_active_after_the_start_date(string date, bool expected) + { + Assert.Equal(expected, NewRecipient().IsActiveOn(DateOnly.Parse(date, CultureInfo.InvariantCulture))); + } + + [Fact] + [Trait("Invariant", "R-5")] + public void Archive_records_the_first_day_without_deliveries() + { + var recipient = NewRecipient(); + var later = Now.AddHours(1); + + recipient.Archive(new DateOnly(2026, 10, 31), later); + + Assert.Equal(new DateOnly(2026, 10, 31), recipient.ArchivedFrom); + Assert.Equal(later, recipient.UpdatedAt); + } + + [Theory] + [Trait("Invariant", "R-5")] + [InlineData("2026-10-01")] + [InlineData("2026-09-01")] + public void Archive_needs_a_date_after_the_start_date(string from) + { + var recipient = NewRecipient(); + + var exception = Assert.Throws( + () => recipient.Archive(DateOnly.Parse(from, CultureInfo.InvariantCulture), Now)); + + Assert.Equal("archivedFrom", exception.Field); + Assert.Null(recipient.ArchivedFrom); + } + + [Fact] + [Trait("Invariant", "R-5")] + public void Archiving_an_archived_recipient_is_rejected() + { + var recipient = NewRecipient(); + recipient.Archive(new DateOnly(2026, 10, 15), Now); + + Assert.Throws(() => recipient.Archive(new DateOnly(2026, 10, 20), Now)); + Assert.Equal(new DateOnly(2026, 10, 15), recipient.ArchivedFrom); + } + + [Fact] + [Trait("Invariant", "R-5")] + public void Unarchive_clears_the_archive_date() + { + var recipient = NewRecipient(); + recipient.Archive(new DateOnly(2026, 10, 15), Now); + var later = Now.AddHours(1); + + recipient.Unarchive(later); + + Assert.Null(recipient.ArchivedFrom); + Assert.Equal(later, recipient.UpdatedAt); + Assert.True(recipient.IsActiveOn(new DateOnly(2026, 10, 20))); + } + + [Fact] + [Trait("Invariant", "R-5")] + public void Unarchive_does_nothing_when_the_recipient_is_not_archived() + { + var recipient = NewRecipient(); + + recipient.Unarchive(Now.AddHours(1)); + + Assert.Null(recipient.ArchivedFrom); + Assert.Equal(Now, recipient.UpdatedAt); + } + + [Fact] + public void An_archive_date_in_the_future_still_counts_as_active_today() + { + var recipient = NewRecipient(); + recipient.Archive(new DateOnly(2026, 12, 1), Now); + + Assert.False(recipient.IsArchivedOn(new DateOnly(2026, 11, 30))); + Assert.True(recipient.IsArchivedOn(new DateOnly(2026, 12, 1))); + Assert.True(recipient.IsArchivedOn(new DateOnly(2027, 1, 1))); + Assert.False(NewRecipient().IsArchivedOn(new DateOnly(2027, 1, 1))); + } + + [Fact] + public void Update_changes_the_editable_fields() + { + var recipient = NewRecipient(); + var later = Now.AddHours(1); + + recipient.Update("Recipient B", Quantity.FromMillilitres(500), new DateOnly(2026, 9, 1), later); + + Assert.Equal("Recipient B", recipient.Name); + Assert.Equal(500, recipient.ExpectedQuantity.Millilitres); + Assert.Equal(new DateOnly(2026, 9, 1), recipient.StartsOn); + Assert.Equal(later, recipient.UpdatedAt); + } + + [Fact] + public void A_rejected_update_leaves_the_recipient_unchanged() + { + var recipient = NewRecipient(); + + Assert.Throws( + () => recipient.Update("Recipient B", Quantity.FromMillilitres(0), Start, Now.AddHours(1))); + + Assert.Equal("Recipient A", recipient.Name); + Assert.Equal(1000, recipient.ExpectedQuantity.Millilitres); + Assert.Equal(Now, recipient.UpdatedAt); + } + + [Fact] + public void The_start_date_cannot_be_moved_to_or_after_the_archive_date() + { + var recipient = NewRecipient(); + recipient.Archive(new DateOnly(2026, 10, 15), Now); + + var exception = Assert.Throws( + () => recipient.Update("Recipient A", Quantity.FromMillilitres(1000), new DateOnly(2026, 10, 15), Now)); + + Assert.Equal("startsOn", exception.Field); + Assert.Equal(Start, recipient.StartsOn); + } +} From b2731e02c1cbcc328161fa047bbf592667c598ea Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:48:49 +0530 Subject: [PATCH 02/10] feat(application): add household, recipient and milk rate use cases Add handlers for setting up the household, listing, adding, updating, archiving and restoring recipients, and listing, adding, changing, deleting and looking up milk rates. Repository interfaces, the unit of work and the current household are declared here without any EF Core types. Conflicts and missing data are reported with stable error codes. Handler tests run against an in-memory database that only commits on save. --- backend/PureFreshMilk.slnx | 2 + .../ApplicationServiceCollectionExtensions.cs | 19 + .../Common/ConflictException.cs | 8 + .../Common/ErrorCodes.cs | 16 + .../Common/NotFoundException.cs | 8 + .../Households/GetHouseholdHandler.cs | 12 + .../Households/HouseholdDetails.cs | 10 + .../Households/ICurrentHousehold.cs | 25 ++ .../Households/IHouseholdRepository.cs | 9 + .../Households/SetUpHouseholdHandler.cs | 44 ++ .../Persistence/IUnitOfWork.cs | 10 + .../Rates/AddMilkRateHandler.cs | 34 ++ .../Rates/DeleteMilkRateHandler.cs | 21 + .../Rates/GetRateInEffectHandler.cs | 22 + .../Rates/IMilkRateRepository.cs | 24 ++ .../Rates/ListMilkRatesHandler.cs | 16 + .../Rates/MilkRateDetails.cs | 11 + .../Rates/MilkRateRules.cs | 35 ++ .../Rates/UpdateMilkRateHandler.cs | 33 ++ .../Recipients/AddRecipientHandler.cs | 37 ++ .../Recipients/ArchiveRecipientHandler.cs | 38 ++ .../Recipients/GetRecipientHandler.cs | 13 + .../Recipients/IRecipientRepository.cs | 26 ++ .../Recipients/ListRecipientsHandler.cs | 19 + .../Recipients/RecipientDetails.cs | 17 + .../Recipients/RecipientRules.cs | 48 +++ .../Recipients/UnarchiveRecipientHandler.cs | 30 ++ .../Recipients/UpdateRecipientHandler.cs | 33 ++ .../Recipients/Recipient.cs | 8 +- .../Households/HouseholdHandlerTests.cs | 105 +++++ .../PureFreshMilk.Application.Tests.csproj | 9 + .../Rates/MilkRateHandlerTests.cs | 249 +++++++++++ .../Recipients/RecipientHandlerTests.cs | 386 ++++++++++++++++++ .../TestHousehold.cs | 90 ++++ .../FixedTimeProvider.cs | 13 + .../InMemoryDatabase.cs | 43 ++ .../InMemoryHouseholdRepository.cs | 13 + .../InMemoryMilkRateRepository.cs | 27 ++ .../InMemoryPersistenceExtensions.cs | 36 ++ .../InMemoryRecipientRepository.cs | 37 ++ .../PureFreshMilk.TestSupport.csproj | 23 ++ 41 files changed, 1658 insertions(+), 1 deletion(-) create mode 100644 backend/src/PureFreshMilk.Application/Common/ConflictException.cs create mode 100644 backend/src/PureFreshMilk.Application/Common/ErrorCodes.cs create mode 100644 backend/src/PureFreshMilk.Application/Common/NotFoundException.cs create mode 100644 backend/src/PureFreshMilk.Application/Households/GetHouseholdHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Households/HouseholdDetails.cs create mode 100644 backend/src/PureFreshMilk.Application/Households/ICurrentHousehold.cs create mode 100644 backend/src/PureFreshMilk.Application/Households/IHouseholdRepository.cs create mode 100644 backend/src/PureFreshMilk.Application/Households/SetUpHouseholdHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Persistence/IUnitOfWork.cs create mode 100644 backend/src/PureFreshMilk.Application/Rates/AddMilkRateHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Rates/DeleteMilkRateHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Rates/GetRateInEffectHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Rates/IMilkRateRepository.cs create mode 100644 backend/src/PureFreshMilk.Application/Rates/ListMilkRatesHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Rates/MilkRateDetails.cs create mode 100644 backend/src/PureFreshMilk.Application/Rates/MilkRateRules.cs create mode 100644 backend/src/PureFreshMilk.Application/Rates/UpdateMilkRateHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/AddRecipientHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/GetRecipientHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/IRecipientRepository.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/ListRecipientsHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/RecipientDetails.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/RecipientRules.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/UnarchiveRecipientHandler.cs create mode 100644 backend/src/PureFreshMilk.Application/Recipients/UpdateRecipientHandler.cs create mode 100644 backend/tests/PureFreshMilk.Application.Tests/Households/HouseholdHandlerTests.cs create mode 100644 backend/tests/PureFreshMilk.Application.Tests/PureFreshMilk.Application.Tests.csproj create mode 100644 backend/tests/PureFreshMilk.Application.Tests/Rates/MilkRateHandlerTests.cs create mode 100644 backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs create mode 100644 backend/tests/PureFreshMilk.Application.Tests/TestHousehold.cs create mode 100644 backend/tests/PureFreshMilk.TestSupport/FixedTimeProvider.cs create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryHouseholdRepository.cs create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryMilkRateRepository.cs create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryPersistenceExtensions.cs create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryRecipientRepository.cs create mode 100644 backend/tests/PureFreshMilk.TestSupport/PureFreshMilk.TestSupport.csproj diff --git a/backend/PureFreshMilk.slnx b/backend/PureFreshMilk.slnx index 696ed1a..135e71a 100644 --- a/backend/PureFreshMilk.slnx +++ b/backend/PureFreshMilk.slnx @@ -7,7 +7,9 @@ + + diff --git a/backend/src/PureFreshMilk.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/PureFreshMilk.Application/ApplicationServiceCollectionExtensions.cs index 6dc2f6f..1f4c840 100644 --- a/backend/src/PureFreshMilk.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/PureFreshMilk.Application/ApplicationServiceCollectionExtensions.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Rates; +using PureFreshMilk.Application.Recipients; using PureFreshMilk.Application.Status; namespace PureFreshMilk.Application; @@ -16,6 +19,22 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/backend/src/PureFreshMilk.Application/Common/ConflictException.cs b/backend/src/PureFreshMilk.Application/Common/ConflictException.cs new file mode 100644 index 0000000..646676d --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Common/ConflictException.cs @@ -0,0 +1,8 @@ +namespace PureFreshMilk.Application.Common; + +/// Raised when a request clashes with data that is already stored. The Api answers 409. +public sealed class ConflictException(string code, string message) : Exception(message) +{ + /// One of . + public string Code { get; } = code; +} diff --git a/backend/src/PureFreshMilk.Application/Common/ErrorCodes.cs b/backend/src/PureFreshMilk.Application/Common/ErrorCodes.cs new file mode 100644 index 0000000..6b8c736 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Common/ErrorCodes.cs @@ -0,0 +1,16 @@ +namespace PureFreshMilk.Application.Common; + +/// +/// Stable, machine-readable codes carried by and +/// . Clients branch on these, so they do not change once released. +/// +public static class ErrorCodes +{ + public const string HouseholdNotSetUp = "household-not-set-up"; + public const string RecipientNotFound = "recipient-not-found"; + public const string RecipientNameTaken = "recipient-name-taken"; + public const string RecipientAlreadyArchived = "recipient-already-archived"; + public const string RateNotFound = "rate-not-found"; + public const string RateDateTaken = "rate-date-taken"; + public const string NoRateInEffect = "no-rate-in-effect"; +} diff --git a/backend/src/PureFreshMilk.Application/Common/NotFoundException.cs b/backend/src/PureFreshMilk.Application/Common/NotFoundException.cs new file mode 100644 index 0000000..43809ec --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Common/NotFoundException.cs @@ -0,0 +1,8 @@ +namespace PureFreshMilk.Application.Common; + +/// Raised when something the caller asked for does not exist. The Api answers 404. +public sealed class NotFoundException(string code, string message) : Exception(message) +{ + /// One of . + public string Code { get; } = code; +} diff --git a/backend/src/PureFreshMilk.Application/Households/GetHouseholdHandler.cs b/backend/src/PureFreshMilk.Application/Households/GetHouseholdHandler.cs new file mode 100644 index 0000000..1ca644d --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Households/GetHouseholdHandler.cs @@ -0,0 +1,12 @@ +namespace PureFreshMilk.Application.Households; + +/// Reads the household, including today's date in its time zone. +public sealed class GetHouseholdHandler(ICurrentHousehold currentHousehold, TimeProvider timeProvider) +{ + public async Task HandleAsync(CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + + return HouseholdDetails.From(household, timeProvider); + } +} diff --git a/backend/src/PureFreshMilk.Application/Households/HouseholdDetails.cs b/backend/src/PureFreshMilk.Application/Households/HouseholdDetails.cs new file mode 100644 index 0000000..e87114f --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Households/HouseholdDetails.cs @@ -0,0 +1,10 @@ +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Application.Households; + +/// The calendar date it is now in the household's time zone, decided by the server. +public sealed record HouseholdDetails(Guid Id, string Name, string TimeZoneId, DateOnly Today) +{ + public static HouseholdDetails From(Household household, TimeProvider timeProvider) => + new(household.Id.Value, household.Name, household.TimeZoneId, household.Today(timeProvider.GetUtcNow())); +} diff --git a/backend/src/PureFreshMilk.Application/Households/ICurrentHousehold.cs b/backend/src/PureFreshMilk.Application/Households/ICurrentHousehold.cs new file mode 100644 index 0000000..f5b7152 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Households/ICurrentHousehold.cs @@ -0,0 +1,25 @@ +using PureFreshMilk.Application.Common; +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Application.Households; + +/// +/// The household the caller is working in. Until authentication exists, Infrastructure answers with the +/// only household (single-household mode); later it will come from the signed-in user, and no use case +/// changes (ADR 0004). +/// +public interface ICurrentHousehold +{ + /// The household, or null if none has been set up yet. + Task FindAsync(CancellationToken cancellationToken); +} + +public static class CurrentHouseholdExtensions +{ + /// The household, or if none has been set up yet. + public static async Task RequireAsync( + this ICurrentHousehold currentHousehold, CancellationToken cancellationToken) => + await currentHousehold.FindAsync(cancellationToken) + ?? throw new NotFoundException( + ErrorCodes.HouseholdNotSetUp, "The household has not been set up yet."); +} diff --git a/backend/src/PureFreshMilk.Application/Households/IHouseholdRepository.cs b/backend/src/PureFreshMilk.Application/Households/IHouseholdRepository.cs new file mode 100644 index 0000000..1262d79 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Households/IHouseholdRepository.cs @@ -0,0 +1,9 @@ +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Application.Households; + +/// Stores households. Implemented (internally) by Infrastructure; saved through IUnitOfWork. +public interface IHouseholdRepository +{ + void Add(Household household); +} diff --git a/backend/src/PureFreshMilk.Application/Households/SetUpHouseholdHandler.cs b/backend/src/PureFreshMilk.Application/Households/SetUpHouseholdHandler.cs new file mode 100644 index 0000000..80532c1 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Households/SetUpHouseholdHandler.cs @@ -0,0 +1,44 @@ +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Application.Households; + +/// An IANA id. Left out, it defaults to Asia/Kolkata on creation and is kept on update. +public sealed record SetUpHouseholdCommand(string? Name, string? TimeZoneId); + +/// true if this call created the household, false if it updated the existing one. +public sealed record SetUpHouseholdResult(HouseholdDetails Household, bool Created); + +/// +/// Creates the household the first time and updates it afterwards. In single-household mode there is +/// never a second household. +/// +public sealed class SetUpHouseholdHandler( + ICurrentHousehold currentHousehold, + IHouseholdRepository households, + IUnitOfWork unitOfWork, + TimeProvider timeProvider) +{ + public async Task HandleAsync( + SetUpHouseholdCommand command, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var existing = await currentHousehold.FindAsync(cancellationToken); + + Household household; + if (existing is null) + { + household = Household.Create(command.Name, command.TimeZoneId ?? Household.DefaultTimeZoneId, now); + households.Add(household); + } + else + { + household = existing; + household.Update(command.Name, command.TimeZoneId ?? household.TimeZoneId, now); + } + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return new SetUpHouseholdResult(HouseholdDetails.From(household, timeProvider), Created: existing is null); + } +} diff --git a/backend/src/PureFreshMilk.Application/Persistence/IUnitOfWork.cs b/backend/src/PureFreshMilk.Application/Persistence/IUnitOfWork.cs new file mode 100644 index 0000000..1ec43dd --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Persistence/IUnitOfWork.cs @@ -0,0 +1,10 @@ +namespace PureFreshMilk.Application.Persistence; + +/// +/// Saves everything a use case changed, in one transaction. Implemented by Infrastructure, which also +/// turns a clash with a unique index (two requests racing) into a ConflictException. +/// +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/PureFreshMilk.Application/Rates/AddMilkRateHandler.cs b/backend/src/PureFreshMilk.Application/Rates/AddMilkRateHandler.cs new file mode 100644 index 0000000..88ec872 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Rates/AddMilkRateHandler.cs @@ -0,0 +1,34 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Application.Rates; + +/// The first day the price applies. It may be in the past, to back-date a rise entered late. +/// The price of one litre, in whole paise. +public sealed record AddMilkRateCommand(DateOnly EffectiveFrom, long PricePerLitrePaise); + +public sealed class AddMilkRateHandler( + ICurrentHousehold currentHousehold, + IMilkRateRepository rates, + IUnitOfWork unitOfWork, + TimeProvider timeProvider) +{ + public async Task HandleAsync(AddMilkRateCommand command, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + + var rate = MilkRate.Create( + household.Id, + command.EffectiveFrom, + PricePerLitre.FromPaise(command.PricePerLitrePaise), + timeProvider.GetUtcNow()); + + await MilkRateRules.EnsureDateIsFreeAsync(rates, household.Id, rate.EffectiveFrom, exceptRate: null, cancellationToken); + + rates.Add(rate); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return MilkRateDetails.From(rate); + } +} diff --git a/backend/src/PureFreshMilk.Application/Rates/DeleteMilkRateHandler.cs b/backend/src/PureFreshMilk.Application/Rates/DeleteMilkRateHandler.cs new file mode 100644 index 0000000..c50283c --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Rates/DeleteMilkRateHandler.cs @@ -0,0 +1,21 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; + +namespace PureFreshMilk.Application.Rates; + +/// +/// Removes a rate. Once billing exists, a rate that governs a finalised month will be refused here +/// (period-finalised). +/// +public sealed class DeleteMilkRateHandler( + ICurrentHousehold currentHousehold, IMilkRateRepository rates, IUnitOfWork unitOfWork) +{ + public async Task HandleAsync(Guid id, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var rate = await MilkRateRules.RequireAsync(rates, household.Id, id, cancellationToken); + + rates.Remove(rate); + await unitOfWork.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/src/PureFreshMilk.Application/Rates/GetRateInEffectHandler.cs b/backend/src/PureFreshMilk.Application/Rates/GetRateInEffectHandler.cs new file mode 100644 index 0000000..d3cf9bc --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Rates/GetRateInEffectHandler.cs @@ -0,0 +1,22 @@ +using PureFreshMilk.Application.Common; +using PureFreshMilk.Application.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Application.Rates; + +/// The price in force on a date. A day before the first rate has no price, which is not the same as free. +public sealed class GetRateInEffectHandler( + ICurrentHousehold currentHousehold, IMilkRateRepository rates, TimeProvider timeProvider) +{ + /// The date to price. Left out, it is today in the household's time zone. + public async Task HandleAsync(DateOnly? on, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var schedule = new RateSchedule(await rates.ListAsync(household.Id, cancellationToken)); + + var rate = schedule.RateOn(on ?? household.Today(timeProvider.GetUtcNow())) + ?? throw new NotFoundException(ErrorCodes.NoRateInEffect, "No price is in force on that date."); + + return MilkRateDetails.From(rate); + } +} diff --git a/backend/src/PureFreshMilk.Application/Rates/IMilkRateRepository.cs b/backend/src/PureFreshMilk.Application/Rates/IMilkRateRepository.cs new file mode 100644 index 0000000..a9d7e6a --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Rates/IMilkRateRepository.cs @@ -0,0 +1,24 @@ +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Application.Rates; + +/// Stores milk rates. Implemented (internally) by Infrastructure; saved through IUnitOfWork. +public interface IMilkRateRepository +{ + /// Every rate of the household, in no particular order. + Task> ListAsync(HouseholdId householdId, CancellationToken cancellationToken); + + Task FindAsync(HouseholdId householdId, MilkRateId id, CancellationToken cancellationToken); + + /// + /// Whether the household already has a rate starting on . The database enforces + /// the same rule with a unique index; this check gives a friendly answer first. + /// + Task EffectiveFromTakenAsync( + HouseholdId householdId, DateOnly effectiveFrom, MilkRateId? exceptRate, CancellationToken cancellationToken); + + void Add(MilkRate rate); + + void Remove(MilkRate rate); +} diff --git a/backend/src/PureFreshMilk.Application/Rates/ListMilkRatesHandler.cs b/backend/src/PureFreshMilk.Application/Rates/ListMilkRatesHandler.cs new file mode 100644 index 0000000..86a989a --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Rates/ListMilkRatesHandler.cs @@ -0,0 +1,16 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Application.Rates; + +/// Lists the household's rates, newest effective date first. +public sealed class ListMilkRatesHandler(ICurrentHousehold currentHousehold, IMilkRateRepository rates) +{ + public async Task> HandleAsync(CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var schedule = new RateSchedule(await rates.ListAsync(household.Id, cancellationToken)); + + return [.. schedule.Rates.Reverse().Select(MilkRateDetails.From)]; + } +} diff --git a/backend/src/PureFreshMilk.Application/Rates/MilkRateDetails.cs b/backend/src/PureFreshMilk.Application/Rates/MilkRateDetails.cs new file mode 100644 index 0000000..d452dd9 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Rates/MilkRateDetails.cs @@ -0,0 +1,11 @@ +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Application.Rates; + +/// The first day this price applies. +/// The price of one litre, in whole paise. +public sealed record MilkRateDetails(Guid Id, DateOnly EffectiveFrom, long PricePerLitrePaise) +{ + public static MilkRateDetails From(MilkRate rate) => + new(rate.Id.Value, rate.EffectiveFrom, rate.Price.PaisePerLitre); +} diff --git a/backend/src/PureFreshMilk.Application/Rates/MilkRateRules.cs b/backend/src/PureFreshMilk.Application/Rates/MilkRateRules.cs new file mode 100644 index 0000000..f745cae --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Rates/MilkRateRules.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using PureFreshMilk.Application.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Application.Rates; + +/// Checks shared by the milk rate use cases. +internal static class MilkRateRules +{ + public static async Task EnsureDateIsFreeAsync( + IMilkRateRepository rates, + HouseholdId householdId, + DateOnly effectiveFrom, + MilkRateId? exceptRate, + CancellationToken cancellationToken) + { + if (await rates.EffectiveFromTakenAsync(householdId, effectiveFrom, exceptRate, cancellationToken)) + { + throw new ConflictException( + ErrorCodes.RateDateTaken, + $"A price already starts on {effectiveFrom.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}. Change that one instead."); + } + } + + public static async Task RequireAsync( + IMilkRateRepository rates, HouseholdId householdId, Guid id, CancellationToken cancellationToken) + { + var rate = id == Guid.Empty + ? null + : await rates.FindAsync(householdId, MilkRateId.From(id), cancellationToken); + + return rate ?? throw new NotFoundException(ErrorCodes.RateNotFound, "There is no such price."); + } +} diff --git a/backend/src/PureFreshMilk.Application/Rates/UpdateMilkRateHandler.cs b/backend/src/PureFreshMilk.Application/Rates/UpdateMilkRateHandler.cs new file mode 100644 index 0000000..d1ea88f --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Rates/UpdateMilkRateHandler.cs @@ -0,0 +1,33 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Application.Rates; + +/// +/// Changes the date or price of a rate. Once billing exists, a rate that governs a finalised month will be +/// refused here (period-finalised). +/// +public sealed record UpdateMilkRateCommand(Guid Id, DateOnly EffectiveFrom, long PricePerLitrePaise); + +public sealed class UpdateMilkRateHandler( + ICurrentHousehold currentHousehold, + IMilkRateRepository rates, + IUnitOfWork unitOfWork, + TimeProvider timeProvider) +{ + public async Task HandleAsync(UpdateMilkRateCommand command, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var rate = await MilkRateRules.RequireAsync(rates, household.Id, command.Id, cancellationToken); + + // Check the price and look for a clash before changing anything, so a rejected request leaves the rate as it was. + var price = PricePerLitre.FromPaise(command.PricePerLitrePaise); + await MilkRateRules.EnsureDateIsFreeAsync(rates, household.Id, command.EffectiveFrom, rate.Id, cancellationToken); + + rate.Change(command.EffectiveFrom, price, timeProvider.GetUtcNow()); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return MilkRateDetails.From(rate); + } +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/AddRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/AddRecipientHandler.cs new file mode 100644 index 0000000..465050b --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/AddRecipientHandler.cs @@ -0,0 +1,37 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Application.Recipients; + +/// The usual daily quantity, in whole millilitres. +/// The first day a delivery is expected. Left out, it is today in the household's time zone. +public sealed record AddRecipientCommand(string? Name, int ExpectedMl, DateOnly? StartsOn); + +public sealed class AddRecipientHandler( + ICurrentHousehold currentHousehold, + IRecipientRepository recipients, + IUnitOfWork unitOfWork, + TimeProvider timeProvider) +{ + public async Task HandleAsync(AddRecipientCommand command, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var now = timeProvider.GetUtcNow(); + + var recipient = Recipient.Add( + household.Id, + command.Name, + RecipientRules.ToQuantity(command.ExpectedMl), + command.StartsOn ?? household.Today(now), + now); + + await RecipientRules.EnsureNameIsFreeAsync( + recipients, household.Id, recipient.Name, exceptRecipient: null, cancellationToken); + + recipients.Add(recipient); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return RecipientDetails.From(recipient); + } +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs new file mode 100644 index 0000000..c888bce --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using PureFreshMilk.Application.Common; +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; + +namespace PureFreshMilk.Application.Recipients; + +/// +/// The first day without deliveries. Left out, it is today in the household's time zone. +/// +public sealed record ArchiveRecipientCommand(Guid Id, DateOnly? ArchivedFrom); + +/// Stops deliveries to a recipient from a date. Their history stays; nothing is deleted. +public sealed class ArchiveRecipientHandler( + ICurrentHousehold currentHousehold, + IRecipientRepository recipients, + IUnitOfWork unitOfWork, + TimeProvider timeProvider) +{ + public async Task HandleAsync(ArchiveRecipientCommand command, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var recipient = await RecipientRules.RequireAsync(recipients, household.Id, command.Id, cancellationToken); + var now = timeProvider.GetUtcNow(); + + if (recipient.ArchivedFrom is { } archivedFrom) + { + throw new ConflictException( + ErrorCodes.RecipientAlreadyArchived, + $"The recipient is already archived from {archivedFrom.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}. Bring them back before archiving again."); + } + + recipient.Archive(command.ArchivedFrom ?? household.Today(now), now); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return RecipientDetails.From(recipient); + } +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/GetRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/GetRecipientHandler.cs new file mode 100644 index 0000000..d266ec5 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/GetRecipientHandler.cs @@ -0,0 +1,13 @@ +using PureFreshMilk.Application.Households; + +namespace PureFreshMilk.Application.Recipients; + +public sealed class GetRecipientHandler(ICurrentHousehold currentHousehold, IRecipientRepository recipients) +{ + public async Task HandleAsync(Guid id, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + + return RecipientDetails.From(await RecipientRules.RequireAsync(recipients, household.Id, id, cancellationToken)); + } +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/IRecipientRepository.cs b/backend/src/PureFreshMilk.Application/Recipients/IRecipientRepository.cs new file mode 100644 index 0000000..a491f99 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/IRecipientRepository.cs @@ -0,0 +1,26 @@ +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Application.Recipients; + +/// Stores recipients. Implemented (internally) by Infrastructure; saved through IUnitOfWork. +public interface IRecipientRepository +{ + /// + /// The household's recipients, ordered by name. Unless is set, a + /// recipient already archived as of is left out. + /// + Task> ListAsync( + HouseholdId householdId, bool includeArchived, DateOnly today, CancellationToken cancellationToken); + + Task FindAsync(HouseholdId householdId, RecipientId id, CancellationToken cancellationToken); + + /// + /// Whether a recipient who is not archived already has this name, ignoring case. The database enforces the + /// same rule with a partial unique index; this check gives a friendly answer first. + /// + Task NameInUseByActiveRecipientAsync( + HouseholdId householdId, string name, RecipientId? exceptRecipient, CancellationToken cancellationToken); + + void Add(Recipient recipient); +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/ListRecipientsHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/ListRecipientsHandler.cs new file mode 100644 index 0000000..a65d476 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/ListRecipientsHandler.cs @@ -0,0 +1,19 @@ +using PureFreshMilk.Application.Households; + +namespace PureFreshMilk.Application.Recipients; + +/// Lists the household's recipients by name. Archived recipients are left out unless asked for. +public sealed class ListRecipientsHandler( + ICurrentHousehold currentHousehold, IRecipientRepository recipients, TimeProvider timeProvider) +{ + public async Task> HandleAsync( + bool includeArchived, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var today = household.Today(timeProvider.GetUtcNow()); + + var found = await recipients.ListAsync(household.Id, includeArchived, today, cancellationToken); + + return [.. found.Select(RecipientDetails.From)]; + } +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/RecipientDetails.cs b/backend/src/PureFreshMilk.Application/Recipients/RecipientDetails.cs new file mode 100644 index 0000000..caadb09 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/RecipientDetails.cs @@ -0,0 +1,17 @@ +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Application.Recipients; + +/// The usual daily quantity, in whole millilitres. +/// The first day a delivery is expected. +/// The first day without deliveries, or null while the recipient is not archived. +public sealed record RecipientDetails(Guid Id, string Name, int ExpectedMl, DateOnly StartsOn, DateOnly? ArchivedFrom) +{ + public static RecipientDetails From(Recipient recipient) => + new( + recipient.Id.Value, + recipient.Name, + recipient.ExpectedQuantity.Millilitres, + recipient.StartsOn, + recipient.ArchivedFrom); +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/RecipientRules.cs b/backend/src/PureFreshMilk.Application/Recipients/RecipientRules.cs new file mode 100644 index 0000000..d29dcd2 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/RecipientRules.cs @@ -0,0 +1,48 @@ +using PureFreshMilk.Application.Common; +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Application.Recipients; + +/// Checks shared by the recipient use cases. +internal static class RecipientRules +{ + /// Turns the millilitres in a request into a , naming the field if it is invalid. + public static Quantity ToQuantity(int millilitres) + { + try + { + return Quantity.FromMillilitres(millilitres); + } + catch (DomainException exception) when (exception.Field is null) + { + throw new DomainException(exception.Message, "expectedMl"); + } + } + + public static async Task EnsureNameIsFreeAsync( + IRecipientRepository recipients, + HouseholdId householdId, + string name, + RecipientId? exceptRecipient, + CancellationToken cancellationToken) + { + if (await recipients.NameInUseByActiveRecipientAsync(householdId, name, exceptRecipient, cancellationToken)) + { + throw new ConflictException( + ErrorCodes.RecipientNameTaken, + $"Another recipient who is not archived is already called '{name}'."); + } + } + + public static async Task RequireAsync( + IRecipientRepository recipients, HouseholdId householdId, Guid id, CancellationToken cancellationToken) + { + var recipient = id == Guid.Empty + ? null + : await recipients.FindAsync(householdId, RecipientId.From(id), cancellationToken); + + return recipient ?? throw new NotFoundException(ErrorCodes.RecipientNotFound, "There is no such recipient."); + } +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/UnarchiveRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/UnarchiveRecipientHandler.cs new file mode 100644 index 0000000..f876e1b --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/UnarchiveRecipientHandler.cs @@ -0,0 +1,30 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; + +namespace PureFreshMilk.Application.Recipients; + +/// Brings an archived recipient back. Does nothing, and succeeds, if they are not archived. +public sealed class UnarchiveRecipientHandler( + ICurrentHousehold currentHousehold, + IRecipientRepository recipients, + IUnitOfWork unitOfWork, + TimeProvider timeProvider) +{ + public async Task HandleAsync(Guid id, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var recipient = await RecipientRules.RequireAsync(recipients, household.Id, id, cancellationToken); + + if (recipient.ArchivedFrom is not null) + { + // Someone else may have taken the name while this recipient was archived. + await RecipientRules.EnsureNameIsFreeAsync( + recipients, household.Id, recipient.Name, recipient.Id, cancellationToken); + + recipient.Unarchive(timeProvider.GetUtcNow()); + await unitOfWork.SaveChangesAsync(cancellationToken); + } + + return RecipientDetails.From(recipient); + } +} diff --git a/backend/src/PureFreshMilk.Application/Recipients/UpdateRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/UpdateRecipientHandler.cs new file mode 100644 index 0000000..391aacf --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/UpdateRecipientHandler.cs @@ -0,0 +1,33 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Application.Recipients; + +/// Replaces the editable fields of a recipient. +public sealed record UpdateRecipientCommand(Guid Id, string? Name, int ExpectedMl, DateOnly StartsOn); + +public sealed class UpdateRecipientHandler( + ICurrentHousehold currentHousehold, + IRecipientRepository recipients, + IUnitOfWork unitOfWork, + TimeProvider timeProvider) +{ + public async Task HandleAsync(UpdateRecipientCommand command, CancellationToken cancellationToken) + { + var household = await currentHousehold.RequireAsync(cancellationToken); + var recipient = await RecipientRules.RequireAsync(recipients, household.Id, command.Id, cancellationToken); + + // Look for a clash before changing anything, so a rejected request leaves the recipient as it was. + var name = Recipient.NormalizeName(command.Name); + if (recipient.ArchivedFrom is null) + { + await RecipientRules.EnsureNameIsFreeAsync(recipients, household.Id, name, recipient.Id, cancellationToken); + } + + recipient.Update(name, RecipientRules.ToQuantity(command.ExpectedMl), command.StartsOn, timeProvider.GetUtcNow()); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return RecipientDetails.From(recipient); + } +} diff --git a/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs index 8989677..0ed2798 100644 --- a/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs +++ b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs @@ -53,6 +53,12 @@ public static Recipient Add( public void Update(string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now) => Apply(name, expectedQuantity, startsOn, now); + /// + /// The name as it will be stored (trimmed), or a rule violation if it is not a valid name. Lets a caller + /// compare names, for example to look for a duplicate, before changing anything. + /// + public static string NormalizeName(string? name) => NameRules.Require(name, "name", "The recipient's name"); + /// Stops deliveries from onwards. The days before it keep their history. public void Archive(DateOnly from, DateTimeOffset now) { @@ -94,7 +100,7 @@ public bool IsActiveOn(DateOnly date) => private void Apply(string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now) { // Check everything before changing anything, so a rejected update leaves the recipient as it was. - var validName = NameRules.Require(name, "name", "The recipient's name"); + var validName = NormalizeName(name); if (expectedQuantity.Millilitres is < MinExpectedMillilitres or > MaxExpectedMillilitres) { diff --git a/backend/tests/PureFreshMilk.Application.Tests/Households/HouseholdHandlerTests.cs b/backend/tests/PureFreshMilk.Application.Tests/Households/HouseholdHandlerTests.cs new file mode 100644 index 0000000..9444485 --- /dev/null +++ b/backend/tests/PureFreshMilk.Application.Tests/Households/HouseholdHandlerTests.cs @@ -0,0 +1,105 @@ +using PureFreshMilk.Application.Common; +using PureFreshMilk.Application.Households; +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Application.Tests.Households; + +public sealed class HouseholdHandlerTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Reading_before_the_household_is_set_up_is_not_found() + { + var app = new TestHousehold(); + + var exception = await Assert.ThrowsAsync(() => app.GetHousehold.HandleAsync(Ct)); + + Assert.Equal(ErrorCodes.HouseholdNotSetUp, exception.Code); + } + + [Fact] + public async Task Set_up_creates_the_household_with_the_default_time_zone() + { + var app = new TestHousehold(); + + var result = await app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand(" Example household ", null), Ct); + + Assert.True(result.Created); + Assert.Equal("Example household", result.Household.Name); + Assert.Equal("Asia/Kolkata", result.Household.TimeZoneId); + Assert.Equal(TestHousehold.Today, result.Household.Today); + Assert.Single(app.Database.Households); + Assert.Equal(1, app.Database.SaveCount); + } + + [Fact] + public async Task Set_up_can_choose_the_time_zone() + { + var app = new TestHousehold(); + + var result = await app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("Home", "Europe/London"), Ct); + + Assert.Equal("Europe/London", result.Household.TimeZoneId); + } + + [Fact] + [Trait("Invariant", "H-5")] + public async Task Setting_up_again_updates_the_only_household_instead_of_creating_a_second() + { + var app = await TestHousehold.SetUpAsync(); + var originalId = Assert.Single(app.Database.Households).Id.Value; + + var renamed = await app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("Renamed", null), Ct); + var moved = await app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("Renamed", "Europe/London"), Ct); + + Assert.False(renamed.Created); + Assert.False(moved.Created); + Assert.Equal(originalId, Assert.Single(app.Database.Households).Id.Value); + Assert.Equal("Renamed", renamed.Household.Name); + Assert.Equal("Asia/Kolkata", renamed.Household.TimeZoneId); // an omitted time zone keeps the current one + Assert.Equal("Europe/London", moved.Household.TimeZoneId); + } + + [Theory] + [InlineData(null, "Asia/Kolkata", "name")] + [InlineData(" ", "Asia/Kolkata", "name")] + [InlineData("Home", "Mars/Olympus_Mons", "timeZoneId")] + [InlineData("Home", "India Standard Time", "timeZoneId")] + public async Task Invalid_input_is_rejected_and_nothing_is_stored(string? name, string timeZoneId, string field) + { + var app = new TestHousehold(); + + var exception = await Assert.ThrowsAsync( + () => app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand(name, timeZoneId), Ct)); + + Assert.Equal(field, exception.Field); + Assert.Empty(app.Database.Households); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + public async Task An_invalid_update_leaves_the_household_as_it_was() + { + var app = await TestHousehold.SetUpAsync(); + + await Assert.ThrowsAsync( + () => app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("New name", "Mars/Olympus_Mons"), Ct)); + + var household = await app.GetHousehold.HandleAsync(Ct); + Assert.Equal("Example household", household.Name); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + public async Task The_household_reports_today_in_its_own_time_zone() + { + var app = await TestHousehold.SetUpAsync(); + + app.Clock.Set(new DateTimeOffset(2026, 10, 30, 18, 29, 0, TimeSpan.Zero)); + Assert.Equal(new DateOnly(2026, 10, 30), (await app.GetHousehold.HandleAsync(Ct)).Today); + + app.Clock.Set(new DateTimeOffset(2026, 10, 30, 18, 30, 0, TimeSpan.Zero)); + Assert.Equal(new DateOnly(2026, 10, 31), (await app.GetHousehold.HandleAsync(Ct)).Today); + } +} diff --git a/backend/tests/PureFreshMilk.Application.Tests/PureFreshMilk.Application.Tests.csproj b/backend/tests/PureFreshMilk.Application.Tests/PureFreshMilk.Application.Tests.csproj new file mode 100644 index 0000000..8648d19 --- /dev/null +++ b/backend/tests/PureFreshMilk.Application.Tests/PureFreshMilk.Application.Tests.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/backend/tests/PureFreshMilk.Application.Tests/Rates/MilkRateHandlerTests.cs b/backend/tests/PureFreshMilk.Application.Tests/Rates/MilkRateHandlerTests.cs new file mode 100644 index 0000000..14dad7b --- /dev/null +++ b/backend/tests/PureFreshMilk.Application.Tests/Rates/MilkRateHandlerTests.cs @@ -0,0 +1,249 @@ +using PureFreshMilk.Application.Common; +using PureFreshMilk.Application.Rates; +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Application.Tests.Rates; + +public sealed class MilkRateHandlerTests +{ + private static readonly DateOnly October1 = new(2026, 10, 1); + private static readonly DateOnly October15 = new(2026, 10, 15); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Adding_stores_the_rate() + { + var app = await TestHousehold.SetUpAsync(); + + var rate = await app.AddRateAsync(October1, 6250); + + Assert.Equal(new MilkRateDetails(rate.Id, October1, 6250), rate); + Assert.Equal(rate.Id, Assert.Single(app.Database.MilkRates).Id.Value); + Assert.Equal(1, app.Database.SaveCount); + } + + [Fact] + [Trait("Invariant", "T-4")] + public async Task A_rate_can_be_added_for_a_day_in_the_past() + { + var app = await TestHousehold.SetUpAsync(); + + var rate = await app.AddRateAsync(new DateOnly(2020, 1, 1), 4000); + + Assert.Equal(new DateOnly(2020, 1, 1), rate.EffectiveFrom); + } + + [Fact] + [Trait("Invariant", "T-3")] + public async Task A_second_rate_for_the_same_day_is_a_conflict() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRateAsync(October1, 6250); + + var exception = await Assert.ThrowsAsync(() => app.AddRateAsync(October1, 6400)); + + Assert.Equal(ErrorCodes.RateDateTaken, exception.Code); + Assert.Single(app.Database.MilkRates); + } + + [Theory] + [Trait("Invariant", "T-1")] + [InlineData(0)] + [InlineData(-100)] + [InlineData(1_000_001)] + public async Task An_invalid_price_is_rejected_with_the_field_and_nothing_is_stored(long paise) + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync(() => app.AddRateAsync(October1, paise)); + + Assert.Equal("pricePerLitrePaise", exception.Field); + Assert.Empty(app.Database.MilkRates); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + [Trait("Invariant", "T-1")] + public async Task A_missing_date_is_rejected() + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync(() => app.AddRateAsync(default, 6250)); + + Assert.Equal("effectiveFrom", exception.Field); + } + + [Fact] + public async Task Adding_before_the_household_is_set_up_is_not_found() + { + var app = new TestHousehold(); + + var exception = await Assert.ThrowsAsync(() => app.AddRateAsync(October1, 6250)); + + Assert.Equal(ErrorCodes.HouseholdNotSetUp, exception.Code); + } + + [Fact] + public async Task The_list_shows_the_newest_effective_date_first() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRateAsync(October15, 6400); + await app.AddRateAsync(new DateOnly(2026, 9, 1), 5500); // added last, but back-dated + await app.AddRateAsync(October1, 6000); + + var list = await app.ListRates.HandleAsync(Ct); + + Assert.Equal([October15, October1, new DateOnly(2026, 9, 1)], list.Select(rate => rate.EffectiveFrom)); + } + + [Fact] + public async Task The_list_is_empty_before_any_rate_is_added() + { + var app = await TestHousehold.SetUpAsync(); + + Assert.Empty(await app.ListRates.HandleAsync(Ct)); + } + + [Fact] + [Trait("Invariant", "T-5")] + public async Task With_no_rate_in_force_the_answer_is_none_not_zero() + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync(() => app.GetRateInEffect.HandleAsync(null, Ct)); + + Assert.Equal(ErrorCodes.NoRateInEffect, exception.Code); + } + + [Fact] + [Trait("Invariant", "T-5")] + public async Task A_day_before_the_first_rate_has_no_price() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRateAsync(October1, 6000); + + await Assert.ThrowsAsync(() => app.GetRateInEffect.HandleAsync(new DateOnly(2026, 9, 30), Ct)); + } + + [Theory] + [InlineData("2026-10-01", 6000)] + [InlineData("2026-10-14", 6000)] + [InlineData("2026-10-15", 6400)] + [InlineData("2027-01-01", 6400)] + public async Task The_rate_in_force_is_the_latest_one_starting_on_or_before_the_day(string date, long expectedPaise) + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRateAsync(October15, 6400); + await app.AddRateAsync(October1, 6000); + + var rate = await app.GetRateInEffect.HandleAsync( + DateOnly.Parse(date, System.Globalization.CultureInfo.InvariantCulture), Ct); + + Assert.Equal(expectedPaise, rate.PricePerLitrePaise); + } + + [Fact] + public async Task The_rate_in_force_defaults_to_today_in_the_household_time_zone() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRateAsync(October1, 6000); + await app.AddRateAsync(new DateOnly(2026, 10, 31), 6400); // starts tomorrow, in Asia/Kolkata + + Assert.Equal(6000, (await app.GetRateInEffect.HandleAsync(null, Ct)).PricePerLitrePaise); + + app.Clock.Set(new DateTimeOffset(2026, 10, 30, 18, 30, 0, TimeSpan.Zero)); // midnight arrives in Asia/Kolkata + Assert.Equal(6400, (await app.GetRateInEffect.HandleAsync(null, Ct)).PricePerLitrePaise); + } + + [Fact] + public async Task Updating_changes_the_date_and_price() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRateAsync(October1, 6000); + app.Database.ResetSaveCount(); + + var updated = await app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(added.Id, October15, 6400), Ct); + + Assert.Equal(new MilkRateDetails(added.Id, October15, 6400), updated); + Assert.Equal(1, app.Database.SaveCount); + } + + [Fact] + public async Task A_rate_can_be_updated_without_changing_its_date() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRateAsync(October1, 6000); + + var updated = await app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(added.Id, October1, 6100), Ct); + + Assert.Equal(6100, updated.PricePerLitrePaise); + } + + [Fact] + [Trait("Invariant", "T-3")] + public async Task Moving_a_rate_onto_the_day_of_another_is_a_conflict_and_changes_nothing() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRateAsync(October1, 6000); + var other = await app.AddRateAsync(October15, 6400); + app.Database.ResetSaveCount(); + + var exception = await Assert.ThrowsAsync( + () => app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(other.Id, October1, 7000), Ct)); + + Assert.Equal(ErrorCodes.RateDateTaken, exception.Code); + Assert.Equal(October15, app.Database.MilkRates.Single(rate => rate.Id.Value == other.Id).EffectiveFrom); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + public async Task An_invalid_update_is_rejected_and_changes_nothing() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRateAsync(October1, 6000); + app.Database.ResetSaveCount(); + + var exception = await Assert.ThrowsAsync( + () => app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(added.Id, October15, 0), Ct)); + + Assert.Equal("pricePerLitrePaise", exception.Field); + var stored = Assert.Single(app.Database.MilkRates); + Assert.Equal(October1, stored.EffectiveFrom); + Assert.Equal(6000, stored.Price.PaisePerLitre); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + public async Task Updating_an_unknown_rate_is_not_found() + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(Guid.CreateVersion7(), October1, 6000), Ct)); + + Assert.Equal(ErrorCodes.RateNotFound, exception.Code); + } + + [Fact] + public async Task Deleting_removes_the_rate() + { + var app = await TestHousehold.SetUpAsync(); + var keep = await app.AddRateAsync(October1, 6000); + var remove = await app.AddRateAsync(October15, 6400); + + await app.DeleteRate.HandleAsync(remove.Id, Ct); + + Assert.Equal(keep.Id, Assert.Single(app.Database.MilkRates).Id.Value); + } + + [Fact] + public async Task Deleting_an_unknown_rate_is_not_found() + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync(() => app.DeleteRate.HandleAsync(Guid.CreateVersion7(), Ct)); + + Assert.Equal(ErrorCodes.RateNotFound, exception.Code); + } +} diff --git a/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs b/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs new file mode 100644 index 0000000..f246b9e --- /dev/null +++ b/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs @@ -0,0 +1,386 @@ +using PureFreshMilk.Application.Common; +using PureFreshMilk.Application.Recipients; +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Application.Tests.Recipients; + +public sealed class RecipientHandlerTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Adding_stores_the_recipient_and_starts_it_today_by_default() + { + var app = await TestHousehold.SetUpAsync(); + + var recipient = await app.AddRecipientAsync(" Recipient A ", 1000); + + Assert.Equal("Recipient A", recipient.Name); + Assert.Equal(1000, recipient.ExpectedMl); + Assert.Equal(TestHousehold.Today, recipient.StartsOn); + Assert.Null(recipient.ArchivedFrom); + Assert.Equal(recipient.Id, Assert.Single(app.Database.Recipients).Id.Value); + Assert.Equal(1, app.Database.SaveCount); + } + + [Fact] + public async Task Adding_can_start_the_recipient_on_another_day() + { + var app = await TestHousehold.SetUpAsync(); + + var recipient = await app.AddRecipientAsync("Recipient A", 500, new DateOnly(2026, 10, 1)); + + Assert.Equal(new DateOnly(2026, 10, 1), recipient.StartsOn); + } + + [Fact] + public async Task Adding_before_the_household_is_set_up_is_not_found() + { + var app = new TestHousehold(); + + var exception = await Assert.ThrowsAsync(() => app.AddRecipientAsync("Recipient A")); + + Assert.Equal(ErrorCodes.HouseholdNotSetUp, exception.Code); + } + + [Theory] + [InlineData(null, 1000, "name")] + [InlineData(" ", 1000, "name")] + [InlineData("Recipient A", 0, "expectedMl")] + [InlineData("Recipient A", -250, "expectedMl")] + [InlineData("Recipient A", 20_001, "expectedMl")] + public async Task Invalid_input_is_rejected_with_the_field_and_nothing_is_stored( + string? name, int expectedMl, string field) + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.AddRecipient.HandleAsync(new AddRecipientCommand(name, expectedMl, null), Ct)); + + Assert.Equal(field, exception.Field); + Assert.Empty(app.Database.Recipients); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task A_name_already_used_by_an_active_recipient_is_a_conflict_whatever_its_case() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRecipientAsync("Recipient A"); + + var exception = await Assert.ThrowsAsync(() => app.AddRecipientAsync(" recipient a ")); + + Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code); + Assert.Single(app.Database.Recipients); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task The_name_of_an_archived_recipient_can_be_used_again() + { + var app = await TestHousehold.SetUpAsync(); + var first = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(first.Id, null), Ct); + + var second = await app.AddRecipientAsync("Recipient A"); + + Assert.NotEqual(first.Id, second.Id); + Assert.Equal(2, app.Database.Recipients.Count); + } + + [Fact] + [Trait("Invariant", "R-9")] + public async Task The_list_hides_archived_recipients_unless_asked_for() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRecipientAsync("Recipient B", 500, new DateOnly(2026, 10, 1)); + var gone = await app.AddRecipientAsync("Recipient C", 500, new DateOnly(2026, 10, 1)); + await app.AddRecipientAsync("Recipient A", 1000, new DateOnly(2026, 10, 1)); + await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(gone.Id, new DateOnly(2026, 10, 20)), Ct); + + var active = await app.ListRecipients.HandleAsync(includeArchived: false, Ct); + var all = await app.ListRecipients.HandleAsync(includeArchived: true, Ct); + + Assert.Equal(["Recipient A", "Recipient B"], active.Select(recipient => recipient.Name)); + Assert.Equal(["Recipient A", "Recipient B", "Recipient C"], all.Select(recipient => recipient.Name)); + } + + [Fact] + [Trait("Invariant", "R-9")] + public async Task A_recipient_archived_from_a_future_day_still_appears_in_the_default_list() + { + var app = await TestHousehold.SetUpAsync(); + var recipient = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(recipient.Id, new DateOnly(2026, 12, 1)), Ct); + + var active = await app.ListRecipients.HandleAsync(includeArchived: false, Ct); + + Assert.Single(active); + } + + [Fact] + public async Task The_list_is_ordered_by_name_ignoring_case() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRecipientAsync("charlie"); + await app.AddRecipientAsync("Alice"); + await app.AddRecipientAsync("bob"); + + var list = await app.ListRecipients.HandleAsync(includeArchived: false, Ct); + + Assert.Equal(["Alice", "bob", "charlie"], list.Select(recipient => recipient.Name)); + } + + [Fact] + public async Task The_list_is_empty_for_a_household_with_no_recipients() + { + var app = await TestHousehold.SetUpAsync(); + + Assert.Empty(await app.ListRecipients.HandleAsync(includeArchived: true, Ct)); + } + + [Fact] + public async Task Listing_before_the_household_is_set_up_is_not_found() + { + var app = new TestHousehold(); + + await Assert.ThrowsAsync(() => app.ListRecipients.HandleAsync(false, Ct)); + } + + [Fact] + public async Task Getting_a_recipient_returns_it() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A"); + + var found = await app.GetRecipient.HandleAsync(added.Id, Ct); + + Assert.Equal(added, found); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Getting_an_unknown_recipient_is_not_found(bool emptyId) + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetRecipient.HandleAsync(emptyId ? Guid.Empty : Guid.CreateVersion7(), Ct)); + + Assert.Equal(ErrorCodes.RecipientNotFound, exception.Code); + } + + [Fact] + public async Task Updating_replaces_the_editable_fields() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", 1000, new DateOnly(2026, 10, 1)); + app.Database.ResetSaveCount(); + + var updated = await app.UpdateRecipient.HandleAsync( + new UpdateRecipientCommand(added.Id, " Recipient Z ", 500, new DateOnly(2026, 9, 15)), Ct); + + Assert.Equal(new RecipientDetails(added.Id, "Recipient Z", 500, new DateOnly(2026, 9, 15), null), updated); + Assert.Equal(1, app.Database.SaveCount); + } + + [Fact] + public async Task A_recipient_can_change_the_case_of_its_own_name() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("recipient a"); + + var updated = await app.UpdateRecipient.HandleAsync( + new UpdateRecipientCommand(added.Id, "Recipient A", 1000, added.StartsOn), Ct); + + Assert.Equal("Recipient A", updated.Name); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task Renaming_to_the_name_of_another_active_recipient_is_a_conflict_and_changes_nothing() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRecipientAsync("Recipient A"); + var other = await app.AddRecipientAsync("Recipient B", 500); + app.Database.ResetSaveCount(); + + var exception = await Assert.ThrowsAsync(() => app.UpdateRecipient.HandleAsync( + new UpdateRecipientCommand(other.Id, "RECIPIENT A", 250, other.StartsOn), Ct)); + + Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code); + Assert.Equal(other, await app.GetRecipient.HandleAsync(other.Id, Ct)); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + public async Task An_archived_recipient_can_be_renamed_to_a_name_in_use() + { + var app = await TestHousehold.SetUpAsync(); + await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + var archived = await app.AddRecipientAsync("Recipient B", startsOn: new DateOnly(2026, 10, 1)); + await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(archived.Id, null), Ct); + + var updated = await app.UpdateRecipient.HandleAsync( + new UpdateRecipientCommand(archived.Id, "Recipient A", 500, archived.StartsOn), Ct); + + Assert.Equal("Recipient A", updated.Name); + } + + [Theory] + [InlineData("", 500, "name")] + [InlineData("Recipient A", 0, "expectedMl")] + public async Task An_invalid_update_is_rejected_and_changes_nothing(string name, int expectedMl, string field) + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", 1000); + app.Database.ResetSaveCount(); + + var exception = await Assert.ThrowsAsync(() => app.UpdateRecipient.HandleAsync( + new UpdateRecipientCommand(added.Id, name, expectedMl, added.StartsOn), Ct)); + + Assert.Equal(field, exception.Field); + Assert.Equal(added, await app.GetRecipient.HandleAsync(added.Id, Ct)); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + public async Task Updating_an_unknown_recipient_is_not_found() + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync(() => app.UpdateRecipient.HandleAsync( + new UpdateRecipientCommand(Guid.CreateVersion7(), "Recipient A", 500, TestHousehold.Today), Ct)); + + Assert.Equal(ErrorCodes.RecipientNotFound, exception.Code); + } + + [Fact] + public async Task Archiving_defaults_to_today() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + + var archived = await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct); + + Assert.Equal(TestHousehold.Today, archived.ArchivedFrom); + Assert.Equal(TestHousehold.Today, Assert.Single(app.Database.Recipients).ArchivedFrom); + } + + [Fact] + public async Task Archiving_can_name_the_day() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + + var archived = await app.ArchiveRecipient.HandleAsync( + new ArchiveRecipientCommand(added.Id, new DateOnly(2026, 11, 15)), Ct); + + Assert.Equal(new DateOnly(2026, 11, 15), archived.ArchivedFrom); + } + + [Fact] + [Trait("Invariant", "R-5")] + public async Task Archiving_on_or_before_the_start_date_is_rejected() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + app.Database.ResetSaveCount(); + + var exception = await Assert.ThrowsAsync(() => app.ArchiveRecipient.HandleAsync( + new ArchiveRecipientCommand(added.Id, new DateOnly(2026, 10, 1)), Ct)); + + Assert.Equal("archivedFrom", exception.Field); + Assert.Null(Assert.Single(app.Database.Recipients).ArchivedFrom); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + [Trait("Invariant", "R-5")] + public async Task Archiving_an_archived_recipient_is_a_conflict() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct); + + var exception = await Assert.ThrowsAsync( + () => app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, new DateOnly(2026, 11, 1)), Ct)); + + Assert.Equal(ErrorCodes.RecipientAlreadyArchived, exception.Code); + Assert.Equal(TestHousehold.Today, Assert.Single(app.Database.Recipients).ArchivedFrom); + } + + [Fact] + public async Task Archiving_an_unknown_recipient_is_not_found() + { + var app = await TestHousehold.SetUpAsync(); + + var exception = await Assert.ThrowsAsync(() => app.ArchiveRecipient.HandleAsync( + new ArchiveRecipientCommand(Guid.CreateVersion7(), null), Ct)); + + Assert.Equal(ErrorCodes.RecipientNotFound, exception.Code); + } + + [Fact] + public async Task Archiving_never_deletes_the_recipient() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + + await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct); + + Assert.Single(app.Database.Recipients); + Assert.Equal(added.Id, (await app.GetRecipient.HandleAsync(added.Id, Ct)).Id); + } + + [Fact] + public async Task Unarchiving_brings_the_recipient_back() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct); + + var restored = await app.UnarchiveRecipient.HandleAsync(added.Id, Ct); + + Assert.Null(restored.ArchivedFrom); + Assert.Single(await app.ListRecipients.HandleAsync(includeArchived: false, Ct)); + } + + [Fact] + public async Task Unarchiving_a_recipient_who_is_not_archived_does_nothing() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A"); + app.Database.ResetSaveCount(); + + var result = await app.UnarchiveRecipient.HandleAsync(added.Id, Ct); + + Assert.Equal(added, result); + Assert.Equal(0, app.Database.SaveCount); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task Unarchiving_into_a_name_that_is_now_taken_is_a_conflict() + { + var app = await TestHousehold.SetUpAsync(); + var first = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1)); + await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(first.Id, null), Ct); + await app.AddRecipientAsync("Recipient A"); + + var exception = await Assert.ThrowsAsync(() => app.UnarchiveRecipient.HandleAsync(first.Id, Ct)); + + Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code); + Assert.Equal(TestHousehold.Today, app.Database.Recipients.Single(recipient => recipient.Id.Value == first.Id).ArchivedFrom); + } + + [Fact] + public async Task Unarchiving_an_unknown_recipient_is_not_found() + { + var app = await TestHousehold.SetUpAsync(); + + await Assert.ThrowsAsync(() => app.UnarchiveRecipient.HandleAsync(Guid.CreateVersion7(), Ct)); + } +} diff --git a/backend/tests/PureFreshMilk.Application.Tests/TestHousehold.cs b/backend/tests/PureFreshMilk.Application.Tests/TestHousehold.cs new file mode 100644 index 0000000..a3a807a --- /dev/null +++ b/backend/tests/PureFreshMilk.Application.Tests/TestHousehold.cs @@ -0,0 +1,90 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Rates; +using PureFreshMilk.Application.Recipients; +using PureFreshMilk.TestSupport; + +namespace PureFreshMilk.Application.Tests; + +/// +/// A household's worth of handlers wired to in-memory persistence. The clock stands at 06:00 UTC on +/// 30 October 2026, which is 11:30 on the same day in Asia/Kolkata. +/// +internal sealed class TestHousehold +{ + public static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero); + public static readonly DateOnly Today = new(2026, 10, 30); + + public TestHousehold() + { + Database = new InMemoryDatabase(); + Clock = new FixedTimeProvider(Now); + + var households = new InMemoryHouseholdRepository(Database); + var recipients = new InMemoryRecipientRepository(Database); + var rates = new InMemoryMilkRateRepository(Database); + + GetHousehold = new GetHouseholdHandler(households, Clock); + SetUpHousehold = new SetUpHouseholdHandler(households, households, Database, Clock); + + ListRecipients = new ListRecipientsHandler(households, recipients, Clock); + GetRecipient = new GetRecipientHandler(households, recipients); + AddRecipient = new AddRecipientHandler(households, recipients, Database, Clock); + UpdateRecipient = new UpdateRecipientHandler(households, recipients, Database, Clock); + ArchiveRecipient = new ArchiveRecipientHandler(households, recipients, Database, Clock); + UnarchiveRecipient = new UnarchiveRecipientHandler(households, recipients, Database, Clock); + + ListRates = new ListMilkRatesHandler(households, rates); + GetRateInEffect = new GetRateInEffectHandler(households, rates, Clock); + AddRate = new AddMilkRateHandler(households, rates, Database, Clock); + UpdateRate = new UpdateMilkRateHandler(households, rates, Database, Clock); + DeleteRate = new DeleteMilkRateHandler(households, rates, Database); + } + + public InMemoryDatabase Database { get; } + + public FixedTimeProvider Clock { get; } + + public GetHouseholdHandler GetHousehold { get; } + + public SetUpHouseholdHandler SetUpHousehold { get; } + + public ListRecipientsHandler ListRecipients { get; } + + public GetRecipientHandler GetRecipient { get; } + + public AddRecipientHandler AddRecipient { get; } + + public UpdateRecipientHandler UpdateRecipient { get; } + + public ArchiveRecipientHandler ArchiveRecipient { get; } + + public UnarchiveRecipientHandler UnarchiveRecipient { get; } + + public ListMilkRatesHandler ListRates { get; } + + public GetRateInEffectHandler GetRateInEffect { get; } + + public AddMilkRateHandler AddRate { get; } + + public UpdateMilkRateHandler UpdateRate { get; } + + public DeleteMilkRateHandler DeleteRate { get; } + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// A household that has been set up, as the first screen of the app does. + public static async Task SetUpAsync(string timeZoneId = "Asia/Kolkata") + { + var household = new TestHousehold(); + await household.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("Example household", timeZoneId), Ct); + household.Database.ResetSaveCount(); + return household; + } + + public async Task AddRecipientAsync( + string name, int expectedMl = 1000, DateOnly? startsOn = null) => + await AddRecipient.HandleAsync(new AddRecipientCommand(name, expectedMl, startsOn), Ct); + + public async Task AddRateAsync(DateOnly effectiveFrom, long pricePerLitrePaise) => + await AddRate.HandleAsync(new AddMilkRateCommand(effectiveFrom, pricePerLitrePaise), Ct); +} diff --git a/backend/tests/PureFreshMilk.TestSupport/FixedTimeProvider.cs b/backend/tests/PureFreshMilk.TestSupport/FixedTimeProvider.cs new file mode 100644 index 0000000..44137c3 --- /dev/null +++ b/backend/tests/PureFreshMilk.TestSupport/FixedTimeProvider.cs @@ -0,0 +1,13 @@ +namespace PureFreshMilk.TestSupport; + +/// A clock that stands still until a test moves it. +public sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider +{ + private DateTimeOffset utcNow = utcNow; + + public override DateTimeOffset GetUtcNow() => utcNow; + + public void Set(DateTimeOffset value) => utcNow = value; + + public void Advance(TimeSpan by) => utcNow += by; +} diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs new file mode 100644 index 0000000..bab5b40 --- /dev/null +++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs @@ -0,0 +1,43 @@ +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.TestSupport; + +/// +/// A stand-in for the database. Like a real unit of work, it keeps what a use case adds or removes +/// to itself until is called, so a handler that forgets to save fails its test. +/// It hands out the stored entity instances, so a handler must check a request before changing an +/// entity (as the real handlers do): nothing here can roll a change back. +/// +public sealed class InMemoryDatabase : IUnitOfWork +{ + private readonly List pendingChanges = []; + + public List Households { get; } = []; + + public List Recipients { get; } = []; + + public List MilkRates { get; } = []; + + /// How many times has been called. + public int SaveCount { get; private set; } + + public Task SaveChangesAsync(CancellationToken cancellationToken) + { + foreach (var change in pendingChanges) + { + change(); + } + + pendingChanges.Clear(); + SaveCount++; + return Task.CompletedTask; + } + + /// Forgets earlier saves, so a test can check that the step under test saved (or did not save). + public void ResetSaveCount() => SaveCount = 0; + + internal void StageChange(Action change) => pendingChanges.Add(change); +} diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryHouseholdRepository.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryHouseholdRepository.cs new file mode 100644 index 0000000..2daea92 --- /dev/null +++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryHouseholdRepository.cs @@ -0,0 +1,13 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.TestSupport; + +/// Stores households, and answers as the current household the way single-household mode does: the only one. +public sealed class InMemoryHouseholdRepository(InMemoryDatabase database) : IHouseholdRepository, ICurrentHousehold +{ + public void Add(Household household) => database.StageChange(() => database.Households.Add(household)); + + public Task FindAsync(CancellationToken cancellationToken) => + Task.FromResult(database.Households.SingleOrDefault()); +} diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryMilkRateRepository.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryMilkRateRepository.cs new file mode 100644 index 0000000..3190913 --- /dev/null +++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryMilkRateRepository.cs @@ -0,0 +1,27 @@ +using PureFreshMilk.Application.Rates; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.TestSupport; + +public sealed class InMemoryMilkRateRepository(InMemoryDatabase database) : IMilkRateRepository +{ + public Task> ListAsync(HouseholdId householdId, CancellationToken cancellationToken) + { + IReadOnlyList found = [.. database.MilkRates.Where(rate => rate.HouseholdId == householdId)]; + + return Task.FromResult(found); + } + + public Task FindAsync(HouseholdId householdId, MilkRateId id, CancellationToken cancellationToken) => + Task.FromResult(database.MilkRates.SingleOrDefault(rate => rate.HouseholdId == householdId && rate.Id == id)); + + public Task EffectiveFromTakenAsync( + HouseholdId householdId, DateOnly effectiveFrom, MilkRateId? exceptRate, CancellationToken cancellationToken) => + Task.FromResult(database.MilkRates.Any(rate => + rate.HouseholdId == householdId && rate.EffectiveFrom == effectiveFrom && rate.Id != exceptRate)); + + public void Add(MilkRate rate) => database.StageChange(() => database.MilkRates.Add(rate)); + + public void Remove(MilkRate rate) => database.StageChange(() => database.MilkRates.Remove(rate)); +} diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryPersistenceExtensions.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryPersistenceExtensions.cs new file mode 100644 index 0000000..31ad179 --- /dev/null +++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryPersistenceExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Application.Rates; +using PureFreshMilk.Application.Recipients; + +namespace PureFreshMilk.TestSupport; + +public static class InMemoryPersistenceExtensions +{ + /// + /// Replaces the real repositories, unit of work and current household with in-memory ones, so the + /// HTTP pipeline can be tested without a database. Returns the database so a test can inspect it. + /// + public static InMemoryDatabase UseInMemoryPersistence(this IServiceCollection services) + { + var database = new InMemoryDatabase(); + var households = new InMemoryHouseholdRepository(database); + + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + + services.AddSingleton(database); + services.AddSingleton(database); + services.AddSingleton(households); + services.AddSingleton(households); + services.AddSingleton(new InMemoryRecipientRepository(database)); + services.AddSingleton(new InMemoryMilkRateRepository(database)); + + return database; + } +} diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryRecipientRepository.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryRecipientRepository.cs new file mode 100644 index 0000000..e961163 --- /dev/null +++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryRecipientRepository.cs @@ -0,0 +1,37 @@ +using PureFreshMilk.Application.Recipients; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.TestSupport; + +public sealed class InMemoryRecipientRepository(InMemoryDatabase database) : IRecipientRepository +{ + public Task> ListAsync( + HouseholdId householdId, bool includeArchived, DateOnly today, CancellationToken cancellationToken) + { + IReadOnlyList found = + [ + .. database.Recipients + .Where(recipient => recipient.HouseholdId == householdId) + .Where(recipient => includeArchived || !recipient.IsArchivedOn(today)) + .OrderBy(recipient => recipient.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(recipient => recipient.Id.Value), + ]; + + return Task.FromResult(found); + } + + public Task FindAsync(HouseholdId householdId, RecipientId id, CancellationToken cancellationToken) => + Task.FromResult(database.Recipients.SingleOrDefault( + recipient => recipient.HouseholdId == householdId && recipient.Id == id)); + + public Task NameInUseByActiveRecipientAsync( + HouseholdId householdId, string name, RecipientId? exceptRecipient, CancellationToken cancellationToken) => + Task.FromResult(database.Recipients.Any(recipient => + recipient.HouseholdId == householdId + && recipient.ArchivedFrom is null + && recipient.Id != exceptRecipient + && string.Equals(recipient.Name, name, StringComparison.OrdinalIgnoreCase))); + + public void Add(Recipient recipient) => database.StageChange(() => database.Recipients.Add(recipient)); +} diff --git a/backend/tests/PureFreshMilk.TestSupport/PureFreshMilk.TestSupport.csproj b/backend/tests/PureFreshMilk.TestSupport/PureFreshMilk.TestSupport.csproj new file mode 100644 index 0000000..59924f7 --- /dev/null +++ b/backend/tests/PureFreshMilk.TestSupport/PureFreshMilk.TestSupport.csproj @@ -0,0 +1,23 @@ + + + + + + Library + false + + + + + + + + + + + + + From 06ab78877684a119f505623241e28cb92b49f340 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:57:31 +0530 Subject: [PATCH 03/10] feat(infrastructure): persist households, recipients and milk rates Map the three aggregates to PostgreSQL with EF Core: typed id, quantity and price converters, check constraints, restrict-on-delete foreign keys, a unique (household_id, id) key for future composite foreign keys, a partial unique index on the lower-cased name of active recipients and a unique index on (household_id, effective_from). Add the repositories, a unit of work that turns a unique-index race into a conflict, and the single-household provider. The readiness check now also fails while migrations are pending. The initial migration is AddHouseholdsRecipientsAndMilkRates. Tests run against a real PostgreSQL, one throwaway database per class, and are tagged Category=Database so the other jobs can exclude them. --- .editorconfig | 4 + backend/PureFreshMilk.slnx | 1 + .../Households/SingleHouseholdProvider.cs | 34 +++ ...frastructureServiceCollectionExtensions.cs | 20 +- .../Configurations/HouseholdConfiguration.cs | 22 ++ .../Configurations/MilkRateConfiguration.cs | 37 +++ .../Configurations/RecipientConfiguration.cs | 58 ++++ ...useholdsRecipientsAndMilkRates.Designer.cs | 188 ++++++++++++ ...139_AddHouseholdsRecipientsAndMilkRates.cs | 109 +++++++ .../PureFreshMilkDbContextModelSnapshot.cs | 185 ++++++++++++ .../Persistence/PureFreshMilkDbContext.cs | 28 +- .../Repositories/HouseholdRepository.cs | 9 + .../Repositories/MilkRateRepository.cs | 29 ++ .../Repositories/RecipientRepository.cs | 41 +++ .../Persistence/UnitOfWork.cs | 35 +++ .../Persistence/ValueConverters.cs | 25 ++ .../PureFreshMilk.Infrastructure.csproj | 5 + .../HandlerFlowTests.cs | 277 ++++++++++++++++++ .../MigrationTests.cs | 138 +++++++++ .../MilkRateConstraintTests.cs | 91 ++++++ .../PostgresDatabaseFixture.cs | 64 ++++ .../PureFreshMilk.Infrastructure.Tests.csproj | 15 + .../RecipientConstraintTests.cs | 156 ++++++++++ .../RepositoryRoundTripTests.cs | 236 +++++++++++++++ .../Seed.cs | 23 ++ 25 files changed, 1827 insertions(+), 3 deletions(-) create mode 100644 backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/HouseholdConfiguration.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/MilkRateConfiguration.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/RecipientConfiguration.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.Designer.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/PureFreshMilkDbContextModelSnapshot.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/HouseholdRepository.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/MilkRateRepository.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/RecipientRepository.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/UnitOfWork.cs create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/ValueConverters.cs create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/HandlerFlowTests.cs create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/MigrationTests.cs create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/MilkRateConstraintTests.cs create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/PostgresDatabaseFixture.cs create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/PureFreshMilk.Infrastructure.Tests.csproj create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/RecipientConstraintTests.cs create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/RepositoryRoundTripTests.cs create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/Seed.cs diff --git a/.editorconfig b/.editorconfig index dd5bf73..802cfd7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -61,3 +61,7 @@ dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning [backend/tests/**.cs] dotnet_naming_rule.types_and_members_are_pascal_case.severity = none dotnet_diagnostic.CA1707.severity = none + +# EF Core migrations are generated by `dotnet ef`; they are not reformatted to match the C# style above. +[backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/*.cs] +generated_code = true diff --git a/backend/PureFreshMilk.slnx b/backend/PureFreshMilk.slnx index 135e71a..a92e71c 100644 --- a/backend/PureFreshMilk.slnx +++ b/backend/PureFreshMilk.slnx @@ -9,6 +9,7 @@ + diff --git a/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs b/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs new file mode 100644 index 0000000..83d1643 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using PureFreshMilk.Application.Households; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Infrastructure.Persistence; + +namespace PureFreshMilk.Infrastructure.Households; + +/// +/// Single-household mode: before authentication exists, the current household is the only one in the +/// database. It will be replaced by an implementation that reads the household from the signed-in user (ADR 0004). +/// +internal sealed class SingleHouseholdProvider(PureFreshMilkDbContext context) : ICurrentHousehold +{ + private Household? household; + + public async Task FindAsync(CancellationToken cancellationToken) + { + if (household is not null) + { + return household; + } + + // Ask for two so that a second household is noticed rather than silently ignored. + var found = await context.Households.Take(2).ToListAsync(cancellationToken); + + if (found.Count > 1) + { + throw new InvalidOperationException( + "Single-household mode found more than one household. Remove the extra rows or enable authentication."); + } + + return household = found.SingleOrDefault(); + } +} diff --git a/backend/src/PureFreshMilk.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/src/PureFreshMilk.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 44d602b..712290f 100644 --- a/backend/src/PureFreshMilk.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/src/PureFreshMilk.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -1,7 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Application.Rates; +using PureFreshMilk.Application.Recipients; +using PureFreshMilk.Infrastructure.Households; using PureFreshMilk.Infrastructure.Persistence; +using PureFreshMilk.Infrastructure.Persistence.Repositories; namespace PureFreshMilk.Infrastructure; @@ -27,8 +33,20 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Ready means PostgreSQL answers and every migration has been applied. Without the second part the + // API would start and then fail every request with "relation does not exist". services.AddHealthChecks() - .AddDbContextCheck(name: "database", tags: [HealthCheckTags.Ready]); + .AddDbContextCheck( + name: "database", + tags: [HealthCheckTags.Ready], + customTestQuery: async (context, cancellationToken) => + !(await context.Database.GetPendingMigrationsAsync(cancellationToken)).Any()); return services; } diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/HouseholdConfiguration.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/HouseholdConfiguration.cs new file mode 100644 index 0000000..5683546 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/HouseholdConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Infrastructure.Persistence.Configurations; + +internal sealed class HouseholdConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("households", table => + table.HasCheckConstraint("ck_households_name_length", "length(btrim(name)) BETWEEN 1 AND 80")); + + builder.HasKey(household => household.Id).HasName("pk_households"); + + builder.Property(household => household.Id).HasColumnName("id").ValueGeneratedNever(); + builder.Property(household => household.Name).HasColumnName("name").HasMaxLength(80).IsRequired(); + builder.Property(household => household.TimeZoneId).HasColumnName("time_zone_id").HasMaxLength(64).IsRequired(); + builder.Property(household => household.CreatedAt).HasColumnName("created_at"); + builder.Property(household => household.UpdatedAt).HasColumnName("updated_at"); + } +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/MilkRateConfiguration.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/MilkRateConfiguration.cs new file mode 100644 index 0000000..b56dc19 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/MilkRateConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Infrastructure.Persistence.Configurations; + +internal sealed class MilkRateConfiguration : IEntityTypeConfiguration +{ + public const string EffectiveFromIndexName = "ux_milk_rates_household_id_effective_from"; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("milk_rates", table => + table.HasCheckConstraint("ck_milk_rates_price", "price_per_litre_paise BETWEEN 1 AND 1000000")); + + builder.HasKey(rate => rate.Id).HasName("pk_milk_rates"); + + builder.Property(rate => rate.Id).HasColumnName("id").ValueGeneratedNever(); + builder.Property(rate => rate.HouseholdId).HasColumnName("household_id").IsRequired(); + builder.Property(rate => rate.EffectiveFrom).HasColumnName("effective_from"); + builder.Property(rate => rate.Price).HasColumnName("price_per_litre_paise"); + builder.Property(rate => rate.CreatedAt).HasColumnName("created_at"); + builder.Property(rate => rate.UpdatedAt).HasColumnName("updated_at"); + + builder.HasOne() + .WithMany() + .HasForeignKey(rate => rate.HouseholdId) + .HasConstraintName("fk_milk_rates_households_household_id") + .OnDelete(DeleteBehavior.Restrict); + + // One price per household per start date. + builder.HasIndex(rate => new { rate.HouseholdId, rate.EffectiveFrom }) + .IsUnique() + .HasDatabaseName(EffectiveFromIndexName); + } +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/RecipientConfiguration.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/RecipientConfiguration.cs new file mode 100644 index 0000000..ba185a4 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/RecipientConfiguration.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Infrastructure.Persistence.Configurations; + +internal sealed class RecipientConfiguration : IEntityTypeConfiguration +{ + /// The generated column holding the lower-cased name, used to keep active names unique. + public const string NameKey = "NameKey"; + + public const string ActiveNameIndexName = "ux_recipients_household_id_name_key_active"; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("recipients", table => + { + table.HasCheckConstraint("ck_recipients_name_length", "length(btrim(name)) BETWEEN 1 AND 80"); + table.HasCheckConstraint("ck_recipients_expected_ml", "expected_ml BETWEEN 1 AND 20000"); + table.HasCheckConstraint( + "ck_recipients_archived_after_start", "archived_from IS NULL OR archived_from > starts_on"); + }); + + builder.HasKey(recipient => recipient.Id).HasName("pk_recipients"); + + // The target of the composite foreign keys that child tables (deliveries) will use, so that a + // child row can never point at a recipient of another household. + builder.HasAlternateKey(recipient => new { recipient.HouseholdId, recipient.Id }) + .HasName("uq_recipients_household_id_id"); + + builder.Property(recipient => recipient.Id).HasColumnName("id").ValueGeneratedNever(); + builder.Property(recipient => recipient.HouseholdId).HasColumnName("household_id").IsRequired(); + builder.Property(recipient => recipient.Name).HasColumnName("name").HasMaxLength(80).IsRequired(); + builder.Property(recipient => recipient.ExpectedQuantity).HasColumnName("expected_ml"); + builder.Property(recipient => recipient.StartsOn).HasColumnName("starts_on"); + builder.Property(recipient => recipient.ArchivedFrom).HasColumnName("archived_from"); + builder.Property(recipient => recipient.CreatedAt).HasColumnName("created_at"); + builder.Property(recipient => recipient.UpdatedAt).HasColumnName("updated_at"); + + builder.Property(NameKey) + .HasColumnName("name_key") + .HasMaxLength(80) + .HasComputedColumnSql("lower(name)", stored: true); + + builder.HasOne() + .WithMany() + .HasForeignKey(recipient => recipient.HouseholdId) + .HasConstraintName("fk_recipients_households_household_id") + .OnDelete(DeleteBehavior.Restrict); + + // Active names are unique within a household, ignoring case. An archived name can be reused. + builder.HasIndex(nameof(Recipient.HouseholdId), NameKey) + .IsUnique() + .HasFilter("archived_from IS NULL") + .HasDatabaseName(ActiveNameIndexName); + } +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.Designer.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.Designer.cs new file mode 100644 index 0000000..591a1e6 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.Designer.cs @@ -0,0 +1,188 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PureFreshMilk.Infrastructure.Persistence; + +#nullable disable + +namespace PureFreshMilk.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(PureFreshMilkDbContext))] + [Migration("20261030122139_AddHouseholdsRecipientsAndMilkRates")] + partial class AddHouseholdsRecipientsAndMilkRates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PureFreshMilk.Domain.Households.Household", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("time_zone_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_households"); + + b.ToTable("households", null, t => + { + t.HasCheckConstraint("ck_households_name_length", "length(btrim(name)) BETWEEN 1 AND 80"); + }); + }); + + modelBuilder.Entity("PureFreshMilk.Domain.Rates.MilkRate", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EffectiveFrom") + .HasColumnType("date") + .HasColumnName("effective_from"); + + b.Property("HouseholdId") + .HasColumnType("uuid") + .HasColumnName("household_id"); + + b.Property("Price") + .HasColumnType("bigint") + .HasColumnName("price_per_litre_paise"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_milk_rates"); + + b.HasIndex("HouseholdId", "EffectiveFrom") + .IsUnique() + .HasDatabaseName("ux_milk_rates_household_id_effective_from"); + + b.ToTable("milk_rates", null, t => + { + t.HasCheckConstraint("ck_milk_rates_price", "price_per_litre_paise BETWEEN 1 AND 1000000"); + }); + }); + + modelBuilder.Entity("PureFreshMilk.Domain.Recipients.Recipient", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArchivedFrom") + .HasColumnType("date") + .HasColumnName("archived_from"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpectedQuantity") + .HasColumnType("integer") + .HasColumnName("expected_ml"); + + b.Property("HouseholdId") + .HasColumnType("uuid") + .HasColumnName("household_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("NameKey") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name_key") + .HasComputedColumnSql("lower(name)", true); + + b.Property("StartsOn") + .HasColumnType("date") + .HasColumnName("starts_on"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_recipients"); + + b.HasAlternateKey("HouseholdId", "Id") + .HasName("uq_recipients_household_id_id"); + + b.HasIndex("HouseholdId", "NameKey") + .IsUnique() + .HasDatabaseName("ux_recipients_household_id_name_key_active") + .HasFilter("archived_from IS NULL"); + + b.ToTable("recipients", null, t => + { + t.HasCheckConstraint("ck_recipients_archived_after_start", "archived_from IS NULL OR archived_from > starts_on"); + + t.HasCheckConstraint("ck_recipients_expected_ml", "expected_ml BETWEEN 1 AND 20000"); + + t.HasCheckConstraint("ck_recipients_name_length", "length(btrim(name)) BETWEEN 1 AND 80"); + }); + }); + + modelBuilder.Entity("PureFreshMilk.Domain.Rates.MilkRate", b => + { + b.HasOne("PureFreshMilk.Domain.Households.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_milk_rates_households_household_id"); + }); + + modelBuilder.Entity("PureFreshMilk.Domain.Recipients.Recipient", b => + { + b.HasOne("PureFreshMilk.Domain.Households.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_recipients_households_household_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.cs new file mode 100644 index 0000000..3fb91da --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.cs @@ -0,0 +1,109 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PureFreshMilk.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddHouseholdsRecipientsAndMilkRates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "households", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + time_zone_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_households", x => x.id); + table.CheckConstraint("ck_households_name_length", "length(btrim(name)) BETWEEN 1 AND 80"); + }); + + migrationBuilder.CreateTable( + name: "milk_rates", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + household_id = table.Column(type: "uuid", nullable: false), + effective_from = table.Column(type: "date", nullable: false), + price_per_litre_paise = table.Column(type: "bigint", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_milk_rates", x => x.id); + table.CheckConstraint("ck_milk_rates_price", "price_per_litre_paise BETWEEN 1 AND 1000000"); + table.ForeignKey( + name: "fk_milk_rates_households_household_id", + column: x => x.household_id, + principalTable: "households", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "recipients", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + household_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + expected_ml = table.Column(type: "integer", nullable: false), + starts_on = table.Column(type: "date", nullable: false), + archived_from = table.Column(type: "date", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + name_key = table.Column(type: "character varying(80)", maxLength: 80, nullable: true, computedColumnSql: "lower(name)", stored: true) + }, + constraints: table => + { + table.PrimaryKey("pk_recipients", x => x.id); + table.UniqueConstraint("uq_recipients_household_id_id", x => new { x.household_id, x.id }); + table.CheckConstraint("ck_recipients_archived_after_start", "archived_from IS NULL OR archived_from > starts_on"); + table.CheckConstraint("ck_recipients_expected_ml", "expected_ml BETWEEN 1 AND 20000"); + table.CheckConstraint("ck_recipients_name_length", "length(btrim(name)) BETWEEN 1 AND 80"); + table.ForeignKey( + name: "fk_recipients_households_household_id", + column: x => x.household_id, + principalTable: "households", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "ux_milk_rates_household_id_effective_from", + table: "milk_rates", + columns: new[] { "household_id", "effective_from" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ux_recipients_household_id_name_key_active", + table: "recipients", + columns: new[] { "household_id", "name_key" }, + unique: true, + filter: "archived_from IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "milk_rates"); + + migrationBuilder.DropTable( + name: "recipients"); + + migrationBuilder.DropTable( + name: "households"); + } + } +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/PureFreshMilkDbContextModelSnapshot.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/PureFreshMilkDbContextModelSnapshot.cs new file mode 100644 index 0000000..d1dbff9 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/PureFreshMilkDbContextModelSnapshot.cs @@ -0,0 +1,185 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PureFreshMilk.Infrastructure.Persistence; + +#nullable disable + +namespace PureFreshMilk.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(PureFreshMilkDbContext))] + partial class PureFreshMilkDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PureFreshMilk.Domain.Households.Household", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("time_zone_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_households"); + + b.ToTable("households", null, t => + { + t.HasCheckConstraint("ck_households_name_length", "length(btrim(name)) BETWEEN 1 AND 80"); + }); + }); + + modelBuilder.Entity("PureFreshMilk.Domain.Rates.MilkRate", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EffectiveFrom") + .HasColumnType("date") + .HasColumnName("effective_from"); + + b.Property("HouseholdId") + .HasColumnType("uuid") + .HasColumnName("household_id"); + + b.Property("Price") + .HasColumnType("bigint") + .HasColumnName("price_per_litre_paise"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_milk_rates"); + + b.HasIndex("HouseholdId", "EffectiveFrom") + .IsUnique() + .HasDatabaseName("ux_milk_rates_household_id_effective_from"); + + b.ToTable("milk_rates", null, t => + { + t.HasCheckConstraint("ck_milk_rates_price", "price_per_litre_paise BETWEEN 1 AND 1000000"); + }); + }); + + modelBuilder.Entity("PureFreshMilk.Domain.Recipients.Recipient", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArchivedFrom") + .HasColumnType("date") + .HasColumnName("archived_from"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpectedQuantity") + .HasColumnType("integer") + .HasColumnName("expected_ml"); + + b.Property("HouseholdId") + .HasColumnType("uuid") + .HasColumnName("household_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("NameKey") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name_key") + .HasComputedColumnSql("lower(name)", true); + + b.Property("StartsOn") + .HasColumnType("date") + .HasColumnName("starts_on"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_recipients"); + + b.HasAlternateKey("HouseholdId", "Id") + .HasName("uq_recipients_household_id_id"); + + b.HasIndex("HouseholdId", "NameKey") + .IsUnique() + .HasDatabaseName("ux_recipients_household_id_name_key_active") + .HasFilter("archived_from IS NULL"); + + b.ToTable("recipients", null, t => + { + t.HasCheckConstraint("ck_recipients_archived_after_start", "archived_from IS NULL OR archived_from > starts_on"); + + t.HasCheckConstraint("ck_recipients_expected_ml", "expected_ml BETWEEN 1 AND 20000"); + + t.HasCheckConstraint("ck_recipients_name_length", "length(btrim(name)) BETWEEN 1 AND 80"); + }); + }); + + modelBuilder.Entity("PureFreshMilk.Domain.Rates.MilkRate", b => + { + b.HasOne("PureFreshMilk.Domain.Households.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_milk_rates_households_household_id"); + }); + + modelBuilder.Entity("PureFreshMilk.Domain.Recipients.Recipient", b => + { + b.HasOne("PureFreshMilk.Domain.Households.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_recipients_households_household_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/PureFreshMilkDbContext.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/PureFreshMilkDbContext.cs index bf003a2..5d020f2 100644 --- a/backend/src/PureFreshMilk.Infrastructure/Persistence/PureFreshMilkDbContext.cs +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/PureFreshMilkDbContext.cs @@ -1,11 +1,35 @@ using Microsoft.EntityFrameworkCore; +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; +using PureFreshMilk.Domain.Recipients; namespace PureFreshMilk.Infrastructure.Persistence; /// /// EF Core context for the PostgreSQL database. Internal so that only this /// assembly (repositories, configurations, migrations) touches persistence directly. -/// No entities are mapped yet; see docs/architecture/overview.md for the mapping conventions. +/// Each table is mapped by a configuration class under Configurations/; +/// see docs/architecture/overview.md for the mapping conventions. /// internal sealed class PureFreshMilkDbContext(DbContextOptions options) - : DbContext(options); + : DbContext(options) +{ + public DbSet Households => Set(); + + public DbSet Recipients => Set(); + + public DbSet MilkRates => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.ApplyConfigurationsFromAssembly(typeof(PureFreshMilkDbContext).Assembly); +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/HouseholdRepository.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/HouseholdRepository.cs new file mode 100644 index 0000000..fdd8b49 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/HouseholdRepository.cs @@ -0,0 +1,9 @@ +using PureFreshMilk.Application.Households; +using PureFreshMilk.Domain.Households; + +namespace PureFreshMilk.Infrastructure.Persistence.Repositories; + +internal sealed class HouseholdRepository(PureFreshMilkDbContext context) : IHouseholdRepository +{ + public void Add(Household household) => context.Households.Add(household); +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/MilkRateRepository.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/MilkRateRepository.cs new file mode 100644 index 0000000..45f3c66 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/MilkRateRepository.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using PureFreshMilk.Application.Rates; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; + +namespace PureFreshMilk.Infrastructure.Persistence.Repositories; + +internal sealed class MilkRateRepository(PureFreshMilkDbContext context) : IMilkRateRepository +{ + public async Task> ListAsync(HouseholdId householdId, CancellationToken cancellationToken) => + await context.MilkRates + .AsNoTracking() + .Where(rate => rate.HouseholdId == householdId) + .ToListAsync(cancellationToken); + + public async Task FindAsync(HouseholdId householdId, MilkRateId id, CancellationToken cancellationToken) => + await context.MilkRates.SingleOrDefaultAsync( + rate => rate.HouseholdId == householdId && rate.Id == id, cancellationToken); + + public async Task EffectiveFromTakenAsync( + HouseholdId householdId, DateOnly effectiveFrom, MilkRateId? exceptRate, CancellationToken cancellationToken) => + await context.MilkRates.AnyAsync( + rate => rate.HouseholdId == householdId && rate.EffectiveFrom == effectiveFrom && rate.Id != exceptRate, + cancellationToken); + + public void Add(MilkRate rate) => context.MilkRates.Add(rate); + + public void Remove(MilkRate rate) => context.MilkRates.Remove(rate); +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/RecipientRepository.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/RecipientRepository.cs new file mode 100644 index 0000000..5e4376a --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/RecipientRepository.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using PureFreshMilk.Application.Recipients; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Recipients; +using PureFreshMilk.Infrastructure.Persistence.Configurations; + +namespace PureFreshMilk.Infrastructure.Persistence.Repositories; + +internal sealed class RecipientRepository(PureFreshMilkDbContext context) : IRecipientRepository +{ + public async Task> ListAsync( + HouseholdId householdId, bool includeArchived, DateOnly today, CancellationToken cancellationToken) => + await context.Recipients + .AsNoTracking() + .Where(recipient => recipient.HouseholdId == householdId) + .Where(recipient => includeArchived || recipient.ArchivedFrom == null || recipient.ArchivedFrom > today) + .OrderBy(recipient => EF.Property(recipient, RecipientConfiguration.NameKey)) + .ThenBy(recipient => recipient.Id) + .ToListAsync(cancellationToken); + + public async Task FindAsync( + HouseholdId householdId, RecipientId id, CancellationToken cancellationToken) => + await context.Recipients.SingleOrDefaultAsync( + recipient => recipient.HouseholdId == householdId && recipient.Id == id, cancellationToken); + + public async Task NameInUseByActiveRecipientAsync( + HouseholdId householdId, string name, RecipientId? exceptRecipient, CancellationToken cancellationToken) + { + // Compare the generated lower-case column, the same one the unique index is built on. + var nameKey = name.ToLowerInvariant(); + + return await context.Recipients.AnyAsync( + recipient => recipient.HouseholdId == householdId + && recipient.ArchivedFrom == null + && recipient.Id != exceptRecipient + && EF.Property(recipient, RecipientConfiguration.NameKey) == nameKey, + cancellationToken); + } + + public void Add(Recipient recipient) => context.Recipients.Add(recipient); +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/UnitOfWork.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/UnitOfWork.cs new file mode 100644 index 0000000..0400243 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/UnitOfWork.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using PureFreshMilk.Application.Common; +using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Infrastructure.Persistence.Configurations; + +namespace PureFreshMilk.Infrastructure.Persistence; + +internal sealed class UnitOfWork(PureFreshMilkDbContext context) : IUnitOfWork +{ + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (ToConflict(exception) is { } conflict) + { + // Two requests raced past the friendly check in the use case; the unique index decided. + throw conflict; + } + } + + private static ConflictException? ToConflict(DbUpdateException exception) => + exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } violation + ? violation.ConstraintName switch + { + RecipientConfiguration.ActiveNameIndexName => new ConflictException( + ErrorCodes.RecipientNameTaken, "Another recipient who is not archived already has that name."), + MilkRateConfiguration.EffectiveFromIndexName => new ConflictException( + ErrorCodes.RateDateTaken, "A price already starts on that date."), + _ => null, + } + : null; +} diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/ValueConverters.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/ValueConverters.cs new file mode 100644 index 0000000..5acc9b5 --- /dev/null +++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/ValueConverters.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Infrastructure.Persistence; + +// Domain value objects are stored as the plain value they wrap, so the tables hold uuid, integer and bigint +// columns. Registered for every property of these types in PureFreshMilkDbContext.ConfigureConventions. + +internal sealed class HouseholdIdConverter() + : ValueConverter(id => id.Value, value => HouseholdId.From(value)); + +internal sealed class RecipientIdConverter() + : ValueConverter(id => id.Value, value => RecipientId.From(value)); + +internal sealed class MilkRateIdConverter() + : ValueConverter(id => id.Value, value => MilkRateId.From(value)); + +internal sealed class QuantityConverter() + : ValueConverter(quantity => quantity.Millilitres, value => Quantity.FromMillilitres(value)); + +internal sealed class PricePerLitreConverter() + : ValueConverter(price => price.PaisePerLitre, value => PricePerLitre.FromPaise(value)); diff --git a/backend/src/PureFreshMilk.Infrastructure/PureFreshMilk.Infrastructure.csproj b/backend/src/PureFreshMilk.Infrastructure/PureFreshMilk.Infrastructure.csproj index f59c8d6..4611f4f 100644 --- a/backend/src/PureFreshMilk.Infrastructure/PureFreshMilk.Infrastructure.csproj +++ b/backend/src/PureFreshMilk.Infrastructure/PureFreshMilk.Infrastructure.csproj @@ -11,6 +11,11 @@ + + + + + diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/HandlerFlowTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/HandlerFlowTests.cs new file mode 100644 index 0000000..c337a84 --- /dev/null +++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/HandlerFlowTests.cs @@ -0,0 +1,277 @@ +using Microsoft.EntityFrameworkCore; +using PureFreshMilk.Application.Common; +using PureFreshMilk.Application.Households; +using PureFreshMilk.Application.Rates; +using PureFreshMilk.Application.Recipients; +using PureFreshMilk.Infrastructure.Households; +using PureFreshMilk.Infrastructure.Persistence; +using PureFreshMilk.Infrastructure.Persistence.Repositories; +using PureFreshMilk.TestSupport; + +namespace PureFreshMilk.Infrastructure.Tests; + +/// +/// The use cases wired to the real repositories and PostgreSQL, the way the API runs them. Single-household +/// mode needs the database to hold exactly one household, so each test starts from empty tables. +/// +[Trait("Category", "Database")] +public sealed class HandlerFlowTests(PostgresDatabaseFixture database) : IClassFixture +{ + private static readonly DateOnly October1 = new(2026, 10, 1); + + private readonly FixedTimeProvider clock = new(Seed.Now); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// One request's worth of services over a fresh context, as dependency injection scopes them. + private sealed class Request(PureFreshMilkDbContext context, TimeProvider clock) : IAsyncDisposable + { + private readonly HouseholdRepository households = new(context); + private readonly RecipientRepository recipients = new(context); + private readonly MilkRateRepository rates = new(context); + private readonly SingleHouseholdProvider current = new(context); + private readonly UnitOfWork unitOfWork = new(context); + + public SetUpHouseholdHandler SetUp => new(current, households, unitOfWork, clock); + + public GetHouseholdHandler GetHousehold => new(current, clock); + + public AddRecipientHandler AddRecipient => new(current, recipients, unitOfWork, clock); + + public ListRecipientsHandler ListRecipients => new(current, recipients, clock); + + public UpdateRecipientHandler UpdateRecipient => new(current, recipients, unitOfWork, clock); + + public ArchiveRecipientHandler ArchiveRecipient => new(current, recipients, unitOfWork, clock); + + public UnarchiveRecipientHandler UnarchiveRecipient => new(current, recipients, unitOfWork, clock); + + public AddMilkRateHandler AddRate => new(current, rates, unitOfWork, clock); + + public ListMilkRatesHandler ListRates => new(current, rates); + + public GetRateInEffectHandler RateInEffect => new(current, rates, clock); + + public UpdateMilkRateHandler UpdateRate => new(current, rates, unitOfWork, clock); + + public DeleteMilkRateHandler DeleteRate => new(current, rates, unitOfWork); + + public ValueTask DisposeAsync() => context.DisposeAsync(); + } + + private Request NewRequest() => new(database.CreateContext(), clock); + + private async Task StartEmptyAsync() + { + await using var context = database.CreateContext(); + await context.Database.ExecuteSqlRawAsync("truncate table milk_rates, recipients, households", Ct); + } + + private async Task SetUpHouseholdAsync() + { + await StartEmptyAsync(); + await using var request = NewRequest(); + await request.SetUp.HandleAsync(new SetUpHouseholdCommand("Example household", null), Ct); + } + + [Fact] + public async Task Before_set_up_the_household_is_not_found() + { + await StartEmptyAsync(); + await using var request = NewRequest(); + + var exception = await Assert.ThrowsAsync(() => request.GetHousehold.HandleAsync(Ct)); + + Assert.Equal(ErrorCodes.HouseholdNotSetUp, exception.Code); + } + + [Fact] + [Trait("Invariant", "H-5")] + public async Task Setting_up_creates_one_household_and_setting_up_again_updates_it() + { + await StartEmptyAsync(); + + await using (var request = NewRequest()) + { + var created = await request.SetUp.HandleAsync(new SetUpHouseholdCommand("Example household", null), Ct); + Assert.True(created.Created); + Assert.Equal("Asia/Kolkata", created.Household.TimeZoneId); + } + + await using (var request = NewRequest()) + { + var updated = await request.SetUp.HandleAsync(new SetUpHouseholdCommand("Renamed", "Europe/London"), Ct); + Assert.False(updated.Created); + } + + await using var reading = NewRequest(); + var household = await reading.GetHousehold.HandleAsync(Ct); + Assert.Equal("Renamed", household.Name); + Assert.Equal("Europe/London", household.TimeZoneId); + + await using var context = database.CreateContext(); + Assert.Equal(1, await context.Households.CountAsync(Ct)); + } + + [Fact] + [Trait("Invariant", "H-5")] + public async Task A_second_household_row_is_noticed_instead_of_silently_ignored() + { + await SetUpHouseholdAsync(); + await using (var context = database.CreateContext()) + { + context.Households.Add(Seed.NewHousehold("Intruder")); + await context.SaveChangesAsync(Ct); + } + + await using var request = NewRequest(); + + await Assert.ThrowsAsync(() => request.GetHousehold.HandleAsync(Ct)); + } + + [Fact] + public async Task Recipients_can_be_added_listed_updated_archived_and_restored() + { + await SetUpHouseholdAsync(); + + RecipientDetails recipientA; + await using (var request = NewRequest()) + { + recipientA = await request.AddRecipient.HandleAsync(new AddRecipientCommand(" Recipient A ", 1000, October1), Ct); + } + + await using (var request = NewRequest()) + { + await request.AddRecipient.HandleAsync(new AddRecipientCommand("Recipient B", 500, null), Ct); + } + + await using (var request = NewRequest()) + { + var list = await request.ListRecipients.HandleAsync(includeArchived: false, Ct); + Assert.Equal(["Recipient A", "Recipient B"], list.Select(recipient => recipient.Name)); + Assert.Equal(new DateOnly(2026, 10, 30), list[1].StartsOn); // defaulted to today in Asia/Kolkata + } + + await using (var request = NewRequest()) + { + var updated = await request.UpdateRecipient.HandleAsync( + new UpdateRecipientCommand(recipientA.Id, "Recipient Z", 750, October1), Ct); + Assert.Equal(new RecipientDetails(recipientA.Id, "Recipient Z", 750, October1, null), updated); + } + + await using (var request = NewRequest()) + { + var archived = await request.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(recipientA.Id, null), Ct); + Assert.Equal(new DateOnly(2026, 10, 30), archived.ArchivedFrom); + } + + await using (var request = NewRequest()) + { + Assert.Equal(["Recipient B"], (await request.ListRecipients.HandleAsync(false, Ct)).Select(recipient => recipient.Name)); + Assert.Equal(2, (await request.ListRecipients.HandleAsync(true, Ct)).Count); + } + + await using (var request = NewRequest()) + { + var restored = await request.UnarchiveRecipient.HandleAsync(recipientA.Id, Ct); + Assert.Null(restored.ArchivedFrom); + } + + await using var reading = NewRequest(); + Assert.Equal(2, (await reading.ListRecipients.HandleAsync(false, Ct)).Count); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task A_duplicate_active_name_is_a_conflict_through_the_real_database_query() + { + await SetUpHouseholdAsync(); + await using (var request = NewRequest()) + { + await request.AddRecipient.HandleAsync(new AddRecipientCommand("Recipient A", 1000, October1), Ct); + } + + await using var duplicate = NewRequest(); + var exception = await Assert.ThrowsAsync( + () => duplicate.AddRecipient.HandleAsync(new AddRecipientCommand(" RECIPIENT a", 500, October1), Ct)); + + Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code); + } + + [Fact] + public async Task Archiving_leaves_the_recipient_in_the_database() + { + await SetUpHouseholdAsync(); + await using (var request = NewRequest()) + { + var added = await request.AddRecipient.HandleAsync(new AddRecipientCommand("Recipient A", 1000, October1), Ct); + await using var archiving = NewRequest(); + await archiving.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct); + } + + await using var context = database.CreateContext(); + Assert.Equal(1, await context.Recipients.CountAsync(Ct)); + } + + [Fact] + public async Task Milk_rates_can_be_added_listed_looked_up_changed_and_deleted() + { + await SetUpHouseholdAsync(); + + MilkRateDetails october; + MilkRateDetails mid; + await using (var request = NewRequest()) + { + october = await request.AddRate.HandleAsync(new AddMilkRateCommand(October1, 6000), Ct); + } + + await using (var request = NewRequest()) + { + mid = await request.AddRate.HandleAsync(new AddMilkRateCommand(new DateOnly(2026, 10, 15), 6400), Ct); + } + + await using (var request = NewRequest()) + { + var list = await request.ListRates.HandleAsync(Ct); + Assert.Equal([mid.Id, october.Id], list.Select(rate => rate.Id)); + } + + await using (var request = NewRequest()) + { + Assert.Equal(6000, (await request.RateInEffect.HandleAsync(new DateOnly(2026, 10, 14), Ct)).PricePerLitrePaise); + Assert.Equal(6400, (await request.RateInEffect.HandleAsync(null, Ct)).PricePerLitrePaise); + await Assert.ThrowsAsync(() => request.RateInEffect.HandleAsync(new DateOnly(2026, 9, 30), Ct)); + } + + await using (var request = NewRequest()) + { + var updated = await request.UpdateRate.HandleAsync(new UpdateMilkRateCommand(mid.Id, new DateOnly(2026, 10, 16), 6500), Ct); + Assert.Equal(new MilkRateDetails(mid.Id, new DateOnly(2026, 10, 16), 6500), updated); + } + + await using (var request = NewRequest()) + { + await request.DeleteRate.HandleAsync(mid.Id, Ct); + } + + await using var reading = NewRequest(); + Assert.Equal(october.Id, Assert.Single(await reading.ListRates.HandleAsync(Ct)).Id); + } + + [Fact] + [Trait("Invariant", "T-3")] + public async Task A_duplicate_rate_date_is_a_conflict_through_the_real_database_query() + { + await SetUpHouseholdAsync(); + await using (var request = NewRequest()) + { + await request.AddRate.HandleAsync(new AddMilkRateCommand(October1, 6000), Ct); + } + + await using var duplicate = NewRequest(); + var exception = await Assert.ThrowsAsync( + () => duplicate.AddRate.HandleAsync(new AddMilkRateCommand(October1, 6400), Ct)); + + Assert.Equal(ErrorCodes.RateDateTaken, exception.Code); + } +} diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/MigrationTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/MigrationTests.cs new file mode 100644 index 0000000..fe35669 --- /dev/null +++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/MigrationTests.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore; + +namespace PureFreshMilk.Infrastructure.Tests; + +[Trait("Category", "Database")] +public sealed class MigrationTests(PostgresDatabaseFixture database) : IClassFixture +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + [Trait("Invariant", "P-1")] + public async Task Migrations_apply_to_an_empty_database_and_the_model_has_no_further_changes() + { + await using var context = database.CreateContext(); + + var applied = await context.Database.GetAppliedMigrationsAsync(Ct); + + Assert.Contains(applied, name => name.EndsWith("_AddHouseholdsRecipientsAndMilkRates", StringComparison.Ordinal)); + Assert.Empty(await context.Database.GetPendingMigrationsAsync(Ct)); + Assert.False(context.Database.HasPendingModelChanges(), "The model differs from the last migration: add a migration."); + } + + [Fact] + public async Task The_tables_have_the_columns_and_types_that_the_design_lists() + { + await using var context = database.CreateContext(); + + var columns = await context.Database + .SqlQueryRaw( + """ + select table_name || '.' || column_name || ' ' || data_type || ' ' || is_nullable as "Value" + from information_schema.columns + where table_schema = 'public' and table_name in ('households', 'recipients', 'milk_rates') + order by 1 + """) + .ToListAsync(Ct); + + Assert.Equal( + [ + "households.created_at timestamp with time zone NO", + "households.id uuid NO", + "households.name character varying NO", + "households.time_zone_id character varying NO", + "households.updated_at timestamp with time zone NO", + "milk_rates.created_at timestamp with time zone NO", + "milk_rates.effective_from date NO", + "milk_rates.household_id uuid NO", + "milk_rates.id uuid NO", + "milk_rates.price_per_litre_paise bigint NO", + "milk_rates.updated_at timestamp with time zone NO", + "recipients.archived_from date YES", + "recipients.created_at timestamp with time zone NO", + "recipients.expected_ml integer NO", + "recipients.household_id uuid NO", + "recipients.id uuid NO", + "recipients.name character varying NO", + "recipients.name_key character varying YES", + "recipients.starts_on date NO", + "recipients.updated_at timestamp with time zone NO", + ], + columns); + } + + [Fact] + public async Task The_constraints_and_indexes_of_the_design_exist() + { + await using var context = database.CreateContext(); + + var constraints = await context.Database + .SqlQueryRaw( + """ + select conname as "Value" from pg_constraint + where conrelid in ('households'::regclass, 'recipients'::regclass, 'milk_rates'::regclass) + and contype in ('p', 'u', 'f', 'c') -- PostgreSQL 18 also lists NOT NULL ('n') here + order by conname + """) + .ToListAsync(Ct); + + Assert.Equal( + [ + "ck_households_name_length", + "ck_milk_rates_price", + "ck_recipients_archived_after_start", + "ck_recipients_expected_ml", + "ck_recipients_name_length", + "fk_milk_rates_households_household_id", + "fk_recipients_households_household_id", + "pk_households", + "pk_milk_rates", + "pk_recipients", + "uq_recipients_household_id_id", + ], + constraints); + } + + [Fact] + public async Task Foreign_keys_restrict_deletion_so_history_outlives_any_delete() + { + await using var context = database.CreateContext(); + + // 'r' is RESTRICT in pg_constraint.confdeltype. + var rules = await context.Database + .SqlQueryRaw( + """ + select conname || ' ' || confdeltype::text as "Value" from pg_constraint + where connamespace = 'public'::regnamespace and contype = 'f' + order by conname + """) + .ToListAsync(Ct); + + Assert.Equal( + ["fk_milk_rates_households_household_id r", "fk_recipients_households_household_id r"], + rules); + } + + [Fact] + public async Task Active_recipient_names_are_unique_through_a_partial_index_on_the_generated_name_key() + { + await using var context = database.CreateContext(); + + var definitions = await context.Database + .SqlQueryRaw( + """ + select indexdef as "Value" from pg_indexes + where schemaname = 'public' and indexname in + ('ux_recipients_household_id_name_key_active', 'ux_milk_rates_household_id_effective_from') + order by indexname + """) + .ToListAsync(Ct); + + Assert.Equal(2, definitions.Count); + Assert.Contains("UNIQUE INDEX ux_milk_rates_household_id_effective_from", definitions[0], StringComparison.Ordinal); + Assert.Contains("(household_id, effective_from)", definitions[0], StringComparison.Ordinal); + Assert.Contains("UNIQUE INDEX ux_recipients_household_id_name_key_active", definitions[1], StringComparison.Ordinal); + Assert.Contains("(household_id, name_key)", definitions[1], StringComparison.Ordinal); + Assert.Contains("WHERE (archived_from IS NULL)", definitions[1], StringComparison.Ordinal); + } +} diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/MilkRateConstraintTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/MilkRateConstraintTests.cs new file mode 100644 index 0000000..7a1cfc8 --- /dev/null +++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/MilkRateConstraintTests.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using PureFreshMilk.Application.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Infrastructure.Persistence; + +namespace PureFreshMilk.Infrastructure.Tests; + +[Trait("Category", "Database")] +public sealed class MilkRateConstraintTests(PostgresDatabaseFixture database) : IClassFixture +{ + private static readonly DateOnly October1 = new(2026, 10, 1); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task SaveHouseholdAsync() + { + await using var context = database.CreateContext(); + var household = Seed.NewHousehold(); + context.Households.Add(household); + await context.SaveChangesAsync(Ct); + return household; + } + + private async Task SaveAsync(Action change) + { + await using var context = database.CreateContext(); + change(context); + await new UnitOfWork(context).SaveChangesAsync(Ct); + } + + [Fact] + [Trait("Invariant", "T-3")] + public async Task A_second_rate_for_the_same_day_is_refused_by_the_unique_index() + { + var household = await SaveHouseholdAsync(); + await SaveAsync(context => context.MilkRates.Add(Seed.NewRate(household, October1, 6000))); + + var exception = await Assert.ThrowsAsync( + () => SaveAsync(context => context.MilkRates.Add(Seed.NewRate(household, October1, 6400)))); + + Assert.Equal(ErrorCodes.RateDateTaken, exception.Code); + } + + [Fact] + public async Task Two_households_can_have_a_rate_starting_on_the_same_day() + { + var first = await SaveHouseholdAsync(); + var second = await SaveHouseholdAsync(); + + await SaveAsync(context => context.MilkRates.Add(Seed.NewRate(first, October1))); + await SaveAsync(context => context.MilkRates.Add(Seed.NewRate(second, October1))); + } + + [Theory] + [Trait("Invariant", "T-1")] + [InlineData(0)] + [InlineData(-1)] + [InlineData(1_000_001)] + public async Task The_database_refuses_a_price_outside_the_allowed_range_even_when_the_domain_is_bypassed(long paise) + { + var household = await SaveHouseholdAsync(); + await using var context = database.CreateContext(); + + var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlAsync( + $""" + insert into milk_rates (id, household_id, effective_from, price_per_litre_paise, created_at, updated_at) + values ({Guid.CreateVersion7()}, {household.Id.Value}, {October1}, {paise}, {Seed.Now}, {Seed.Now}) + """, + Ct)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + Assert.Equal("ck_milk_rates_price", exception.ConstraintName); + } + + [Fact] + public async Task A_rate_cannot_point_at_a_household_that_does_not_exist() + { + await using var context = database.CreateContext(); + + var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlAsync( + $""" + insert into milk_rates (id, household_id, effective_from, price_per_litre_paise, created_at, updated_at) + values ({Guid.CreateVersion7()}, {Guid.CreateVersion7()}, {October1}, {6250L}, {Seed.Now}, {Seed.Now}) + """, + Ct)); + + Assert.Equal(PostgresErrorCodes.ForeignKeyViolation, exception.SqlState); + Assert.Equal("fk_milk_rates_households_household_id", exception.ConstraintName); + } +} diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/PostgresDatabaseFixture.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/PostgresDatabaseFixture.cs new file mode 100644 index 0000000..57222c6 --- /dev/null +++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/PostgresDatabaseFixture.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using PureFreshMilk.Infrastructure.Persistence; + +namespace PureFreshMilk.Infrastructure.Tests; + +/// +/// Creates a throwaway PostgreSQL database for one test class, builds it from the migrations, and drops it +/// afterwards. The server comes from ConnectionStrings__PureFreshMilkTests: the same Compose PostgreSQL +/// that local development uses. A missing setting is a failure, never a silent skip, so these tests cannot +/// quietly stop running in CI. +/// +public sealed class PostgresDatabaseFixture : IAsyncLifetime +{ + public const string ConnectionStringVariable = "ConnectionStrings__PureFreshMilkTests"; + + private string serverConnectionString = string.Empty; + private string databaseName = string.Empty; + + public string ConnectionString { get; private set; } = string.Empty; + + public async ValueTask InitializeAsync() + { + var configured = Environment.GetEnvironmentVariable(ConnectionStringVariable); + if (string.IsNullOrWhiteSpace(configured)) + { + throw new InvalidOperationException( + $"The database tests need PostgreSQL. Start it with 'docker compose up -d --wait' and set {ConnectionStringVariable} " + + "to its connection string (see docs/development.md). To run everything else, exclude the 'Database' category."); + } + + serverConnectionString = configured; + databaseName = $"purefreshmilk_test_{Guid.NewGuid():N}"; + + await ExecuteOnServerAsync($"CREATE DATABASE \"{databaseName}\""); + + ConnectionString = new NpgsqlConnectionStringBuilder(configured) { Database = databaseName }.ConnectionString; + + await using var context = CreateContext(); + await context.Database.MigrateAsync(); + } + + public async ValueTask DisposeAsync() + { + if (databaseName.Length == 0) + { + return; + } + + NpgsqlConnection.ClearAllPools(); + await ExecuteOnServerAsync($"DROP DATABASE IF EXISTS \"{databaseName}\" WITH (FORCE)"); + } + + internal PureFreshMilkDbContext CreateContext() => + new(new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options); + + private async Task ExecuteOnServerAsync(string sql) + { + await using var connection = new NpgsqlConnection(serverConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } +} diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/PureFreshMilk.Infrastructure.Tests.csproj b/backend/tests/PureFreshMilk.Infrastructure.Tests/PureFreshMilk.Infrastructure.Tests.csproj new file mode 100644 index 0000000..6b17539 --- /dev/null +++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/PureFreshMilk.Infrastructure.Tests.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/RecipientConstraintTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/RecipientConstraintTests.cs new file mode 100644 index 0000000..a877cee --- /dev/null +++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/RecipientConstraintTests.cs @@ -0,0 +1,156 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using PureFreshMilk.Application.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Infrastructure.Persistence; + +namespace PureFreshMilk.Infrastructure.Tests; + +[Trait("Category", "Database")] +public sealed class RecipientConstraintTests(PostgresDatabaseFixture database) : IClassFixture +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task SaveHouseholdAsync() + { + await using var context = database.CreateContext(); + var household = Seed.NewHousehold(); + context.Households.Add(household); + await context.SaveChangesAsync(Ct); + return household; + } + + private async Task SaveAsync(Action change) + { + await using var context = database.CreateContext(); + change(context); + await new UnitOfWork(context).SaveChangesAsync(Ct); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task Active_names_are_unique_within_a_household_ignoring_case() + { + var household = await SaveHouseholdAsync(); + await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household, "Recipient A"))); + + var exception = await Assert.ThrowsAsync( + () => SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household, "RECIPIENT a")))); + + Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task The_same_name_can_be_used_in_another_household() + { + var first = await SaveHouseholdAsync(); + var second = await SaveHouseholdAsync(); + + await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(first, "Recipient A"))); + await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(second, "Recipient A"))); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task An_archived_recipient_does_not_block_its_name() + { + var household = await SaveHouseholdAsync(); + var archived = Seed.NewRecipient(household, "Recipient A"); + archived.Archive(new DateOnly(2026, 10, 15), Seed.Now); + await SaveAsync(context => context.Recipients.Add(archived)); + + await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household, "Recipient A"))); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task Unarchiving_into_a_name_that_is_now_taken_is_refused_by_the_index() + { + var household = await SaveHouseholdAsync(); + var archived = Seed.NewRecipient(household, "Recipient A"); + archived.Archive(new DateOnly(2026, 10, 15), Seed.Now); + await SaveAsync(context => context.Recipients.Add(archived)); + await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household, "Recipient A"))); + + var exception = await Assert.ThrowsAsync(() => SaveAsync(context => + { + var stored = context.Recipients.Single(recipient => recipient.Id == archived.Id); + stored.Unarchive(Seed.Now); + })); + + Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code); + } + + [Fact] + public async Task The_name_key_is_generated_from_the_name() + { + var household = await SaveHouseholdAsync(); + var recipient = Seed.NewRecipient(household, "Recipient MIXED Case"); + await SaveAsync(context => context.Recipients.Add(recipient)); + + await using var context = database.CreateContext(); + var key = await context.Database + .SqlQuery($"select name_key as \"Value\" from recipients where id = {recipient.Id.Value}") + .SingleAsync(Ct); + + Assert.Equal("recipient mixed case", key); + } + + [Theory] + [Trait("Invariant", "R-2")] + [InlineData("Recipient A", 0, "2026-10-01", null, "ck_recipients_expected_ml")] + [InlineData("Recipient A", 20_001, "2026-10-01", null, "ck_recipients_expected_ml")] + [InlineData(" ", 500, "2026-10-01", null, "ck_recipients_name_length")] + [InlineData("Recipient A", 500, "2026-10-01", "2026-10-01", "ck_recipients_archived_after_start")] + [InlineData("Recipient A", 500, "2026-10-01", "2026-09-01", "ck_recipients_archived_after_start")] + public async Task The_database_refuses_rows_that_break_the_checks_even_when_the_domain_is_bypassed( + string name, int expectedMl, string startsOn, string? archivedFrom, string constraint) + { + var household = await SaveHouseholdAsync(); + await using var context = database.CreateContext(); + var start = DateOnly.Parse(startsOn, System.Globalization.CultureInfo.InvariantCulture); + DateOnly? archived = archivedFrom is null ? null : DateOnly.Parse(archivedFrom, System.Globalization.CultureInfo.InvariantCulture); + + var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlAsync( + $""" + insert into recipients (id, household_id, name, expected_ml, starts_on, archived_from, created_at, updated_at) + values ({Guid.CreateVersion7()}, {household.Id.Value}, {name}, {expectedMl}, {start}, {archived}, {Seed.Now}, {Seed.Now}) + """, + Ct)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + Assert.Equal(constraint, exception.ConstraintName); + } + + [Fact] + public async Task A_recipient_cannot_point_at_a_household_that_does_not_exist() + { + await using var context = database.CreateContext(); + + var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlAsync( + $""" + insert into recipients (id, household_id, name, expected_ml, starts_on, created_at, updated_at) + values ({Guid.CreateVersion7()}, {Guid.CreateVersion7()}, {"Recipient A"}, {500}, {Seed.Start}, {Seed.Now}, {Seed.Now}) + """, + Ct)); + + Assert.Equal(PostgresErrorCodes.ForeignKeyViolation, exception.SqlState); + Assert.Equal("fk_recipients_households_household_id", exception.ConstraintName); + } + + [Fact] + public async Task A_household_with_recipients_cannot_be_deleted() + { + var household = await SaveHouseholdAsync(); + await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household))); + await using var context = database.CreateContext(); + + var exception = await Assert.ThrowsAsync( + () => context.Database.ExecuteSqlAsync($"delete from households where id = {household.Id.Value}", Ct)); + + // RESTRICT, rather than NO ACTION, reports its own error code. + Assert.Equal(PostgresErrorCodes.RestrictViolation, exception.SqlState); + Assert.Equal("fk_recipients_households_household_id", exception.ConstraintName); + } +} diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/RepositoryRoundTripTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/RepositoryRoundTripTests.cs new file mode 100644 index 0000000..ea23c75 --- /dev/null +++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/RepositoryRoundTripTests.cs @@ -0,0 +1,236 @@ +using Microsoft.EntityFrameworkCore; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Recipients; +using PureFreshMilk.Infrastructure.Persistence; +using PureFreshMilk.Infrastructure.Persistence.Repositories; + +namespace PureFreshMilk.Infrastructure.Tests; + +[Trait("Category", "Database")] +public sealed class RepositoryRoundTripTests(PostgresDatabaseFixture database) : IClassFixture +{ + private static readonly DateOnly October1 = new(2026, 10, 1); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task SaveHouseholdAsync() + { + await using var context = database.CreateContext(); + var household = Seed.NewHousehold(); + new HouseholdRepository(context).Add(household); + await new UnitOfWork(context).SaveChangesAsync(Ct); + return household; + } + + private async Task SaveRecipientAsync(Recipient recipient) + { + await using var context = database.CreateContext(); + new RecipientRepository(context).Add(recipient); + await new UnitOfWork(context).SaveChangesAsync(Ct); + } + + [Fact] + [Trait("Invariant", "P-2")] + public async Task A_household_round_trips() + { + var household = await SaveHouseholdAsync(); + + await using var context = database.CreateContext(); + var stored = await context.Households.SingleAsync(candidate => candidate.Id == household.Id, Ct); + + Assert.Equal(household.Id, stored.Id); + Assert.Equal("Example household", stored.Name); + Assert.Equal("Asia/Kolkata", stored.TimeZoneId); + Assert.Equal(Seed.Now, stored.CreatedAt); + Assert.Equal(Seed.Now, stored.UpdatedAt); + } + + [Fact] + [Trait("Invariant", "P-2")] + public async Task A_recipient_round_trips_with_its_dates_and_quantity_unshifted() + { + var household = await SaveHouseholdAsync(); + var recipient = Seed.NewRecipient(household, "Recipient A", 1250, new DateOnly(2026, 12, 31)); + recipient.Archive(new DateOnly(2027, 1, 1), Seed.Now.AddHours(1)); + await SaveRecipientAsync(recipient); + + await using var context = database.CreateContext(); + var stored = await new RecipientRepository(context).FindAsync(household.Id, recipient.Id, Ct); + + Assert.NotNull(stored); + Assert.Equal(recipient.Id, stored.Id); + Assert.Equal(household.Id, stored.HouseholdId); + Assert.Equal("Recipient A", stored.Name); + Assert.Equal(1250, stored.ExpectedQuantity.Millilitres); + Assert.Equal(new DateOnly(2026, 12, 31), stored.StartsOn); + Assert.Equal(new DateOnly(2027, 1, 1), stored.ArchivedFrom); + Assert.Equal(Seed.Now, stored.CreatedAt); + Assert.Equal(Seed.Now.AddHours(1), stored.UpdatedAt); + } + + [Fact] + [Trait("Invariant", "P-2")] + public async Task A_recipient_who_is_not_archived_has_no_archive_date() + { + var household = await SaveHouseholdAsync(); + var recipient = Seed.NewRecipient(household); + await SaveRecipientAsync(recipient); + + await using var context = database.CreateContext(); + var stored = await new RecipientRepository(context).FindAsync(household.Id, recipient.Id, Ct); + + Assert.Null(Assert.IsType(stored).ArchivedFrom); + } + + [Fact] + [Trait("Invariant", "P-2")] + public async Task A_milk_rate_round_trips() + { + var household = await SaveHouseholdAsync(); + var rate = Seed.NewRate(household, new DateOnly(2026, 10, 15), 6250); + await using (var context = database.CreateContext()) + { + new MilkRateRepository(context).Add(rate); + await new UnitOfWork(context).SaveChangesAsync(Ct); + } + + await using var reading = database.CreateContext(); + var stored = await new MilkRateRepository(reading).FindAsync(household.Id, rate.Id, Ct); + + Assert.NotNull(stored); + Assert.Equal(rate.Id, stored.Id); + Assert.Equal(household.Id, stored.HouseholdId); + Assert.Equal(new DateOnly(2026, 10, 15), stored.EffectiveFrom); + Assert.Equal(6250, stored.Price.PaisePerLitre); + } + + [Fact] + public async Task Changes_to_a_loaded_recipient_are_saved() + { + var household = await SaveHouseholdAsync(); + var recipient = Seed.NewRecipient(household, "Recipient A"); + await SaveRecipientAsync(recipient); + + await using (var context = database.CreateContext()) + { + var loaded = await new RecipientRepository(context).FindAsync(household.Id, recipient.Id, Ct); + Assert.NotNull(loaded); + loaded.Update("Recipient Z", PureFreshMilk.Domain.Common.Quantity.FromMillilitres(500), October1, Seed.Now.AddHours(2)); + loaded.Archive(new DateOnly(2026, 11, 1), Seed.Now.AddHours(2)); + await new UnitOfWork(context).SaveChangesAsync(Ct); + } + + await using var reading = database.CreateContext(); + var stored = await new RecipientRepository(reading).FindAsync(household.Id, recipient.Id, Ct); + Assert.NotNull(stored); + Assert.Equal("Recipient Z", stored.Name); + Assert.Equal(500, stored.ExpectedQuantity.Millilitres); + Assert.Equal(new DateOnly(2026, 11, 1), stored.ArchivedFrom); + } + + [Fact] + [Trait("Invariant", "R-9")] + public async Task The_list_is_ordered_by_name_and_hides_archived_recipients_as_of_today() + { + var household = await SaveHouseholdAsync(); + var today = new DateOnly(2026, 10, 30); + var active = Seed.NewRecipient(household, "bob"); + var first = Seed.NewRecipient(household, "Alice"); + var archived = Seed.NewRecipient(household, "Carol"); + archived.Archive(new DateOnly(2026, 10, 20), Seed.Now); + var archivingLater = Seed.NewRecipient(household, "Dave"); + archivingLater.Archive(new DateOnly(2026, 12, 1), Seed.Now); + foreach (var recipient in new[] { active, first, archived, archivingLater }) + { + await SaveRecipientAsync(recipient); + } + + await using var context = database.CreateContext(); + var repository = new RecipientRepository(context); + var visible = await repository.ListAsync(household.Id, includeArchived: false, today, Ct); + var all = await repository.ListAsync(household.Id, includeArchived: true, today, Ct); + + Assert.Equal(["Alice", "bob", "Dave"], visible.Select(recipient => recipient.Name)); + Assert.Equal(["Alice", "bob", "Carol", "Dave"], all.Select(recipient => recipient.Name)); + } + + [Fact] + public async Task The_list_only_contains_the_recipients_of_the_requested_household() + { + var mine = await SaveHouseholdAsync(); + var other = await SaveHouseholdAsync(); + await SaveRecipientAsync(Seed.NewRecipient(mine, "Mine")); + await SaveRecipientAsync(Seed.NewRecipient(other, "Theirs")); + + await using var context = database.CreateContext(); + var list = await new RecipientRepository(context).ListAsync(mine.Id, true, new DateOnly(2026, 10, 30), Ct); + + Assert.Equal("Mine", Assert.Single(list).Name); + } + + [Fact] + public async Task A_recipient_of_another_household_cannot_be_found() + { + var mine = await SaveHouseholdAsync(); + var other = await SaveHouseholdAsync(); + var theirs = Seed.NewRecipient(other, "Theirs"); + await SaveRecipientAsync(theirs); + + await using var context = database.CreateContext(); + + Assert.Null(await new RecipientRepository(context).FindAsync(mine.Id, theirs.Id, Ct)); + } + + [Fact] + [Trait("Invariant", "R-8")] + public async Task The_name_check_ignores_case_archived_recipients_and_the_recipient_itself() + { + var household = await SaveHouseholdAsync(); + var active = Seed.NewRecipient(household, "Recipient A"); + var archived = Seed.NewRecipient(household, "Recipient B"); + archived.Archive(new DateOnly(2026, 10, 20), Seed.Now); + await SaveRecipientAsync(active); + await SaveRecipientAsync(archived); + + await using var context = database.CreateContext(); + var repository = new RecipientRepository(context); + + Assert.True(await repository.NameInUseByActiveRecipientAsync(household.Id, "recipient a", null, Ct)); + Assert.False(await repository.NameInUseByActiveRecipientAsync(household.Id, "recipient a", active.Id, Ct)); + Assert.False(await repository.NameInUseByActiveRecipientAsync(household.Id, "recipient b", null, Ct)); + Assert.False(await repository.NameInUseByActiveRecipientAsync(household.Id, "recipient c", null, Ct)); + } + + [Fact] + public async Task The_rate_repository_lists_finds_checks_dates_and_removes() + { + var household = await SaveHouseholdAsync(); + var october = Seed.NewRate(household, October1, 6000); + var mid = Seed.NewRate(household, new DateOnly(2026, 10, 15), 6400); + await using (var context = database.CreateContext()) + { + var repository = new MilkRateRepository(context); + repository.Add(october); + repository.Add(mid); + await new UnitOfWork(context).SaveChangesAsync(Ct); + } + + await using (var context = database.CreateContext()) + { + var repository = new MilkRateRepository(context); + + Assert.Equal(2, (await repository.ListAsync(household.Id, Ct)).Count); + Assert.True(await repository.EffectiveFromTakenAsync(household.Id, October1, null, Ct)); + Assert.False(await repository.EffectiveFromTakenAsync(household.Id, October1, october.Id, Ct)); + Assert.False(await repository.EffectiveFromTakenAsync(household.Id, new DateOnly(2026, 10, 2), null, Ct)); + + var loaded = await repository.FindAsync(household.Id, mid.Id, Ct); + Assert.NotNull(loaded); + repository.Remove(loaded); + await new UnitOfWork(context).SaveChangesAsync(Ct); + } + + await using var reading = database.CreateContext(); + Assert.Equal(october.Id, Assert.Single(await new MilkRateRepository(reading).ListAsync(household.Id, Ct)).Id); + } +} diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/Seed.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/Seed.cs new file mode 100644 index 0000000..8f5b154 --- /dev/null +++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/Seed.cs @@ -0,0 +1,23 @@ +using PureFreshMilk.Domain.Common; +using PureFreshMilk.Domain.Households; +using PureFreshMilk.Domain.Rates; +using PureFreshMilk.Domain.Recipients; + +namespace PureFreshMilk.Infrastructure.Tests; + +/// Builds domain objects for the database tests. Timestamps use whole seconds: PostgreSQL keeps microseconds. +internal static class Seed +{ + public static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero); + public static readonly DateOnly Start = new(2026, 10, 1); + + public static Household NewHousehold(string name = "Example household") => + Household.Create(name, "Asia/Kolkata", Now); + + public static Recipient NewRecipient( + Household household, string name = "Recipient A", int millilitres = 1000, DateOnly? startsOn = null) => + Recipient.Add(household.Id, name, Quantity.FromMillilitres(millilitres), startsOn ?? Start, Now); + + public static MilkRate NewRate(Household household, DateOnly effectiveFrom, long paise = 6250) => + MilkRate.Create(household.Id, effectiveFrom, PricePerLitre.FromPaise(paise), Now); +} From f3a6a56fe7bffe7cd53ddd84564026ade4e0a564 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:03:08 +0530 Subject: [PATCH 04/10] feat(api): expose household, recipient and milk rate endpoints Add the /api/v1 endpoints for the household (get, set up), recipients (list, add, get, update, archive, restore) and milk rates (list, add, rate in effect, change, delete). Validation failures are Problem Details naming the field; missing data is 404 and clashes are 409, each with a stable code. There is no way to delete a recipient. Until authentication exists the data endpoints are mapped only in Development and Testing, or when Access__AllowUnauthenticated is set to true. Any other value, including an empty one, leaves them off. --- .../Features/Households/HouseholdEndpoints.cs | 38 +++ .../Features/Households/HouseholdResponse.cs | 12 + .../Households/SetUpHouseholdRequest.cs | 5 + .../Features/ItemsResponse.cs | 7 + .../Features/MilkRates/MilkRateEndpoints.cs | 67 ++++ .../Features/MilkRates/MilkRateRequests.cs | 6 + .../Features/MilkRates/MilkRateResponse.cs | 12 + .../Features/Recipients/RecipientEndpoints.cs | 77 +++++ .../Features/Recipients/RecipientRequests.cs | 13 + .../Features/Recipients/RecipientResponse.cs | 13 + .../PureFreshMilk.Api/Hosting/ApiRoutes.cs | 7 + .../Hosting/ApplicationExceptionHandler.cs | 66 ++++ .../Hosting/UnauthenticatedAccess.cs | 29 ++ backend/src/PureFreshMilk.Api/Program.cs | 22 +- .../tests/PureFreshMilk.Api.Tests/ApiJson.cs | 48 +++ .../Households/HouseholdEndpointTests.cs | 111 ++++++ .../MilkRates/MilkRateEndpointTests.cs | 219 ++++++++++++ .../Recipients/RecipientEndpointTests.cs | 316 ++++++++++++++++++ .../Features/StatusEndpointTests.cs | 1 + .../FixedTimeProvider.cs | 6 - .../Hosting/UnauthenticatedAccessTests.cs | 100 ++++++ .../PureFreshMilk.Api.Tests.csproj | 1 + .../PureFreshMilkApiFactory.cs | 19 +- .../InMemoryDatabase.cs | 10 + 24 files changed, 1197 insertions(+), 8 deletions(-) create mode 100644 backend/src/PureFreshMilk.Api/Features/Households/HouseholdEndpoints.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/Households/HouseholdResponse.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/Households/SetUpHouseholdRequest.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/ItemsResponse.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateEndpoints.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateRequests.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateResponse.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/Recipients/RecipientEndpoints.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/Recipients/RecipientRequests.cs create mode 100644 backend/src/PureFreshMilk.Api/Features/Recipients/RecipientResponse.cs create mode 100644 backend/src/PureFreshMilk.Api/Hosting/ApiRoutes.cs create mode 100644 backend/src/PureFreshMilk.Api/Hosting/ApplicationExceptionHandler.cs create mode 100644 backend/src/PureFreshMilk.Api/Hosting/UnauthenticatedAccess.cs create mode 100644 backend/tests/PureFreshMilk.Api.Tests/ApiJson.cs create mode 100644 backend/tests/PureFreshMilk.Api.Tests/Features/Households/HouseholdEndpointTests.cs create mode 100644 backend/tests/PureFreshMilk.Api.Tests/Features/MilkRates/MilkRateEndpointTests.cs create mode 100644 backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs delete mode 100644 backend/tests/PureFreshMilk.Api.Tests/FixedTimeProvider.cs create mode 100644 backend/tests/PureFreshMilk.Api.Tests/Hosting/UnauthenticatedAccessTests.cs diff --git a/backend/src/PureFreshMilk.Api/Features/Households/HouseholdEndpoints.cs b/backend/src/PureFreshMilk.Api/Features/Households/HouseholdEndpoints.cs new file mode 100644 index 0000000..bc17158 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/Households/HouseholdEndpoints.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using PureFreshMilk.Api.Hosting; +using PureFreshMilk.Application.Households; + +namespace PureFreshMilk.Api.Features.Households; + +internal static class HouseholdEndpoints +{ + public static IEndpointRouteBuilder MapHouseholdEndpoints(this IEndpointRouteBuilder api) + { + var household = api.MapGroup("/household").WithTags("Household"); + + household.MapGet( + string.Empty, + async (GetHouseholdHandler handler, CancellationToken cancellationToken) => + TypedResults.Ok(HouseholdResponse.From(await handler.HandleAsync(cancellationToken)))) + .WithName("GetHousehold") + .WithSummary("Reads the household, including today's date in its time zone."); + + household.MapPut( + string.Empty, + async Task, Ok>> ( + SetUpHouseholdRequest request, SetUpHouseholdHandler handler, CancellationToken cancellationToken) => + { + var result = await handler.HandleAsync( + new SetUpHouseholdCommand(request.Name, request.TimeZoneId), cancellationToken); + var body = HouseholdResponse.From(result.Household); + + return result.Created + ? TypedResults.Created($"{ApiRoutes.Prefix}/household", body) + : TypedResults.Ok(body); + }) + .WithName("SetUpHousehold") + .WithSummary("Creates the household the first time and updates it afterwards."); + + return api; + } +} diff --git a/backend/src/PureFreshMilk.Api/Features/Households/HouseholdResponse.cs b/backend/src/PureFreshMilk.Api/Features/Households/HouseholdResponse.cs new file mode 100644 index 0000000..bbbe544 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/Households/HouseholdResponse.cs @@ -0,0 +1,12 @@ +using PureFreshMilk.Application.Households; + +namespace PureFreshMilk.Api.Features.Households; + +/// Response body for GET and PUT /api/v1/household. +/// An IANA time zone id, for example Asia/Kolkata. +/// The calendar date it is now in the household's time zone, decided by the server. +internal sealed record HouseholdResponse(Guid Id, string Name, string TimeZoneId, DateOnly Today) +{ + public static HouseholdResponse From(HouseholdDetails household) => + new(household.Id, household.Name, household.TimeZoneId, household.Today); +} diff --git a/backend/src/PureFreshMilk.Api/Features/Households/SetUpHouseholdRequest.cs b/backend/src/PureFreshMilk.Api/Features/Households/SetUpHouseholdRequest.cs new file mode 100644 index 0000000..c633e75 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/Households/SetUpHouseholdRequest.cs @@ -0,0 +1,5 @@ +namespace PureFreshMilk.Api.Features.Households; + +/// Request body for PUT /api/v1/household. +/// An IANA id. Left out, it defaults to Asia/Kolkata on creation and is kept on update. +internal sealed record SetUpHouseholdRequest(string? Name, string? TimeZoneId); diff --git a/backend/src/PureFreshMilk.Api/Features/ItemsResponse.cs b/backend/src/PureFreshMilk.Api/Features/ItemsResponse.cs new file mode 100644 index 0000000..5cccb6e --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/ItemsResponse.cs @@ -0,0 +1,7 @@ +namespace PureFreshMilk.Api.Features; + +/// +/// The body of every list endpoint: an object with an items array, so a field can be added later +/// without breaking clients. +/// +internal sealed record ItemsResponse(IReadOnlyList Items); diff --git a/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateEndpoints.cs b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateEndpoints.cs new file mode 100644 index 0000000..197c938 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateEndpoints.cs @@ -0,0 +1,67 @@ +using PureFreshMilk.Api.Hosting; +using PureFreshMilk.Application.Rates; + +namespace PureFreshMilk.Api.Features.MilkRates; + +internal static class MilkRateEndpoints +{ + public static IEndpointRouteBuilder MapMilkRateEndpoints(this IEndpointRouteBuilder api) + { + var rates = api.MapGroup("/milk-rates").WithTags("Milk rates"); + + rates.MapGet( + string.Empty, + async (ListMilkRatesHandler handler, CancellationToken cancellationToken) => + { + var found = await handler.HandleAsync(cancellationToken); + + return TypedResults.Ok(new ItemsResponse([.. found.Select(MilkRateResponse.From)])); + }) + .WithName("ListMilkRates") + .WithSummary("Lists the rates, newest effective date first."); + + rates.MapGet( + "in-effect", + async (DateOnly? on, GetRateInEffectHandler handler, CancellationToken cancellationToken) => + TypedResults.Ok(MilkRateResponse.From(await handler.HandleAsync(on, cancellationToken)))) + .WithName("GetMilkRateInEffect") + .WithSummary("The rate in force on a date (default: today). 404 if no rate has started yet."); + + rates.MapPost( + string.Empty, + async (MilkRateRequest request, AddMilkRateHandler handler, CancellationToken cancellationToken) => + { + var added = await handler.HandleAsync( + new AddMilkRateCommand(request.EffectiveFrom, request.PricePerLitrePaise), cancellationToken); + + return TypedResults.Created($"{ApiRoutes.Prefix}/milk-rates/{added.Id}", MilkRateResponse.From(added)); + }) + .WithName("AddMilkRate") + .WithSummary("Adds a rate."); + + rates.MapPut( + "{id:guid}", + async (Guid id, MilkRateRequest request, UpdateMilkRateHandler handler, CancellationToken cancellationToken) => + { + var updated = await handler.HandleAsync( + new UpdateMilkRateCommand(id, request.EffectiveFrom, request.PricePerLitrePaise), cancellationToken); + + return TypedResults.Ok(MilkRateResponse.From(updated)); + }) + .WithName("UpdateMilkRate") + .WithSummary("Changes the date or price of a rate."); + + rates.MapDelete( + "{id:guid}", + async (Guid id, DeleteMilkRateHandler handler, CancellationToken cancellationToken) => + { + await handler.HandleAsync(id, cancellationToken); + + return TypedResults.NoContent(); + }) + .WithName("DeleteMilkRate") + .WithSummary("Removes a rate."); + + return api; + } +} diff --git a/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateRequests.cs b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateRequests.cs new file mode 100644 index 0000000..c380009 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateRequests.cs @@ -0,0 +1,6 @@ +namespace PureFreshMilk.Api.Features.MilkRates; + +/// Request body for POST /api/v1/milk-rates and PUT /api/v1/milk-rates/{id}. +/// The first day the price applies. It may be in the past, to back-date a rise entered late. +/// The price of one litre, in whole paise. +internal sealed record MilkRateRequest(DateOnly EffectiveFrom, long PricePerLitrePaise); diff --git a/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateResponse.cs b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateResponse.cs new file mode 100644 index 0000000..322d4c9 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateResponse.cs @@ -0,0 +1,12 @@ +using PureFreshMilk.Application.Rates; + +namespace PureFreshMilk.Api.Features.MilkRates; + +/// A milk rate in API responses. +/// The first day this price applies (YYYY-MM-DD). +/// The price of one litre, in whole paise. +internal sealed record MilkRateResponse(Guid Id, DateOnly EffectiveFrom, long PricePerLitrePaise) +{ + public static MilkRateResponse From(MilkRateDetails rate) => + new(rate.Id, rate.EffectiveFrom, rate.PricePerLitrePaise); +} diff --git a/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientEndpoints.cs b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientEndpoints.cs new file mode 100644 index 0000000..e69f9d3 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientEndpoints.cs @@ -0,0 +1,77 @@ +using PureFreshMilk.Api.Hosting; +using PureFreshMilk.Application.Recipients; + +namespace PureFreshMilk.Api.Features.Recipients; + +internal static class RecipientEndpoints +{ + public static IEndpointRouteBuilder MapRecipientEndpoints(this IEndpointRouteBuilder api) + { + var recipients = api.MapGroup("/recipients").WithTags("Recipients"); + + recipients.MapGet( + string.Empty, + async (bool? includeArchived, ListRecipientsHandler handler, CancellationToken cancellationToken) => + { + var found = await handler.HandleAsync(includeArchived ?? false, cancellationToken); + + return TypedResults.Ok(new ItemsResponse([.. found.Select(RecipientResponse.From)])); + }) + .WithName("ListRecipients") + .WithSummary("Lists the recipients by name. Archived ones are left out unless includeArchived is true."); + + recipients.MapPost( + string.Empty, + async (AddRecipientRequest request, AddRecipientHandler handler, CancellationToken cancellationToken) => + { + var added = await handler.HandleAsync( + new AddRecipientCommand(request.Name, request.ExpectedMl, request.StartsOn), cancellationToken); + + return TypedResults.Created($"{ApiRoutes.Prefix}/recipients/{added.Id}", RecipientResponse.From(added)); + }) + .WithName("AddRecipient") + .WithSummary("Adds a recipient."); + + recipients.MapGet( + "{id:guid}", + async (Guid id, GetRecipientHandler handler, CancellationToken cancellationToken) => + TypedResults.Ok(RecipientResponse.From(await handler.HandleAsync(id, cancellationToken)))) + .WithName("GetRecipient") + .WithSummary("Reads one recipient."); + + recipients.MapPut( + "{id:guid}", + async ( + Guid id, UpdateRecipientRequest request, UpdateRecipientHandler handler, CancellationToken cancellationToken) => + { + var updated = await handler.HandleAsync( + new UpdateRecipientCommand(id, request.Name, request.ExpectedMl, request.StartsOn), cancellationToken); + + return TypedResults.Ok(RecipientResponse.From(updated)); + }) + .WithName("UpdateRecipient") + .WithSummary("Replaces the name, usual quantity and start date of a recipient."); + + recipients.MapPost( + "{id:guid}/archive", + async ( + Guid id, ArchiveRecipientRequest? request, ArchiveRecipientHandler handler, CancellationToken cancellationToken) => + { + var archived = await handler.HandleAsync( + new ArchiveRecipientCommand(id, request?.ArchivedFrom), cancellationToken); + + return TypedResults.Ok(RecipientResponse.From(archived)); + }) + .WithName("ArchiveRecipient") + .WithSummary("Stops deliveries to a recipient from a date. Their history stays."); + + recipients.MapPost( + "{id:guid}/unarchive", + async (Guid id, UnarchiveRecipientHandler handler, CancellationToken cancellationToken) => + TypedResults.Ok(RecipientResponse.From(await handler.HandleAsync(id, cancellationToken)))) + .WithName("UnarchiveRecipient") + .WithSummary("Brings an archived recipient back."); + + return api; + } +} diff --git a/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientRequests.cs b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientRequests.cs new file mode 100644 index 0000000..cc447b5 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientRequests.cs @@ -0,0 +1,13 @@ +namespace PureFreshMilk.Api.Features.Recipients; + +/// Request body for POST /api/v1/recipients. +/// The usual daily quantity, in whole millilitres. +/// The first day a delivery is expected. Left out, it is today in the household's time zone. +internal sealed record AddRecipientRequest(string? Name, int ExpectedMl, DateOnly? StartsOn); + +/// Request body for PUT /api/v1/recipients/{id}: every editable field. +internal sealed record UpdateRecipientRequest(string? Name, int ExpectedMl, DateOnly StartsOn); + +/// Optional request body for POST /api/v1/recipients/{id}/archive. +/// The first day without deliveries. Left out, it is today in the household's time zone. +internal sealed record ArchiveRecipientRequest(DateOnly? ArchivedFrom); diff --git a/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientResponse.cs b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientResponse.cs new file mode 100644 index 0000000..3ea7bcb --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientResponse.cs @@ -0,0 +1,13 @@ +using PureFreshMilk.Application.Recipients; + +namespace PureFreshMilk.Api.Features.Recipients; + +/// A recipient in API responses. +/// The usual daily quantity, in whole millilitres. +/// The first day a delivery is expected (YYYY-MM-DD). +/// The first day without deliveries, or null while the recipient is not archived. +internal sealed record RecipientResponse(Guid Id, string Name, int ExpectedMl, DateOnly StartsOn, DateOnly? ArchivedFrom) +{ + public static RecipientResponse From(RecipientDetails recipient) => + new(recipient.Id, recipient.Name, recipient.ExpectedMl, recipient.StartsOn, recipient.ArchivedFrom); +} diff --git a/backend/src/PureFreshMilk.Api/Hosting/ApiRoutes.cs b/backend/src/PureFreshMilk.Api/Hosting/ApiRoutes.cs new file mode 100644 index 0000000..5ddbd51 --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Hosting/ApiRoutes.cs @@ -0,0 +1,7 @@ +namespace PureFreshMilk.Api.Hosting; + +internal static class ApiRoutes +{ + /// The versioned prefix of every API route. A breaking change gets a new version group. + public const string Prefix = "/api/v1"; +} diff --git a/backend/src/PureFreshMilk.Api/Hosting/ApplicationExceptionHandler.cs b/backend/src/PureFreshMilk.Api/Hosting/ApplicationExceptionHandler.cs new file mode 100644 index 0000000..64a68ca --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Hosting/ApplicationExceptionHandler.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using PureFreshMilk.Application.Common; +using PureFreshMilk.Domain.Common; + +namespace PureFreshMilk.Api.Hosting; + +/// +/// Turns the exceptions that use cases raise on purpose into RFC 9457 Problem Details: +/// a broken rule is 400 (with the field it concerns, when there is one), something missing is 404, +/// and a clash with stored data is 409. Both 404 and 409 carry a stable code that clients can branch on. +/// Anything else is left to the default handler, which answers 500 without details. +/// +internal sealed class ApplicationExceptionHandler(IProblemDetailsService problemDetailsService) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + var problem = exception switch + { + DomainException { Field: { } field } domain => new ValidationProblemDetails( + new Dictionary { [field] = [domain.Message] }) + { + Status = StatusCodes.Status400BadRequest, + Title = "One or more validation errors occurred.", + Extensions = { ["code"] = "validation-failed" }, + }, + DomainException domain => new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "A business rule was broken.", + Detail = domain.Message, + Extensions = { ["code"] = "rule-violated" }, + }, + NotFoundException notFound => new ProblemDetails + { + Status = StatusCodes.Status404NotFound, + Title = "Not found", + Detail = notFound.Message, + Extensions = { ["code"] = notFound.Code }, + }, + ConflictException conflict => new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Conflict", + Detail = conflict.Message, + Extensions = { ["code"] = conflict.Code }, + }, + _ => null, + }; + + if (problem is null) + { + return false; + } + + httpContext.Response.StatusCode = problem.Status ?? StatusCodes.Status500InternalServerError; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = problem, + }); + } +} diff --git a/backend/src/PureFreshMilk.Api/Hosting/UnauthenticatedAccess.cs b/backend/src/PureFreshMilk.Api/Hosting/UnauthenticatedAccess.cs new file mode 100644 index 0000000..1204c8d --- /dev/null +++ b/backend/src/PureFreshMilk.Api/Hosting/UnauthenticatedAccess.cs @@ -0,0 +1,29 @@ +namespace PureFreshMilk.Api.Hosting; + +/// +/// Until authentication exists the data endpoints read and write a household without a login, so they are +/// switched on only where that is safe: Development and Testing, or when Access:AllowUnauthenticated +/// (environment variable Access__AllowUnauthenticated) is set to true on purpose (ADR 0004). +/// +internal static class UnauthenticatedAccess +{ + public const string SettingName = "Access:AllowUnauthenticated"; + + public const string TestingEnvironment = "Testing"; + + /// Whether the data endpoints are allowed to run without a login in this environment. + public static bool IsAllowed(IHostEnvironment environment, IConfiguration configuration) => + IsLocalEnvironment(environment) || IsSwitchedOn(configuration); + + /// Whether the data endpoints are on only because the setting was turned on explicitly. + public static bool IsExplicitOptIn(IHostEnvironment environment, IConfiguration configuration) => + !IsLocalEnvironment(environment) && IsSwitchedOn(configuration); + + private static bool IsLocalEnvironment(IHostEnvironment environment) => + environment.IsDevelopment() || environment.IsEnvironment(TestingEnvironment); + + // Only an exact "true" counts. An empty or misspelled value leaves the endpoints off instead of + // stopping the API from starting. + private static bool IsSwitchedOn(IConfiguration configuration) => + bool.TryParse(configuration[SettingName], out var switchedOn) && switchedOn; +} diff --git a/backend/src/PureFreshMilk.Api/Program.cs b/backend/src/PureFreshMilk.Api/Program.cs index 92f45ab..b3a679a 100644 --- a/backend/src/PureFreshMilk.Api/Program.cs +++ b/backend/src/PureFreshMilk.Api/Program.cs @@ -1,3 +1,6 @@ +using PureFreshMilk.Api.Features.Households; +using PureFreshMilk.Api.Features.MilkRates; +using PureFreshMilk.Api.Features.Recipients; using PureFreshMilk.Api.Features.Status; using PureFreshMilk.Api.Hosting; using PureFreshMilk.Application; @@ -6,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); builder.Services.AddOpenApi(); builder.Services.AddFrontendCors(builder.Configuration); @@ -25,7 +29,23 @@ app.MapHealthEndpoints(); -var api = app.MapGroup("/api/v1"); +var api = app.MapGroup(ApiRoutes.Prefix); api.MapStatusEndpoints(); +// These endpoints read and write a household without a login, so they exist only where that is safe. +if (UnauthenticatedAccess.IsAllowed(app.Environment, app.Configuration)) +{ + if (UnauthenticatedAccess.IsExplicitOptIn(app.Environment, app.Configuration)) + { + app.Logger.LogWarning( + "{Setting} is true: the data endpoints are open to anyone who can reach this API. " + + "Do not expose it to a network you do not trust until authentication exists.", + UnauthenticatedAccess.SettingName); + } + + api.MapHouseholdEndpoints(); + api.MapRecipientEndpoints(); + api.MapMilkRateEndpoints(); +} + app.Run(); diff --git a/backend/tests/PureFreshMilk.Api.Tests/ApiJson.cs b/backend/tests/PureFreshMilk.Api.Tests/ApiJson.cs new file mode 100644 index 0000000..ecaac33 --- /dev/null +++ b/backend/tests/PureFreshMilk.Api.Tests/ApiJson.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace PureFreshMilk.Api.Tests; + +/// Small helpers for reading what the API sends back and for the household every data test needs. +internal static class ApiJson +{ + public static async Task ReadAsync(this HttpResponseMessage response) => + await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + /// Asserts a Problem Details response and returns its body. + public static async Task ReadProblemAsync(this HttpResponseMessage response, HttpStatusCode expected) + { + Assert.Equal(expected, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + + var problem = await response.ReadAsync(); + Assert.Equal((int)expected, problem.GetProperty("status").GetInt32()); + return problem; + } + + /// The validation messages for one field of a 400 response. + public static string[] ErrorsFor(this JsonElement problem, string field) => + [.. problem.GetProperty("errors").GetProperty(field).EnumerateArray().Select(message => message.GetString()!)]; + + public static async Task SetUpHouseholdAsync(this HttpClient client, string timeZoneId = "Asia/Kolkata") => + await client.PutAsJsonAsync( + "/api/v1/household", new { name = "Example household", timeZoneId }, TestContext.Current.CancellationToken); + + public static async Task AddRecipientAsync( + this HttpClient client, string name, int expectedMl = 1000, string? startsOn = null) + { + var response = await client.PostAsJsonAsync( + "/api/v1/recipients", new { name, expectedMl, startsOn }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return await response.ReadAsync(); + } + + public static async Task AddRateAsync(this HttpClient client, string effectiveFrom, long pricePerLitrePaise) + { + var response = await client.PostAsJsonAsync( + "/api/v1/milk-rates", new { effectiveFrom, pricePerLitrePaise }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return await response.ReadAsync(); + } +} diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/Households/HouseholdEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/Households/HouseholdEndpointTests.cs new file mode 100644 index 0000000..6e31472 --- /dev/null +++ b/backend/tests/PureFreshMilk.Api.Tests/Features/Households/HouseholdEndpointTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using PureFreshMilk.TestSupport; + +namespace PureFreshMilk.Api.Tests.Features.Households; + +public sealed class HouseholdEndpointTests(PureFreshMilkApiFactory factory) : IClassFixture +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private HttpClient Start() + { + factory.Database.Clear(); + return factory.CreateClient(); + } + + [Fact] + public async Task Reading_before_set_up_is_404_with_a_code_clients_can_branch_on() + { + using var client = Start(); + + var response = await client.GetAsync("/api/v1/household", Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.NotFound); + Assert.Equal("household-not-set-up", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task Setting_up_creates_the_household_then_updates_it() + { + using var client = Start(); + + var created = await client.SetUpHouseholdAsync(); + + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + Assert.Equal("/api/v1/household", created.Headers.Location?.ToString()); + Assert.Equal("application/json", created.Content.Headers.ContentType?.MediaType); + var body = await created.ReadAsync(); + Assert.Equal("Example household", body.GetProperty("name").GetString()); + Assert.Equal("Asia/Kolkata", body.GetProperty("timeZoneId").GetString()); + Assert.Equal("2026-10-30", body.GetProperty("today").GetString()); + Assert.True(body.GetProperty("id").TryGetGuid(out var id)); + + var updated = await client.PutAsJsonAsync("/api/v1/household", new { name = "Renamed" }, Ct); + + Assert.Equal(HttpStatusCode.OK, updated.StatusCode); + var updatedBody = await updated.ReadAsync(); + Assert.Equal(id, updatedBody.GetProperty("id").GetGuid()); + Assert.Equal("Renamed", updatedBody.GetProperty("name").GetString()); + Assert.Equal("Asia/Kolkata", updatedBody.GetProperty("timeZoneId").GetString()); + + var read = await (await client.GetAsync("/api/v1/household", Ct)).ReadAsync(); + Assert.Equal("Renamed", read.GetProperty("name").GetString()); + } + + [Fact] + public async Task Today_is_the_date_in_the_household_time_zone() + { + factory.Database.Clear(); + var atMidnightInKolkata = new DateTimeOffset(2026, 10, 30, 18, 30, 0, TimeSpan.Zero); + using var client = factory + .WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + services.AddSingleton(new FixedTimeProvider(atMidnightInKolkata)))) + .CreateClient(); + + var body = await (await client.SetUpHouseholdAsync()).ReadAsync(); + + Assert.Equal("2026-10-31", body.GetProperty("today").GetString()); + } + + [Theory] + [InlineData("{}", "name")] + [InlineData("""{"name": " "}""", "name")] + [InlineData("""{"name": "Home", "timeZoneId": "Mars/Olympus_Mons"}""", "timeZoneId")] + [InlineData("""{"name": "Home", "timeZoneId": "India Standard Time"}""", "timeZoneId")] + public async Task Invalid_input_is_400_naming_the_field(string json, string field) + { + using var client = Start(); + + var response = await client.PutAsync("/api/v1/household", Json(json), Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest); + Assert.Equal("validation-failed", problem.GetProperty("code").GetString()); + Assert.NotEmpty(problem.ErrorsFor(field)); + Assert.Empty(factory.Database.Households); + } + + [Fact] + public async Task Malformed_json_is_400_problem_details() + { + using var client = Start(); + + var response = await client.PutAsync("/api/v1/household", Json("{ not json"), Ct); + + await response.ReadProblemAsync(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task A_missing_body_is_400_problem_details() + { + using var client = Start(); + + var response = await client.PutAsync("/api/v1/household", content: null, Ct); + + await response.ReadProblemAsync(HttpStatusCode.BadRequest); + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/MilkRates/MilkRateEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/MilkRates/MilkRateEndpointTests.cs new file mode 100644 index 0000000..867005f --- /dev/null +++ b/backend/tests/PureFreshMilk.Api.Tests/Features/MilkRates/MilkRateEndpointTests.cs @@ -0,0 +1,219 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; + +namespace PureFreshMilk.Api.Tests.Features.MilkRates; + +public sealed class MilkRateEndpointTests(PureFreshMilkApiFactory factory) : IClassFixture +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task StartWithHouseholdAsync() + { + factory.Database.Clear(); + var client = factory.CreateClient(); + await client.SetUpHouseholdAsync(); + return client; + } + + [Fact] + public async Task Listing_before_the_household_is_set_up_is_404() + { + factory.Database.Clear(); + using var client = factory.CreateClient(); + + var problem = await (await client.GetAsync("/api/v1/milk-rates", Ct)).ReadProblemAsync(HttpStatusCode.NotFound); + + Assert.Equal("household-not-set-up", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task Adding_returns_201_with_a_location_and_the_rate() + { + using var client = await StartWithHouseholdAsync(); + + var response = await client.PostAsJsonAsync( + "/api/v1/milk-rates", new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 6250 }, Ct); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.ReadAsync(); + Assert.Equal($"/api/v1/milk-rates/{body.GetProperty("id").GetGuid()}", response.Headers.Location?.ToString()); + Assert.Equal("2026-10-01", body.GetProperty("effectiveFrom").GetString()); + Assert.Equal(6250, body.GetProperty("pricePerLitrePaise").GetInt64()); + } + + [Fact] + public async Task A_rate_can_start_in_the_past() + { + using var client = await StartWithHouseholdAsync(); + + var body = await client.AddRateAsync("2020-01-01", 4000); + + Assert.Equal("2020-01-01", body.GetProperty("effectiveFrom").GetString()); + } + + [Fact] + public async Task The_list_is_an_object_with_items_newest_first() + { + using var client = await StartWithHouseholdAsync(); + await client.AddRateAsync("2026-10-15", 6400); + await client.AddRateAsync("2026-09-01", 5500); + await client.AddRateAsync("2026-10-01", 6000); + + var body = await (await client.GetAsync("/api/v1/milk-rates", Ct)).ReadAsync(); + + var dates = body.GetProperty("items").EnumerateArray().Select(item => item.GetProperty("effectiveFrom").GetString()); + Assert.Equal(["2026-10-15", "2026-10-01", "2026-09-01"], dates); + } + + [Theory] + [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": 0}""", "pricePerLitrePaise")] + [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": -5}""", "pricePerLitrePaise")] + [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": 1000001}""", "pricePerLitrePaise")] + [InlineData("""{"effectiveFrom": "2026-10-01"}""", "pricePerLitrePaise")] + [InlineData("""{"pricePerLitrePaise": 6250}""", "effectiveFrom")] + public async Task Adding_invalid_input_is_400_naming_the_field(string json, string field) + { + using var client = await StartWithHouseholdAsync(); + + var response = await client.PostAsync("/api/v1/milk-rates", Json(json), Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest); + Assert.Equal("validation-failed", problem.GetProperty("code").GetString()); + Assert.NotEmpty(problem.ErrorsFor(field)); + Assert.Empty(factory.Database.MilkRates); + } + + [Theory] + [InlineData("""{"effectiveFrom": "yesterday", "pricePerLitrePaise": 6250}""")] + [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": 62.5}""")] + [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": "₹62.50"}""")] + public async Task Adding_the_wrong_kind_of_value_is_400_problem_details(string json) + { + using var client = await StartWithHouseholdAsync(); + + var response = await client.PostAsync("/api/v1/milk-rates", Json(json), Ct); + + await response.ReadProblemAsync(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task A_second_rate_for_the_same_day_is_409() + { + using var client = await StartWithHouseholdAsync(); + await client.AddRateAsync("2026-10-01", 6000); + + var response = await client.PostAsJsonAsync( + "/api/v1/milk-rates", new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 6400 }, Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict); + Assert.Equal("rate-date-taken", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task The_rate_in_effect_on_a_date_is_the_latest_one_that_has_started() + { + using var client = await StartWithHouseholdAsync(); + await client.AddRateAsync("2026-10-01", 6000); + await client.AddRateAsync("2026-10-15", 6400); + + var before = await (await client.GetAsync("/api/v1/milk-rates/in-effect?on=2026-10-14", Ct)).ReadAsync(); + var onTheDay = await (await client.GetAsync("/api/v1/milk-rates/in-effect?on=2026-10-15", Ct)).ReadAsync(); + var today = await (await client.GetAsync("/api/v1/milk-rates/in-effect", Ct)).ReadAsync(); + + Assert.Equal(6000, before.GetProperty("pricePerLitrePaise").GetInt64()); + Assert.Equal(6400, onTheDay.GetProperty("pricePerLitrePaise").GetInt64()); + Assert.Equal(6400, today.GetProperty("pricePerLitrePaise").GetInt64()); // today is 30 October, after both start dates + } + + [Fact] + [Trait("Invariant", "T-5")] + public async Task With_no_rate_in_force_the_answer_is_404_not_a_zero_price() + { + using var client = await StartWithHouseholdAsync(); + await client.AddRateAsync("2026-10-01", 6000); + + var empty = await client.GetAsync("/api/v1/milk-rates/in-effect?on=2026-09-30", Ct); + + var problem = await empty.ReadProblemAsync(HttpStatusCode.NotFound); + Assert.Equal("no-rate-in-effect", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task A_malformed_date_in_the_query_is_400() + { + using var client = await StartWithHouseholdAsync(); + + var response = await client.GetAsync("/api/v1/milk-rates/in-effect?on=tomorrow", Ct); + + await response.ReadProblemAsync(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Updating_changes_the_date_and_price() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRateAsync("2026-10-01", 6000); + var id = added.GetProperty("id").GetGuid(); + + var response = await client.PutAsJsonAsync( + $"/api/v1/milk-rates/{id}", new { effectiveFrom = "2026-10-05", pricePerLitrePaise = 6400 }, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.ReadAsync(); + Assert.Equal(id, body.GetProperty("id").GetGuid()); + Assert.Equal("2026-10-05", body.GetProperty("effectiveFrom").GetString()); + Assert.Equal(6400, body.GetProperty("pricePerLitrePaise").GetInt64()); + } + + [Fact] + public async Task Moving_a_rate_onto_the_day_of_another_is_409() + { + using var client = await StartWithHouseholdAsync(); + await client.AddRateAsync("2026-10-01", 6000); + var other = await client.AddRateAsync("2026-10-15", 6400); + + var response = await client.PutAsJsonAsync( + $"/api/v1/milk-rates/{other.GetProperty("id").GetGuid()}", + new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 7000 }, + Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict); + Assert.Equal("rate-date-taken", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task Updating_with_an_invalid_price_is_400_and_an_unknown_rate_is_404() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRateAsync("2026-10-01", 6000); + + var invalid = await client.PutAsJsonAsync( + $"/api/v1/milk-rates/{added.GetProperty("id").GetGuid()}", + new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 0 }, + Ct); + var unknown = await client.PutAsJsonAsync( + $"/api/v1/milk-rates/{Guid.CreateVersion7()}", new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 6000 }, Ct); + + var problem = await invalid.ReadProblemAsync(HttpStatusCode.BadRequest); + Assert.NotEmpty(problem.ErrorsFor("pricePerLitrePaise")); + var notFound = await unknown.ReadProblemAsync(HttpStatusCode.NotFound); + Assert.Equal("rate-not-found", notFound.GetProperty("code").GetString()); + } + + [Fact] + public async Task Deleting_returns_204_and_an_unknown_rate_is_404() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRateAsync("2026-10-01", 6000); + + var deleted = await client.DeleteAsync($"/api/v1/milk-rates/{added.GetProperty("id").GetGuid()}", Ct); + var again = await client.DeleteAsync($"/api/v1/milk-rates/{added.GetProperty("id").GetGuid()}", Ct); + + Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); + Assert.Empty(factory.Database.MilkRates); + await again.ReadProblemAsync(HttpStatusCode.NotFound); + } + + private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); +} diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs new file mode 100644 index 0000000..559dbc8 --- /dev/null +++ b/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs @@ -0,0 +1,316 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; + +namespace PureFreshMilk.Api.Tests.Features.Recipients; + +public sealed class RecipientEndpointTests(PureFreshMilkApiFactory factory) : IClassFixture +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task StartWithHouseholdAsync() + { + factory.Database.Clear(); + var client = factory.CreateClient(); + await client.SetUpHouseholdAsync(); + return client; + } + + [Fact] + public async Task Listing_before_the_household_is_set_up_is_404() + { + factory.Database.Clear(); + using var client = factory.CreateClient(); + + var problem = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadProblemAsync(HttpStatusCode.NotFound); + + Assert.Equal("household-not-set-up", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task Adding_returns_201_with_a_location_and_the_recipient() + { + using var client = await StartWithHouseholdAsync(); + + var response = await client.PostAsJsonAsync( + "/api/v1/recipients", new { name = " Recipient A ", expectedMl = 1000 }, Ct); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + var body = await response.ReadAsync(); + var id = body.GetProperty("id").GetGuid(); + Assert.Equal($"/api/v1/recipients/{id}", response.Headers.Location?.ToString()); + Assert.Equal("Recipient A", body.GetProperty("name").GetString()); + Assert.Equal(1000, body.GetProperty("expectedMl").GetInt32()); + Assert.Equal("2026-10-30", body.GetProperty("startsOn").GetString()); // defaults to today + Assert.Equal(System.Text.Json.JsonValueKind.Null, body.GetProperty("archivedFrom").ValueKind); + } + + [Fact] + public async Task The_list_is_an_object_with_items_ordered_by_name() + { + using var client = await StartWithHouseholdAsync(); + await client.AddRecipientAsync("Recipient B", 500, "2026-10-01"); + await client.AddRecipientAsync("Recipient A", 1000, "2026-10-01"); + + var body = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadAsync(); + + var names = body.GetProperty("items").EnumerateArray().Select(item => item.GetProperty("name").GetString()); + Assert.Equal(["Recipient A", "Recipient B"], names); + } + + [Fact] + public async Task An_empty_household_lists_no_recipients() + { + using var client = await StartWithHouseholdAsync(); + + var body = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadAsync(); + + Assert.Equal(0, body.GetProperty("items").GetArrayLength()); + } + + [Theory] + [InlineData("""{"expectedMl": 500}""", "name")] + [InlineData("""{"name": " ", "expectedMl": 500}""", "name")] + [InlineData("""{"name": "Recipient A"}""", "expectedMl")] + [InlineData("""{"name": "Recipient A", "expectedMl": 0}""", "expectedMl")] + [InlineData("""{"name": "Recipient A", "expectedMl": -250}""", "expectedMl")] + [InlineData("""{"name": "Recipient A", "expectedMl": 20001}""", "expectedMl")] + public async Task Adding_invalid_input_is_400_naming_the_field(string json, string field) + { + using var client = await StartWithHouseholdAsync(); + + var response = await client.PostAsync("/api/v1/recipients", Json(json), Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest); + Assert.Equal("validation-failed", problem.GetProperty("code").GetString()); + Assert.NotEmpty(problem.ErrorsFor(field)); + Assert.Empty(factory.Database.Recipients); + } + + [Theory] + [InlineData("""{"name": "Recipient A", "expectedMl": "plenty"}""")] + [InlineData("""{"name": "Recipient A", "expectedMl": 500, "startsOn": "30/10/2026"}""")] + [InlineData("""{"name": "Recipient A", "expectedMl": 500.5}""")] + [InlineData("{ not json")] + public async Task Adding_the_wrong_kind_of_value_is_400_problem_details(string json) + { + using var client = await StartWithHouseholdAsync(); + + var response = await client.PostAsync("/api/v1/recipients", Json(json), Ct); + + await response.ReadProblemAsync(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Adding_a_duplicate_name_is_409_whatever_its_case() + { + using var client = await StartWithHouseholdAsync(); + await client.AddRecipientAsync("Recipient A"); + + var response = await client.PostAsJsonAsync("/api/v1/recipients", new { name = "recipient a", expectedMl = 500 }, Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict); + Assert.Equal("recipient-name-taken", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task A_recipient_can_be_read_by_id() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A", 750, "2026-10-01"); + + var body = await (await client.GetAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}", Ct)).ReadAsync(); + + Assert.Equal("Recipient A", body.GetProperty("name").GetString()); + Assert.Equal(750, body.GetProperty("expectedMl").GetInt32()); + Assert.Equal("2026-10-01", body.GetProperty("startsOn").GetString()); + } + + [Fact] + public async Task An_unknown_recipient_is_404_and_a_malformed_id_is_not_a_route() + { + using var client = await StartWithHouseholdAsync(); + + var unknown = await client.GetAsync($"/api/v1/recipients/{Guid.CreateVersion7()}", Ct); + var problem = await unknown.ReadProblemAsync(HttpStatusCode.NotFound); + Assert.Equal("recipient-not-found", problem.GetProperty("code").GetString()); + + var malformed = await client.GetAsync("/api/v1/recipients/not-a-guid", Ct); + Assert.Equal(HttpStatusCode.NotFound, malformed.StatusCode); + } + + [Fact] + public async Task Updating_replaces_the_editable_fields() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A", 1000, "2026-10-01"); + var id = added.GetProperty("id").GetGuid(); + + var response = await client.PutAsJsonAsync( + $"/api/v1/recipients/{id}", new { name = "Recipient Z", expectedMl = 500, startsOn = "2026-09-15" }, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.ReadAsync(); + Assert.Equal(id, body.GetProperty("id").GetGuid()); + Assert.Equal("Recipient Z", body.GetProperty("name").GetString()); + Assert.Equal(500, body.GetProperty("expectedMl").GetInt32()); + Assert.Equal("2026-09-15", body.GetProperty("startsOn").GetString()); + } + + [Theory] + [InlineData("""{"name": "", "expectedMl": 500, "startsOn": "2026-10-01"}""", "name")] + [InlineData("""{"name": "Recipient A", "expectedMl": 0, "startsOn": "2026-10-01"}""", "expectedMl")] + [InlineData("""{"name": "Recipient A", "expectedMl": 500}""", "startsOn")] + public async Task Updating_with_invalid_input_is_400_naming_the_field(string json, string field) + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A", 1000, "2026-10-01"); + + var response = await client.PutAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}", Json(json), Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest); + Assert.NotEmpty(problem.ErrorsFor(field)); + } + + [Fact] + public async Task Renaming_onto_another_active_recipient_is_409() + { + using var client = await StartWithHouseholdAsync(); + await client.AddRecipientAsync("Recipient A"); + var other = await client.AddRecipientAsync("Recipient B"); + + var response = await client.PutAsJsonAsync( + $"/api/v1/recipients/{other.GetProperty("id").GetGuid()}", + new { name = "Recipient A", expectedMl = 500, startsOn = "2026-10-30" }, + Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict); + Assert.Equal("recipient-name-taken", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task Updating_an_unknown_recipient_is_404() + { + using var client = await StartWithHouseholdAsync(); + + var response = await client.PutAsJsonAsync( + $"/api/v1/recipients/{Guid.CreateVersion7()}", new { name = "Recipient A", expectedMl = 500, startsOn = "2026-10-30" }, Ct); + + await response.ReadProblemAsync(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Archiving_without_a_body_archives_from_today() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01"); + + var response = await client.PostAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive", content: null, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("2026-10-30", (await response.ReadAsync()).GetProperty("archivedFrom").GetString()); + } + + [Fact] + public async Task Archiving_can_name_the_day() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01"); + + var response = await client.PostAsJsonAsync( + $"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive", new { archivedFrom = "2026-11-15" }, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("2026-11-15", (await response.ReadAsync()).GetProperty("archivedFrom").GetString()); + } + + [Fact] + public async Task Archiving_on_or_before_the_start_date_is_400_naming_the_field() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01"); + + var response = await client.PostAsJsonAsync( + $"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive", new { archivedFrom = "2026-10-01" }, Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest); + Assert.NotEmpty(problem.ErrorsFor("archivedFrom")); + } + + [Fact] + public async Task Archiving_twice_is_409() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01"); + var url = $"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive"; + await client.PostAsync(url, content: null, Ct); + + var second = await client.PostAsync(url, content: null, Ct); + + var problem = await second.ReadProblemAsync(HttpStatusCode.Conflict); + Assert.Equal("recipient-already-archived", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task Archived_recipients_are_hidden_unless_asked_for_and_can_be_restored() + { + using var client = await StartWithHouseholdAsync(); + var gone = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01"); + await client.AddRecipientAsync("Recipient B", startsOn: "2026-10-01"); + var id = gone.GetProperty("id").GetGuid(); + await client.PostAsync($"/api/v1/recipients/{id}/archive", content: null, Ct); + + var visible = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadAsync(); + var everyone = await (await client.GetAsync("/api/v1/recipients?includeArchived=true", Ct)).ReadAsync(); + Assert.Equal(1, visible.GetProperty("items").GetArrayLength()); + Assert.Equal(2, everyone.GetProperty("items").GetArrayLength()); + + var restored = await client.PostAsync($"/api/v1/recipients/{id}/unarchive", content: null, Ct); + Assert.Equal(HttpStatusCode.OK, restored.StatusCode); + Assert.Equal(System.Text.Json.JsonValueKind.Null, (await restored.ReadAsync()).GetProperty("archivedFrom").ValueKind); + var again = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadAsync(); + Assert.Equal(2, again.GetProperty("items").GetArrayLength()); + } + + [Fact] + public async Task Restoring_into_a_name_that_is_now_taken_is_409() + { + using var client = await StartWithHouseholdAsync(); + var first = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01"); + var id = first.GetProperty("id").GetGuid(); + await client.PostAsync($"/api/v1/recipients/{id}/archive", content: null, Ct); + await client.AddRecipientAsync("Recipient A"); + + var response = await client.PostAsync($"/api/v1/recipients/{id}/unarchive", content: null, Ct); + + var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict); + Assert.Equal("recipient-name-taken", problem.GetProperty("code").GetString()); + } + + [Fact] + public async Task Archiving_or_restoring_an_unknown_recipient_is_404() + { + using var client = await StartWithHouseholdAsync(); + + var archive = await client.PostAsync($"/api/v1/recipients/{Guid.CreateVersion7()}/archive", content: null, Ct); + var unarchive = await client.PostAsync($"/api/v1/recipients/{Guid.CreateVersion7()}/unarchive", content: null, Ct); + + await archive.ReadProblemAsync(HttpStatusCode.NotFound); + await unarchive.ReadProblemAsync(HttpStatusCode.NotFound); + } + + [Fact] + public async Task There_is_no_way_to_delete_a_recipient() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A"); + + var response = await client.DeleteAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}", Ct); + + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Single(factory.Database.Recipients); + } + + private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); +} diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/StatusEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/StatusEndpointTests.cs index 4333edd..63c5cf3 100644 --- a/backend/tests/PureFreshMilk.Api.Tests/Features/StatusEndpointTests.cs +++ b/backend/tests/PureFreshMilk.Api.Tests/Features/StatusEndpointTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using PureFreshMilk.TestSupport; namespace PureFreshMilk.Api.Tests.Features; diff --git a/backend/tests/PureFreshMilk.Api.Tests/FixedTimeProvider.cs b/backend/tests/PureFreshMilk.Api.Tests/FixedTimeProvider.cs deleted file mode 100644 index e2fd7eb..0000000 --- a/backend/tests/PureFreshMilk.Api.Tests/FixedTimeProvider.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace PureFreshMilk.Api.Tests; - -internal sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider -{ - public override DateTimeOffset GetUtcNow() => utcNow; -} diff --git a/backend/tests/PureFreshMilk.Api.Tests/Hosting/UnauthenticatedAccessTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Hosting/UnauthenticatedAccessTests.cs new file mode 100644 index 0000000..90d8b99 --- /dev/null +++ b/backend/tests/PureFreshMilk.Api.Tests/Hosting/UnauthenticatedAccessTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Hosting; + +namespace PureFreshMilk.Api.Tests.Hosting; + +/// +/// The data endpoints read and write a household without a login, so they exist only in Development and +/// Testing, or when Access:AllowUnauthenticated is switched on explicitly (ADR 0004). +/// +public sealed class UnauthenticatedAccessTests(PureFreshMilkApiFactory factory) : IClassFixture +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static readonly string[] DataRoutes = + [ + "/api/v1/household", + "/api/v1/recipients", + "/api/v1/milk-rates", + ]; + + private HttpClient ClientFor(string environment, string? allowUnauthenticated = null) + { + // Each derived host gets its own in-memory database, so there is nothing to clear. + return factory + .WithWebHostBuilder(builder => + { + builder.UseEnvironment(environment); + if (allowUnauthenticated is not null) + { + builder.UseSetting("Access:AllowUnauthenticated", allowUnauthenticated); + } + }) + .CreateClient(); + } + + [Theory] + [InlineData("Production")] + [InlineData("Staging")] + public async Task Outside_development_and_testing_the_data_endpoints_are_not_mapped(string environment) + { + using var client = ClientFor(environment); + + foreach (var route in DataRoutes) + { + var response = await client.GetAsync(route, Ct); + var problem = await response.ReadProblemAsync(HttpStatusCode.NotFound); + + // A route that does not exist, not the "household not set up" answer of a route that does. + Assert.False(problem.TryGetProperty("code", out _), route); + } + + var write = await client.PutAsJsonAsync("/api/v1/household", new { name = "Home" }, Ct); + Assert.Equal(HttpStatusCode.NotFound, write.StatusCode); + } + + [Fact] + public async Task Outside_development_and_testing_the_status_and_health_endpoints_still_work() + { + using var client = ClientFor("Production"); + + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status", Ct)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/health/live", Ct)).StatusCode); + } + + [Theory] + [InlineData("false")] + [InlineData("")] + [InlineData("maybe")] + public async Task Anything_but_true_leaves_the_data_endpoints_off(string setting) + { + using var client = ClientFor("Production", setting); + + var response = await client.PutAsJsonAsync("/api/v1/household", new { name = "Home" }, Ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task The_setting_turns_the_data_endpoints_on_outside_development() + { + using var client = ClientFor("Production", "true"); + + var response = await client.PutAsJsonAsync("/api/v1/household", new { name = "Home" }, Ct); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Theory] + [InlineData("Development")] + [InlineData("Testing")] + public async Task In_development_and_testing_the_data_endpoints_are_mapped(string environment) + { + using var client = ClientFor(environment); + + var response = await client.PutAsJsonAsync("/api/v1/household", new { name = "Home" }, Ct); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } +} diff --git a/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilk.Api.Tests.csproj b/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilk.Api.Tests.csproj index 95b9b19..d4272d7 100644 --- a/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilk.Api.Tests.csproj +++ b/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilk.Api.Tests.csproj @@ -2,6 +2,7 @@ + diff --git a/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilkApiFactory.cs b/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilkApiFactory.cs index d70aed5..21b1180 100644 --- a/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilkApiFactory.cs +++ b/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilkApiFactory.cs @@ -1,24 +1,41 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using PureFreshMilk.TestSupport; namespace PureFreshMilk.Api.Tests; /// /// Hosts the API in memory. Runs in a dedicated "Testing" environment so that developer /// user secrets and appsettings.Development.json never affect test results. The connection -/// string points at a closed local port: these tests never need a running database. +/// string points at a closed local port: these tests never need a running database. The +/// repositories are replaced by in-memory ones, so the endpoints can be tested end to end +/// without PostgreSQL, and the clock stands still at . /// public sealed class PureFreshMilkApiFactory : WebApplicationFactory { public const string AllowedOrigin = "http://localhost:3000"; + /// 06:00 UTC on 30 October 2026: 11:30 on the same day in Asia/Kolkata. + public static readonly DateTimeOffset StartTime = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero); + private const string UnreachableDatabase = "Host=127.0.0.1;Port=1;Database=purefreshmilk_tests;Username=tests;Timeout=2"; + /// The in-memory database behind the endpoints. Clear it at the start of a test that shares this host. + public InMemoryDatabase Database => Services.GetRequiredService(); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); builder.UseSetting("ConnectionStrings:PureFreshMilk", UnreachableDatabase); builder.UseSetting("Cors:AllowedOrigins:0", AllowedOrigin); + + builder.ConfigureTestServices(services => + { + services.AddSingleton(new FixedTimeProvider(StartTime)); + services.UseInMemoryPersistence(); + }); } } diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs index bab5b40..28d0a07 100644 --- a/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs +++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs @@ -39,5 +39,15 @@ public Task SaveChangesAsync(CancellationToken cancellationToken) /// Forgets earlier saves, so a test can check that the step under test saved (or did not save). public void ResetSaveCount() => SaveCount = 0; + /// Empties the database, so tests that share a host start from nothing. + public void Clear() + { + Households.Clear(); + Recipients.Clear(); + MilkRates.Clear(); + pendingChanges.Clear(); + SaveCount = 0; + } + internal void StageChange(Action change) => pendingChanges.Add(change); } From e49e41d54b6aa6d98945e94a10d17276029dfc59 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:04:50 +0530 Subject: [PATCH 05/10] fix(infrastructure): order the single-household lookup Taking two households without an ORDER BY made EF Core warn on every request. Order by id so the lookup is deterministic. --- .../Households/SingleHouseholdProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs b/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs index 83d1643..825d6a3 100644 --- a/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs +++ b/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs @@ -21,7 +21,7 @@ internal sealed class SingleHouseholdProvider(PureFreshMilkDbContext context) : } // Ask for two so that a second household is noticed rather than silently ignored. - var found = await context.Households.Take(2).ToListAsync(cancellationToken); + var found = await context.Households.OrderBy(candidate => candidate.Id).Take(2).ToListAsync(cancellationToken); if (found.Count > 1) { From c9bfef4f1b547eb4c067e53f246ef9b2be5b0cca Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:18:28 +0530 Subject: [PATCH 06/10] fix(application): archive from the day after a recipient starts by default A recipient is active for at least one day, so archiving "from today" was refused for anyone who started today, which is exactly who gets archived after a mistake. With no date given, archive from today or the day after the start date, whichever is later. --- .../Recipients/ArchiveRecipientHandler.cs | 15 ++++++++-- .../Recipients/RecipientEndpointTests.cs | 12 ++++++++ .../Recipients/RecipientHandlerTests.cs | 29 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs index c888bce..4954210 100644 --- a/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs +++ b/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs @@ -2,11 +2,14 @@ using PureFreshMilk.Application.Common; using PureFreshMilk.Application.Households; using PureFreshMilk.Application.Persistence; +using PureFreshMilk.Domain.Recipients; namespace PureFreshMilk.Application.Recipients; /// -/// The first day without deliveries. Left out, it is today in the household's time zone. +/// The first day without deliveries. Left out, it is today in the household's time zone, or the day after the +/// recipient starts if that is later (a recipient is active for at least one day, so one who starts today +/// cannot be archived from today). /// public sealed record ArchiveRecipientCommand(Guid Id, DateOnly? ArchivedFrom); @@ -30,9 +33,17 @@ public async Task HandleAsync(ArchiveRecipientCommand command, $"The recipient is already archived from {archivedFrom.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}. Bring them back before archiving again."); } - recipient.Archive(command.ArchivedFrom ?? household.Today(now), now); + recipient.Archive(command.ArchivedFrom ?? DefaultArchiveDate(recipient, household.Today(now)), now); await unitOfWork.SaveChangesAsync(cancellationToken); return RecipientDetails.From(recipient); } + + private static DateOnly DefaultArchiveDate(Recipient recipient, DateOnly today) + { + // Not past the last representable day: the domain then rejects the date with its own message. + var dayAfterStart = recipient.StartsOn == DateOnly.MaxValue ? recipient.StartsOn : recipient.StartsOn.AddDays(1); + + return dayAfterStart > today ? dayAfterStart : today; + } } diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs index 559dbc8..2ced6d8 100644 --- a/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs +++ b/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs @@ -212,6 +212,18 @@ public async Task Archiving_without_a_body_archives_from_today() Assert.Equal("2026-10-30", (await response.ReadAsync()).GetProperty("archivedFrom").GetString()); } + [Fact] + public async Task Archiving_a_recipient_added_today_archives_from_tomorrow_because_they_are_active_for_a_day() + { + using var client = await StartWithHouseholdAsync(); + var added = await client.AddRecipientAsync("Recipient A"); // starts today, 30 October + + var response = await client.PostAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive", content: null, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("2026-10-31", (await response.ReadAsync()).GetProperty("archivedFrom").GetString()); + } + [Fact] public async Task Archiving_can_name_the_day() { diff --git a/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs b/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs index f246b9e..679d63a 100644 --- a/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs +++ b/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs @@ -269,6 +269,35 @@ public async Task Archiving_defaults_to_today() Assert.Equal(TestHousehold.Today, Assert.Single(app.Database.Recipients).ArchivedFrom); } + [Theory] + [Trait("Invariant", "R-5")] + [InlineData("2026-10-30", "2026-10-31")] // starts today: active for one day, so archived from tomorrow + [InlineData("2026-10-29", "2026-10-30")] // started yesterday: archived from today + [InlineData("2026-10-01", "2026-10-30")] + [InlineData("2026-11-05", "2026-11-06")] // starts in the future: archived the day after it starts + public async Task Archiving_by_default_leaves_the_recipient_active_for_at_least_one_day(string startsOn, string expected) + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync( + "Recipient A", startsOn: DateOnly.Parse(startsOn, System.Globalization.CultureInfo.InvariantCulture)); + + var archived = await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct); + + Assert.Equal(DateOnly.Parse(expected, System.Globalization.CultureInfo.InvariantCulture), archived.ArchivedFrom); + } + + [Fact] + public async Task Archiving_a_recipient_who_starts_on_the_last_representable_day_is_rejected_not_a_crash() + { + var app = await TestHousehold.SetUpAsync(); + var added = await app.AddRecipientAsync("Recipient A", startsOn: DateOnly.MaxValue); + + var exception = await Assert.ThrowsAsync( + () => app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct)); + + Assert.Equal("archivedFrom", exception.Field); + } + [Fact] public async Task Archiving_can_name_the_day() { From e8148a76d1c3ae47b0e98dfd79a32f25fde60474 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:22:19 +0530 Subject: [PATCH 07/10] feat(web): add API services and price and date helpers Add household, recipient and milk rate services on top of an API client that now sends POST, PUT and DELETE and keeps the Problem Details of an error, with its stable code and per-field messages. Prices are formatted and parsed as whole paise without floating-point arithmetic, and calendar dates are shown without turning them into time-zone dependent Date objects. --- apps/web/src/lib/dates.ts | 38 ++++ apps/web/src/lib/money.ts | 52 +++++ apps/web/src/lib/time-zones.ts | 24 +++ apps/web/src/services/api-client.ts | 88 +++++++- apps/web/src/services/household-service.ts | 21 ++ apps/web/src/services/milk-rate-service.ts | 44 ++++ apps/web/src/services/recipient-service.ts | 61 ++++++ apps/web/src/types/api.ts | 48 +++++ apps/web/tests/unit/lib/dates.test.ts | 35 +++ apps/web/tests/unit/lib/money.test.ts | 69 ++++++ .../tests/unit/services/api-client.test.ts | 157 +++++++++++++- .../unit/services/resource-services.test.ts | 202 ++++++++++++++++++ 12 files changed, 830 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/lib/money.ts create mode 100644 apps/web/src/lib/time-zones.ts create mode 100644 apps/web/src/services/household-service.ts create mode 100644 apps/web/src/services/milk-rate-service.ts create mode 100644 apps/web/src/services/recipient-service.ts create mode 100644 apps/web/tests/unit/lib/dates.test.ts create mode 100644 apps/web/tests/unit/lib/money.test.ts create mode 100644 apps/web/tests/unit/services/resource-services.test.ts diff --git a/apps/web/src/lib/dates.ts b/apps/web/src/lib/dates.ts index 85177a7..c728d76 100644 --- a/apps/web/src/lib/dates.ts +++ b/apps/web/src/lib/dates.ts @@ -6,3 +6,41 @@ export function formatLongDate(date: Date, locale?: string): string { month: "long", }).format(date); } + +/* + * Calendar dates travel as "YYYY-MM-DD" strings and are never turned into a Date with the local time + * zone: new Date("2026-10-30") is midnight UTC, which is the previous day in some zones. The household's + * calendar date is decided by the server, so these helpers only read and display it. + */ + +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** Whether `value` is a real calendar date written as YYYY-MM-DD. */ +export function isIsoDate(value: string): boolean { + return toUtcDate(value) !== null; +} + +/** "2026-10-30" → "30 Oct 2026" in the viewer's locale (or the given one). Anything else is returned as it is. */ +export function formatIsoDate(value: string, locale?: string): string { + const date = toUtcDate(value); + if (!date) return value; + + return new Intl.DateTimeFormat(locale, { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "UTC", + }).format(date); +} + +function toUtcDate(value: string): Date | null { + const match = ISO_DATE.exec(value); + if (!match) return null; + + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const date = new Date(Date.UTC(year, month - 1, day)); + + const isRealDate = + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; + return isRealDate ? date : null; +} diff --git a/apps/web/src/lib/money.ts b/apps/web/src/lib/money.ts new file mode 100644 index 0000000..f471f3f --- /dev/null +++ b/apps/web/src/lib/money.ts @@ -0,0 +1,52 @@ +/** + * Display and input helpers for prices. Money is whole paise everywhere, matching PureFreshMilk.Domain's + * Money and PricePerLitre (ADR 0004). These helpers format and parse text; they never do billing + * arithmetic, which the server owns. + */ + +export const MIN_PRICE_PAISE = 1; +export const MAX_PRICE_PAISE = 1_000_000; + +const rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }); + +/** 6250 → "₹62.50". */ +export function formatPaise(paise: number): string { + return rupees.format(paise / 100); +} + +/** 6250 → "₹62.50 a litre". */ +export function formatPricePerLitre(paisePerLitre: number): string { + return `${formatPaise(paisePerLitre)} a litre`; +} + +export type ParsedPrice = { ok: true; paise: number } | { ok: false; message: string }; + +const PRICE_PATTERN = /^(\d{1,5})(?:\.(\d{1,2}))?$/; + +/** + * Reads what a person types for the price of a litre in rupees, for example "62.50", "62.5", "₹62" or + * "62". Only digits and up to two decimals are accepted, and the text is turned into paise without + * floating-point arithmetic. + */ +export function parseRupees(input: string): ParsedPrice { + const text = input.replace(/^\s*₹/, "").trim(); + + if (text === "") { + return { ok: false, message: "Enter the price of one litre, for example 62.50." }; + } + + const match = PRICE_PATTERN.exec(text); + if (!match) { + return { ok: false, message: "Use numbers only, with up to two decimals, for example 62.50." }; + } + + const paise = Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0")); + if (paise < MIN_PRICE_PAISE || paise > MAX_PRICE_PAISE) { + return { + ok: false, + message: `Enter a price between ${formatPaise(MIN_PRICE_PAISE)} and ${formatPaise(MAX_PRICE_PAISE)} a litre.`, + }; + } + + return { ok: true, paise }; +} diff --git a/apps/web/src/lib/time-zones.ts b/apps/web/src/lib/time-zones.ts new file mode 100644 index 0000000..bf9bd1a --- /dev/null +++ b/apps/web/src/lib/time-zones.ts @@ -0,0 +1,24 @@ +/** + * Time zones offered when setting up a household. The API accepts any IANA id; this short list covers the + * likely cases, and a household already on another zone keeps it (see timeZoneOptions). + */ +export const DEFAULT_TIME_ZONE_ID = "Asia/Kolkata"; + +const COMMON_TIME_ZONES: readonly { id: string; label: string }[] = [ + { id: "Asia/Kolkata", label: "India (Asia/Kolkata)" }, + { id: "Asia/Dubai", label: "United Arab Emirates (Asia/Dubai)" }, + { id: "Asia/Singapore", label: "Singapore (Asia/Singapore)" }, + { id: "Europe/London", label: "United Kingdom (Europe/London)" }, + { id: "America/New_York", label: "US Eastern (America/New_York)" }, + { id: "America/Los_Angeles", label: "US Pacific (America/Los_Angeles)" }, + { id: "Australia/Sydney", label: "Sydney (Australia/Sydney)" }, + { id: "UTC", label: "UTC" }, +]; + +/** The common zones, plus `currentId` when the household is on a zone that is not in the list. */ +export function timeZoneOptions(currentId?: string): readonly { id: string; label: string }[] { + if (!currentId || COMMON_TIME_ZONES.some((zone) => zone.id === currentId)) { + return COMMON_TIME_ZONES; + } + return [{ id: currentId, label: currentId }, ...COMMON_TIME_ZONES]; +} diff --git a/apps/web/src/services/api-client.ts b/apps/web/src/services/api-client.ts index a9b76f4..9e1d6a1 100644 --- a/apps/web/src/services/api-client.ts +++ b/apps/web/src/services/api-client.ts @@ -3,21 +3,48 @@ * Components never call fetch directly; they go through a service module. */ +import type { ApiProblem } from "@/types/api"; + const API_PREFIX = "/api/v1"; const DEFAULT_TIMEOUT_MS = 5000; export type ApiErrorKind = "not-configured" | "unreachable" | "http"; +interface ApiErrorOptions extends ErrorOptions { + /** The Problem Details body of an error response, when the API sent one. */ + problem?: ApiProblem; +} + export class ApiError extends Error { + readonly problem?: ApiProblem; + constructor( message: string, readonly kind: ApiErrorKind, readonly status?: number, - options?: ErrorOptions, + options?: ApiErrorOptions, ) { super(message, options); this.name = "ApiError"; + this.problem = options?.problem; + } +} + +/** Whether `error` is an API error answered with the given stable `code`, for example "recipient-name-taken". */ +export function hasProblemCode(error: unknown, code: string): error is ApiError { + return error instanceof ApiError && error.problem?.code === code; +} + +/** The first validation message for each input field of a 400 response, keyed by the API's field name. */ +export function fieldErrors(error: unknown): Record { + if (!(error instanceof ApiError) || !error.problem?.errors) return {}; + + const messages: Record = {}; + for (const [field, list] of Object.entries(error.problem.errors)) { + const first = list[0]; + if (first) messages[field] = first; } + return messages; } /** Base URL from NEXT_PUBLIC_API_BASE_URL without a trailing slash, or null when unset. */ @@ -31,7 +58,37 @@ export interface ApiRequestOptions { timeoutMs?: number; } -export async function apiGet(path: string, options: ApiRequestOptions = {}): Promise { +export function apiGet(path: string, options: ApiRequestOptions = {}): Promise { + return request("GET", path, undefined, options); +} + +/** POST, with an optional JSON body. */ +export function apiPost( + path: string, + body?: unknown, + options: ApiRequestOptions = {}, +): Promise { + return request("POST", path, body, options); +} + +export function apiPut( + path: string, + body: unknown, + options: ApiRequestOptions = {}, +): Promise { + return request("PUT", path, body, options); +} + +export async function apiDelete(path: string, options: ApiRequestOptions = {}): Promise { + await request("DELETE", path, undefined, options); +} + +async function request( + method: string, + path: string, + body: unknown, + options: ApiRequestOptions, +): Promise { const baseUrl = getApiBaseUrl(); if (!baseUrl) { throw new ApiError("NEXT_PUBLIC_API_BASE_URL is not set.", "not-configured"); @@ -40,12 +97,16 @@ export async function apiGet(path: string, options: ApiRequestOptions = {}): const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS); const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + const headers: Record = { Accept: "application/json" }; + const init: RequestInit = { method, headers, signal }; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + let response: Response; try { - response = await fetch(`${baseUrl}${API_PREFIX}${path}`, { - headers: { Accept: "application/json" }, - signal, - }); + response = await fetch(`${baseUrl}${API_PREFIX}${path}`, init); } catch (error) { throw new ApiError(`Could not reach the API at ${baseUrl}.`, "unreachable", undefined, { cause: error, @@ -53,8 +114,21 @@ export async function apiGet(path: string, options: ApiRequestOptions = {}): } if (!response.ok) { - throw new ApiError(`The API responded with HTTP ${response.status}.`, "http", response.status); + throw new ApiError(`The API responded with HTTP ${response.status}.`, "http", response.status, { + problem: await readProblem(response), + }); } + if (response.status === 204) return undefined as T; return (await response.json()) as T; } + +async function readProblem(response: Response): Promise { + if (!response.headers.get("content-type")?.includes("json")) return undefined; + + try { + return (await response.json()) as ApiProblem; + } catch { + return undefined; + } +} diff --git a/apps/web/src/services/household-service.ts b/apps/web/src/services/household-service.ts new file mode 100644 index 0000000..5987657 --- /dev/null +++ b/apps/web/src/services/household-service.ts @@ -0,0 +1,21 @@ +import type { ApiHousehold } from "@/types/api"; +import { apiGet, apiPut, type ApiRequestOptions } from "./api-client"; + +/** Rejects with a "household-not-set-up" problem (HTTP 404) until the household has been set up. */ +export function getHousehold(options?: ApiRequestOptions): Promise { + return apiGet("/household", options); +} + +export interface HouseholdInput { + name: string; + /** An IANA id. Left out, the API keeps the current one (Asia/Kolkata for a new household). */ + timeZoneId?: string; +} + +/** Creates the household the first time and updates it afterwards. */ +export function setUpHousehold( + input: HouseholdInput, + options?: ApiRequestOptions, +): Promise { + return apiPut("/household", input, options); +} diff --git a/apps/web/src/services/milk-rate-service.ts b/apps/web/src/services/milk-rate-service.ts new file mode 100644 index 0000000..4c72024 --- /dev/null +++ b/apps/web/src/services/milk-rate-service.ts @@ -0,0 +1,44 @@ +import type { ApiItems, ApiMilkRate } from "@/types/api"; +import { apiDelete, apiGet, apiPost, hasProblemCode, type ApiRequestOptions } from "./api-client"; + +/** Every rate of the household, newest effective date first. */ +export async function listMilkRates(options?: ApiRequestOptions): Promise { + const result = await apiGet>("/milk-rates", options); + return result.items; +} + +/** + * The rate in force on `on` (YYYY-MM-DD; default: today in the household's time zone), decided by the server. + * `null` means no price has started yet: that is not the same as a price of zero. + */ +export async function getMilkRateInEffect( + on?: string, + options?: ApiRequestOptions, +): Promise { + const query = on ? `?on=${encodeURIComponent(on)}` : ""; + + try { + return await apiGet(`/milk-rates/in-effect${query}`, options); + } catch (error) { + if (hasProblemCode(error, "no-rate-in-effect")) return null; + throw error; + } +} + +export interface MilkRateInput { + /** YYYY-MM-DD, the first day the price applies. It may be in the past. */ + effectiveFrom: string; + /** The price of one litre, in whole paise. */ + pricePerLitrePaise: number; +} + +export function addMilkRate( + input: MilkRateInput, + options?: ApiRequestOptions, +): Promise { + return apiPost("/milk-rates", input, options); +} + +export function deleteMilkRate(id: string, options?: ApiRequestOptions): Promise { + return apiDelete(`/milk-rates/${encodeURIComponent(id)}`, options); +} diff --git a/apps/web/src/services/recipient-service.ts b/apps/web/src/services/recipient-service.ts new file mode 100644 index 0000000..afccb33 --- /dev/null +++ b/apps/web/src/services/recipient-service.ts @@ -0,0 +1,61 @@ +import type { ApiItems, ApiRecipient } from "@/types/api"; +import { apiGet, apiPost, apiPut, type ApiRequestOptions } from "./api-client"; + +export interface ListRecipientsOptions extends ApiRequestOptions { + /** Also return recipients who have been archived. */ + includeArchived?: boolean; +} + +/** The household's recipients, ordered by name by the server. */ +export async function listRecipients({ + includeArchived = false, + ...options +}: ListRecipientsOptions = {}): Promise { + const query = includeArchived ? "?includeArchived=true" : ""; + const result = await apiGet>(`/recipients${query}`, options); + return result.items; +} + +export interface RecipientInput { + name: string; + /** Usual daily quantity, in whole millilitres. */ + expectedMl: number; + /** YYYY-MM-DD, the first day a delivery is expected. */ + startsOn: string; +} + +export function addRecipient( + input: RecipientInput, + options?: ApiRequestOptions, +): Promise { + return apiPost("/recipients", input, options); +} + +export function updateRecipient( + id: string, + input: RecipientInput, + options?: ApiRequestOptions, +): Promise { + return apiPut(`/recipients/${encodeURIComponent(id)}`, input, options); +} + +/** Stops deliveries from `archivedFrom` (YYYY-MM-DD; default: today). The recipient's history is kept. */ +export function archiveRecipient( + id: string, + archivedFrom?: string, + options?: ApiRequestOptions, +): Promise { + return apiPost( + `/recipients/${encodeURIComponent(id)}/archive`, + archivedFrom ? { archivedFrom } : undefined, + options, + ); +} + +export function unarchiveRecipient(id: string, options?: ApiRequestOptions): Promise { + return apiPost( + `/recipients/${encodeURIComponent(id)}/unarchive`, + undefined, + options, + ); +} diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 5e39392..8537a69 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -3,6 +3,8 @@ * keep them in sync by hand (there is deliberately no shared .NET/TypeScript package). */ +import type { Recipient } from "@/types/household"; + /** GET /api/v1/status — PureFreshMilk.Api.Features.Status.StatusResponse */ export interface ApiStatusResponse { service: string; @@ -10,3 +12,49 @@ export interface ApiStatusResponse { /** ISO 8601 timestamp. */ serverTime: string; } + +/** + * An error body: RFC 9457 Problem Details plus the `code` extension. + * Every error the API raises on purpose has one; a 500 may not. + */ +export interface ApiProblem { + status?: number; + title?: string; + detail?: string; + /** Stable, machine-readable, for example "recipient-name-taken" or "household-not-set-up". */ + code?: string; + /** Present on validation errors (400): input field name → messages. */ + errors?: Record; +} + +/** Every list endpoint answers with an object holding an `items` array. */ +export interface ApiItems { + items: T[]; +} + +/** GET and PUT /api/v1/household — PureFreshMilk.Api.Features.Households.HouseholdResponse */ +export interface ApiHousehold { + id: string; + name: string; + /** An IANA time zone id, for example "Asia/Kolkata". */ + timeZoneId: string; + /** The calendar date now, in the household's time zone, as YYYY-MM-DD. The server decides it. */ + today: string; +} + +/** /api/v1/recipients — PureFreshMilk.Api.Features.Recipients.RecipientResponse */ +export interface ApiRecipient extends Recipient { + /** First day a delivery is expected, YYYY-MM-DD. */ + startsOn: string; + /** First day without deliveries, YYYY-MM-DD, or null while the recipient is not archived. */ + archivedFrom: string | null; +} + +/** /api/v1/milk-rates — PureFreshMilk.Api.Features.MilkRates.MilkRateResponse */ +export interface ApiMilkRate { + id: string; + /** First day this price applies, YYYY-MM-DD. */ + effectiveFrom: string; + /** The price of one litre, in whole paise. */ + pricePerLitrePaise: number; +} diff --git a/apps/web/tests/unit/lib/dates.test.ts b/apps/web/tests/unit/lib/dates.test.ts new file mode 100644 index 0000000..1e629c6 --- /dev/null +++ b/apps/web/tests/unit/lib/dates.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { formatIsoDate, isIsoDate } from "@/lib/dates"; + +describe("isIsoDate", () => { + it("accepts real calendar dates", () => { + expect(isIsoDate("2026-10-30")).toBe(true); + expect(isIsoDate("2028-02-29")).toBe(true); + }); + + it.each([ + "2026-02-30", + "2026-13-01", + "2026-00-10", + "2027-02-29", + "30/10/2026", + "2026-1-5", + "", + "today", + ])("rejects %j", (value) => { + expect(isIsoDate(value)).toBe(false); + }); +}); + +describe("formatIsoDate", () => { + it("shows the calendar date written, whatever the machine's time zone", () => { + expect(formatIsoDate("2026-10-30", "en-GB")).toBe("30 Oct 2026"); + expect(formatIsoDate("2026-01-01", "en-GB")).toBe("1 Jan 2026"); + expect(formatIsoDate("2026-12-31", "en-GB")).toBe("31 Dec 2026"); + }); + + it("returns anything that is not a date as it is", () => { + expect(formatIsoDate("soon", "en-GB")).toBe("soon"); + expect(formatIsoDate("2026-02-30", "en-GB")).toBe("2026-02-30"); + }); +}); diff --git a/apps/web/tests/unit/lib/money.test.ts b/apps/web/tests/unit/lib/money.test.ts new file mode 100644 index 0000000..d0fdced --- /dev/null +++ b/apps/web/tests/unit/lib/money.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { MAX_PRICE_PAISE, formatPaise, formatPricePerLitre, parseRupees } from "@/lib/money"; + +describe("formatPaise", () => { + it("shows rupees and paise", () => { + expect(formatPaise(6250)).toBe("₹62.50"); + expect(formatPaise(6000)).toBe("₹60.00"); + expect(formatPaise(1)).toBe("₹0.01"); + }); + + it("groups large amounts the Indian way", () => { + expect(formatPaise(1_000_000)).toBe("₹10,000.00"); + expect(formatPaise(12_345_600)).toBe("₹1,23,456.00"); + }); +}); + +describe("formatPricePerLitre", () => { + it("adds the unit", () => { + expect(formatPricePerLitre(6250)).toBe("₹62.50 a litre"); + }); +}); + +describe("parseRupees", () => { + it.each([ + ["62.50", 6250], + ["62.5", 6250], + ["62", 6200], + ["0.01", 1], + ["0.1", 10], + ["10000", 1_000_000], + ["₹62.50", 6250], + [" ₹ 62 ", 6200], + ])("reads %j as %i paise", (text, paise) => { + expect(parseRupees(text)).toEqual({ ok: true, paise }); + }); + + it("converts without floating-point error", () => { + // 0.07 * 100 is 7.000000000000001 in floating point. + expect(parseRupees("0.07")).toEqual({ ok: true, paise: 7 }); + expect(parseRupees("19.99")).toEqual({ ok: true, paise: 1999 }); + }); + + it("asks for a price when the text is empty", () => { + expect(parseRupees(" ")).toMatchObject({ + ok: false, + message: expect.stringContaining("Enter"), + }); + }); + + it.each(["abc", "62,50", "62.505", "-5", "1e3", "6 2", ".5", "62.", "₹₹62"])( + "rejects %j as not a price", + (text) => { + expect(parseRupees(text)).toMatchObject({ + ok: false, + message: expect.stringContaining("numbers only"), + }); + }, + ); + + it("rejects zero and anything above the largest allowed price", () => { + for (const text of ["0", "0.00", "10000.01", "99999"]) { + expect(parseRupees(text)).toMatchObject({ + ok: false, + message: expect.stringContaining("between"), + }); + } + expect(MAX_PRICE_PAISE).toBe(1_000_000); + }); +}); diff --git a/apps/web/tests/unit/services/api-client.test.ts b/apps/web/tests/unit/services/api-client.test.ts index 8c4d941..8697c98 100644 --- a/apps/web/tests/unit/services/api-client.test.ts +++ b/apps/web/tests/unit/services/api-client.test.ts @@ -1,6 +1,15 @@ // @vitest-environment node import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiError, apiGet, getApiBaseUrl } from "@/services/api-client"; +import { + ApiError, + apiDelete, + apiGet, + apiPost, + apiPut, + fieldErrors, + getApiBaseUrl, + hasProblemCode, +} from "@/services/api-client"; import { getApiStatus } from "@/services/status-service"; afterEach(() => { @@ -17,6 +26,13 @@ function stubFetch(response: Response | Error) { return fetchMock; } +function problemResponse(status: number, body: object) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/problem+json" }, + }); +} + describe("getApiBaseUrl", () => { it("drops trailing slashes", () => { vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080//"); @@ -48,7 +64,7 @@ describe("apiGet", () => { await expect(getApiStatus()).resolves.toEqual(body); expect(fetchMock).toHaveBeenCalledWith( "http://localhost:5080/api/v1/status", - expect.objectContaining({ headers: { Accept: "application/json" } }), + expect.objectContaining({ method: "GET", headers: { Accept: "application/json" } }), ); }); @@ -59,6 +75,7 @@ describe("apiGet", () => { const error = await apiGet("/status").catch((caught: unknown) => caught); expect(error).toBeInstanceOf(ApiError); expect(error).toMatchObject({ kind: "http", status: 503 }); + expect((error as ApiError).problem).toBeUndefined(); }); it("reports network failures as unreachable", async () => { @@ -68,3 +85,139 @@ describe("apiGet", () => { await expect(apiGet("/status")).rejects.toMatchObject({ kind: "unreachable" }); }); }); + +describe("writing", () => { + it("sends a JSON body with POST and PUT", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + // A new Response per call: a body can only be read once. + const fetchMock = vi.fn(async () => Response.json({ id: "1" })); + vi.stubGlobal("fetch", fetchMock); + + await apiPost("/recipients", { name: "Recipient A" }); + await apiPut("/household", { name: "Home" }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "http://localhost:5080/api/v1/recipients", + expect.objectContaining({ + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Recipient A" }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "http://localhost:5080/api/v1/household", + expect.objectContaining({ method: "PUT", body: JSON.stringify({ name: "Home" }) }), + ); + }); + + it("sends no body and no content type when there is nothing to send", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + const fetchMock = stubFetch(Response.json({})); + + await apiPost("/recipients/1/archive"); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.body).toBeUndefined(); + expect(init.headers).toEqual({ Accept: "application/json" }); + }); + + it("treats 204 No Content as success with no body", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + const fetchMock = stubFetch(new Response(null, { status: 204 })); + + await expect(apiDelete("/milk-rates/1")).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:5080/api/v1/milk-rates/1", + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("does not call the API when its address is not configured", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + const fetchMock = stubFetch(Response.json({})); + + await expect(apiPost("/recipients", {})).rejects.toMatchObject({ kind: "not-configured" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("Problem Details", () => { + it("keeps the problem body, with its code, on the error", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + stubFetch( + problemResponse(409, { + status: 409, + title: "Conflict", + detail: "Another recipient who is not archived is already called 'Recipient A'.", + code: "recipient-name-taken", + }), + ); + + const error = await apiPost("/recipients", {}).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ kind: "http", status: 409 }); + expect((error as ApiError).problem?.detail).toContain("already called"); + expect(hasProblemCode(error, "recipient-name-taken")).toBe(true); + expect(hasProblemCode(error, "rate-date-taken")).toBe(false); + }); + + it("reads the first message for each invalid field", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + stubFetch( + problemResponse(400, { + status: 400, + title: "One or more validation errors occurred.", + code: "validation-failed", + errors: { + name: ["The recipient's name is required.", "Another message."], + expectedMl: ["Too small."], + }, + }), + ); + + const error = await apiPost("/recipients", {}).catch((caught: unknown) => caught); + + expect(fieldErrors(error)).toEqual({ + name: "The recipient's name is required.", + expectedMl: "Too small.", + }); + }); + + it("copes with an error body that is not JSON", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + stubFetch( + new Response("Bad gateway", { + status: 502, + headers: { "Content-Type": "text/html" }, + }), + ); + + const error = await apiGet("/status").catch((caught: unknown) => caught); + + expect(error).toMatchObject({ kind: "http", status: 502 }); + expect(fieldErrors(error)).toEqual({}); + expect(hasProblemCode(error, "anything")).toBe(false); + }); + + it("copes with JSON that does not parse", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + stubFetch( + new Response("{ nope", { + status: 500, + headers: { "Content-Type": "application/problem+json" }, + }), + ); + + const error = await apiGet("/status").catch((caught: unknown) => caught); + + expect(error).toMatchObject({ kind: "http", status: 500 }); + expect((error as ApiError).problem).toBeUndefined(); + }); + + it("returns no field errors for errors that are not API errors", () => { + expect(fieldErrors(new Error("boom"))).toEqual({}); + expect(fieldErrors(undefined)).toEqual({}); + }); +}); diff --git a/apps/web/tests/unit/services/resource-services.test.ts b/apps/web/tests/unit/services/resource-services.test.ts new file mode 100644 index 0000000..8e724e1 --- /dev/null +++ b/apps/web/tests/unit/services/resource-services.test.ts @@ -0,0 +1,202 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getHousehold, setUpHousehold } from "@/services/household-service"; +import { + addMilkRate, + deleteMilkRate, + getMilkRateInEffect, + listMilkRates, +} from "@/services/milk-rate-service"; +import { + addRecipient, + archiveRecipient, + listRecipients, + unarchiveRecipient, + updateRecipient, +} from "@/services/recipient-service"; + +const BASE = "http://localhost:5080/api/v1"; + +beforeEach(() => vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080")); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function stubFetch(response: Response) { + const fetchMock = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +/** The method, URL and parsed JSON body of the one request that was made. */ +function sent(fetchMock: ReturnType) { + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + return { + url, + method: init.method, + body: typeof init.body === "string" ? (JSON.parse(init.body) as unknown) : undefined, + }; +} + +function noRateProblem() { + return new Response(JSON.stringify({ status: 404, code: "no-rate-in-effect" }), { + status: 404, + headers: { "Content-Type": "application/problem+json" }, + }); +} + +describe("household service", () => { + it("reads the household", async () => { + const household = { id: "h", name: "Home", timeZoneId: "Asia/Kolkata", today: "2026-10-30" }; + const fetchMock = stubFetch(Response.json(household)); + + await expect(getHousehold()).resolves.toEqual(household); + expect(sent(fetchMock)).toMatchObject({ url: `${BASE}/household`, method: "GET" }); + }); + + it("sets the household up with a PUT", async () => { + const fetchMock = stubFetch(Response.json({ id: "h" })); + + await setUpHousehold({ name: "Home", timeZoneId: "Europe/London" }); + + expect(sent(fetchMock)).toEqual({ + url: `${BASE}/household`, + method: "PUT", + body: { name: "Home", timeZoneId: "Europe/London" }, + }); + }); +}); + +describe("recipient service", () => { + it("lists the recipients from the items of the response", async () => { + const recipient = { + id: "r", + name: "Recipient A", + expectedMl: 1000, + startsOn: "2026-10-01", + archivedFrom: null, + }; + const fetchMock = stubFetch(Response.json({ items: [recipient] })); + + await expect(listRecipients()).resolves.toEqual([recipient]); + expect(sent(fetchMock).url).toBe(`${BASE}/recipients`); + }); + + it("asks for archived recipients only when told to", async () => { + const fetchMock = stubFetch(Response.json({ items: [] })); + + await listRecipients({ includeArchived: true }); + + expect(sent(fetchMock).url).toBe(`${BASE}/recipients?includeArchived=true`); + }); + + it("adds a recipient with a POST", async () => { + const fetchMock = stubFetch(Response.json({ id: "r" }, { status: 201 })); + + await addRecipient({ name: "Recipient A", expectedMl: 1000, startsOn: "2026-10-01" }); + + expect(sent(fetchMock)).toEqual({ + url: `${BASE}/recipients`, + method: "POST", + body: { name: "Recipient A", expectedMl: 1000, startsOn: "2026-10-01" }, + }); + }); + + it("updates a recipient with a PUT to its address", async () => { + const fetchMock = stubFetch(Response.json({ id: "r/1" })); + + await updateRecipient("r/1", { name: "Recipient B", expectedMl: 500, startsOn: "2026-10-01" }); + + expect(sent(fetchMock)).toEqual({ + url: `${BASE}/recipients/r%2F1`, + method: "PUT", + body: { name: "Recipient B", expectedMl: 500, startsOn: "2026-10-01" }, + }); + }); + + it("archives from today by sending no date", async () => { + const fetchMock = stubFetch(Response.json({ id: "r" })); + + await archiveRecipient("r"); + + expect(sent(fetchMock)).toEqual({ + url: `${BASE}/recipients/r/archive`, + method: "POST", + body: undefined, + }); + }); + + it("archives from a chosen date", async () => { + const fetchMock = stubFetch(Response.json({ id: "r" })); + + await archiveRecipient("r", "2026-11-15"); + + expect(sent(fetchMock).body).toEqual({ archivedFrom: "2026-11-15" }); + }); + + it("restores an archived recipient", async () => { + const fetchMock = stubFetch(Response.json({ id: "r" })); + + await unarchiveRecipient("r"); + + expect(sent(fetchMock)).toEqual({ + url: `${BASE}/recipients/r/unarchive`, + method: "POST", + body: undefined, + }); + }); +}); + +describe("milk rate service", () => { + it("lists the rates from the items of the response", async () => { + const rate = { id: "m", effectiveFrom: "2026-10-01", pricePerLitrePaise: 6250 }; + const fetchMock = stubFetch(Response.json({ items: [rate] })); + + await expect(listMilkRates()).resolves.toEqual([rate]); + expect(sent(fetchMock).url).toBe(`${BASE}/milk-rates`); + }); + + it("asks which rate is in force today, or on a date", async () => { + const rate = { id: "m", effectiveFrom: "2026-10-01", pricePerLitrePaise: 6250 }; + const today = stubFetch(Response.json(rate)); + await expect(getMilkRateInEffect()).resolves.toEqual(rate); + expect(sent(today).url).toBe(`${BASE}/milk-rates/in-effect`); + + const onDate = stubFetch(Response.json(rate)); + await getMilkRateInEffect("2026-10-14"); + expect(sent(onDate).url).toBe(`${BASE}/milk-rates/in-effect?on=2026-10-14`); + }); + + it("answers null, not an error, when no price has started yet", async () => { + stubFetch(noRateProblem()); + + await expect(getMilkRateInEffect()).resolves.toBeNull(); + }); + + it("still fails for other errors", async () => { + stubFetch(new Response(null, { status: 500 })); + + await expect(getMilkRateInEffect()).rejects.toMatchObject({ kind: "http", status: 500 }); + }); + + it("adds a rate with a POST", async () => { + const fetchMock = stubFetch(Response.json({ id: "m" }, { status: 201 })); + + await addMilkRate({ effectiveFrom: "2026-10-01", pricePerLitrePaise: 6250 }); + + expect(sent(fetchMock)).toEqual({ + url: `${BASE}/milk-rates`, + method: "POST", + body: { effectiveFrom: "2026-10-01", pricePerLitrePaise: 6250 }, + }); + }); + + it("deletes a rate", async () => { + const fetchMock = stubFetch(new Response(null, { status: 204 })); + + await expect(deleteMilkRate("m")).resolves.toBeUndefined(); + expect(sent(fetchMock)).toMatchObject({ url: `${BASE}/milk-rates/m`, method: "DELETE" }); + }); +}); From 38cb7f254c4d203f1efffc0927fc8380dbcccd24 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:22:25 +0530 Subject: [PATCH 08/10] feat(web): manage recipients and the milk price on real API data The Recipients page lists, adds, edits, archives and restores the household's recipients, and Settings sets up the household and manages the milk price, all through the services layer. Each screen has loading, empty, error and validation states and works at 360px. With no API address the Recipients page keeps its labelled demo data and Settings says it needs the API; with one it never falls back to demo data silently. Unit tests cover the page states with mocked service calls. The Playwright tests, pinned to a build with no API address, cover the demo mode of Recipients and Settings. --- apps/web/playwright.config.ts | 4 + apps/web/src/app/app/recipients/page.tsx | 23 +- apps/web/src/app/app/settings/page.tsx | 16 +- apps/web/src/components/ui/select-field.tsx | 59 +++ apps/web/src/components/ui/text-field.tsx | 16 +- .../src/features/household/household-form.tsx | 85 ++++ .../features/recipients/recipient-form.tsx | 130 ++++++ .../src/features/recipients/recipient-row.tsx | 77 +++ .../features/recipients/recipients-screen.tsx | 285 +++++++++++ .../src/features/settings/household-card.tsx | 72 +++ .../features/settings/household-settings.tsx | 28 ++ .../src/features/settings/milk-rate-card.tsx | 293 ++++++++++++ .../src/features/shared/api-error-message.ts | 25 + apps/web/src/features/shared/use-resource.ts | 40 ++ apps/web/tests/e2e/recipients.spec.ts | 72 +++ .../unit/features/api-error-message.test.ts | 49 ++ .../unit/features/household-settings.test.tsx | 125 +++++ .../unit/features/milk-rate-card.test.tsx | 252 ++++++++++ .../unit/features/recipients-screen.test.tsx | 442 ++++++++++++++++++ 19 files changed, 2060 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/components/ui/select-field.tsx create mode 100644 apps/web/src/features/household/household-form.tsx create mode 100644 apps/web/src/features/recipients/recipient-form.tsx create mode 100644 apps/web/src/features/recipients/recipient-row.tsx create mode 100644 apps/web/src/features/recipients/recipients-screen.tsx create mode 100644 apps/web/src/features/settings/household-card.tsx create mode 100644 apps/web/src/features/settings/household-settings.tsx create mode 100644 apps/web/src/features/settings/milk-rate-card.tsx create mode 100644 apps/web/src/features/shared/api-error-message.ts create mode 100644 apps/web/src/features/shared/use-resource.ts create mode 100644 apps/web/tests/e2e/recipients.spec.ts create mode 100644 apps/web/tests/unit/features/api-error-message.test.ts create mode 100644 apps/web/tests/unit/features/household-settings.test.tsx create mode 100644 apps/web/tests/unit/features/milk-rate-card.test.tsx create mode 100644 apps/web/tests/unit/features/recipients-screen.test.tsx diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index aa52498..791de77 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -43,6 +43,10 @@ export default defineConfig({ command: isCI ? `npm run start -- --port ${port}` : `npm run build && npm run start -- --port ${port}`, + // These tests cover the app with no API address, where the Recipients and Settings screens say so + // and show labelled demo data. Pin it, so a developer's .env.local cannot change what is tested. + // (CI builds earlier, in a clean checkout.) + env: { NEXT_PUBLIC_API_BASE_URL: "" }, url: baseURL, reuseExistingServer: !isCI, timeout: 240_000, diff --git a/apps/web/src/app/app/recipients/page.tsx b/apps/web/src/app/app/recipients/page.tsx index 5c4328e..a440299 100644 --- a/apps/web/src/app/app/recipients/page.tsx +++ b/apps/web/src/app/app/recipients/page.tsx @@ -1,27 +1,8 @@ import type { Metadata } from "next"; -import { PageHeader } from "@/components/layout/page-header"; -import { DemoBadge } from "@/components/ui/badge"; -import { Notice } from "@/components/ui/notice"; -import { DemoRecipientList } from "@/features/recipients/demo-recipient-list"; -import { DEMO_RECIPIENTS } from "@/lib/demo-household"; +import { RecipientsScreen } from "@/features/recipients/recipients-screen"; export const metadata: Metadata = { title: "Recipients" }; export default function RecipientsPage() { - return ( - <> - } - /> -
- - - These are the demo household's recipients. Managing your own arrives once data is - saved. - -
- - ); + return ; } diff --git a/apps/web/src/app/app/settings/page.tsx b/apps/web/src/app/app/settings/page.tsx index 3d368ab..05842a3 100644 --- a/apps/web/src/app/app/settings/page.tsx +++ b/apps/web/src/app/app/settings/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from "next"; -import { House } from "lucide-react"; +import { CalendarOff } from "lucide-react"; import { PageHeader } from "@/components/layout/page-header"; import { PlaceholderPanel } from "@/components/ui/placeholder-panel"; import { ApiStatusCard } from "@/features/settings/api-status-card"; +import { HouseholdSettings } from "@/features/settings/household-settings"; export const metadata: Metadata = { title: "Settings" }; @@ -11,15 +12,12 @@ export default function SettingsPage() { <>
+
diff --git a/apps/web/src/components/ui/select-field.tsx b/apps/web/src/components/ui/select-field.tsx new file mode 100644 index 0000000..e5c9ce2 --- /dev/null +++ b/apps/web/src/components/ui/select-field.tsx @@ -0,0 +1,59 @@ +import type { ComponentProps } from "react"; +import { cn } from "@/lib/cn"; + +interface SelectFieldProps extends Omit, "id"> { + id: string; + label: string; + hint?: string; + /** What is wrong with the value. Shown under the field and announced with it. */ + error?: string; + options: readonly { value: string; label: string }[]; +} + +export function SelectField({ + id, + label, + hint, + error, + options, + className, + ...selectProps +}: SelectFieldProps) { + const hintId = hint ? `${id}-hint` : undefined; + const errorId = error ? `${id}-error` : undefined; + + return ( +
+ + + {hint ? ( +

+ {hint} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/ui/text-field.tsx b/apps/web/src/components/ui/text-field.tsx index 4e0e997..7a692ad 100644 --- a/apps/web/src/components/ui/text-field.tsx +++ b/apps/web/src/components/ui/text-field.tsx @@ -5,10 +5,13 @@ interface TextFieldProps extends Omit, "id"> { id: string; label: string; hint?: string; + /** What is wrong with the value. Shown under the field and announced with it. */ + error?: string; } -export function TextField({ id, label, hint, className, ...inputProps }: TextFieldProps) { +export function TextField({ id, label, hint, error, className, ...inputProps }: TextFieldProps) { const hintId = hint ? `${id}-hint` : undefined; + const errorId = error ? `${id}-error` : undefined; return (
@@ -17,9 +20,11 @@ export function TextField({ id, label, hint, className, ...inputProps }: TextFie ) : null} + {error ? ( +

+ {error} +

+ ) : null}
); } diff --git a/apps/web/src/features/household/household-form.tsx b/apps/web/src/features/household/household-form.tsx new file mode 100644 index 0000000..67cfeac --- /dev/null +++ b/apps/web/src/features/household/household-form.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { SelectField } from "@/components/ui/select-field"; +import { TextField } from "@/components/ui/text-field"; +import { describeApiError } from "@/features/shared/api-error-message"; +import { DEFAULT_TIME_ZONE_ID, timeZoneOptions } from "@/lib/time-zones"; +import { fieldErrors } from "@/services/api-client"; +import { setUpHousehold } from "@/services/household-service"; +import type { ApiHousehold } from "@/types/api"; + +/** + * Creates the household (first run) or changes its name and time zone. The household has to exist before + * anything else can be saved, because everything belongs to it. + */ +export function HouseholdForm({ + household, + submitLabel, + onSaved, +}: { + /** The existing household, or undefined when it is being created. */ + household?: ApiHousehold; + submitLabel: string; + onSaved: (household: ApiHousehold) => void; +}) { + const [name, setName] = useState(household?.name ?? ""); + const [timeZoneId, setTimeZoneId] = useState(household?.timeZoneId ?? DEFAULT_TIME_ZONE_ID); + const [errors, setErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setErrors({}); + setFormError(null); + + try { + onSaved(await setUpHousehold({ name, timeZoneId })); + } catch (error) { + const fields = fieldErrors(error); + if (Object.keys(fields).length > 0) setErrors(fields); + else setFormError(describeApiError(error)); + } finally { + setSaving(false); + } + } + + return ( +
+ setName(event.target.value)} + error={errors.name} + /> + setTimeZoneId(event.target.value)} + options={timeZoneOptions(household?.timeZoneId).map((zone) => ({ + value: zone.id, + label: zone.label, + }))} + error={errors.timeZoneId} + /> + {formError ? ( +

+ {formError} +

+ ) : null} +
+ +
+ + ); +} diff --git a/apps/web/src/features/recipients/recipient-form.tsx b/apps/web/src/features/recipients/recipient-form.tsx new file mode 100644 index 0000000..3f42f3b --- /dev/null +++ b/apps/web/src/features/recipients/recipient-form.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { TextField } from "@/components/ui/text-field"; +import { describeApiError } from "@/features/shared/api-error-message"; +import { formatQuantity } from "@/lib/quantity"; +import { fieldErrors, hasProblemCode } from "@/services/api-client"; +import { addRecipient, updateRecipient } from "@/services/recipient-service"; +import type { ApiRecipient } from "@/types/api"; + +const WHOLE_NUMBER = /^\d+$/; + +/** Adds a recipient, or changes the name, usual quantity and start date of an existing one. */ +export function RecipientForm({ + recipient, + today, + onSaved, + onCancel, +}: { + /** The recipient being edited, or undefined when adding. */ + recipient?: ApiRecipient; + /** The household's calendar date now, YYYY-MM-DD, from the server. */ + today: string; + onSaved: () => void; + onCancel: () => void; +}) { + const idPrefix = recipient ? `recipient-${recipient.id}` : "recipient-new"; + const nameRef = useRef(null); + + const [name, setName] = useState(recipient?.name ?? ""); + const [expectedMl, setExpectedMl] = useState(recipient ? String(recipient.expectedMl) : ""); + const [startsOn, setStartsOn] = useState(recipient?.startsOn ?? today); + const [errors, setErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + // Move straight to the first field when the form opens. + useEffect(() => nameRef.current?.focus(), []); + + const quantityHint = WHOLE_NUMBER.test(expectedMl.trim()) + ? `That is ${formatQuantity(Number(expectedMl))} a day.` + : "In millilitres, for example 500 or 1000."; + + async function submit(event: FormEvent) { + event.preventDefault(); + setFormError(null); + + const problems: Record = {}; + if (!WHOLE_NUMBER.test(expectedMl.trim())) { + problems.expectedMl = "Enter a whole number of millilitres, for example 500."; + } + if (startsOn === "") problems.startsOn = "Choose the first day a delivery is expected."; + if (Object.keys(problems).length > 0) { + setErrors(problems); + return; + } + + setErrors({}); + setSaving(true); + try { + const input = { name, expectedMl: Number(expectedMl.trim()), startsOn }; + if (recipient) await updateRecipient(recipient.id, input); + else await addRecipient(input); + onSaved(); + } catch (error) { + const fields = fieldErrors(error); + if (hasProblemCode(error, "recipient-name-taken")) { + setErrors({ name: "Another recipient is already called that. Use a different name." }); + } else if (Object.keys(fields).length > 0) { + setErrors(fields); + } else { + setFormError(describeApiError(error)); + } + } finally { + setSaving(false); + } + } + + return ( +
+ setName(event.target.value)} + error={errors.name} + /> + setExpectedMl(event.target.value)} + error={errors.expectedMl} + /> + setStartsOn(event.target.value)} + error={errors.startsOn} + /> + {formError ? ( +

+ {formError} +

+ ) : null} +
+ + +
+ + ); +} diff --git a/apps/web/src/features/recipients/recipient-row.tsx b/apps/web/src/features/recipients/recipient-row.tsx new file mode 100644 index 0000000..3676e07 --- /dev/null +++ b/apps/web/src/features/recipients/recipient-row.tsx @@ -0,0 +1,77 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { RecipientMarker } from "@/components/ui/recipient-marker"; +import { formatIsoDate } from "@/lib/dates"; +import { formatQuantity } from "@/lib/quantity"; +import { recipientMarker } from "@/lib/recipient-marker"; +import type { ApiRecipient } from "@/types/api"; + +/** One recipient in the list: who, their usual quantity, when they start or stop, and what can be done. */ +export function RecipientRow({ + recipient, + today, + busy, + onEdit, + onArchive, + onRestore, +}: { + recipient: ApiRecipient; + /** The household's calendar date now, YYYY-MM-DD, from the server. */ + today: string; + busy: boolean; + onEdit: () => void; + onArchive: () => void; + onRestore: () => void; +}) { + // YYYY-MM-DD dates sort as text, so they can be compared without turning them into Date objects. + const archived = recipient.archivedFrom !== null && recipient.archivedFrom <= today; + const startsLater = recipient.startsOn > today; + + return ( +
+ +
+

{recipient.name}

+

+ {formatQuantity(recipient.expectedMl)} a day + {startsLater ? ` · starts ${formatIsoDate(recipient.startsOn)}` : null} +

+ {recipient.archivedFrom !== null ? ( + + {archived ? "Archived" : "Stops"} from {formatIsoDate(recipient.archivedFrom)} + + ) : null} +
+
+ + {recipient.archivedFrom !== null ? ( + // Archived: bring them back. Archiving scheduled for later: cancel it. + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/features/recipients/recipients-screen.tsx b/apps/web/src/features/recipients/recipients-screen.tsx new file mode 100644 index 0000000..52a1ef1 --- /dev/null +++ b/apps/web/src/features/recipients/recipients-screen.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { LoaderCircle, Plus, UsersRound } from "lucide-react"; +import { PageHeader } from "@/components/layout/page-header"; +import { DemoBadge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Notice } from "@/components/ui/notice"; +import { HouseholdForm } from "@/features/household/household-form"; +import { describeApiError } from "@/features/shared/api-error-message"; +import { useResource } from "@/features/shared/use-resource"; +import { DEMO_RECIPIENTS } from "@/lib/demo-household"; +import { getApiBaseUrl, hasProblemCode } from "@/services/api-client"; +import { getHousehold } from "@/services/household-service"; +import { archiveRecipient, listRecipients, unarchiveRecipient } from "@/services/recipient-service"; +import type { ApiRecipient } from "@/types/api"; +import { DemoRecipientList } from "./demo-recipient-list"; +import { RecipientForm } from "./recipient-form"; +import { RecipientRow } from "./recipient-row"; + +const DESCRIPTION = + "Everyone in the household who gets a daily delivery, with their usual quantity."; + +/** + * The Recipients page. With no API address it shows the synthetic demo household, clearly labelled. + * With one, it works on the household's real recipients and never falls back to demo data silently. + */ +export function RecipientsScreen() { + return getApiBaseUrl() ? : ; +} + +function DemoRecipients() { + return ( + <> + } /> +
+ + + These are sample recipients and nothing here is saved. To manage your own, run the API and + point the web app at it (see docs/development.md). + +
+ + ); +} + +/** What the page is showing besides the list: nothing, the add form, or a form or question for one recipient. */ +type Panel = + | { kind: "closed" } + | { kind: "add" } + | { kind: "edit"; id: string } + | { kind: "confirm-archive"; id: string }; + +function ConnectedRecipients() { + const [includeArchived, setIncludeArchived] = useState(false); + const load = useCallback( + async (signal: AbortSignal) => { + const [household, recipients] = await Promise.all([ + getHousehold({ signal }), + listRecipients({ includeArchived, signal }), + ]); + return { household, recipients }; + }, + [includeArchived], + ); + const { state, refresh, retry } = useResource(load); + + const [panel, setPanel] = useState({ kind: "closed" }); + const [busyId, setBusyId] = useState(null); + const [actionError, setActionError] = useState(null); + + async function run(id: string, action: () => Promise) { + setBusyId(id); + setActionError(null); + try { + await action(); + setPanel({ kind: "closed" }); + refresh(); + } catch (error) { + setActionError(describeApiError(error)); + } finally { + setBusyId(null); + } + } + + if (state.kind === "loading") { + return ( + <> + +

+ + Loading recipients… +

+ + ); + } + + if (state.kind === "failed") { + if (hasProblemCode(state.error, "household-not-set-up")) { + return ( + <> + + +

Set up your household first

+

+ Recipients, prices and deliveries all belong to a household. Name yours to begin. +

+ +
+ + ); + } + + return ( + <> + + + Try again + + } + > + {describeApiError(state.error)} + + + ); + } + + const { household, recipients } = state.data; + const editing = panel.kind === "edit" ? panel.id : null; + const confirming = panel.kind === "confirm-archive" ? panel.id : null; + + return ( + <> + setPanel({ kind: "add" })} disabled={panel.kind === "add"}> + + Add recipient + + } + /> + +
+ {actionError ? ( + + {actionError} + + ) : null} + + {panel.kind === "add" ? ( + +

Add a recipient

+ { + setPanel({ kind: "closed" }); + refresh(); + }} + onCancel={() => setPanel({ kind: "closed" })} + /> +
+ ) : null} + + {recipients.length === 0 ? ( + setPanel({ kind: "add" })} + /> + ) : ( + +
    + {recipients.map((recipient) => ( +
  • + {editing === recipient.id ? ( +
    +

    Edit {recipient.name}

    + { + setPanel({ kind: "closed" }); + refresh(); + }} + onCancel={() => setPanel({ kind: "closed" })} + /> +
    + ) : ( + <> + setPanel({ kind: "edit", id: recipient.id })} + onArchive={() => setPanel({ kind: "confirm-archive", id: recipient.id })} + onRestore={() => run(recipient.id, () => unarchiveRecipient(recipient.id))} + /> + {confirming === recipient.id ? ( + run(recipient.id, () => archiveRecipient(recipient.id))} + onCancel={() => setPanel({ kind: "closed" })} + /> + ) : null} + + )} +
  • + ))} +
+
+ )} + + +
+ + ); +} + +function EmptyRecipients({ archivedShown, onAdd }: { archivedShown: boolean; onAdd: () => void }) { + return ( + + + + +

No recipients yet

+

+ {archivedShown + ? "Nobody has been added to this household." + : "Add each person who gets milk, with how much they usually get a day. Deliveries are recorded against this list."} +

+ +
+ ); +} + +function ArchiveQuestion({ + recipient, + busy, + onConfirm, + onCancel, +}: { + recipient: ApiRecipient; + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+

Archive {recipient.name}?

+

+ No more deliveries are expected from today, or from tomorrow if they only started today. + Their history is kept, and you can restore them later. +

+
+ + +
+
+ ); +} diff --git a/apps/web/src/features/settings/household-card.tsx b/apps/web/src/features/settings/household-card.tsx new file mode 100644 index 0000000..38b2a8b --- /dev/null +++ b/apps/web/src/features/settings/household-card.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useCallback } from "react"; +import { House, LoaderCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Notice } from "@/components/ui/notice"; +import { HouseholdForm } from "@/features/household/household-form"; +import { describeApiError } from "@/features/shared/api-error-message"; +import { useResource } from "@/features/shared/use-resource"; +import { hasProblemCode } from "@/services/api-client"; +import { getHousehold } from "@/services/household-service"; + +/** Settings: the household's name and time zone. Also where a brand-new household is created. */ +export function HouseholdCard() { + const load = useCallback((signal: AbortSignal) => getHousehold({ signal }), []); + const { state, refresh, retry } = useResource(load); + + const needsSetUp = state.kind === "failed" && hasProblemCode(state.error, "household-not-set-up"); + + return ( + +
+ + + +
+

Household

+

+ {needsSetUp + ? "Not set up yet. Everything else belongs to the household." + : "Its name, and the time zone that decides what “today” means."} +

+
+
+ +
+ {state.kind === "loading" ? ( +

+ + Loading the household… +

+ ) : null} + + {state.kind === "failed" && !needsSetUp ? ( + + Try again + + } + > + {describeApiError(state.error)} + + ) : null} + + {needsSetUp ? : null} + + {state.kind === "ready" ? ( + // Keyed by the saved values, so the form starts again from what the server now holds. + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/features/settings/household-settings.tsx b/apps/web/src/features/settings/household-settings.tsx new file mode 100644 index 0000000..c2a8c53 --- /dev/null +++ b/apps/web/src/features/settings/household-settings.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Notice } from "@/components/ui/notice"; +import { getApiBaseUrl } from "@/services/api-client"; +import { HouseholdCard } from "./household-card"; +import { MilkRateCard } from "./milk-rate-card"; + +/** + * The settings that live in the API: household and milk price. Without an API address there is nowhere to + * save them, so say so instead of showing forms that cannot work. + */ +export function HouseholdSettings() { + if (!getApiBaseUrl()) { + return ( + + This app is not connected to an API, so there is nowhere to save them. Run the API and point + the web app at it (see docs/development.md). + + ); + } + + return ( + <> + + + + ); +} diff --git a/apps/web/src/features/settings/milk-rate-card.tsx b/apps/web/src/features/settings/milk-rate-card.tsx new file mode 100644 index 0000000..7c05842 --- /dev/null +++ b/apps/web/src/features/settings/milk-rate-card.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useCallback, useState, type FormEvent } from "react"; +import { IndianRupee, LoaderCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Notice } from "@/components/ui/notice"; +import { TextField } from "@/components/ui/text-field"; +import { describeApiError } from "@/features/shared/api-error-message"; +import { useResource } from "@/features/shared/use-resource"; +import { formatIsoDate } from "@/lib/dates"; +import { formatPaise, formatPricePerLitre, parseRupees } from "@/lib/money"; +import { fieldErrors, hasProblemCode } from "@/services/api-client"; +import { getHousehold } from "@/services/household-service"; +import { + addMilkRate, + deleteMilkRate, + getMilkRateInEffect, + listMilkRates, +} from "@/services/milk-rate-service"; +import type { ApiMilkRate } from "@/types/api"; + +/** + * Settings: the price of a litre of milk, which changes over time. A new price has a first day; the days + * before it keep the price they had. Which price is in force is decided by the server. + */ +export function MilkRateCard() { + const load = useCallback(async (signal: AbortSignal) => { + const [household, rates, inEffect] = await Promise.all([ + getHousehold({ signal }), + listMilkRates({ signal }), + getMilkRateInEffect(undefined, { signal }), + ]); + return { household, rates, inEffect }; + }, []); + const { state, refresh, retry } = useResource(load); + + const needsHousehold = + state.kind === "failed" && hasProblemCode(state.error, "household-not-set-up"); + + return ( + +
+ + + +
+

Milk price

+

+ What one litre costs. When the price changes, add the new price and the day it starts. +

+
+
+ +
+ {state.kind === "loading" ? ( +

+ + Loading prices… +

+ ) : null} + + {needsHousehold ? ( + + The price belongs to the household. Create it in the Household section above. + + ) : null} + + {state.kind === "failed" && !needsHousehold ? ( + + Try again + + } + > + {describeApiError(state.error)} + + ) : null} + + {state.kind === "ready" ? ( + + ) : null} +
+
+ ); +} + +function RatesPanel({ + today, + rates, + inEffect, + onChanged, +}: { + today: string; + rates: readonly ApiMilkRate[]; + inEffect: ApiMilkRate | null; + onChanged: () => void; +}) { + const [confirmingId, setConfirmingId] = useState(null); + const [busyId, setBusyId] = useState(null); + const [removeError, setRemoveError] = useState(null); + + async function remove(rate: ApiMilkRate) { + setBusyId(rate.id); + setRemoveError(null); + try { + await deleteMilkRate(rate.id); + setConfirmingId(null); + onChanged(); + } catch (error) { + setRemoveError(describeApiError(error)); + } finally { + setBusyId(null); + } + } + + return ( +
+
+ {inEffect ? ( +

+ + {formatPricePerLitre(inEffect.pricePerLitrePaise)} + + + since {formatIsoDate(inEffect.effectiveFrom)} + +

+ ) : ( +

+ No price is in force yet. Days before the first price have no price, and are never + counted as free. +

+ )} +
+ + {removeError ? ( + + {removeError} + + ) : null} + + {rates.length > 0 ? ( +
+

Price history

+
    + {rates.map((rate) => ( +
  • +
    +
    +

    + {formatPricePerLitre(rate.pricePerLitrePaise)} +

    +

    + from {formatIsoDate(rate.effectiveFrom)} +

    +
    + {inEffect?.id === rate.id ? In effect : null} + +
    + {confirmingId === rate.id ? ( +
    +

    + Remove {formatPaise(rate.pricePerLitrePaise)} from{" "} + {formatIsoDate(rate.effectiveFrom)}? +

    +

    + The days it covered use the earlier price, or have none if there is no earlier + one. +

    +
    + + +
    +
    + ) : null} +
  • + ))} +
+
+ ) : null} + + +
+ ); +} + +function AddRateForm({ today, onAdded }: { today: string; onAdded: () => void }) { + const [price, setPrice] = useState(""); + const [effectiveFrom, setEffectiveFrom] = useState(today); + const [errors, setErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setFormError(null); + + const parsed = parseRupees(price); + const problems: Record = {}; + if (!parsed.ok) problems.pricePerLitrePaise = parsed.message; + if (effectiveFrom === "") problems.effectiveFrom = "Choose the first day this price applies."; + if (!parsed.ok || Object.keys(problems).length > 0) { + setErrors(problems); + return; + } + + setErrors({}); + setSaving(true); + try { + await addMilkRate({ effectiveFrom, pricePerLitrePaise: parsed.paise }); + setPrice(""); + setEffectiveFrom(today); + onAdded(); + } catch (error) { + const fields = fieldErrors(error); + if (hasProblemCode(error, "rate-date-taken")) { + setErrors({ + effectiveFrom: + "A price already starts on that day. Remove it first, or choose another day.", + }); + } else if (Object.keys(fields).length > 0) { + setErrors(fields); + } else { + setFormError(describeApiError(error)); + } + } finally { + setSaving(false); + } + } + + return ( +
+

Add a price

+ setPrice(event.target.value)} + error={errors.pricePerLitrePaise} + /> + setEffectiveFrom(event.target.value)} + error={errors.effectiveFrom} + /> + {formError ? ( +

+ {formError} +

+ ) : null} +
+ +
+ + ); +} diff --git a/apps/web/src/features/shared/api-error-message.ts b/apps/web/src/features/shared/api-error-message.ts new file mode 100644 index 0000000..de73439 --- /dev/null +++ b/apps/web/src/features/shared/api-error-message.ts @@ -0,0 +1,25 @@ +import { ApiError, getApiBaseUrl } from "@/services/api-client"; + +/** A sentence a person can act on, for any failure of an API call. */ +export function describeApiError(error: unknown): string { + if (error instanceof ApiError) { + switch (error.kind) { + case "not-configured": + return "No API address is set. Add NEXT_PUBLIC_API_BASE_URL to apps/web/.env.local, then restart the dev server."; + case "unreachable": + return `Can't reach the API at ${getApiBaseUrl()}. Start it (see docs/development.md), then try again.`; + case "http": + return ( + error.problem?.detail ?? + firstValidationMessage(error) ?? + `The API answered with HTTP ${error.status}. Check the API logs.` + ); + } + } + return "Something went wrong. Check the browser console for details."; +} + +/** A rejected request names the field it is about; when no form field can show that, say it in words. */ +function firstValidationMessage(error: ApiError): string | undefined { + return Object.values(error.problem?.errors ?? {})[0]?.[0]; +} diff --git a/apps/web/src/features/shared/use-resource.ts b/apps/web/src/features/shared/use-resource.ts new file mode 100644 index 0000000..85e0d03 --- /dev/null +++ b/apps/web/src/features/shared/use-resource.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export type Resource = + { kind: "loading" } | { kind: "ready"; data: T } | { kind: "failed"; error: unknown }; + +/** + * Loads something from the API and keeps it in step with what the screen does to it. + * + * `load` must be stable (wrap it in useCallback with the values it uses): a new function starts a new load. + * - `refresh()` fetches again but keeps showing the current data, for use after a change was saved. + * - `retry()` goes back to the loading state first, for use after a failure. + */ +export function useResource(load: (signal: AbortSignal) => Promise) { + const [state, setState] = useState>({ kind: "loading" }); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + + load(controller.signal) + .then((data) => { + if (!controller.signal.aborted) setState({ kind: "ready", data }); + }) + .catch((error: unknown) => { + if (!controller.signal.aborted) setState({ kind: "failed", error }); + }); + + return () => controller.abort(); + }, [load, attempt]); + + const refresh = useCallback(() => setAttempt((value) => value + 1), []); + const retry = useCallback(() => { + setState({ kind: "loading" }); + setAttempt((value) => value + 1); + }, []); + + return { state, refresh, retry }; +} diff --git a/apps/web/tests/e2e/recipients.spec.ts b/apps/web/tests/e2e/recipients.spec.ts new file mode 100644 index 0000000..90812f5 --- /dev/null +++ b/apps/web/tests/e2e/recipients.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; + +// These run against a build with no API address (see playwright.config.ts). The screens that talk to the +// API are covered by the Vitest tests with mocked service calls and by the API's own tests. + +test.describe("Recipients with no API address", () => { + test("shows sample recipients, clearly labelled as demo data", async ({ page }) => { + await page.goto("/app/recipients"); + + await expect(page.getByRole("heading", { level: 1, name: "Recipients" })).toBeVisible(); + await expect(page.getByText("Demo data", { exact: true })).toBeVisible(); + for (const name of ["Recipient A", "Recipient B", "Recipient C"]) { + await expect(page.getByText(name, { exact: true })).toBeVisible(); + } + await expect(page.getByText("Demo data: this app is not connected to an API")).toBeVisible(); + }); + + test("offers no way to add or edit demo recipients", async ({ page }) => { + await page.goto("/app/recipients"); + await expect(page.getByRole("heading", { level: 1, name: "Recipients" })).toBeVisible(); + + await expect(page.getByRole("button", { name: /add .*recipient/i })).toHaveCount(0); + await expect(page.getByRole("button", { name: /^edit /i })).toHaveCount(0); + await expect(page.getByRole("checkbox")).toHaveCount(0); + }); + + test("can be reached from the navigation", async ({ page }, testInfo) => { + await page.goto("/app/today"); + const link = + testInfo.project.name === "mobile-360" + ? page + .getByRole("navigation", { name: "Household" }) + .getByRole("link", { name: "Recipients" }) + : page.getByRole("navigation", { name: "Main" }).getByRole("link", { name: "Recipients" }); + + await link.click(); + + await expect(page).toHaveURL(/\/app\/recipients$/); + await expect(page.getByRole("heading", { level: 1, name: "Recipients" })).toBeVisible(); + }); +}); + +test.describe("Settings with no API address", () => { + test("says the household and price settings need the API", async ({ page }) => { + await page.goto("/app/settings"); + + await expect(page.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible(); + await expect(page.getByText("Household and price settings need the API")).toBeVisible(); + await expect(page.getByRole("button", { name: "Add price" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Save household" })).toHaveCount(0); + }); + + test("still shows the API connection check and what is not built yet", async ({ page }) => { + await page.goto("/app/settings"); + + await expect(page.getByRole("heading", { name: "API connection" })).toBeVisible(); + await expect(page.getByText(/No API address is set/)).toBeVisible(); + await expect(page.getByText("Delivery days and pauses aren't built yet")).toBeVisible(); + }); +}); + +test("the demo screens that are not built on real data say so", async ({ page }) => { + await page.goto("/app/today"); + await expect(page.getByText("Demo data", { exact: true })).toBeVisible(); + await expect(page.getByText(/Sample household/)).toBeVisible(); + + await page.goto("/app/history"); + await expect(page.getByText("History isn't built yet")).toBeVisible(); + + await page.goto("/app/month"); + await expect(page.getByText("The monthly view isn't built yet")).toBeVisible(); +}); diff --git a/apps/web/tests/unit/features/api-error-message.test.ts b/apps/web/tests/unit/features/api-error-message.test.ts new file mode 100644 index 0000000..4e7a1c5 --- /dev/null +++ b/apps/web/tests/unit/features/api-error-message.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describeApiError } from "@/features/shared/api-error-message"; +import { ApiError } from "@/services/api-client"; + +beforeEach(() => vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080")); +afterEach(() => vi.unstubAllEnvs()); + +describe("describeApiError", () => { + it("tells you how to set the API address when there is none", () => { + expect(describeApiError(new ApiError("x", "not-configured"))).toMatch( + /NEXT_PUBLIC_API_BASE_URL/, + ); + }); + + it("names the address it could not reach", () => { + expect(describeApiError(new ApiError("x", "unreachable"))).toBe( + "Can't reach the API at http://localhost:5080. Start it (see docs/development.md), then try again.", + ); + }); + + it("uses the API's own explanation when it gave one", () => { + const error = new ApiError("x", "http", 409, { + problem: { status: 409, detail: "The recipient is already archived." }, + }); + + expect(describeApiError(error)).toBe("The recipient is already archived."); + }); + + it("falls back to the first validation message when there is no explanation", () => { + const error = new ApiError("x", "http", 400, { + problem: { + status: 400, + errors: { archivedFrom: ["A recipient must be active for at least one day."] }, + }, + }); + + expect(describeApiError(error)).toBe("A recipient must be active for at least one day."); + }); + + it("falls back to the status when the API said nothing useful", () => { + expect(describeApiError(new ApiError("x", "http", 500))).toBe( + "The API answered with HTTP 500. Check the API logs.", + ); + }); + + it("copes with errors that are not API errors", () => { + expect(describeApiError(new Error("boom"))).toMatch(/Something went wrong/); + }); +}); diff --git a/apps/web/tests/unit/features/household-settings.test.tsx b/apps/web/tests/unit/features/household-settings.test.tsx new file mode 100644 index 0000000..868af65 --- /dev/null +++ b/apps/web/tests/unit/features/household-settings.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HouseholdSettings } from "@/features/settings/household-settings"; +import { ApiError } from "@/services/api-client"; +import { getHousehold, setUpHousehold } from "@/services/household-service"; +import { getMilkRateInEffect, listMilkRates } from "@/services/milk-rate-service"; +import type { ApiHousehold } from "@/types/api"; + +vi.mock("@/services/household-service", () => ({ getHousehold: vi.fn(), setUpHousehold: vi.fn() })); +vi.mock("@/services/milk-rate-service", () => ({ + listMilkRates: vi.fn(), + getMilkRateInEffect: vi.fn(), + addMilkRate: vi.fn(), + deleteMilkRate: vi.fn(), +})); + +const household: ApiHousehold = { + id: "h1", + name: "Example household", + timeZoneId: "Asia/Kolkata", + today: "2026-10-30", +}; + +beforeEach(() => { + vi.mocked(getHousehold).mockResolvedValue(household); + vi.mocked(listMilkRates).mockResolvedValue([]); + vi.mocked(getMilkRateInEffect).mockResolvedValue(null); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetAllMocks(); +}); + +describe("HouseholdSettings without an API address", () => { + it("says the settings need the API instead of showing forms that cannot work", () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + render(); + + expect(screen.getByText("Household and price settings need the API")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save household" })).not.toBeInTheDocument(); + expect(getHousehold).not.toHaveBeenCalled(); + }); +}); + +describe("HouseholdSettings with an API", () => { + beforeEach(() => vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080")); + + it("shows the household's name and time zone, ready to change", async () => { + render(); + + expect(await screen.findByLabelText("Household name")).toHaveValue("Example household"); + expect(screen.getByLabelText("Time zone")).toHaveValue("Asia/Kolkata"); + expect(screen.getByRole("heading", { name: "Milk price" })).toBeInTheDocument(); + }); + + it("saves a new name and time zone", async () => { + vi.mocked(setUpHousehold).mockResolvedValue({ + ...household, + name: "Renamed", + timeZoneId: "Europe/London", + }); + const user = userEvent.setup(); + + render(); + const name = await screen.findByLabelText("Household name"); + await user.clear(name); + await user.type(name, "Renamed"); + await user.selectOptions(screen.getByLabelText("Time zone"), "Europe/London"); + await user.click(screen.getByRole("button", { name: "Save household" })); + + expect(setUpHousehold).toHaveBeenCalledWith({ name: "Renamed", timeZoneId: "Europe/London" }); + // The household card loaded again to show what was saved; the price card loaded once, at the start. + expect(getHousehold).toHaveBeenCalledTimes(3); + }); + + it("keeps a time zone that is not in the short list", async () => { + vi.mocked(getHousehold).mockResolvedValue({ ...household, timeZoneId: "Africa/Nairobi" }); + + render(); + + expect(await screen.findByLabelText("Time zone")).toHaveValue("Africa/Nairobi"); + }); + + it("offers to create the household when there is none", async () => { + vi.mocked(getHousehold).mockRejectedValue( + new ApiError("missing", "http", 404, { + problem: { status: 404, code: "household-not-set-up" }, + }), + ); + vi.mocked(listMilkRates).mockRejectedValue( + new ApiError("missing", "http", 404, { + problem: { status: 404, code: "household-not-set-up" }, + }), + ); + + render(); + + expect(await screen.findByText(/Not set up yet/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create household" })).toBeInTheDocument(); + expect(screen.getByText("Set up your household first")).toBeInTheDocument(); + }); + + it("shows the API's message under the field it is about", async () => { + vi.mocked(setUpHousehold).mockRejectedValue( + new ApiError("bad", "http", 400, { + problem: { + status: 400, + code: "validation-failed", + errors: { name: ["The household name is required."] }, + }, + }), + ); + const user = userEvent.setup(); + + render(); + const name = await screen.findByLabelText("Household name"); + await user.clear(name); + await user.click(screen.getByRole("button", { name: "Save household" })); + + expect(await screen.findByText("The household name is required.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/tests/unit/features/milk-rate-card.test.tsx b/apps/web/tests/unit/features/milk-rate-card.test.tsx new file mode 100644 index 0000000..26d841b --- /dev/null +++ b/apps/web/tests/unit/features/milk-rate-card.test.tsx @@ -0,0 +1,252 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MilkRateCard } from "@/features/settings/milk-rate-card"; +import { formatIsoDate } from "@/lib/dates"; +import { ApiError } from "@/services/api-client"; +import { getHousehold } from "@/services/household-service"; +import { + addMilkRate, + deleteMilkRate, + getMilkRateInEffect, + listMilkRates, +} from "@/services/milk-rate-service"; +import type { ApiHousehold, ApiMilkRate, ApiProblem } from "@/types/api"; + +vi.mock("@/services/household-service", () => ({ getHousehold: vi.fn(), setUpHousehold: vi.fn() })); +vi.mock("@/services/milk-rate-service", () => ({ + listMilkRates: vi.fn(), + getMilkRateInEffect: vi.fn(), + addMilkRate: vi.fn(), + deleteMilkRate: vi.fn(), +})); + +const household: ApiHousehold = { + id: "h1", + name: "Example household", + timeZoneId: "Asia/Kolkata", + today: "2026-10-30", +}; + +const october: ApiMilkRate = { id: "m1", effectiveFrom: "2026-10-01", pricePerLitrePaise: 6000 }; +const midOctober: ApiMilkRate = { id: "m2", effectiveFrom: "2026-10-15", pricePerLitrePaise: 6400 }; + +function httpError(status: number, problem?: ApiProblem) { + return new ApiError(`The API responded with HTTP ${status}.`, "http", status, { problem }); +} + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + vi.mocked(getHousehold).mockResolvedValue(household); + vi.mocked(listMilkRates).mockResolvedValue([midOctober, october]); + vi.mocked(getMilkRateInEffect).mockResolvedValue(midOctober); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetAllMocks(); +}); + +describe("MilkRateCard", () => { + it("says it is loading until the prices arrive", () => { + vi.mocked(getHousehold).mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByRole("status")).toHaveTextContent("Loading prices"); + }); + + it("shows the price in force, as the server decides it, and the history", async () => { + render(); + + expect(await screen.findByText("₹64.00 a litre", { selector: "span" })).toBeInTheDocument(); + expect(screen.getByText(`since ${formatIsoDate("2026-10-15")}`)).toBeInTheDocument(); + + const history = screen.getByRole("list"); + const items = within(history).getAllByRole("listitem"); + expect(items).toHaveLength(2); + expect(within(items[0]!).getByText("In effect")).toBeInTheDocument(); // the 15 October price + expect(within(items[1]!).queryByText("In effect")).not.toBeInTheDocument(); + expect(getMilkRateInEffect).toHaveBeenCalledWith(undefined, expect.anything()); + }); + + it("says plainly that days with no price are not free", async () => { + vi.mocked(listMilkRates).mockResolvedValue([]); + vi.mocked(getMilkRateInEffect).mockResolvedValue(null); + + render(); + + expect(await screen.findByText(/No price is in force yet/)).toBeInTheDocument(); + expect(screen.getByText(/never counted as free/)).toBeInTheDocument(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + + it("points to the household section when there is no household yet", async () => { + vi.mocked(getHousehold).mockRejectedValue( + httpError(404, { status: 404, code: "household-not-set-up" }), + ); + vi.mocked(listMilkRates).mockRejectedValue( + httpError(404, { status: 404, code: "household-not-set-up" }), + ); + + render(); + + expect(await screen.findByText("Set up your household first")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add price" })).not.toBeInTheDocument(); + }); + + it("explains an unreachable API and loads again when you try again", async () => { + vi.mocked(getHousehold).mockRejectedValueOnce(new ApiError("down", "unreachable")); + const user = userEvent.setup(); + + render(); + expect(await screen.findByText(/Can't reach the API/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Try again" })); + + expect(await screen.findByText("₹64.00 a litre", { selector: "span" })).toBeInTheDocument(); + }); + + it("adds a price in paise, starting today unless told otherwise", async () => { + vi.mocked(addMilkRate).mockResolvedValue({ + id: "m3", + effectiveFrom: "2026-10-30", + pricePerLitrePaise: 6250, + }); + const user = userEvent.setup(); + + render(); + const form = await screen.findByRole("form", { name: "Add a price" }); + + expect(within(form).getByLabelText("Applies from")).toHaveValue("2026-10-30"); + await user.type(within(form).getByLabelText("Price of one litre (₹)"), "62.50"); + await user.click(within(form).getByRole("button", { name: "Add price" })); + + expect(addMilkRate).toHaveBeenCalledWith({ + effectiveFrom: "2026-10-30", + pricePerLitrePaise: 6250, + }); + expect(listMilkRates).toHaveBeenCalledTimes(2); // refreshed after saving + }); + + it("can back-date a price", async () => { + vi.mocked(addMilkRate).mockResolvedValue(october); + const user = userEvent.setup(); + + render(); + const form = await screen.findByRole("form", { name: "Add a price" }); + await user.type(within(form).getByLabelText("Price of one litre (₹)"), "60"); + await user.clear(within(form).getByLabelText("Applies from")); + await user.type(within(form).getByLabelText("Applies from"), "2026-09-01"); + await user.click(within(form).getByRole("button", { name: "Add price" })); + + expect(addMilkRate).toHaveBeenCalledWith({ + effectiveFrom: "2026-09-01", + pricePerLitrePaise: 6000, + }); + }); + + it("checks the price before calling the API", async () => { + const user = userEvent.setup(); + + render(); + const form = await screen.findByRole("form", { name: "Add a price" }); + await user.type(within(form).getByLabelText("Price of one litre (₹)"), "sixty"); + await user.click(within(form).getByRole("button", { name: "Add price" })); + + expect(await screen.findByText(/Use numbers only/)).toBeInTheDocument(); + expect(within(form).getByLabelText("Price of one litre (₹)")).toBeInvalid(); + expect(addMilkRate).not.toHaveBeenCalled(); + }); + + it("explains a day that already has a price", async () => { + vi.mocked(addMilkRate).mockRejectedValue( + httpError(409, { + status: 409, + code: "rate-date-taken", + detail: "A price already starts on 2026-10-01.", + }), + ); + const user = userEvent.setup(); + + render(); + const form = await screen.findByRole("form", { name: "Add a price" }); + await user.type(within(form).getByLabelText("Price of one litre (₹)"), "62.50"); + await user.click(within(form).getByRole("button", { name: "Add price" })); + + expect(await screen.findByText(/A price already starts on that day/)).toBeInTheDocument(); + }); + + it("shows the API's message for an invalid price", async () => { + vi.mocked(addMilkRate).mockRejectedValue( + httpError(400, { + status: 400, + code: "validation-failed", + errors: { + pricePerLitrePaise: ["A price must be between 1 and 1000000 paise a litre (got 0)."], + }, + }), + ); + const user = userEvent.setup(); + + render(); + const form = await screen.findByRole("form", { name: "Add a price" }); + await user.type(within(form).getByLabelText("Price of one litre (₹)"), "62.50"); + await user.click(within(form).getByRole("button", { name: "Add price" })); + + expect( + await screen.findByText(/A price must be between 1 and 1000000 paise/), + ).toBeInTheDocument(); + }); + + it("removes a price only after you confirm", async () => { + vi.mocked(deleteMilkRate).mockResolvedValue(undefined); + const user = userEvent.setup(); + + render(); + await user.click( + await screen.findByRole("button", { + name: `Remove the price from ${formatIsoDate("2026-10-01")}`, + }), + ); + const question = screen.getByRole("group", { name: "Confirm removing the price" }); + + expect(deleteMilkRate).not.toHaveBeenCalled(); + await user.click(within(question).getByRole("button", { name: "Keep" })); + expect( + screen.queryByRole("group", { name: "Confirm removing the price" }), + ).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: `Remove the price from ${formatIsoDate("2026-10-01")}` }), + ); + await user.click( + within(screen.getByRole("group", { name: "Confirm removing the price" })).getByRole( + "button", + { + name: "Remove price", + }, + ), + ); + + expect(deleteMilkRate).toHaveBeenCalledWith("m1"); + expect(listMilkRates).toHaveBeenCalledTimes(2); + }); + + it("shows what went wrong when removing fails", async () => { + vi.mocked(deleteMilkRate).mockRejectedValue( + httpError(404, { status: 404, detail: "There is no such price." }), + ); + const user = userEvent.setup(); + + render(); + await user.click( + await screen.findByRole("button", { + name: `Remove the price from ${formatIsoDate("2026-10-01")}`, + }), + ); + await user.click(screen.getByRole("button", { name: "Remove price" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("There is no such price."); + }); +}); diff --git a/apps/web/tests/unit/features/recipients-screen.test.tsx b/apps/web/tests/unit/features/recipients-screen.test.tsx new file mode 100644 index 0000000..d2e220f --- /dev/null +++ b/apps/web/tests/unit/features/recipients-screen.test.tsx @@ -0,0 +1,442 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RecipientsScreen } from "@/features/recipients/recipients-screen"; +import { formatIsoDate } from "@/lib/dates"; +import { ApiError } from "@/services/api-client"; +import { getHousehold, setUpHousehold } from "@/services/household-service"; +import { + addRecipient, + archiveRecipient, + listRecipients, + unarchiveRecipient, + updateRecipient, +} from "@/services/recipient-service"; +import type { ApiHousehold, ApiProblem, ApiRecipient } from "@/types/api"; + +vi.mock("@/services/household-service", () => ({ getHousehold: vi.fn(), setUpHousehold: vi.fn() })); +vi.mock("@/services/recipient-service", () => ({ + listRecipients: vi.fn(), + addRecipient: vi.fn(), + updateRecipient: vi.fn(), + archiveRecipient: vi.fn(), + unarchiveRecipient: vi.fn(), +})); + +const household: ApiHousehold = { + id: "h1", + name: "Example household", + timeZoneId: "Asia/Kolkata", + today: "2026-10-30", +}; + +const recipientA: ApiRecipient = { + id: "r-a", + name: "Recipient A", + expectedMl: 1000, + startsOn: "2026-10-01", + archivedFrom: null, +}; + +const recipientB: ApiRecipient = { + id: "r-b", + name: "Recipient B", + expectedMl: 500, + startsOn: "2026-10-30", + archivedFrom: null, +}; + +function httpError(status: number, problem?: ApiProblem) { + return new ApiError(`The API responded with HTTP ${status}.`, "http", status, { problem }); +} + +const notSetUp = () => httpError(404, { status: 404, code: "household-not-set-up" }); + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"); + vi.mocked(getHousehold).mockResolvedValue(household); + vi.mocked(listRecipients).mockResolvedValue([recipientA]); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetAllMocks(); +}); + +describe("RecipientsScreen without an API address", () => { + it("shows the sample household, labelled as demo data, and never calls the API", async () => { + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + render(); + + expect(screen.getByRole("heading", { level: 1, name: "Recipients" })).toBeInTheDocument(); + expect(screen.getByText("Demo data")).toBeInTheDocument(); + expect(screen.getByText("Recipient A")).toBeInTheDocument(); + expect(screen.getByText("Recipient C")).toBeInTheDocument(); + expect(screen.getByText(/not connected to an API/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add recipient" })).not.toBeInTheDocument(); + expect(getHousehold).not.toHaveBeenCalled(); + expect(listRecipients).not.toHaveBeenCalled(); + }); +}); + +describe("RecipientsScreen with an API", () => { + it("says it is loading until the recipients arrive", async () => { + vi.mocked(getHousehold).mockReturnValue(new Promise(() => {})); + vi.mocked(listRecipients).mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByRole("status")).toHaveTextContent("Loading recipients"); + expect(screen.queryByText("Demo data")).not.toBeInTheDocument(); + }); + + it("lists the household's recipients with their usual quantity, and no demo label", async () => { + vi.mocked(listRecipients).mockResolvedValue([recipientA, recipientB]); + + render(); + + expect(await screen.findByText("Recipient A")).toBeInTheDocument(); + expect(screen.getByText("Recipient B")).toBeInTheDocument(); + expect(screen.getByText("1 L a day")).toBeInTheDocument(); + expect(screen.getByText(/500 ml a day/)).toBeInTheDocument(); + expect(screen.queryByText("Demo data")).not.toBeInTheDocument(); + expect(listRecipients).toHaveBeenCalledWith( + expect.objectContaining({ includeArchived: false }), + ); + }); + + it("says when a recipient has not started yet", async () => { + vi.mocked(listRecipients).mockResolvedValue([ + recipientB, + { ...recipientA, startsOn: "2026-11-05" }, + ]); + + render(); + + expect( + await screen.findByText(new RegExp(`starts ${formatIsoDate("2026-11-05")}`)), + ).toBeInTheDocument(); + expect( + screen.queryByText(new RegExp(`starts ${formatIsoDate("2026-10-30")}`)), + ).not.toBeInTheDocument(); + }); + + it("invites you to add the first recipient when there are none", async () => { + vi.mocked(listRecipients).mockResolvedValue([]); + + render(); + + expect(await screen.findByRole("heading", { name: "No recipients yet" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add the first recipient" })).toBeInTheDocument(); + }); + + it("asks you to set up the household first, then shows the recipients", async () => { + vi.mocked(getHousehold).mockRejectedValueOnce(notSetUp()).mockResolvedValue(household); + vi.mocked(listRecipients).mockRejectedValueOnce(notSetUp()).mockResolvedValue([]); + vi.mocked(setUpHousehold).mockResolvedValue(household); + const user = userEvent.setup(); + + render(); + + expect( + await screen.findByRole("heading", { name: "Set up your household first" }), + ).toBeInTheDocument(); + await user.type(screen.getByLabelText("Household name"), "Example household"); + await user.click(screen.getByRole("button", { name: "Create household" })); + + expect(setUpHousehold).toHaveBeenCalledWith({ + name: "Example household", + timeZoneId: "Asia/Kolkata", + }); + expect(await screen.findByRole("heading", { name: "No recipients yet" })).toBeInTheDocument(); + }); + + it("shows the API's message under the household name field", async () => { + vi.mocked(getHousehold).mockRejectedValue(notSetUp()); + vi.mocked(listRecipients).mockRejectedValue(notSetUp()); + vi.mocked(setUpHousehold).mockRejectedValue( + httpError(400, { + status: 400, + code: "validation-failed", + errors: { name: ["The household name is required."] }, + }), + ); + const user = userEvent.setup(); + + render(); + await user.click(await screen.findByRole("button", { name: "Create household" })); + + expect(await screen.findByText("The household name is required.")).toBeInTheDocument(); + expect(screen.getByLabelText("Household name")).toBeInvalid(); + }); + + it("explains an unreachable API and loads again when you try again", async () => { + vi.mocked(getHousehold) + .mockRejectedValueOnce(new ApiError("down", "unreachable")) + .mockResolvedValue(household); + vi.mocked(listRecipients).mockRejectedValueOnce(new ApiError("down", "unreachable")); + const user = userEvent.setup(); + + render(); + + expect( + await screen.findByText(/Can't reach the API at http:\/\/localhost:5080/), + ).toBeInTheDocument(); + expect(screen.queryByText("Recipient A")).not.toBeInTheDocument(); + expect(screen.queryByText("Demo data")).not.toBeInTheDocument(); + + vi.mocked(listRecipients).mockResolvedValue([recipientA]); + await user.click(screen.getByRole("button", { name: "Try again" })); + + expect(await screen.findByText("Recipient A")).toBeInTheDocument(); + }); + + it("shows the server's explanation for other failures", async () => { + vi.mocked(getHousehold).mockRejectedValue(httpError(500)); + + render(); + + expect(await screen.findByText(/The API answered with HTTP 500/)).toBeInTheDocument(); + }); +}); + +describe("adding a recipient", () => { + async function openAddForm(user: ReturnType) { + await user.click(await screen.findByRole("button", { name: "Add recipient" })); + return screen.getByRole("form", { name: "Add a recipient" }); + } + + it("saves the recipient, closes the form and shows the new list", async () => { + vi.mocked(listRecipients) + .mockResolvedValueOnce([recipientA]) + .mockResolvedValue([recipientA, recipientB]); + vi.mocked(addRecipient).mockResolvedValue(recipientB); + const user = userEvent.setup(); + + render(); + const form = await openAddForm(user); + + expect(within(form).getByLabelText("First delivery on")).toHaveValue("2026-10-30"); // today, from the server + expect(within(form).getByLabelText("Name")).toHaveFocus(); + + await user.type(within(form).getByLabelText("Name"), "Recipient B"); + await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "500"); + expect(within(form).getByText("That is 500 ml a day.")).toBeInTheDocument(); + await user.click(within(form).getByRole("button", { name: "Save recipient" })); + + expect(addRecipient).toHaveBeenCalledWith({ + name: "Recipient B", + expectedMl: 500, + startsOn: "2026-10-30", + }); + expect(await screen.findByText("Recipient B")).toBeInTheDocument(); + expect(screen.queryByRole("form", { name: "Add a recipient" })).not.toBeInTheDocument(); + }); + + it("shows the API's messages under the fields they are about and keeps what was typed", async () => { + vi.mocked(addRecipient).mockRejectedValue( + httpError(400, { + status: 400, + code: "validation-failed", + errors: { + name: ["The recipient's name is required."], + expectedMl: ["The usual quantity must be between 1 ml and 20000 ml (got 30000 ml)."], + }, + }), + ); + const user = userEvent.setup(); + + render(); + const form = await openAddForm(user); + await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "30000"); + await user.click(within(form).getByRole("button", { name: "Save recipient" })); + + expect(await screen.findByText("The recipient's name is required.")).toBeInTheDocument(); + expect(screen.getByText(/must be between 1 ml and 20000 ml/)).toBeInTheDocument(); + expect(within(form).getByLabelText("Name")).toBeInvalid(); + expect(within(form).getByLabelText("Usual daily quantity (ml)")).toHaveValue("30000"); + }); + + it("explains a name that is already taken", async () => { + vi.mocked(addRecipient).mockRejectedValue( + httpError(409, { + status: 409, + code: "recipient-name-taken", + detail: "Another recipient ...", + }), + ); + const user = userEvent.setup(); + + render(); + const form = await openAddForm(user); + await user.type(within(form).getByLabelText("Name"), "Recipient A"); + await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "500"); + await user.click(within(form).getByRole("button", { name: "Save recipient" })); + + expect(await screen.findByText(/already called that/)).toBeInTheDocument(); + }); + + it("checks the quantity is a whole number before calling the API", async () => { + const user = userEvent.setup(); + + render(); + const form = await openAddForm(user); + await user.type(within(form).getByLabelText("Name"), "Recipient B"); + await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "1.5 litres"); + await user.click(within(form).getByRole("button", { name: "Save recipient" })); + + expect( + await screen.findByText("Enter a whole number of millilitres, for example 500."), + ).toBeInTheDocument(); + expect(addRecipient).not.toHaveBeenCalled(); + }); + + it("shows a message in the form when the API cannot be reached", async () => { + vi.mocked(addRecipient).mockRejectedValue(new ApiError("down", "unreachable")); + const user = userEvent.setup(); + + render(); + const form = await openAddForm(user); + await user.type(within(form).getByLabelText("Name"), "Recipient B"); + await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "500"); + await user.click(within(form).getByRole("button", { name: "Save recipient" })); + + expect(await within(form).findByRole("alert")).toHaveTextContent("Can't reach the API"); + expect(within(form).getByRole("button", { name: "Save recipient" })).toBeEnabled(); + }); + + it("closes the form without saving when you cancel", async () => { + const user = userEvent.setup(); + + render(); + const form = await openAddForm(user); + await user.click(within(form).getByRole("button", { name: "Cancel" })); + + expect(screen.queryByRole("form", { name: "Add a recipient" })).not.toBeInTheDocument(); + expect(addRecipient).not.toHaveBeenCalled(); + }); +}); + +describe("changing a recipient", () => { + it("edits the details in place", async () => { + vi.mocked(updateRecipient).mockResolvedValue({ ...recipientA, name: "Recipient Z" }); + const user = userEvent.setup(); + + render(); + await user.click(await screen.findByRole("button", { name: "Edit Recipient A" })); + const form = screen.getByRole("form", { name: "Edit Recipient A" }); + + expect(within(form).getByLabelText("Name")).toHaveValue("Recipient A"); + expect(within(form).getByLabelText("Usual daily quantity (ml)")).toHaveValue("1000"); + expect(within(form).getByLabelText("First delivery on")).toHaveValue("2026-10-01"); + + await user.clear(within(form).getByLabelText("Name")); + await user.type(within(form).getByLabelText("Name"), "Recipient Z"); + await user.click(within(form).getByRole("button", { name: "Save recipient" })); + + expect(updateRecipient).toHaveBeenCalledWith("r-a", { + name: "Recipient Z", + expectedMl: 1000, + startsOn: "2026-10-01", + }); + expect(screen.queryByRole("form", { name: "Edit Recipient A" })).not.toBeInTheDocument(); + }); + + it("archives only after you confirm, and keeps the recipient if you change your mind", async () => { + vi.mocked(archiveRecipient).mockResolvedValue({ ...recipientA, archivedFrom: "2026-10-30" }); + const user = userEvent.setup(); + + render(); + await user.click(await screen.findByRole("button", { name: "Archive Recipient A" })); + const question = screen.getByRole("group", { name: "Archive Recipient A?" }); + + expect(archiveRecipient).not.toHaveBeenCalled(); + expect(within(question).getByText(/Their history is kept/)).toBeInTheDocument(); + + await user.click(within(question).getByRole("button", { name: "Keep" })); + expect(screen.queryByRole("group", { name: "Archive Recipient A?" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Archive Recipient A" })); + await user.click( + within(screen.getByRole("group", { name: "Archive Recipient A?" })).getByRole("button", { + name: "Archive Recipient A", + }), + ); + + expect(archiveRecipient).toHaveBeenCalledTimes(1); + expect(archiveRecipient).toHaveBeenCalledWith("r-a"); + expect(listRecipients).toHaveBeenCalledTimes(2); // refreshed after saving + }); + + it("shows what went wrong when archiving fails", async () => { + vi.mocked(archiveRecipient).mockRejectedValue( + httpError(409, { + status: 409, + code: "recipient-already-archived", + detail: "The recipient is already archived.", + }), + ); + const user = userEvent.setup(); + + render(); + await user.click(await screen.findByRole("button", { name: "Archive Recipient A" })); + await user.click( + within(screen.getByRole("group", { name: "Archive Recipient A?" })).getByRole("button", { + name: "Archive Recipient A", + }), + ); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "The recipient is already archived.", + ); + }); + + it("says why archiving was refused when the API names a field but gives no explanation", async () => { + vi.mocked(archiveRecipient).mockRejectedValue( + httpError(400, { + status: 400, + code: "validation-failed", + errors: { archivedFrom: ["A recipient must be active for at least one day."] }, + }), + ); + const user = userEvent.setup(); + + render(); + await user.click(await screen.findByRole("button", { name: "Archive Recipient A" })); + await user.click( + within(screen.getByRole("group", { name: "Archive Recipient A?" })).getByRole("button", { + name: "Archive Recipient A", + }), + ); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "A recipient must be active for at least one day.", + ); + }); + + it("shows archived recipients on request and restores one", async () => { + const archived: ApiRecipient = { ...recipientA, archivedFrom: "2026-10-15" }; + vi.mocked(listRecipients).mockImplementation(async ({ includeArchived } = {}) => + includeArchived ? [archived, recipientB] : [recipientB], + ); + vi.mocked(unarchiveRecipient).mockResolvedValue(recipientA); + const user = userEvent.setup(); + + render(); + expect(await screen.findByText("Recipient B")).toBeInTheDocument(); + expect(screen.queryByText("Recipient A")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("checkbox", { name: "Show archived recipients" })); + + expect(await screen.findByText("Recipient A")).toBeInTheDocument(); + expect(screen.getByText(`Archived from ${formatIsoDate("2026-10-15")}`)).toBeInTheDocument(); + expect(listRecipients).toHaveBeenLastCalledWith( + expect.objectContaining({ includeArchived: true }), + ); + + await user.click(screen.getByRole("button", { name: "Restore Recipient A" })); + + expect(unarchiveRecipient).toHaveBeenCalledWith("r-a"); + }); +}); From 43aa570278a0120017dd39cf601318a54e6a449f Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:25:58 +0530 Subject: [PATCH 09/10] ci: run the database tests and a smoke test after applying the migrations The api job and scripts/check.* now leave out the tests tagged Category=Database and tolerate the resulting empty test project. The database job applies the migrations to its empty PostgreSQL, runs those tests, then starts the API, waits for /health/ready (which now also needs every migration applied) and runs scripts/smoke-api.sh against the data endpoints. No job name changes. scripts/check.* take --db (-Database) to run the database tests locally, and the development guide covers the migration, the database tests and the unauthenticated-access setting. --- .github/workflows/ci.yml | 36 ++++++++++++--- apps/web/.env.example | 3 +- docs/development.md | 95 ++++++++++++++++++++++++++++++++-------- scripts/check.ps1 | 34 ++++++++++++-- scripts/check.sh | 43 ++++++++++++++++-- scripts/smoke-api.sh | 78 +++++++++++++++++++++++++++++++++ 6 files changed, 256 insertions(+), 33 deletions(-) create mode 100755 scripts/smoke-api.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2845fd3..d8efd5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,9 @@ jobs: - run: dotnet format PureFreshMilk.slnx --verify-no-changes --no-restore # Directory.Build.props sets TreatWarningsAsErrors, so warnings fail the build. - run: dotnet build PureFreshMilk.slnx --configuration Release --no-restore - - run: dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build + # Tests that need PostgreSQL are tagged Category=Database and run in the database job. The test + # project that holds only those is left with no tests, which ends with exit code 8 ("zero tests ran"). + - run: dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build --filter-not-trait "Category=Database" --ignore-exit-code 8 database: name: Database — Compose PostgreSQL + API readiness @@ -92,24 +94,46 @@ jobs: working-directory: backend run: dotnet build src/PureFreshMilk.Api --configuration Release - - name: Start the API and wait until /health/ready is healthy + - name: Apply the migrations to the empty database + working-directory: backend + env: + ConnectionStrings__PureFreshMilk: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }} + run: | + dotnet tool restore + dotnet ef database update --project src/PureFreshMilk.Infrastructure --startup-project src/PureFreshMilk.Api --configuration Release --no-build + + - name: Database tests (a throwaway database per test class, built from the migrations) + working-directory: backend + env: + ConnectionStrings__PureFreshMilkTests: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }} + run: dotnet test --project tests/PureFreshMilk.Infrastructure.Tests --configuration Release + + - name: Start the API, wait until /health/ready is healthy, then smoke-test the data endpoints working-directory: backend env: ConnectionStrings__PureFreshMilk: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }} ASPNETCORE_URLS: http://127.0.0.1:5080 + # The data endpoints exist outside Development only when switched on, until authentication exists. + Access__AllowUnauthenticated: "true" run: | dotnet run --project src/PureFreshMilk.Api --configuration Release --no-build --no-launch-profile > api.log 2>&1 & + ready=false + # Ready also means every migration has been applied. for attempt in $(seq 1 30); do if curl --fail --silent http://127.0.0.1:5080/health/ready; then echo echo "API is ready." - exit 0 + ready=true + break fi sleep 2 done - echo "The API did not become ready within 60 seconds." - cat api.log - exit 1 + if [ "$ready" != true ]; then + echo "The API did not become ready within 60 seconds." + cat api.log + exit 1 + fi + bash ../scripts/smoke-api.sh http://127.0.0.1:5080 || { cat api.log; exit 1; } - name: Stop PostgreSQL if: always() diff --git a/apps/web/.env.example b/apps/web/.env.example index 849a315..6fb7133 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,6 +1,7 @@ # Copy to .env.local (git-ignored) and adjust. Placeholders only — never commit real values. # Base URL of the ASP.NET Core API, without a trailing slash or /api/v1. -# Optional: the web app works without the API (the Today screen uses demo data). +# Optional: the web app works without the API. Then Today and Recipients show labelled demo data, and +# Settings says it needs the API. With an address, Recipients and Settings use the API's data. # NEXT_PUBLIC_* values are inlined into the browser bundle at build time, so never put secrets here. NEXT_PUBLIC_API_BASE_URL=http://localhost:5080 diff --git a/docs/development.md b/docs/development.md index 7ede73d..c09d1ef 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,11 +33,15 @@ npm ci npm run dev ``` -Open http://localhost:3000, then **Try the demo**. The Today screen runs on synthetic demo data -and saves nothing. +Open http://localhost:3000, then **Try the demo**. With no API address the Today and Recipients +screens show synthetic data, clearly labelled "Demo data", and save nothing. Settings says that the +household and price settings need the API. -To let **Settings → API connection** reach a running API, copy `apps/web/.env.example` to -`apps/web/.env.local` (the default points at http://localhost:5080) and restart `npm run dev`. +To work on real data, run the database and the API (steps 2 and 3 below), copy +`apps/web/.env.example` to `apps/web/.env.local` (the default points at http://localhost:5080) and +restart `npm run dev`. Recipients and Settings (household and milk price) then use the API. If the +API cannot be reached they say so; they never fall back to demo data silently. Today still shows +demo data until daily deliveries are built. ## 2. Database (PostgreSQL in Docker) @@ -72,15 +76,25 @@ dotnet run --project src/PureFreshMilk.Api **C. Environment variable.** `ConnectionStrings__PureFreshMilk=`. +The database must have its tables before the API can serve data. On a new database, apply the +migrations once (see [Database migrations](#database-migrations)). + Check it is up: ```bash curl http://localhost:5080/health/live # Healthy — the process is running -curl http://localhost:5080/health/ready # Healthy — PostgreSQL is reachable (Unhealthy if not) +curl http://localhost:5080/health/ready # Healthy — PostgreSQL is reachable and every migration is applied curl http://localhost:5080/api/v1/status # {"service":"purefreshmilk-api","version":"0.1.0",...} ``` -The API starts without a running database. Only `/health/ready` reports the database. +The API starts without a running database. Only `/health/ready` reports the database: it is +`Unhealthy` while PostgreSQL is down and also while migrations are still pending. + +The data endpoints (`/api/v1/household`, `/recipients`, `/milk-rates`) read and write a household +without a login, because authentication is not built yet (ADR 0004). +They exist only in the Development and Testing environments, or when +`Access__AllowUnauthenticated=true` is set explicitly. In any other environment they are not +mapped and answer 404. Do not put a copy that stores real data on a network you do not trust. ## Environment variables @@ -89,6 +103,8 @@ The API starts without a running database. Only `/health/ready` reports the data | `POSTGRES_PASSWORD` | `.env` (root) | Yes, for Compose | Password for the local database container | | `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PORT` | `.env` (root) | No | Default to `purefreshmilk`, `purefreshmilk`, `5432` | | `ConnectionStrings__PureFreshMilk` | Environment or user secrets | Yes, for the API | Npgsql connection string | +| `ConnectionStrings__PureFreshMilkTests` | Environment | Only for the database tests | Connection string of a PostgreSQL server the tests may create and drop databases on (the Compose one). `scripts/check.*` builds it from `.env` | +| `Access__AllowUnauthenticated` | Environment | No | `true` serves the household, recipient and price endpoints without a login outside Development. Anything else leaves them off | | `Cors__AllowedOrigins__0` (…`__1`, …) | Environment | No | Web origins allowed to call the API. Development allows `http://localhost:3000` | | `NEXT_PUBLIC_API_BASE_URL` | `apps/web/.env.local` | No | API base URL for the web app. It is inlined into the browser bundle, so never put secrets in it | @@ -100,8 +116,9 @@ Run what CI runs: ```bash bash scripts/check.sh # backend format/build/tests + web format/lint/types/tests/build +bash scripts/check.sh --db # …plus the database tests (start PostgreSQL first, see below) bash scripts/check.sh --e2e # …plus the Playwright browser tests -pwsh scripts/check.ps1 [-E2E] # the same on Windows +pwsh scripts/check.ps1 [-Database] [-E2E] # the same on Windows ``` Or one at a time: @@ -110,7 +127,9 @@ Or one at a time: # Backend (from backend/) dotnet format PureFreshMilk.slnx --verify-no-changes dotnet build PureFreshMilk.slnx # warnings are errors -dotnet test --solution PureFreshMilk.slnx # Microsoft.Testing.Platform (see global.json) +dotnet test --solution PureFreshMilk.slnx --filter-not-trait "Category=Database" --ignore-exit-code 8 +# Microsoft.Testing.Platform (see global.json). The filter leaves out the tests that need PostgreSQL; +# one test project then has no tests left, which would otherwise end with exit code 8. # Web (from apps/web/) npm run format:check @@ -123,37 +142,75 @@ npm run test:e2e # builds, serves on :3100, runs br Backend test projects: -- `PureFreshMilk.Domain.Tests`: domain rules such as `Quantity`. -- `PureFreshMilk.Api.Tests`: the HTTP pipeline in memory (status endpoint, health checks, Problem - Details, CORS, fail-fast configuration). They need no database. +- `PureFreshMilk.Domain.Tests`: domain rules such as `Quantity`, `Recipient` and `RateSchedule`. +- `PureFreshMilk.Application.Tests`: the use cases, against an in-memory stand-in for the database. +- `PureFreshMilk.Api.Tests`: the HTTP pipeline in memory (status endpoint, the household, recipient + and milk rate endpoints, health checks, Problem Details, CORS, fail-fast configuration). They need + no database. +- `PureFreshMilk.Infrastructure.Tests`: the mapping, constraints and migrations against a real + PostgreSQL. Every test is tagged `Category=Database`. - `PureFreshMilk.ArchitectureTests`: the layer dependency rules. +- `PureFreshMilk.TestSupport`: shared fakes (a library, not a test project). + +### Database tests + +They create a throwaway database for each test class on the server named in +`ConnectionStrings__PureFreshMilkTests`, build it from the migrations, and drop it afterwards. They +fail, rather than skip, when that setting is missing, so they cannot quietly stop running. + +```bash +docker compose up -d --wait +bash scripts/check.sh --db # builds the connection string from .env (or: pwsh scripts/check.ps1 -Database) +# or by hand, from backend/: +export ConnectionStrings__PureFreshMilkTests="Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=" +dotnet test --project tests/PureFreshMilk.Infrastructure.Tests +``` + +CI runs them in the `database` job, after applying the migrations to an empty database and before +starting the API for a smoke test (`scripts/smoke-api.sh`). ## Database migrations -There are no migrations yet; the first one arrives with the first persisted entity. The tooling is -ready: +The first migration is `AddHouseholdsRecipientsAndMilkRates`. Apply the migrations to a new +database before starting the API (it reports `/health/ready` as `Unhealthy` until they are applied): ```bash cd backend dotnet tool restore # installs dotnet-ef from dotnet-tools.json # The API host is used as the startup project, so a connection string must be configured (step 3). +dotnet ef database update \ + --project src/PureFreshMilk.Infrastructure \ + --startup-project src/PureFreshMilk.Api +``` + +To add a migration after changing the model: + +```bash dotnet ef migrations add \ --project src/PureFreshMilk.Infrastructure \ --startup-project src/PureFreshMilk.Api \ --output-dir Persistence/Migrations -dotnet ef database update \ - --project src/PureFreshMilk.Infrastructure \ - --startup-project src/PureFreshMilk.Api ``` +Use the `dotnet-ef` version pinned by `dotnet tool restore`, and keep it in step with the EF Core +package version. Migration files are generated code: `.editorconfig` marks them so that +`dotnet format` leaves them alone. A test fails if the model has changes that no migration covers. + ## Troubleshooting - **`Connection string 'PureFreshMilk' is not configured`**: provide it using one of the options in step 3. - **`required variable POSTGRES_PASSWORD is missing a value`**: create `.env` from `.env.example` and set a password. -- **`/health/ready` returns `Unhealthy`**: the database isn't running or the credentials don't - match. Check `docker compose ps` and your `.env`. -- **Port already in use**: change `POSTGRES_PORT` in `.env`, run the web app with +- **`/health/ready` returns `Unhealthy`**: the database isn't running, the credentials don't + match, or the migrations have not been applied. Check `docker compose ps` and your `.env`, then + run `dotnet ef database update` (see [Database migrations](#database-migrations)). +- **The data endpoints answer 404**: outside Development and Testing they are off unless + `Access__AllowUnauthenticated=true` is set. See step 3. +- **The Recipients page says it can't reach the API**: start the API, check + `NEXT_PUBLIC_API_BASE_URL` in `apps/web/.env.local` (it is read when the dev server starts), and + that the web origin is allowed by `Cors__AllowedOrigins`. +- **Port already in use** (for example 5432, by a local PostgreSQL service): change `POSTGRES_PORT` in `.env` + (`scripts/run-api.*` and `scripts/check.* --db` read it), run the web app with `npm run dev -- --port 3001`, or edit `backend/src/PureFreshMilk.Api/Properties/launchSettings.json`. If you change the web port, also allow the new origin in the API's CORS settings. - **Settings shows "No API address is set"**: create `apps/web/.env.local` from `.env.example` and diff --git a/scripts/check.ps1 b/scripts/check.ps1 index c767bf6..941ac70 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -1,9 +1,10 @@ # Runs the same checks as CI (.github/workflows/ci.yml), stopping at the first failure. # Expects dependencies to be installed (npm ci in apps/web). # -# pwsh scripts/check.ps1 # backend + web checks -# pwsh scripts/check.ps1 -E2E # also the Playwright browser tests -param([switch] $E2E) +# pwsh scripts/check.ps1 # backend + web checks (the database tests are left out) +# pwsh scripts/check.ps1 -Database # also the database tests; start PostgreSQL first: docker compose up -d --wait +# pwsh scripts/check.ps1 -E2E # also the Playwright browser tests +param([switch] $Database, [switch] $E2E) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot @@ -20,7 +21,32 @@ Set-Location (Join-Path $root "backend") Invoke-Checked "dotnet restore PureFreshMilk.slnx" Invoke-Checked "dotnet format PureFreshMilk.slnx --verify-no-changes --no-restore" Invoke-Checked "dotnet build PureFreshMilk.slnx --configuration Release --no-restore" -Invoke-Checked "dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build" +# Tests that need PostgreSQL are tagged Category=Database. The test project that holds only those is left with +# no tests, which ends with exit code 8 ("zero tests ran"). +Invoke-Checked 'dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build --filter-not-trait "Category=Database" --ignore-exit-code 8' + +if ($Database) { + Write-Host "`n==> Backend: database tests (PostgreSQL from compose.yaml)" + if (-not $env:ConnectionStrings__PureFreshMilkTests) { + $envFile = Join-Path $root ".env" + if (-not (Test-Path $envFile)) { + throw "No .env file found. Copy .env.example to .env and set POSTGRES_PASSWORD, or set ConnectionStrings__PureFreshMilkTests." + } + # Reads KEY=value from .env without executing it as code. + function Get-EnvValue([string] $Name, [string] $Default) { + $line = Get-Content $envFile | Where-Object { $_ -match "^$Name=" } | Select-Object -Last 1 + if ($line) { return ($line -replace "^$Name=", "").Trim() } else { return $Default } + } + $password = Get-EnvValue "POSTGRES_PASSWORD" "" + if (-not $password) { throw "POSTGRES_PASSWORD is empty in .env." } + $port = Get-EnvValue "POSTGRES_PORT" "5432" + $db = Get-EnvValue "POSTGRES_DB" "purefreshmilk" + $user = Get-EnvValue "POSTGRES_USER" "purefreshmilk" + $env:ConnectionStrings__PureFreshMilkTests = "Host=localhost;Port=$port;Database=$db;Username=$user;Password=$password" + } + try { Invoke-Checked "dotnet test --project tests/PureFreshMilk.Infrastructure.Tests --configuration Release --no-build" } + finally { Remove-Item Env:ConnectionStrings__PureFreshMilkTests -ErrorAction SilentlyContinue } +} Write-Host "`n==> Web: format, lint, types, unit tests, build" Set-Location (Join-Path $root "apps/web") diff --git a/scripts/check.sh b/scripts/check.sh index 0507042..6b7b3f7 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -2,10 +2,21 @@ # Runs the same checks as CI (.github/workflows/ci.yml), stopping at the first failure. # Expects dependencies to be installed (npm ci in apps/web). # -# bash scripts/check.sh # backend + web checks +# bash scripts/check.sh # backend + web checks (the database tests are left out) +# bash scripts/check.sh --db # also the database tests; start PostgreSQL first: docker compose up -d --wait # bash scripts/check.sh --e2e # also the Playwright browser tests set -euo pipefail +run_db=false +run_e2e=false +for option in "$@"; do + case "$option" in + --db) run_db=true ;; + --e2e) run_e2e=true ;; + *) echo "Unknown option: $option (use --db and/or --e2e)" >&2; exit 2 ;; + esac +done + root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" step() { printf '\n==> %s\n' "$*"; } @@ -14,7 +25,33 @@ cd "$root/backend" dotnet restore PureFreshMilk.slnx dotnet format PureFreshMilk.slnx --verify-no-changes --no-restore dotnet build PureFreshMilk.slnx --configuration Release --no-restore -dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build +# Tests that need PostgreSQL are tagged Category=Database. The test project that holds only those is left with +# no tests, which ends with exit code 8 ("zero tests ran"). +dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build --filter-not-trait "Category=Database" --ignore-exit-code 8 + +if [[ "$run_db" == true ]]; then + step "Backend: database tests (PostgreSQL from compose.yaml)" + if [[ -z "${ConnectionStrings__PureFreshMilkTests:-}" ]]; then + env_file="$root/.env" + if [[ ! -f "$env_file" ]]; then + echo "No .env file found. Copy .env.example to .env and set POSTGRES_PASSWORD, or set ConnectionStrings__PureFreshMilkTests." >&2 + exit 1 + fi + # Reads KEY=value from .env without executing it as shell code; $2 is the default for a missing key. + env_value() { + local value + value="$(grep -E "^$1=" "$env_file" | tail -n 1 | cut -d= -f2- | tr -d '\r' || true)" + echo "${value:-$2}" + } + password="$(env_value POSTGRES_PASSWORD '')" + if [[ -z "$password" ]]; then + echo "POSTGRES_PASSWORD is empty in .env." >&2 + exit 1 + fi + export ConnectionStrings__PureFreshMilkTests="Host=localhost;Port=$(env_value POSTGRES_PORT 5432);Database=$(env_value POSTGRES_DB purefreshmilk);Username=$(env_value POSTGRES_USER purefreshmilk);Password=${password}" + fi + dotnet test --project tests/PureFreshMilk.Infrastructure.Tests --configuration Release --no-build +fi step "Web: format, lint, types, unit tests, build" cd "$root/apps/web" @@ -24,7 +61,7 @@ npm run typecheck npm test npm run build -if [[ "${1:-}" == "--e2e" ]]; then +if [[ "$run_e2e" == true ]]; then step "Web: browser tests" # CI=1 makes Playwright serve the build above instead of building again. CI=1 npm run test:e2e diff --git a/scripts/smoke-api.sh b/scripts/smoke-api.sh new file mode 100755 index 0000000..948328f --- /dev/null +++ b/scripts/smoke-api.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Smoke-tests the data endpoints of a running API against its real database: sets up the household, adds +# a recipient and a milk rate, and checks the error answers. CI runs it after the migrations are applied. +# +# It expects an EMPTY database and an API that serves the data endpoints (Development, or +# Access__AllowUnauthenticated=true). It leaves its data behind, so do not point it at a database you care about. +# +# bash scripts/smoke-api.sh [base url] # default: http://localhost:5080 +set -euo pipefail + +base="${1:-http://localhost:5080}/api/v1" +response="$(mktemp)" +trap 'rm -f "$response"' EXIT + +status="" + +# call METHOD PATH [JSON BODY]: sets $status and leaves the body in $response. +call() { + local method="$1" path="$2" body="${3:-}" + local args=(--silent --show-error --output "$response" --write-out '%{http_code}' --request "$method" "$base$path") + [[ -n "$body" ]] && args+=(--header 'Content-Type: application/json' --data "$body") + status="$(curl "${args[@]}")" +} + +# expect STATUS TEXT STEP: the last call answered STATUS and its body contains TEXT (when TEXT is not empty). +expect() { + local want="$1" text="$2" step="$3" + if [[ "$status" != "$want" ]]; then + echo "FAILED: $step: expected HTTP $want, got $status" >&2 + cat "$response" >&2 + echo >&2 + exit 1 + fi + if [[ -n "$text" ]] && ! grep -q -F -- "$text" "$response"; then + echo "FAILED: $step: the body does not contain $text" >&2 + cat "$response" >&2 + echo >&2 + exit 1 + fi + echo " ok $step" +} + +call GET /household +expect 404 'household-not-set-up' 'the household does not exist yet' + +call PUT /household '{"name":"Smoke test household"}' +expect 201 '"timeZoneId":"Asia/Kolkata"' 'setting up the household creates it' + +call POST /recipients '{"name":"Recipient A","expectedMl":1000,"startsOn":"2026-10-01"}' +expect 201 '"name":"Recipient A"' 'adding a recipient' +recipient_id="$(sed -n 's/.*"id":"\([^"]*\)".*/\1/p' "$response")" +[[ -n "$recipient_id" ]] || { echo "FAILED: no recipient id in the response" >&2; exit 1; } + +call POST /recipients '{"name":"recipient a","expectedMl":500}' +expect 409 'recipient-name-taken' 'a duplicate name is a conflict, whatever its case' + +call POST /recipients '{"name":" ","expectedMl":0}' +expect 400 'validation-failed' 'invalid input is a validation problem' + +call GET /recipients +expect 200 '"name":"Recipient A"' 'listing the recipients' + +call POST /milk-rates '{"effectiveFrom":"2026-10-01","pricePerLitrePaise":6250}' +expect 201 '"pricePerLitrePaise":6250' 'adding a milk rate' + +call GET '/milk-rates/in-effect?on=2026-10-02' +expect 200 '"pricePerLitrePaise":6250' 'the rate in effect after it starts' + +call GET '/milk-rates/in-effect?on=2026-09-30' +expect 404 'no-rate-in-effect' 'there is no price before the first rate' + +call POST "/recipients/$recipient_id/archive" +expect 200 '"archivedFrom":"' 'archiving a recipient' + +call DELETE "/recipients/$recipient_id" +expect 405 '' 'a recipient cannot be deleted' + +echo "Smoke test passed." From efc176fc85013ed87a6310e4d96588afb5eab1d1 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:29:56 +0530 Subject: [PATCH 10/10] docs: describe what slice 1 PR A built Update the architecture overview (current phase, readiness check, what is and is not implemented, single-household mode) and add a "PR A as built" section to the slice plan listing where the implementation differs from it, including the confirmed test filter. ADR 0004 stays Proposed. --- docs/architecture/overview.md | 23 +++++++------ .../slice-1-recipients-and-deliveries.md | 32 ++++++++++++++++--- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a18e2e9..8dce72a 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -5,8 +5,9 @@ payments and outstanding balances. This document describes how the code is organ that keep it that way. Decisions and their reasoning are recorded as [architecture decision records](decisions/README.md). -**Current phase: foundation.** Only the walking skeleton exists: a status endpoint, health checks, -an empty database context and a front-end visual prototype on synthetic demo data. See +**Current phase: slice 1, first half.** Households, recipients and milk rates are persisted and +served by the API, and the Recipients and Settings screens use them. Daily deliveries, billing and +authentication do not exist yet, and Today and History still run on demo data. See [What is not implemented yet](#what-is-not-implemented-yet). ## System context @@ -104,7 +105,7 @@ DTO. Business calculations belong in Domain or Application, never in an endpoint | Endpoint | Meaning | Checks | | --- | --- | --- | | `GET /health/live` | The process is running | None | -| `GET /health/ready` | The API can serve traffic | PostgreSQL connection (EF Core `CanConnectAsync`) | +| `GET /health/ready` | The API can serve traffic | PostgreSQL connection (EF Core `CanConnectAsync`) and no pending migrations | The API fails at start-up with a clear message if `ConnectionStrings:PureFreshMilk` is missing. @@ -164,17 +165,19 @@ Rules: ## What is not implemented yet -The model for the next slice (households, recipients, milk rates and daily deliveries) is proposed -in [ADR 0004](decisions/0004-domain-model-for-household-deliveries.md), with the tables and the +The model for slice 1 (households, recipients, milk rates and daily deliveries) is proposed in +[ADR 0004](decisions/0004-domain-model-for-household-deliveries.md), with the tables and the invariants that need tests in [Domain model](domain-model.md) and the build order in the -[slice 1 plan](../plans/slice-1-recipients-and-deliveries.md). +[slice 1 plan](../plans/slice-1-recipients-and-deliveries.md). Households, recipients and milk rates +(PR A) are built; daily deliveries (PR B) are not. | Area | State | | --- | --- | -| Persistence | `PureFreshMilkDbContext` exists with no entities and no migrations. The first model is designed, not built. | -| Recipients, deliveries, history, month view | Web screens are placeholders or demo-data prototypes. No API endpoints. | +| Persistence | Households, recipients and milk rates are mapped, with one migration (`AddHouseholdsRecipientsAndMilkRates`). Deliveries are designed, not built. | +| Recipients and the milk price | Built: API endpoints under `/api/v1` and the Recipients and Settings screens. Without an API address those screens show labelled demo data or say they need the API. | +| Deliveries, Today, history, month view | Today runs on demo data, History and Month are placeholders. No delivery endpoints. | | Billing: prices, charges, payments, balances | Not started. Will be server-side only. Money is designed as whole paise in ADR 0004. | -| Authentication and households | Not started. Login and sign-up are disabled previews. | -| Household data isolation | Designed (above), not built. | +| Authentication | Not started. Login and sign-up are disabled previews. Until it exists the data endpoints run in single-household mode with no login: they are mapped only in Development and Testing, or when `Access__AllowUnauthenticated=true` is set. | +| Household data isolation | Designed (above). Tables carry `household_id` and the parent key that composite foreign keys will use; the query filter and the isolation tests wait for authentication. | | Deployment, TLS termination, forwarded headers, rate limiting | Not decided. No hosting target yet. | | Dark mode, localisation | Not started. | diff --git a/docs/plans/slice-1-recipients-and-deliveries.md b/docs/plans/slice-1-recipients-and-deliveries.md index 1a1d999..e5eed1f 100644 --- a/docs/plans/slice-1-recipients-and-deliveries.md +++ b/docs/plans/slice-1-recipients-and-deliveries.md @@ -1,6 +1,6 @@ # Slice 1 plan: recipients, milk rates and daily deliveries -> **Status: proposed, awaiting maintainer review.** This plan changes no code. The model it builds is +> **Status: PR A is built (see [PR A as built](#pr-a-as-built)); PR B is not.** The model it builds is > described in [Domain model](../architecture/domain-model.md) and justified in > [ADR 0004](../architecture/decisions/0004-domain-model-for-household-deliveries.md). @@ -112,9 +112,10 @@ today only because nothing is stored; PR B removes it. already starts. - **`dotnet test` stays database-free by default.** Tests that need PostgreSQL carry `[Trait("Category", "Database")]`, live in `PureFreshMilk.Infrastructure.Tests`, and are excluded - from the `api` job and from `scripts/check.*`. xUnit v3 supports trait filters; the exact option for - `dotnet test` on Microsoft.Testing.Platform is confirmed when PR A wires it in. The `database` job - runs them. A database test never skips silently: with no connection string it fails and says how + from the `api` job and from `scripts/check.*` with + `--filter-not-trait "Category=Database" --ignore-exit-code 8` (the second option because the test + project that holds only those tests then runs none, which Microsoft.Testing.Platform reports as exit + code 8). The `database` job runs them. A database test never skips silently: with no connection string it fails and says how to start PostgreSQL. - **Each database test class gets a throwaway database**, created from the migrations and dropped afterwards, through `ConnectionStrings__PureFreshMilkTests`. @@ -249,6 +250,29 @@ migrations, runs them, and adds a curl smoke test with `Access__AllowUnauthentic flag), `docs/development.md` (first migration, database tests, single-household mode), `docs/architecture/overview.md` (what now exists), and ADR 0004's status once accepted. +### PR A as built + +What differs from the plan above: + +- **More than the minimum.** All routes in the PR A tables exist (including `GET /recipients/{id}`, + unarchive, `GET /milk-rates/in-effect`, and changing and deleting a rate). `Money` (whole paise, + checked arithmetic) was built here, not with billing, along with `PricePerLitre.CostOf`, which + rounds half-up once per line. +- **Archive default.** With no date, a recipient is archived from today, or from the day after they + start if that is later. A recipient is active for at least one day, so "from today" was refused for + anyone who started today. +- **Single-household mode** is switched by the environment (Development and Testing) or + `Access__AllowUnauthenticated=true`; `appsettings.Development.json` needed no change. Anything other + than exactly `true` leaves the endpoints off. +- **Composite foreign keys** need a child table, so none exists yet. `recipients` carries the + `UNIQUE (household_id, id)` key they will point at; they arrive with `deliveries` in PR B. +- **Web.** `ApiRecipient` lives in `types/api.ts` (extending `Recipient`) rather than changing + `types/household.ts`. Settings offers a short list of time zones. The Playwright tests run against a + build with no API address; the screens that use the API are covered by Vitest with mocked services, + and the whole path was driven once in a browser against the real API and PostgreSQL. +- **Added to CI.** `scripts/smoke-api.sh`, run by the `database` job after the migrations are applied. + The readiness check now also fails while migrations are pending. + ## PR B: daily deliveries and history Branch `feat/daily-deliveries`. Depends on PR A.