Compare commits

...

4 Commits

Author SHA1 Message Date
José René White Enciso
1e5e082061 feat(customer-orders-bff): align lifecycle detail routes 2026-03-31 19:59:32 -06:00
José René White Enciso
59554a217f docs(customer-orders-bff): align customer workflows to shared lifecycle 2026-03-31 18:51:36 -06:00
José René White Enciso
40933edb84 feat(customer-orders-bff): add customer order workflows 2026-03-31 16:30:26 -06:00
José René White Enciso
9d3bcc6e60 merge: integrate customer-orders-bff auth and web updates 2026-03-11 12:38:57 -06:00
22 changed files with 526 additions and 26 deletions

View File

@ -4,4 +4,7 @@
<Project Path="src/Customer.Orders.Bff.Contracts/Customer.Orders.Bff.Contracts.csproj" /> <Project Path="src/Customer.Orders.Bff.Contracts/Customer.Orders.Bff.Contracts.csproj" />
<Project Path="src/Customer.Orders.Bff.Rest/Customer.Orders.Bff.Rest.csproj" /> <Project Path="src/Customer.Orders.Bff.Rest/Customer.Orders.Bff.Rest.csproj" />
</Folder> </Folder>
<Folder Name="/tests/">
<Project Path="tests/Customer.Orders.Bff.Application.UnitTests/Customer.Orders.Bff.Application.UnitTests.csproj" />
</Folder>
</Solution> </Solution>

View File

@ -0,0 +1,33 @@
# 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=<value>`
- Returns the current customer order status snapshot.
- `GET /api/customer/orders/history?contextId=<value>`
- Returns the recent customer order history snapshot.
- `GET /api/customer/orders/{orderId}?contextId=<value>`
- 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`
- `GET /internal/operations/customer/orders/{orderId}`
- `GET /internal/operations/customer/history`
- `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.
- Successful submission writes into the same shared restaurant lifecycle later observed by waiter-floor, kitchen-ops, and POS projections.
- Customer detail and history now come from dedicated lifecycle-backed upstream routes instead of projecting everything from the status snapshot.
- Correlation IDs are preserved through Thalos session checks and operations-service calls.

View File

@ -9,10 +9,10 @@ customer-orders-bff
- Epic 3: Improve observability and operational readiness for demo compose environments. - Epic 3: Improve observability and operational readiness for demo compose environments.
## Domain-Specific Candidate Features ## Domain-Specific Candidate Features
- Order lifecycle consistency and state transitions. - Customer-facing projection over the shared restaurant lifecycle.
- Kitchen queue and dispatch optimization hooks. - Customer order detail and history views that stay aligned with waiter, kitchen, and POS state changes.
- Cross-app order continuity from customer submission to kitchen preparation and POS payment.
- Operations control-plane policies (flags, service windows, overrides). - Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment.
## Documentation Contract ## Documentation Contract
Any code change in this repository must include docs updates in the same branch. Any code change in this repository must include docs updates in the same branch.

View File

@ -23,6 +23,7 @@ docker run --rm -p 8080:8080 --name customer-orders-bff agilewebs/customer-order
## Runtime Notes ## Runtime Notes
- Exposes REST edge endpoints for customer order lifecycle flows. - 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. - Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint.
- Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation. - Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation.
@ -38,5 +39,6 @@ docker run --rm -p 8080:8080 --name customer-orders-bff agilewebs/customer-order
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations ## Known Limitations
- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. - Customer-orders now delegates workflow snapshots to `operations-service`, which projects persisted shared lifecycle state from `operations-dal`.
- Kitchen and POS visibility still depend on the remaining Stage 46-48 restaurant flow tasks being wired end-to-end.
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity. - Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.

View File

@ -7,6 +7,8 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio
## Protected Endpoints ## Protected Endpoints
- `/api/customer/orders/status` - `/api/customer/orders/status`
- `/api/customer/orders/history`
- `/api/customer/orders/{orderId}`
- `/api/customer/orders` - `/api/customer/orders`
## Anonymous Endpoints ## Anonymous Endpoints

View File

@ -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<GetCustomerOrderStatusResponse> FetchAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new GetCustomerOrderStatusResponse(request.ContextId, "Default service-backed response."));
}
public Task<SubmitCustomerOrderResponse> SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new SubmitCustomerOrderResponse(request.ContextId, request.OrderId, true, "Order accepted by default adapter."));
}
}

View File

@ -5,6 +5,8 @@ namespace Customer.Orders.Bff.Application.Adapters;
public interface ICustomerOrdersServiceClient public interface ICustomerOrdersServiceClient
{ {
Task<GetCustomerOrderStatusResponse> FetchAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken); Task<GetCustomerOrderStatusResponse> FetchStatusAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken);
Task<GetCustomerOrderDetailResponse> FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken);
Task<GetCustomerOrderHistoryResponse> FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken);
Task<SubmitCustomerOrderResponse> SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken); Task<SubmitCustomerOrderResponse> SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken);
} }

View File

@ -0,0 +1,155 @@
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<GetCustomerOrderStatusResponse> 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<GetCustomerOrderDetailResponse> FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken)
{
var payload = await GetDetailPayloadAsync(request.ContextId, request.OrderId, cancellationToken);
return new GetCustomerOrderDetailResponse(
payload.ContextId,
payload.Summary,
payload.Order is null ? null : MapOrder(payload.Order),
payload.RecentEvents);
}
public async Task<GetCustomerOrderHistoryResponse> FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken)
{
var payload = await GetHistoryPayloadAsync(request.ContextId, cancellationToken);
return new GetCustomerOrderHistoryResponse(
payload.ContextId,
payload.Summary,
MapOrders(payload.Orders),
payload.RecentEvents);
}
public async Task<SubmitCustomerOrderResponse> SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken)
{
// The customer BFF writes into the same shared restaurant lifecycle used by waiter, kitchen, and POS.
// The upstream contract still accepts aggregate counts, so the edge contract projects item IDs into count.
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<GetCustomerOrderStatusPayload> GetStatusPayloadAsync(string contextId, CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetCustomerOrderStatusPayload>(
$"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<GetCustomerOrderDetailPayload> GetDetailPayloadAsync(
string contextId,
string orderId,
CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetCustomerOrderDetailPayload>(
$"internal/operations/customer/orders/{Uri.EscapeDataString(orderId)}?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order detail payload.");
}
private async Task<GetCustomerOrderHistoryPayload> GetHistoryPayloadAsync(string contextId, CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetCustomerOrderHistoryPayload>(
$"internal/operations/customer/history?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order history payload.");
}
private async Task<SubmitRestaurantOrderResponsePayload> SubmitOrderPayloadAsync(
SubmitRestaurantOrderPayload request,
CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync("internal/operations/orders", request, cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SubmitRestaurantOrderResponsePayload>(cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order workflow payload.");
}
private static IReadOnlyCollection<CustomerOrderSummaryContract> MapOrders(IReadOnlyCollection<CustomerOrderPayload> orders)
{
return orders
.Select(MapOrder)
.ToArray();
}
private static CustomerOrderSummaryContract MapOrder(CustomerOrderPayload order)
{
return new CustomerOrderSummaryContract(
order.OrderId,
order.TableId,
order.Status,
order.GuestCount,
order.ItemIds);
}
private sealed record GetCustomerOrderStatusPayload(
string ContextId,
string Summary,
IReadOnlyCollection<CustomerOrderPayload> Orders,
IReadOnlyCollection<string> RecentEvents);
private sealed record CustomerOrderPayload(
string OrderId,
string TableId,
string Status,
int GuestCount,
IReadOnlyCollection<string> ItemIds);
private sealed record GetCustomerOrderDetailPayload(
string ContextId,
string Summary,
CustomerOrderPayload? Order,
IReadOnlyCollection<string> RecentEvents);
private sealed record GetCustomerOrderHistoryPayload(
string ContextId,
string Summary,
IReadOnlyCollection<CustomerOrderPayload> Orders,
IReadOnlyCollection<string> RecentEvents);
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);
}

View File

@ -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<GetCustomerOrderDetailResponse> HandleAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken)
{
return serviceClient.FetchDetailAsync(request, cancellationToken);
}
}

View File

@ -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<GetCustomerOrderHistoryResponse> HandleAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken)
{
return serviceClient.FetchHistoryAsync(request, cancellationToken);
}
}

View File

@ -8,6 +8,6 @@ public sealed class GetCustomerOrderStatusHandler(ICustomerOrdersServiceClient s
{ {
public Task<GetCustomerOrderStatusResponse> HandleAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) public Task<GetCustomerOrderStatusResponse> HandleAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken)
{ {
return serviceClient.FetchAsync(request, cancellationToken); return serviceClient.FetchStatusAsync(request, cancellationToken);
} }
} }

View File

@ -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<GetCustomerOrderDetailResponse> HandleAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken);
}

View File

@ -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<GetCustomerOrderHistoryResponse> HandleAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,8 @@
namespace Customer.Orders.Bff.Contracts.Contracts;
public sealed record CustomerOrderSummaryContract(
string OrderId,
string TableId,
string Status,
int GuestCount,
IReadOnlyCollection<string> ItemIds);

View File

@ -0,0 +1,3 @@
namespace Customer.Orders.Bff.Contracts.Requests;
public sealed record GetCustomerOrderDetailRequest(string ContextId, string OrderId);

View File

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

View File

@ -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<CustomerOrderSummaryContract> Orders,
IReadOnlyCollection<string> RecentEvents);

View File

@ -1,3 +1,9 @@
using Customer.Orders.Bff.Contracts.Contracts;
namespace Customer.Orders.Bff.Contracts.Responses; namespace Customer.Orders.Bff.Contracts.Responses;
public sealed record GetCustomerOrderStatusResponse(string ContextId, string Summary); public sealed record GetCustomerOrderStatusResponse(
string ContextId,
string Summary,
IReadOnlyCollection<CustomerOrderSummaryContract> Orders,
IReadOnlyCollection<string> RecentEvents);

View File

@ -4,4 +4,5 @@ public sealed record SubmitCustomerOrderResponse(
string ContextId, string ContextId,
string OrderId, string OrderId,
bool Accepted, bool Accepted,
string Summary); string Summary,
string Status);

View File

@ -10,8 +10,15 @@ const string SessionAccessCookieName = "thalos_session";
const string SessionRefreshCookieName = "thalos_refresh"; const string SessionRefreshCookieName = "thalos_refresh";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ICustomerOrdersServiceClient, DefaultCustomerOrdersServiceClient>(); builder.Services.AddHttpClient<ICustomerOrdersServiceClient, OperationsCustomerOrdersServiceClient>((serviceProvider, httpClient) =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var operationsBaseAddress = configuration["OperationsService:BaseAddress"] ?? "http://operations-service:8080";
httpClient.BaseAddress = new Uri($"{operationsBaseAddress.TrimEnd('/')}/");
});
builder.Services.AddSingleton<IGetCustomerOrderStatusHandler, GetCustomerOrderStatusHandler>(); builder.Services.AddSingleton<IGetCustomerOrderStatusHandler, GetCustomerOrderStatusHandler>();
builder.Services.AddSingleton<IGetCustomerOrderDetailHandler, GetCustomerOrderDetailHandler>();
builder.Services.AddSingleton<IGetCustomerOrderHistoryHandler, GetCustomerOrderHistoryHandler>();
builder.Services.AddSingleton<ISubmitCustomerOrderHandler, SubmitCustomerOrderHandler>(); builder.Services.AddSingleton<ISubmitCustomerOrderHandler, SubmitCustomerOrderHandler>();
builder.Services.AddHttpClient("ThalosAuth"); builder.Services.AddHttpClient("ThalosAuth");
@ -44,6 +51,43 @@ app.MapGet("/api/customer/orders/status", async (
return Results.Ok(await handler.HandleAsync(request, ct)); 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 ( app.MapPost("/api/customer/orders", async (
SubmitCustomerOrderRequest request, SubmitCustomerOrderRequest request,
HttpContext context, HttpContext context,

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/Customer.Orders.Bff.Application/Customer.Orders.Bff.Application.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,177 @@
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(new Dictionary<string, string>
{
["/internal/operations/customer/status"] = 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(new Dictionary<string, string>
{
["/internal/operations/customer/orders/ORD-1001"] = DetailPayload
}));
var response = await adapter.FetchDetailAsync(new GetCustomerOrderDetailRequest("demo-context", "ORD-1001"), CancellationToken.None);
Assert.NotNull(response.Order);
Assert.Equal("ORD-1001", response.Order!.OrderId);
Assert.Contains("preparing", response.Summary, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task FetchHistoryAsync_ReturnsLifecycleBackedHistorySnapshot()
{
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
{
["/internal/operations/customer/history"] = HistoryPayload
}));
var response = await adapter.FetchHistoryAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None);
Assert.Equal(2, response.Orders.Count);
Assert.Equal(2, response.RecentEvents.Count);
Assert.Contains("lifecycle-backed", response.Summary, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitOrderAsync_MapsSharedLifecycleAcceptanceUsingItemCount()
{
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
{
["/internal/operations/orders"] = """
{
"contextId": "demo-context",
"orderId": "ORD-1009",
"accepted": true,
"summary": "Order ORD-1009 was accepted and is ready for kitchen dispatch.",
"status": "accepted",
"submittedAtUtc": "2026-03-31T12:30:00Z"
}
"""
}));
var response = await adapter.SubmitOrderAsync(
new SubmitCustomerOrderRequest("demo-context", "ORD-1009", "T-18", 4, ["ITEM-101", "ITEM-202", "ITEM-303"]),
CancellationToken.None);
Assert.True(response.Accepted);
Assert.Equal("accepted", response.Status);
Assert.Contains("kitchen dispatch", response.Summary);
}
private static HttpClient CreateClient(IReadOnlyDictionary<string, string> routes)
{
return new HttpClient(new StubHttpMessageHandler(routes))
{
BaseAddress = new Uri("http://operations-service:8080/")
};
}
private sealed class StubHttpMessageHandler(IReadOnlyDictionary<string, string> routes) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (!routes.TryGetValue(path, out var json))
{
throw new InvalidOperationException($"No stub payload configured for '{path}'.");
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
}
private const string StatusPayload = """
{
"contextId": "demo-context",
"summary": "2 customer orders are currently tracked in the shared restaurant lifecycle.",
"orders": [
{
"orderId": "ORD-1001",
"tableId": "T-08",
"status": "preparing",
"guestCount": 2,
"itemIds": [ "ITEM-101", "ITEM-202" ]
},
{
"orderId": "ORD-1002",
"tableId": "T-15",
"status": "ready",
"guestCount": 4,
"itemIds": [ "ITEM-301", "ITEM-404", "ITEM-405" ]
}
],
"recentEvents": [
"Order ORD-1001 moved to preparing at the kitchen hot-line station.",
"Order ORD-1002 is ready for table pickup."
]
}
""";
private const string DetailPayload = """
{
"contextId": "demo-context",
"summary": "Order ORD-1001 is currently preparing in the shared restaurant lifecycle.",
"order": {
"orderId": "ORD-1001",
"tableId": "T-08",
"status": "preparing",
"guestCount": 2,
"itemIds": [ "ITEM-101", "ITEM-202" ]
},
"recentEvents": [
"Order ORD-1001 was accepted into the shared restaurant lifecycle.",
"Order ORD-1001 moved to preparing at the kitchen hot-line station."
]
}
""";
private const string HistoryPayload = """
{
"contextId": "demo-context",
"summary": "2 customer orders are currently available through lifecycle-backed history.",
"orders": [
{
"orderId": "ORD-1001",
"tableId": "T-08",
"status": "preparing",
"guestCount": 2,
"itemIds": [ "ITEM-101", "ITEM-202" ]
},
{
"orderId": "ORD-1002",
"tableId": "T-15",
"status": "ready",
"guestCount": 4,
"itemIds": [ "ITEM-301", "ITEM-404", "ITEM-405" ]
}
],
"recentEvents": [
"Order ORD-1001 moved to preparing at the kitchen hot-line station.",
"Order ORD-1002 is ready for table pickup."
]
}
""";
}