diff --git a/Zeta.sln b/Zeta.sln index fe653c14..64df853e 100644 --- a/Zeta.sln +++ b/Zeta.sln @@ -20,6 +20,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Bayesian.Tests", "tests\Bay EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Feldera.Bench", "bench\Feldera.Bench\Feldera.Bench.fsproj", "{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FactoryDemo.Api.FSharp", "samples\FactoryDemo.Api.FSharp\FactoryDemo.Api.FSharp.fsproj", "{40534D09-439E-4E5F-9A69-A73844DB674D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -66,5 +68,9 @@ Global {AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Debug|Any CPU.Build.0 = Debug|Any CPU {AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Release|Any CPU.ActiveCfg = Release|Any CPU {AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Release|Any CPU.Build.0 = Release|Any CPU + {40534D09-439E-4E5F-9A69-A73844DB674D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40534D09-439E-4E5F-9A69-A73844DB674D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40534D09-439E-4E5F-9A69-A73844DB674D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40534D09-439E-4E5F-9A69-A73844DB674D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj b/samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj new file mode 100644 index 00000000..a825afaf --- /dev/null +++ b/samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj @@ -0,0 +1,16 @@ + + + Exe + Zeta.Samples.FactoryDemo.Api + true + + + + + + + + + + + diff --git a/samples/FactoryDemo.Api.FSharp/Program.fs b/samples/FactoryDemo.Api.FSharp/Program.fs new file mode 100644 index 00000000..035c9a9f --- /dev/null +++ b/samples/FactoryDemo.Api.FSharp/Program.fs @@ -0,0 +1,90 @@ +module Zeta.Samples.FactoryDemo.Api.Program + +open System +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.DependencyInjection + +// Minimal F# ASP.NET Core Web API serving the seed data for the +// factory-demo. Stack-independent — any frontend choice (Blazor / +// React / Vue / Svelte / curl) consumes the same JSON. +// +// V0 scope: in-memory seed only, no DB wiring. A Postgres backing +// with schema.sql + seed-data.sql is a planned follow-up PR; until +// then this API carries its own in-memory seed so the sample runs +// with zero external dependencies. + +let private pipelineFunnel () = + Seed.opportunities + |> List.groupBy (fun o -> o.Stage) + |> List.map (fun (stage, opps) -> + {| Stage = stage + Count = opps |> List.length + TotalCents = opps |> List.sumBy (fun o -> o.AmountCents) |}) + +let private duplicateEmails () = + Seed.customers + |> List.groupBy (fun c -> c.Email) + |> List.filter (fun (_, xs) -> xs |> List.length > 1) + |> List.map (fun (email, xs) -> + {| Email = email + CustomerIds = xs |> List.map (fun c -> c.Id) |}) + +[] +let main args = + let builder = WebApplication.CreateBuilder(args) + // Emit consistent JSON for an external consumer — default + // System.Text.Json options are fine; documented for future tuning. + builder.Services.AddEndpointsApiExplorer() |> ignore + let app = builder.Build() + + // Trivial root — lets a browser confirm the API is up. The + // `endpoints` list is the full contract surface (all 9 paths + // listed, including parameterised routes). + app.MapGet("/", Func<_>(fun () -> + {| name = "Factory-demo API (F#)" + version = "0.0.1" + endpoints = + [ "/" + "/api/customers" + "/api/customers/{id}" + "/api/customers/{id}/activities" + "/api/opportunities" + "/api/opportunities/{id}" + "/api/activities" + "/api/pipeline/funnel" + "/api/pipeline/duplicates" ] |} :> obj)) + |> ignore + + app.MapGet("/api/customers", Func<_>(fun () -> Seed.customers :> obj)) |> ignore + app.MapGet("/api/customers/{id:long}", Func(fun id -> + Seed.customers + |> List.tryFind (fun c -> c.Id = id) + |> function + | Some c -> Results.Ok c + | None -> Results.NotFound())) + |> ignore + + app.MapGet("/api/opportunities", Func<_>(fun () -> Seed.opportunities :> obj)) |> ignore + app.MapGet("/api/opportunities/{id:long}", Func(fun id -> + Seed.opportunities + |> List.tryFind (fun o -> o.Id = id) + |> function + | Some o -> Results.Ok o + | None -> Results.NotFound())) + |> ignore + + app.MapGet("/api/activities", Func<_>(fun () -> Seed.activities :> obj)) |> ignore + app.MapGet("/api/customers/{id:long}/activities", Func(fun id -> + Seed.activities + |> List.filter (fun a -> a.CustomerId = id) + :> obj)) + |> ignore + + // Derived views that a frontend would otherwise compute client- + // side. Landing them server-side keeps the API contract tight. + app.MapGet("/api/pipeline/funnel", Func<_>(fun () -> pipelineFunnel () :> obj)) |> ignore + app.MapGet("/api/pipeline/duplicates", Func<_>(fun () -> duplicateEmails () :> obj)) |> ignore + + app.Run() + 0 diff --git a/samples/FactoryDemo.Api.FSharp/README.md b/samples/FactoryDemo.Api.FSharp/README.md new file mode 100644 index 00000000..6cb04a96 --- /dev/null +++ b/samples/FactoryDemo.Api.FSharp/README.md @@ -0,0 +1,101 @@ +# Factory-demo — JSON API (F#) + +**What this is:** A minimal F# ASP.NET Core Web API that serves +the demo's seed data as JSON. Stack-independent — any frontend +(Blazor / React / Vue / curl) consumes the same endpoints. + +**What this is NOT:** A pitch for Zeta as a data store. The +demo sells the **software factory**, not the database layer. +Backend is in-memory (v0) and will be swapped to Postgres (v1) +without changing the public API contract. + +## Why this sample exists + +The factory-demo needs a JSON API that any frontend choice can +consume. This API ships now so the backend is not on the +critical path when the frontend stack is chosen. + +This is the F# reference implementation. A C# companion sample +(`samples/FactoryDemo.Api.CSharp/`, sibling PR #147) is planned +with the same 9 endpoints, matching JSON shapes, and identical +seed. C# is the more popular .NET language, so it is the natural +primary demo path; F# stays the reference because F# looks +closer to math, which makes theorems over the algebra easier +to express. + +## How to run + +```bash +dotnet run --project samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj +# API is up on http://localhost:5000 (or whatever ASP.NET picks). +# curl it: +curl http://localhost:5000/api/customers +curl http://localhost:5000/api/pipeline/funnel +curl http://localhost:5000/api/pipeline/duplicates +``` + +## Endpoints (v0) + +| Method | Path | Returns | +|---|---|---| +| GET | `/` | API metadata + endpoint list | +| GET | `/api/customers` | All customers | +| GET | `/api/customers/{id}` | Single customer, 404 if missing | +| GET | `/api/customers/{id}/activities` | Activities for one customer | +| GET | `/api/opportunities` | All opportunities | +| GET | `/api/opportunities/{id}` | Single opportunity, 404 if missing | +| GET | `/api/activities` | All activities | +| GET | `/api/pipeline/funnel` | Per-stage count + $ total | +| GET | `/api/pipeline/duplicates` | Customers sharing an email | + +All responses are JSON. + +## V0 seed data + +`Seed.fs` carries the in-memory seed (v1 will mirror a Postgres +seed-data.sql under `samples/FactoryDemo.Db/`, not yet in repo): + +- 20 customers (trades contractors — plumbing, HVAC, electric, roofing, etc.) +- 30 opportunities across 5 stages (Lead, Qualified, Proposal, Won, Lost) +- 33 activities (calls, emails, SMS, notes) +- 2 intentional email collisions to drive the duplicate-review scenario + +Seed is deterministic — restarting the server replays the same data. + +## What v1 adds + +- Postgres backing (Npgsql) wired against a planned + `samples/FactoryDemo.Db/` schema + seed (not yet in repo) +- CRUD endpoints (POST / PUT / DELETE) — v0 is read-only +- docker-compose for one-command Postgres + API +- Environment-variable configuration for connection string + +Each of those is a follow-up PR. + +## Design notes + +- **`Microsoft.NET.Sdk.Web` SDK.** Pulls in ASP.NET Core via + framework reference — no package version edit in + `Directory.Packages.props` needed. Only `FSharp.Core` is an + explicit package reference. +- **Minimal APIs over MVC.** F# + minimal APIs is a clean + combination: one file, no controllers, no heavy routing ceremony. +- **Anonymous records for derived views.** `/api/pipeline/funnel` + returns an anonymous record with `Stage`, `Count`, and + `TotalCents` fields using F#'s `{| Stage = ...; Count = ...; + TotalCents = ... |}` syntax. Keeps the output shape local to + the endpoint handler. +- **`System.Text.Json` defaults.** No converter customisation in v0. + If a frontend needs camelCase / different date shapes, add it as + a targeted follow-up rather than reshape everything. +- **No OpenAPI / Swagger yet.** v0 intentionally minimal; Swagger + UI lands when endpoint count grows or frontend needs it. + +## What this does NOT do + +- Does not persist writes — v0 is read-only +- Does not authenticate or authorise — no auth for the demo v0 +- Does not wire to Postgres — in-memory for v0 +- Does not expose algebraic-delta / retraction-native internals + to the frontend (that's the internal kernel sample's job; the + factory-demo audience gets standard CRUD shape) diff --git a/samples/FactoryDemo.Api.FSharp/Seed.fs b/samples/FactoryDemo.Api.FSharp/Seed.fs new file mode 100644 index 00000000..1f7439d6 --- /dev/null +++ b/samples/FactoryDemo.Api.FSharp/Seed.fs @@ -0,0 +1,151 @@ +module Zeta.Samples.FactoryDemo.Api.Seed + +open System + +// Deterministic in-memory seed data for the API sample. Keep these +// values stable so the same demo scenarios work whether the backing +// store is Postgres (production-shape, planned follow-up) or this +// in-memory fallback (zero-dependency, current). + +type Customer = + { Id: int64 + Name: string + Email: string + Phone: string + Address: string + CreatedAt: DateTimeOffset + UpdatedAt: DateTimeOffset } + +type Opportunity = + { Id: int64 + CustomerId: int64 + Stage: string + AmountCents: int64 + CreatedAt: DateTimeOffset + UpdatedAt: DateTimeOffset } + +type Activity = + { Id: int64 + CustomerId: int64 + OpportunityId: int64 option + Kind: string + Notes: string + OccurredAt: DateTimeOffset } + +// Fixed clock so the seed is deterministic. Any timestamp computed +// from this is reproducible across restarts. +let private now = DateTimeOffset(2026, 4, 23, 0, 0, 0, TimeSpan.Zero) +let private ago (days: int) = now.AddDays(float -days) + +let customers : Customer list = + let mk id name email phone address = + { Id = id + Name = name + Email = email + Phone = phone + Address = address + CreatedAt = ago 120 + UpdatedAt = ago 1 } + // Email collision #1: Alice Plumbing (1) and Aaron Smith (13) share alice@acme.example. + // Email collision #2: Bob HVAC (5) and Quincy Assistant (19) share bob@trades.example. + [ mk 1L "Alice Plumbing LLC" "alice@acme.example" "555-0101" "123 Elm St, Portland OR" + mk 2L "Benson Roofing" "benson@roof.example" "555-0102" "45 Oak Ave, Seattle WA" + mk 3L "Crystal Electric" "crystal@sparks.example" "555-0103" "9 Pine Rd, Boise ID" + mk 4L "Delta HVAC & Mechanical" "delta@hvac.example" "555-0104" "700 Main St, Spokane WA" + mk 5L "Bob HVAC Services" "bob@trades.example" "555-0105" "12 Bay Blvd, Tacoma WA" + mk 6L "Evergreen Landscaping" "info@evergreen.example" "555-0106" "88 Forest Ln, Eugene OR" + mk 7L "Fairbanks Plumbing" "contact@fairbanks.example" "555-0107" "5 River Rd, Anchorage AK" + mk 8L "Granite Pest Control" "hello@granite.example" "555-0108" "301 Stone Way, Boise ID" + mk 9L "Highland Roofing Co" "highland@roof.example" "555-0109" "22 Hill Dr, Bend OR" + mk 10L "Iron Tree Electric" "iron@tree.example" "555-0110" "17 Spruce St, Salem OR" + mk 11L "Jackson Pool Services" "jackson@pools.example" "555-0111" "600 Lake Rd, Reno NV" + mk 12L "Klein Garage Doors" "klein@doors.example" "555-0112" "44 4th Ave, Medford OR" + mk 13L "Aaron Smith (new contact)" "alice@acme.example" "555-0113" "123 Elm St, Portland OR" + mk 14L "Lakeview Solar" "lakeview@solar.example" "555-0114" "250 Shore Dr, Bellevue WA" + mk 15L "Mountain Well Drilling" "mountain@wells.example" "555-0115" "12 Ridge Rd, Coeur dAlene ID" + mk 16L "Nightingale Security" "ngale@secure.example" "555-0116" "88 Watch Way, Vancouver WA" + mk 17L "Oak Hill Septic" "oak@septic.example" "555-0117" "14 Rural Rt 3, Gresham OR" + mk 18L "Prairie Window Cleaning" "prairie@windows.example" "555-0118" "66 Glass Rd, Kennewick WA" + mk 19L "Quincy Assistant (Bob HVAC)" "bob@trades.example" "555-0119" "12 Bay Blvd, Tacoma WA" + mk 20L "Redwood Tree Service" "redwood@trees.example" "555-0120" "3 Canopy Ct, Hillsboro OR" ] + +let opportunities : Opportunity list = + let mk id custId stage amount = + { Id = id + CustomerId = custId + Stage = stage + AmountCents = amount + CreatedAt = ago 30 + UpdatedAt = ago 2 } + [ mk 1L 1L "Lead" 250000L + mk 2L 1L "Qualified" 800000L + mk 3L 2L "Lead" 180000L + mk 4L 3L "Proposal" 450000L + mk 5L 3L "Won" 120000L + mk 6L 4L "Lead" 2200000L + mk 7L 4L "Qualified" 600000L + mk 8L 5L "Proposal" 350000L + mk 9L 5L "Won" 900000L + mk 10L 6L "Lead" 150000L + mk 11L 7L "Qualified" 500000L + mk 12L 7L "Proposal" 700000L + mk 13L 8L "Won" 220000L + mk 14L 9L "Lead" 300000L + mk 15L 9L "Lead" 1800000L + mk 16L 10L "Qualified" 950000L + mk 17L 11L "Proposal" 1400000L + mk 18L 12L "Won" 380000L + mk 19L 13L "Lead" 50000L + mk 20L 14L "Proposal" 2500000L + mk 21L 14L "Qualified" 1100000L + mk 22L 15L "Won" 600000L + mk 23L 16L "Lead" 180000L + mk 24L 17L "Qualified" 270000L + mk 25L 18L "Lead" 80000L + mk 26L 19L "Proposal" 320000L + mk 27L 20L "Won" 450000L + mk 28L 20L "Lead" 210000L + mk 29L 2L "Lost" 90000L + mk 30L 6L "Lost" 400000L ] + +let activities : Activity list = + let mk id custId oppId kind notes daysAgo = + { Id = id + CustomerId = custId + OpportunityId = oppId + Kind = kind + Notes = notes + OccurredAt = ago daysAgo } + [ mk 1L 1L (Some 1L) "Call" "Initial intake call — 3 units, basement finish" 14 + mk 2L 1L (Some 1L) "Email" "Sent follow-up with rough estimate" 13 + mk 3L 1L (Some 2L) "Call" "Scope expanded to full house repipe" 6 + mk 4L 2L (Some 3L) "Email" "Insurance paperwork sent for roof claim" 10 + mk 5L 3L (Some 4L) "Call" "Walkthrough scheduled for Tuesday" 8 + mk 6L 3L (Some 5L) "Note" "Payment received — closed won" 3 + mk 7L 4L (Some 6L) "Call" "Commercial HVAC replacement — 6 rooftop units" 20 + mk 8L 4L (Some 6L) "Email" "Technical specs and load calcs sent" 18 + mk 9L 4L (Some 7L) "Call" "Second opportunity — server-room cooling" 5 + mk 10L 5L (Some 8L) "SMS" "Confirmed 10am arrival window" 2 + mk 11L 5L (Some 9L) "Note" "Deposit received; scheduled for next week" 7 + mk 12L 6L (Some 10L) "Email" "Initial inquiry from website" 4 + mk 13L 7L (Some 11L) "Call" "Alaska project — remote site, flew tools in" 30 + mk 14L 7L (Some 12L) "Email" "Proposal sent with permitting schedule" 15 + mk 15L 8L (Some 13L) "Note" "Quarterly service contract signed" 45 + mk 16L 9L (Some 14L) "Call" "Storm damage — needs quick turnaround" 1 + mk 17L 9L (Some 15L) "Email" "Large hotel roof — sent credentials package" 2 + mk 18L 10L (Some 16L) "Call" "Panel upgrade consult" 11 + mk 19L 11L (Some 17L) "SMS" "Pool opening scheduled for May 1" 5 + mk 20L 12L (Some 18L) "Note" "Installed — 3yr warranty registered" 60 + mk 21L 13L (Some 19L) "Email" "Intro call tomorrow 2pm" 1 + mk 22L 14L (Some 20L) "Call" "Roof assessment + solar compatibility check" 12 + mk 23L 14L (Some 21L) "Email" "Federal tax credit paperwork sent" 9 + mk 24L 15L (Some 22L) "Note" "Test-well results clean; contract signed" 25 + mk 25L 16L (Some 23L) "Call" "Camera system walkthrough" 6 + mk 26L 17L (Some 24L) "SMS" "Septic pump appointment confirmed" 3 + mk 27L 18L (Some 25L) "Email" "Storefront window quote" 7 + mk 28L 19L (Some 26L) "Call" "Coordinating with Bob HVAC on combined job" 4 + mk 29L 20L (Some 27L) "Note" "Repeat customer — 2nd tree removal this year" 40 + mk 30L 20L (Some 28L) "Email" "Quarterly pruning proposal" 2 + mk 31L 2L (Some 29L) "Note" "Customer went with competitor on price" 22 + mk 32L 6L (Some 30L) "Note" "Lost deal — decided to self-install" 18 + mk 33L 1L None "Email" "General follow-up — hope repipe went well" 90 ] diff --git a/samples/FactoryDemo.Api.FSharp/smoke-test.sh b/samples/FactoryDemo.Api.FSharp/smoke-test.sh new file mode 100755 index 00000000..09bd911b --- /dev/null +++ b/samples/FactoryDemo.Api.FSharp/smoke-test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Factory-demo F# API smoke test — exercises all 9 endpoints and validates +# the JSON-shape contract. Exits 0 on pass, 1 on any failure. +# +# Usage: +# bash samples/FactoryDemo.Api.FSharp/smoke-test.sh +# +# Starts the API on a random free port, waits for /, hits each endpoint, +# verifies response shape + key invariants (row counts, duplicate-pair +# identity, funnel totals). Stops the API cleanly on exit. +# +# Dependencies on host: dotnet, curl, jq. A C# sibling API (sibling +# PR #147) will land its own parallel smoke-test so parity between +# the two APIs is ground-truth-testable. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT="$SCRIPT_DIR/FactoryDemo.Api.FSharp.fsproj" + +for cmd in dotnet curl jq; do + if ! command -v "$cmd" >/dev/null; then + echo "Missing required tool: $cmd" >&2 + exit 2 + fi +done + +# Pick a high random port to avoid clashes with other dev services. +PORT=$(( 5100 + RANDOM % 400 )) +URL="http://localhost:${PORT}" + +echo "Building API..." +dotnet build "$PROJECT" -c Release --nologo -v quiet >/dev/null +echo "Starting API on ${URL}..." + +dotnet run --project "$PROJECT" -c Release --no-build --urls "$URL" \ + > /tmp/factory-demo-api-fsharp.log 2>&1 & +API_PID=$! + +cleanup() { + kill "$API_PID" 2>/dev/null || true + wait "$API_PID" 2>/dev/null || true +} +trap cleanup EXIT + +for _ in {1..20}; do + if curl -sf "${URL}/" >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +if ! curl -sf "${URL}/" >/dev/null 2>&1; then + echo "API did not come up within budget. Log:" >&2 + cat /tmp/factory-demo-api-fsharp.log >&2 + exit 1 +fi + +fail=0 +check() { + local label="$1" + local path="$2" + local jq_expr="$3" + local expected="$4" + local actual + actual=$(curl -sf "${URL}${path}" | jq -r "$jq_expr" 2>/dev/null || echo "ERROR") + if [ "$actual" = "$expected" ]; then + printf " OK %-50s (%s)\n" "$label" "$actual" + else + printf " FAIL %-50s expected=%s got=%s\n" "$label" "$expected" "$actual" + fail=1 + fi +} + +echo "" +echo "Factory-demo F# API smoke test" +echo "==============================" + +# Root metadata — F# anonymous-record fields declared lowercase emit +# lowercase JSON property names. Same as the C# sibling after +# System.Text.Json default camelCasing. The `(.Name // .name | ...)` +# jq pattern below is tolerant either way, which keeps the parity +# test resilient if field-casing conventions drift between the two +# APIs in the future. +check "root.name contains 'Factory-demo'" "/" "(.Name // .name | test(\"Factory-demo\"))" "true" + +check "/api/customers length" "/api/customers" ". | length" "20" +check "/api/opportunities length" "/api/opportunities" ". | length" "30" +check "/api/activities length" "/api/activities" ". | length" "33" + +check "customer #1 name" "/api/customers/1" "(.Name // .name)" "Alice Plumbing LLC" +check "opportunity #1 stage" "/api/opportunities/1" "(.Stage // .stage)" "Lead" + +check "customer #1 activities count" "/api/customers/1/activities" ". | length" "4" + +# Pipeline funnel — per-stage counts. F# emits PascalCase; jq handles both. +check "funnel Lead count" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Lead\")].[0] | (.Count // .count)" "10" +check "funnel Qualified count" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Qualified\")].[0] | (.Count // .count)" "6" +check "funnel Won count" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Won\")].[0] | (.Count // .count)" "6" +check "funnel Lost count" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Lost\")].[0] | (.Count // .count)" "2" + +check "funnel Lead totalCents" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Lead\")].[0] | (.TotalCents // .totalCents)" "5400000" +check "funnel Won totalCents" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Won\")].[0] | (.TotalCents // .totalCents)" "2670000" + +check "duplicate pairs count" "/api/pipeline/duplicates" ". | length" "2" +check "alice@acme.example pair members" "/api/pipeline/duplicates" "[.[] | select((.Email // .email)==\"alice@acme.example\")].[0] | (.CustomerIds // .customerIds) | join(\",\")" "1,13" +check "bob@trades.example pair members" "/api/pipeline/duplicates" "[.[] | select((.Email // .email)==\"bob@trades.example\")].[0] | (.CustomerIds // .customerIds) | join(\",\")" "5,19" + +# 404 behavior +status=$(curl -o /dev/null -s -w "%{http_code}" "${URL}/api/customers/999") +if [ "$status" = "404" ]; then + printf " OK %-50s (%s)\n" "missing customer HTTP status" "404" +else + printf " FAIL %-50s expected=404 got=%s\n" "missing customer HTTP status" "$status" + fail=1 +fi + +echo "" +if [ "$fail" -eq 0 ]; then + echo "All checks passed." + exit 0 +else + echo "One or more checks failed — see /tmp/factory-demo-api-fsharp.log for server output." + exit 1 +fi