feat(customer-orders-bff): add customer order workflows
This commit is contained in:
parent
9d3bcc6e60
commit
40933edb84
@ -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>
|
||||||
|
|||||||
29
docs/api/customer-order-workflows.md
Normal file
29
docs/api/customer-order-workflows.md
Normal file
@ -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=<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`
|
||||||
|
- `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.
|
||||||
@ -10,6 +10,7 @@ customer-orders-bff
|
|||||||
|
|
||||||
## Domain-Specific Candidate Features
|
## Domain-Specific Candidate Features
|
||||||
- Order lifecycle consistency and state transitions.
|
- 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.
|
- Kitchen queue and dispatch optimization hooks.
|
||||||
- Operations control-plane policies (flags, service windows, overrides).
|
- Operations control-plane policies (flags, service windows, overrides).
|
||||||
- POS closeout and settlement summary alignment.
|
- POS closeout and settlement summary alignment.
|
||||||
|
|||||||
@ -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,5 @@ 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`, 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.
|
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<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 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<GetCustomerOrderHistoryResponse> 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<SubmitCustomerOrderResponse> 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<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<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(static order => new CustomerOrderSummaryContract(
|
||||||
|
order.OrderId,
|
||||||
|
order.TableId,
|
||||||
|
order.Status,
|
||||||
|
order.GuestCount,
|
||||||
|
order.ItemIds))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
namespace Customer.Orders.Bff.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record GetCustomerOrderDetailRequest(string ContextId, string OrderId);
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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<HttpResponseMessage> 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user