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/.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/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/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/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"); + }); +}); 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" }); + }); +}); diff --git a/backend/PureFreshMilk.slnx b/backend/PureFreshMilk.slnx index 696ed1a..a92e71c 100644 --- a/backend/PureFreshMilk.slnx +++ b/backend/PureFreshMilk.slnx @@ -7,7 +7,10 @@ + + + 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/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..4954210 --- /dev/null +++ b/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs @@ -0,0 +1,49 @@ +using System.Globalization; +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, 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); + +/// 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 ?? 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/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/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..0ed2798 --- /dev/null +++ b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs @@ -0,0 +1,130 @@ +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); + + /// + /// 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) + { + 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 = NormalizeName(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/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs b/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs new file mode 100644 index 0000000..825d6a3 --- /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.OrderBy(candidate => candidate.Id).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.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..2ced6d8 --- /dev/null +++ b/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs @@ -0,0 +1,328 @@ +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_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() + { + 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.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..679d63a --- /dev/null +++ b/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs @@ -0,0 +1,415 @@ +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); + } + + [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() + { + 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.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); + } +} 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); +} 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..28d0a07 --- /dev/null +++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs @@ -0,0 +1,53 @@ +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; + + /// 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); +} 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 + + + + + + + + + + + + + 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/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/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. 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."