diff --git a/Customer.Orders.Bff.slnx b/Customer.Orders.Bff.slnx index d4b6450..44e245f 100644 --- a/Customer.Orders.Bff.slnx +++ b/Customer.Orders.Bff.slnx @@ -4,4 +4,7 @@ + + + diff --git a/docs/api/customer-order-workflows.md b/docs/api/customer-order-workflows.md new file mode 100644 index 0000000..4d5ca7d --- /dev/null +++ b/docs/api/customer-order-workflows.md @@ -0,0 +1,29 @@ +# Customer Order Workflow API + +## Purpose + +This BFF exposes customer-facing order submission, status, detail, and history workflows over REST while delegating orchestration to `operations-service`. + +## Endpoints + +- `GET /api/customer/orders/status?contextId=` + - Returns the current customer order status snapshot. +- `GET /api/customer/orders/history?contextId=` + - Returns the recent customer order history snapshot. +- `GET /api/customer/orders/{orderId}?contextId=` + - Returns the current detail projection for a single order within the active customer context. +- `POST /api/customer/orders` + - Submits a customer order snapshot for processing. + +## Upstream Dependency + +- Base address configuration: `OperationsService:BaseAddress` +- Default runtime target: `http://operations-service:8080` +- Internal upstream routes: + - `GET /internal/operations/customer/status` + - `POST /internal/operations/orders` + +## Notes + +- Customer order submission currently maps `ItemIds.Count` into the upstream restaurant order workflow because the internal service contract still accepts aggregate item counts. +- Correlation IDs are preserved through Thalos session checks and operations-service calls. diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index 05b7966..6e660f5 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -10,6 +10,7 @@ customer-orders-bff ## Domain-Specific Candidate Features - Order lifecycle consistency and state transitions. +- Customer order detail and history views that stay aligned with operations-service workflows. - Kitchen queue and dispatch optimization hooks. - Operations control-plane policies (flags, service windows, overrides). - POS closeout and settlement summary alignment. diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 13d294f..cd8a94b 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -23,6 +23,7 @@ docker run --rm -p 8080:8080 --name customer-orders-bff agilewebs/customer-order ## Runtime Notes - Exposes REST edge endpoints for customer order lifecycle flows. +- Requires `OperationsService__BaseAddress` to resolve the upstream operations-service runtime. - Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint. - Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation. @@ -38,5 +39,5 @@ docker run --rm -p 8080:8080 --name customer-orders-bff agilewebs/customer-order - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` ## Known Limitations -- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. +- Customer-orders now delegates workflow snapshots to `operations-service`, but the upstream operations adapter still serves deterministic demo data rather than database-backed state. - Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity. diff --git a/docs/security/auth-enforcement.md b/docs/security/auth-enforcement.md index 981a858..fb02a2e 100644 --- a/docs/security/auth-enforcement.md +++ b/docs/security/auth-enforcement.md @@ -7,6 +7,8 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio ## Protected Endpoints - `/api/customer/orders/status` +- `/api/customer/orders/history` +- `/api/customer/orders/{orderId}` - `/api/customer/orders` ## Anonymous Endpoints diff --git a/src/Customer.Orders.Bff.Application/Adapters/DefaultCustomerOrdersServiceClient.cs b/src/Customer.Orders.Bff.Application/Adapters/DefaultCustomerOrdersServiceClient.cs deleted file mode 100644 index 6786e85..0000000 --- a/src/Customer.Orders.Bff.Application/Adapters/DefaultCustomerOrdersServiceClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Customer.Orders.Bff.Contracts.Requests; -using Customer.Orders.Bff.Contracts.Responses; - -namespace Customer.Orders.Bff.Application.Adapters; - -public sealed class DefaultCustomerOrdersServiceClient : ICustomerOrdersServiceClient -{ - public Task FetchAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new GetCustomerOrderStatusResponse(request.ContextId, "Default service-backed response.")); - } - - public Task SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new SubmitCustomerOrderResponse(request.ContextId, request.OrderId, true, "Order accepted by default adapter.")); - } -} diff --git a/src/Customer.Orders.Bff.Application/Adapters/ICustomerOrdersServiceClient.cs b/src/Customer.Orders.Bff.Application/Adapters/ICustomerOrdersServiceClient.cs index 88c94fa..ed526a9 100644 --- a/src/Customer.Orders.Bff.Application/Adapters/ICustomerOrdersServiceClient.cs +++ b/src/Customer.Orders.Bff.Application/Adapters/ICustomerOrdersServiceClient.cs @@ -5,6 +5,8 @@ namespace Customer.Orders.Bff.Application.Adapters; public interface ICustomerOrdersServiceClient { - Task FetchAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken); + Task FetchStatusAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken); + Task FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken); + Task FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken); Task SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken); } diff --git a/src/Customer.Orders.Bff.Application/Adapters/OperationsCustomerOrdersServiceClient.cs b/src/Customer.Orders.Bff.Application/Adapters/OperationsCustomerOrdersServiceClient.cs new file mode 100644 index 0000000..1d523c3 --- /dev/null +++ b/src/Customer.Orders.Bff.Application/Adapters/OperationsCustomerOrdersServiceClient.cs @@ -0,0 +1,120 @@ +using System.Net.Http.Json; +using Customer.Orders.Bff.Contracts.Contracts; +using Customer.Orders.Bff.Contracts.Requests; +using Customer.Orders.Bff.Contracts.Responses; + +namespace Customer.Orders.Bff.Application.Adapters; + +public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient) : ICustomerOrdersServiceClient +{ + public async Task FetchStatusAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) + { + var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken); + + return new GetCustomerOrderStatusResponse( + payload.ContextId, + payload.Summary, + MapOrders(payload.Orders), + payload.RecentEvents); + } + + public async Task FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken) + { + var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken); + var orders = MapOrders(payload.Orders); + var matchingOrder = orders.FirstOrDefault(summary => summary.OrderId == request.OrderId); + + return new GetCustomerOrderDetailResponse( + payload.ContextId, + matchingOrder is null + ? $"Order {request.OrderId} is not visible in the current customer context." + : $"Order {request.OrderId} is currently {matchingOrder.Status}.", + matchingOrder, + payload.RecentEvents); + } + + public async Task FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) + { + var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken); + + return new GetCustomerOrderHistoryResponse( + payload.ContextId, + payload.Summary, + MapOrders(payload.Orders), + payload.RecentEvents); + } + + public async Task SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken) + { + // operations-service still accepts aggregate item counts for restaurant order submission. + var payload = await SubmitOrderPayloadAsync( + new SubmitRestaurantOrderPayload(request.ContextId, request.OrderId, request.TableId, request.ItemIds.Count), + cancellationToken); + + return new SubmitCustomerOrderResponse( + payload.ContextId, + payload.OrderId, + payload.Accepted, + payload.Summary, + payload.Status); + } + + private async Task GetStatusPayloadAsync(string contextId, CancellationToken cancellationToken) + { + var payload = await httpClient.GetFromJsonAsync( + $"internal/operations/customer/status?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order status payload."); + } + + private async Task SubmitOrderPayloadAsync( + SubmitRestaurantOrderPayload request, + CancellationToken cancellationToken) + { + using var response = await httpClient.PostAsJsonAsync("internal/operations/orders", request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order workflow payload."); + } + + private static IReadOnlyCollection MapOrders(IReadOnlyCollection orders) + { + return orders + .Select(static order => new CustomerOrderSummaryContract( + order.OrderId, + order.TableId, + order.Status, + order.GuestCount, + order.ItemIds)) + .ToArray(); + } + + private sealed record GetCustomerOrderStatusPayload( + string ContextId, + string Summary, + IReadOnlyCollection Orders, + IReadOnlyCollection RecentEvents); + + private sealed record CustomerOrderPayload( + string OrderId, + string TableId, + string Status, + int GuestCount, + IReadOnlyCollection ItemIds); + + private sealed record SubmitRestaurantOrderPayload( + string ContextId, + string OrderId, + string TableId, + int ItemCount); + + private sealed record SubmitRestaurantOrderResponsePayload( + string ContextId, + string OrderId, + bool Accepted, + string Summary, + string Status, + DateTime SubmittedAtUtc); +} diff --git a/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderDetailHandler.cs b/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderDetailHandler.cs new file mode 100644 index 0000000..7a0b47d --- /dev/null +++ b/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderDetailHandler.cs @@ -0,0 +1,13 @@ +using Customer.Orders.Bff.Application.Adapters; +using Customer.Orders.Bff.Contracts.Requests; +using Customer.Orders.Bff.Contracts.Responses; + +namespace Customer.Orders.Bff.Application.Handlers; + +public sealed class GetCustomerOrderDetailHandler(ICustomerOrdersServiceClient serviceClient) : IGetCustomerOrderDetailHandler +{ + public Task HandleAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken) + { + return serviceClient.FetchDetailAsync(request, cancellationToken); + } +} diff --git a/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderHistoryHandler.cs b/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderHistoryHandler.cs new file mode 100644 index 0000000..d9fed44 --- /dev/null +++ b/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderHistoryHandler.cs @@ -0,0 +1,13 @@ +using Customer.Orders.Bff.Application.Adapters; +using Customer.Orders.Bff.Contracts.Requests; +using Customer.Orders.Bff.Contracts.Responses; + +namespace Customer.Orders.Bff.Application.Handlers; + +public sealed class GetCustomerOrderHistoryHandler(ICustomerOrdersServiceClient serviceClient) : IGetCustomerOrderHistoryHandler +{ + public Task HandleAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) + { + return serviceClient.FetchHistoryAsync(request, cancellationToken); + } +} diff --git a/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderStatusHandler.cs b/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderStatusHandler.cs index 48de8b9..28db0d5 100644 --- a/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderStatusHandler.cs +++ b/src/Customer.Orders.Bff.Application/Handlers/GetCustomerOrderStatusHandler.cs @@ -8,6 +8,6 @@ public sealed class GetCustomerOrderStatusHandler(ICustomerOrdersServiceClient s { public Task HandleAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) { - return serviceClient.FetchAsync(request, cancellationToken); + return serviceClient.FetchStatusAsync(request, cancellationToken); } } diff --git a/src/Customer.Orders.Bff.Application/Handlers/IGetCustomerOrderDetailHandler.cs b/src/Customer.Orders.Bff.Application/Handlers/IGetCustomerOrderDetailHandler.cs new file mode 100644 index 0000000..bf889d2 --- /dev/null +++ b/src/Customer.Orders.Bff.Application/Handlers/IGetCustomerOrderDetailHandler.cs @@ -0,0 +1,9 @@ +using Customer.Orders.Bff.Contracts.Requests; +using Customer.Orders.Bff.Contracts.Responses; + +namespace Customer.Orders.Bff.Application.Handlers; + +public interface IGetCustomerOrderDetailHandler +{ + Task HandleAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken); +} diff --git a/src/Customer.Orders.Bff.Application/Handlers/IGetCustomerOrderHistoryHandler.cs b/src/Customer.Orders.Bff.Application/Handlers/IGetCustomerOrderHistoryHandler.cs new file mode 100644 index 0000000..cf4b27c --- /dev/null +++ b/src/Customer.Orders.Bff.Application/Handlers/IGetCustomerOrderHistoryHandler.cs @@ -0,0 +1,9 @@ +using Customer.Orders.Bff.Contracts.Requests; +using Customer.Orders.Bff.Contracts.Responses; + +namespace Customer.Orders.Bff.Application.Handlers; + +public interface IGetCustomerOrderHistoryHandler +{ + Task HandleAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken); +} diff --git a/src/Customer.Orders.Bff.Contracts/Contracts/CustomerOrderSummaryContract.cs b/src/Customer.Orders.Bff.Contracts/Contracts/CustomerOrderSummaryContract.cs new file mode 100644 index 0000000..5ab26e0 --- /dev/null +++ b/src/Customer.Orders.Bff.Contracts/Contracts/CustomerOrderSummaryContract.cs @@ -0,0 +1,8 @@ +namespace Customer.Orders.Bff.Contracts.Contracts; + +public sealed record CustomerOrderSummaryContract( + string OrderId, + string TableId, + string Status, + int GuestCount, + IReadOnlyCollection ItemIds); diff --git a/src/Customer.Orders.Bff.Contracts/Requests/GetCustomerOrderDetailRequest.cs b/src/Customer.Orders.Bff.Contracts/Requests/GetCustomerOrderDetailRequest.cs new file mode 100644 index 0000000..ced782a --- /dev/null +++ b/src/Customer.Orders.Bff.Contracts/Requests/GetCustomerOrderDetailRequest.cs @@ -0,0 +1,3 @@ +namespace Customer.Orders.Bff.Contracts.Requests; + +public sealed record GetCustomerOrderDetailRequest(string ContextId, string OrderId); diff --git a/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderDetailResponse.cs b/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderDetailResponse.cs new file mode 100644 index 0000000..3587f8a --- /dev/null +++ b/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderDetailResponse.cs @@ -0,0 +1,9 @@ +using Customer.Orders.Bff.Contracts.Contracts; + +namespace Customer.Orders.Bff.Contracts.Responses; + +public sealed record GetCustomerOrderDetailResponse( + string ContextId, + string Summary, + CustomerOrderSummaryContract? Order, + IReadOnlyCollection RecentEvents); diff --git a/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderHistoryResponse.cs b/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderHistoryResponse.cs new file mode 100644 index 0000000..7944c6d --- /dev/null +++ b/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderHistoryResponse.cs @@ -0,0 +1,9 @@ +using Customer.Orders.Bff.Contracts.Contracts; + +namespace Customer.Orders.Bff.Contracts.Responses; + +public sealed record GetCustomerOrderHistoryResponse( + string ContextId, + string Summary, + IReadOnlyCollection Orders, + IReadOnlyCollection RecentEvents); diff --git a/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderStatusResponse.cs b/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderStatusResponse.cs index e01c065..6a3aea1 100644 --- a/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderStatusResponse.cs +++ b/src/Customer.Orders.Bff.Contracts/Responses/GetCustomerOrderStatusResponse.cs @@ -1,3 +1,9 @@ +using Customer.Orders.Bff.Contracts.Contracts; + namespace Customer.Orders.Bff.Contracts.Responses; -public sealed record GetCustomerOrderStatusResponse(string ContextId, string Summary); +public sealed record GetCustomerOrderStatusResponse( + string ContextId, + string Summary, + IReadOnlyCollection Orders, + IReadOnlyCollection RecentEvents); diff --git a/src/Customer.Orders.Bff.Contracts/Responses/SubmitCustomerOrderResponse.cs b/src/Customer.Orders.Bff.Contracts/Responses/SubmitCustomerOrderResponse.cs index 4791ece..df45f19 100644 --- a/src/Customer.Orders.Bff.Contracts/Responses/SubmitCustomerOrderResponse.cs +++ b/src/Customer.Orders.Bff.Contracts/Responses/SubmitCustomerOrderResponse.cs @@ -4,4 +4,5 @@ public sealed record SubmitCustomerOrderResponse( string ContextId, string OrderId, bool Accepted, - string Summary); + string Summary, + string Status); diff --git a/src/Customer.Orders.Bff.Rest/Program.cs b/src/Customer.Orders.Bff.Rest/Program.cs index 8cccb99..6262ec8 100644 --- a/src/Customer.Orders.Bff.Rest/Program.cs +++ b/src/Customer.Orders.Bff.Rest/Program.cs @@ -10,8 +10,15 @@ const string SessionAccessCookieName = "thalos_session"; const string SessionRefreshCookieName = "thalos_refresh"; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSingleton(); +builder.Services.AddHttpClient((serviceProvider, httpClient) => +{ + var configuration = serviceProvider.GetRequiredService(); + var operationsBaseAddress = configuration["OperationsService:BaseAddress"] ?? "http://operations-service:8080"; + httpClient.BaseAddress = new Uri($"{operationsBaseAddress.TrimEnd('/')}/"); +}); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("ThalosAuth"); @@ -44,6 +51,43 @@ app.MapGet("/api/customer/orders/status", async ( return Results.Ok(await handler.HandleAsync(request, ct)); }); +app.MapGet("/api/customer/orders/history", async ( + string contextId, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IGetCustomerOrderHistoryHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + var request = new GetCustomerOrderStatusRequest(contextId); + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + +app.MapGet("/api/customer/orders/{orderId}", async ( + string orderId, + string contextId, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IGetCustomerOrderDetailHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + var request = new GetCustomerOrderDetailRequest(contextId, orderId); + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + app.MapPost("/api/customer/orders", async ( SubmitCustomerOrderRequest request, HttpContext context, diff --git a/tests/Customer.Orders.Bff.Application.UnitTests/Customer.Orders.Bff.Application.UnitTests.csproj b/tests/Customer.Orders.Bff.Application.UnitTests/Customer.Orders.Bff.Application.UnitTests.csproj new file mode 100644 index 0000000..70d0375 --- /dev/null +++ b/tests/Customer.Orders.Bff.Application.UnitTests/Customer.Orders.Bff.Application.UnitTests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/tests/Customer.Orders.Bff.Application.UnitTests/OperationsCustomerOrdersServiceClientTests.cs b/tests/Customer.Orders.Bff.Application.UnitTests/OperationsCustomerOrdersServiceClientTests.cs new file mode 100644 index 0000000..7a1dd14 --- /dev/null +++ b/tests/Customer.Orders.Bff.Application.UnitTests/OperationsCustomerOrdersServiceClientTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Text; +using Customer.Orders.Bff.Application.Adapters; +using Customer.Orders.Bff.Contracts.Requests; + +namespace Customer.Orders.Bff.Application.UnitTests; + +public sealed class OperationsCustomerOrdersServiceClientTests +{ + [Fact] + public async Task FetchStatusAsync_MapsOrdersAndEvents() + { + var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); + + var response = await adapter.FetchStatusAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None); + + Assert.Equal("demo-context", response.ContextId); + Assert.NotEmpty(response.Orders); + Assert.NotEmpty(response.RecentEvents); + } + + [Fact] + public async Task FetchDetailAsync_ReturnsMatchingOrderWhenPresent() + { + var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); + + var response = await adapter.FetchDetailAsync(new GetCustomerOrderDetailRequest("demo-context", "CO-1001"), CancellationToken.None); + + Assert.NotNull(response.Order); + Assert.Equal("CO-1001", response.Order!.OrderId); + } + + [Fact] + public async Task FetchHistoryAsync_ReturnsProjectedHistorySnapshot() + { + var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); + + var response = await adapter.FetchHistoryAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None); + + Assert.Equal(2, response.Orders.Count); + Assert.Equal(2, response.RecentEvents.Count); + } + + [Fact] + public async Task SubmitOrderAsync_MapsSubmitPayloadUsingItemCount() + { + var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(""" + { + "contextId": "demo-context", + "orderId": "CO-1009", + "accepted": true, + "summary": "Order CO-1009 for table T-18 was accepted with 3 items.", + "status": "queued", + "submittedAtUtc": "2026-03-31T12:30:00Z" + } + """)); + + var response = await adapter.SubmitOrderAsync( + new SubmitCustomerOrderRequest("demo-context", "CO-1009", "T-18", 4, ["ITEM-101", "ITEM-202", "ITEM-303"]), + CancellationToken.None); + + Assert.True(response.Accepted); + Assert.Equal("queued", response.Status); + } + + private static HttpClient CreateClient(string json) + { + return new HttpClient(new StubHttpMessageHandler(json)) + { + BaseAddress = new Uri("http://operations-service:8080/") + }; + } + + private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + } + + private const string StatusPayload = """ + { + "contextId": "demo-context", + "summary": "2 recent customer orders are visible for the active context.", + "orders": [ + { + "orderId": "CO-1001", + "tableId": "T-08", + "status": "preparing", + "guestCount": 2, + "itemIds": [ "ITEM-101", "ITEM-202" ] + }, + { + "orderId": "CO-1002", + "tableId": "T-15", + "status": "ready", + "guestCount": 4, + "itemIds": [ "ITEM-301", "ITEM-404", "ITEM-405" ] + } + ], + "recentEvents": [ + "CO-1001 moved to preparing at kitchen hot-line station.", + "CO-1002 is ready for table pickup." + ] + } + """; +}