diff --git a/Waiter.Floor.Bff.slnx b/Waiter.Floor.Bff.slnx index f237148..28dea0c 100644 --- a/Waiter.Floor.Bff.slnx +++ b/Waiter.Floor.Bff.slnx @@ -4,4 +4,7 @@ + + + diff --git a/docs/api/waiter-floor-workflows.md b/docs/api/waiter-floor-workflows.md new file mode 100644 index 0000000..5a41857 --- /dev/null +++ b/docs/api/waiter-floor-workflows.md @@ -0,0 +1,29 @@ +# Waiter Floor Workflow API + +## Purpose + +This BFF exposes execution-facing waiter workflows over REST while delegating orchestration to `operations-service`. + +## Endpoints + +- `GET /api/waiter/floor/assignments?contextId=` + - Returns assignment summary, location context, current assignments, and recent activity. +- `GET /api/waiter/floor/activity?contextId=` + - Returns recent waiter activity projected from the same operations workflow snapshot. +- `POST /api/waiter/floor/orders` + - Submits a waiter order snapshot for processing. +- `PUT /api/waiter/floor/orders/{orderId}` + - Updates an existing waiter order snapshot using the same operations workflow contract. + +## Upstream Dependency + +- Base address configuration: `OperationsService:BaseAddress` +- Default runtime target: `http://operations-service:8080` +- Internal upstream routes: + - `GET /internal/operations/waiter/assignments` + - `POST /internal/operations/orders` + +## Notes + +- The update route currently reuses the operations order submission contract so waiter-floor can expose update semantics without introducing a new cross-repo dependency. +- Correlation IDs are preserved through Thalos session checks and operations-service calls. diff --git a/docs/architecture/ownership-boundary.md b/docs/architecture/ownership-boundary.md index c092119..2534945 100644 --- a/docs/architecture/ownership-boundary.md +++ b/docs/architecture/ownership-boundary.md @@ -1,6 +1,6 @@ # Waiter Floor Ownership Boundary - Execution-facing BFF for floor staff workflows. -- Owns edge contracts for assignment views and order-taking orchestration. +- Owns edge contracts for assignment views, recent floor activity, and order submit or update orchestration. - Does not own policy/config control-plane concerns. - Consumes service APIs only; no direct DAL access. diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index c23fd74..0ab211f 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -10,6 +10,8 @@ waiter-floor-bff ## Domain-Specific Candidate Features - Order lifecycle consistency and state transitions. +- Waiter assignment visibility with recent floor activity context. +- Waiter order update workflows that stay aligned with service-level restaurant order orchestration. - 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 9eb66f4..714232b 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name waiter-floor-bff agilewebs/waiter-floor-bff: ## Runtime Notes -- Exposes REST edge endpoints for waiter assignment and order submission flows. +- Exposes REST edge endpoints for waiter assignment, recent activity, and order submit or update 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 waiter-floor-bff agilewebs/waiter-floor-bff: - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` ## Known Limitations -- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. +- Waiter-floor 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 3e2463b..13927af 100644 --- a/docs/security/auth-enforcement.md +++ b/docs/security/auth-enforcement.md @@ -7,7 +7,9 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio ## Protected Endpoints - `/api/waiter/floor/assignments` +- `/api/waiter/floor/activity` - `/api/waiter/floor/orders` +- `/api/waiter/floor/orders/{orderId}` ## Anonymous Endpoints diff --git a/src/Waiter.Floor.Bff.Application/Adapters/DefaultWaiterServiceClient.cs b/src/Waiter.Floor.Bff.Application/Adapters/DefaultWaiterServiceClient.cs deleted file mode 100644 index 82789b2..0000000 --- a/src/Waiter.Floor.Bff.Application/Adapters/DefaultWaiterServiceClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Waiter.Floor.Bff.Contracts.Requests; -using Waiter.Floor.Bff.Contracts.Responses; - -namespace Waiter.Floor.Bff.Application.Adapters; - -public sealed class DefaultWaiterServiceClient : IWaiterServiceClient -{ - public Task FetchAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new GetWaiterAssignmentsResponse(request.ContextId, "Default service-backed response.")); - } - - public Task SubmitOrderAsync(SubmitFloorOrderRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new SubmitFloorOrderResponse(request.OrderId, true, "Order accepted by waiter-floor default adapter.")); - } -} diff --git a/src/Waiter.Floor.Bff.Application/Adapters/IWaiterServiceClient.cs b/src/Waiter.Floor.Bff.Application/Adapters/IWaiterServiceClient.cs index c768397..6dbdccf 100644 --- a/src/Waiter.Floor.Bff.Application/Adapters/IWaiterServiceClient.cs +++ b/src/Waiter.Floor.Bff.Application/Adapters/IWaiterServiceClient.cs @@ -5,6 +5,8 @@ namespace Waiter.Floor.Bff.Application.Adapters; public interface IWaiterServiceClient { - Task FetchAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken); + Task FetchAssignmentsAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken); + Task FetchRecentActivityAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken); Task SubmitOrderAsync(SubmitFloorOrderRequest request, CancellationToken cancellationToken); + Task UpdateOrderAsync(UpdateFloorOrderRequest request, CancellationToken cancellationToken); } diff --git a/src/Waiter.Floor.Bff.Application/Adapters/OperationsWaiterServiceClient.cs b/src/Waiter.Floor.Bff.Application/Adapters/OperationsWaiterServiceClient.cs new file mode 100644 index 0000000..76d47c0 --- /dev/null +++ b/src/Waiter.Floor.Bff.Application/Adapters/OperationsWaiterServiceClient.cs @@ -0,0 +1,120 @@ +using System.Net.Http.Json; +using Waiter.Floor.Bff.Contracts.Contracts; +using Waiter.Floor.Bff.Contracts.Requests; +using Waiter.Floor.Bff.Contracts.Responses; + +namespace Waiter.Floor.Bff.Application.Adapters; + +public sealed class OperationsWaiterServiceClient(HttpClient httpClient) : IWaiterServiceClient +{ + public async Task FetchAssignmentsAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken) + { + var payload = await GetAssignmentsPayloadAsync(request.ContextId, cancellationToken); + + return new GetWaiterAssignmentsResponse( + payload.ContextId, + payload.LocationId, + payload.Summary, + payload.Assignments + .Select(static assignment => new WaiterAssignmentContract( + assignment.WaiterId, + assignment.TableId, + assignment.Status, + assignment.ActiveOrders)) + .ToArray(), + payload.RecentActivity); + } + + public async Task FetchRecentActivityAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken) + { + var payload = await GetAssignmentsPayloadAsync(request.ContextId, cancellationToken); + + return new GetWaiterRecentActivityResponse( + payload.ContextId, + payload.LocationId, + payload.Summary, + payload.RecentActivity); + } + + public async Task SubmitOrderAsync(SubmitFloorOrderRequest request, CancellationToken cancellationToken) + { + var payload = await SubmitOrderPayloadAsync( + new SubmitRestaurantOrderPayload(request.ContextId, request.OrderId, request.TableId, request.ItemCount), + cancellationToken); + + return new SubmitFloorOrderResponse( + payload.ContextId, + payload.OrderId, + payload.Accepted, + payload.Summary, + payload.Status, + payload.SubmittedAtUtc); + } + + public async Task UpdateOrderAsync(UpdateFloorOrderRequest request, CancellationToken cancellationToken) + { + var payload = await SubmitOrderPayloadAsync( + new SubmitRestaurantOrderPayload(request.ContextId, request.OrderId, request.TableId, request.ItemCount), + cancellationToken); + + // operations-service currently accepts a full order snapshot for both submit and update semantics. + var summary = payload.Accepted + ? $"Updated order {payload.OrderId}. {payload.Summary}" + : payload.Summary; + + return new UpdateFloorOrderResponse( + payload.ContextId, + payload.OrderId, + payload.Accepted, + summary, + payload.Status, + payload.SubmittedAtUtc); + } + + private async Task GetAssignmentsPayloadAsync(string contextId, CancellationToken cancellationToken) + { + var payload = await httpClient.GetFromJsonAsync( + $"internal/operations/waiter/assignments?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return payload ?? throw new InvalidOperationException("Operations service returned an empty waiter assignments 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 order workflow payload."); + } + + private sealed record GetWaiterAssignmentsPayload( + string ContextId, + string LocationId, + string Summary, + IReadOnlyCollection Assignments, + IReadOnlyCollection RecentActivity); + + private sealed record WaiterAssignmentPayload( + string WaiterId, + string TableId, + string Status, + int ActiveOrders); + + 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/Waiter.Floor.Bff.Application/Handlers/GetWaiterAssignmentsHandler.cs b/src/Waiter.Floor.Bff.Application/Handlers/GetWaiterAssignmentsHandler.cs index 234ba91..31f5f8d 100644 --- a/src/Waiter.Floor.Bff.Application/Handlers/GetWaiterAssignmentsHandler.cs +++ b/src/Waiter.Floor.Bff.Application/Handlers/GetWaiterAssignmentsHandler.cs @@ -8,6 +8,6 @@ public sealed class GetWaiterAssignmentsHandler(IWaiterServiceClient serviceClie { public Task HandleAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken) { - return serviceClient.FetchAsync(request, cancellationToken); + return serviceClient.FetchAssignmentsAsync(request, cancellationToken); } } diff --git a/src/Waiter.Floor.Bff.Application/Handlers/GetWaiterRecentActivityHandler.cs b/src/Waiter.Floor.Bff.Application/Handlers/GetWaiterRecentActivityHandler.cs new file mode 100644 index 0000000..db1a852 --- /dev/null +++ b/src/Waiter.Floor.Bff.Application/Handlers/GetWaiterRecentActivityHandler.cs @@ -0,0 +1,13 @@ +using Waiter.Floor.Bff.Application.Adapters; +using Waiter.Floor.Bff.Contracts.Requests; +using Waiter.Floor.Bff.Contracts.Responses; + +namespace Waiter.Floor.Bff.Application.Handlers; + +public sealed class GetWaiterRecentActivityHandler(IWaiterServiceClient serviceClient) : IGetWaiterRecentActivityHandler +{ + public Task HandleAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken) + { + return serviceClient.FetchRecentActivityAsync(request, cancellationToken); + } +} diff --git a/src/Waiter.Floor.Bff.Application/Handlers/IGetWaiterRecentActivityHandler.cs b/src/Waiter.Floor.Bff.Application/Handlers/IGetWaiterRecentActivityHandler.cs new file mode 100644 index 0000000..a2b7658 --- /dev/null +++ b/src/Waiter.Floor.Bff.Application/Handlers/IGetWaiterRecentActivityHandler.cs @@ -0,0 +1,9 @@ +using Waiter.Floor.Bff.Contracts.Requests; +using Waiter.Floor.Bff.Contracts.Responses; + +namespace Waiter.Floor.Bff.Application.Handlers; + +public interface IGetWaiterRecentActivityHandler +{ + Task HandleAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken); +} diff --git a/src/Waiter.Floor.Bff.Application/Handlers/IUpdateFloorOrderHandler.cs b/src/Waiter.Floor.Bff.Application/Handlers/IUpdateFloorOrderHandler.cs new file mode 100644 index 0000000..6416639 --- /dev/null +++ b/src/Waiter.Floor.Bff.Application/Handlers/IUpdateFloorOrderHandler.cs @@ -0,0 +1,9 @@ +using Waiter.Floor.Bff.Contracts.Requests; +using Waiter.Floor.Bff.Contracts.Responses; + +namespace Waiter.Floor.Bff.Application.Handlers; + +public interface IUpdateFloorOrderHandler +{ + Task HandleAsync(UpdateFloorOrderRequest request, CancellationToken cancellationToken); +} diff --git a/src/Waiter.Floor.Bff.Application/Handlers/UpdateFloorOrderHandler.cs b/src/Waiter.Floor.Bff.Application/Handlers/UpdateFloorOrderHandler.cs new file mode 100644 index 0000000..e2ad88c --- /dev/null +++ b/src/Waiter.Floor.Bff.Application/Handlers/UpdateFloorOrderHandler.cs @@ -0,0 +1,13 @@ +using Waiter.Floor.Bff.Application.Adapters; +using Waiter.Floor.Bff.Contracts.Requests; +using Waiter.Floor.Bff.Contracts.Responses; + +namespace Waiter.Floor.Bff.Application.Handlers; + +public sealed class UpdateFloorOrderHandler(IWaiterServiceClient serviceClient) : IUpdateFloorOrderHandler +{ + public Task HandleAsync(UpdateFloorOrderRequest request, CancellationToken cancellationToken) + { + return serviceClient.UpdateOrderAsync(request, cancellationToken); + } +} diff --git a/src/Waiter.Floor.Bff.Contracts/Contracts/WaiterAssignmentContract.cs b/src/Waiter.Floor.Bff.Contracts/Contracts/WaiterAssignmentContract.cs new file mode 100644 index 0000000..57cc653 --- /dev/null +++ b/src/Waiter.Floor.Bff.Contracts/Contracts/WaiterAssignmentContract.cs @@ -0,0 +1,7 @@ +namespace Waiter.Floor.Bff.Contracts.Contracts; + +public sealed record WaiterAssignmentContract( + string WaiterId, + string TableId, + string Status, + int ActiveOrders); diff --git a/src/Waiter.Floor.Bff.Contracts/Requests/UpdateFloorOrderRequest.cs b/src/Waiter.Floor.Bff.Contracts/Requests/UpdateFloorOrderRequest.cs new file mode 100644 index 0000000..be6d57f --- /dev/null +++ b/src/Waiter.Floor.Bff.Contracts/Requests/UpdateFloorOrderRequest.cs @@ -0,0 +1,7 @@ +namespace Waiter.Floor.Bff.Contracts.Requests; + +public sealed record UpdateFloorOrderRequest( + string ContextId, + string TableId, + string OrderId, + int ItemCount); diff --git a/src/Waiter.Floor.Bff.Contracts/Responses/GetWaiterAssignmentsResponse.cs b/src/Waiter.Floor.Bff.Contracts/Responses/GetWaiterAssignmentsResponse.cs index c241257..05edbac 100644 --- a/src/Waiter.Floor.Bff.Contracts/Responses/GetWaiterAssignmentsResponse.cs +++ b/src/Waiter.Floor.Bff.Contracts/Responses/GetWaiterAssignmentsResponse.cs @@ -1,3 +1,10 @@ +using Waiter.Floor.Bff.Contracts.Contracts; + namespace Waiter.Floor.Bff.Contracts.Responses; -public sealed record GetWaiterAssignmentsResponse(string ContextId, string Summary); +public sealed record GetWaiterAssignmentsResponse( + string ContextId, + string LocationId, + string Summary, + IReadOnlyCollection Assignments, + IReadOnlyCollection RecentActivity); diff --git a/src/Waiter.Floor.Bff.Contracts/Responses/GetWaiterRecentActivityResponse.cs b/src/Waiter.Floor.Bff.Contracts/Responses/GetWaiterRecentActivityResponse.cs new file mode 100644 index 0000000..f45831d --- /dev/null +++ b/src/Waiter.Floor.Bff.Contracts/Responses/GetWaiterRecentActivityResponse.cs @@ -0,0 +1,7 @@ +namespace Waiter.Floor.Bff.Contracts.Responses; + +public sealed record GetWaiterRecentActivityResponse( + string ContextId, + string LocationId, + string Summary, + IReadOnlyCollection RecentActivity); diff --git a/src/Waiter.Floor.Bff.Contracts/Responses/SubmitFloorOrderResponse.cs b/src/Waiter.Floor.Bff.Contracts/Responses/SubmitFloorOrderResponse.cs index fc3c9e2..d361646 100644 --- a/src/Waiter.Floor.Bff.Contracts/Responses/SubmitFloorOrderResponse.cs +++ b/src/Waiter.Floor.Bff.Contracts/Responses/SubmitFloorOrderResponse.cs @@ -1,6 +1,9 @@ namespace Waiter.Floor.Bff.Contracts.Responses; public sealed record SubmitFloorOrderResponse( + string ContextId, string OrderId, bool Accepted, - string Message); + string Summary, + string Status, + DateTime ProcessedAtUtc); diff --git a/src/Waiter.Floor.Bff.Contracts/Responses/UpdateFloorOrderResponse.cs b/src/Waiter.Floor.Bff.Contracts/Responses/UpdateFloorOrderResponse.cs new file mode 100644 index 0000000..c1ba664 --- /dev/null +++ b/src/Waiter.Floor.Bff.Contracts/Responses/UpdateFloorOrderResponse.cs @@ -0,0 +1,9 @@ +namespace Waiter.Floor.Bff.Contracts.Responses; + +public sealed record UpdateFloorOrderResponse( + string ContextId, + string OrderId, + bool Accepted, + string Summary, + string Status, + DateTime ProcessedAtUtc); diff --git a/src/Waiter.Floor.Bff.Rest/Program.cs b/src/Waiter.Floor.Bff.Rest/Program.cs index aaae0f1..ef40474 100644 --- a/src/Waiter.Floor.Bff.Rest/Program.cs +++ b/src/Waiter.Floor.Bff.Rest/Program.cs @@ -10,9 +10,16 @@ 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"); var app = builder.Build(); @@ -44,6 +51,24 @@ app.MapGet("/api/waiter/floor/assignments", async ( return Results.Ok(await handler.HandleAsync(request, ct)); }); +app.MapGet("/api/waiter/floor/activity", async ( + string contextId, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IGetWaiterRecentActivityHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + var request = new GetWaiterAssignmentsRequest(contextId); + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + app.MapPost("/api/waiter/floor/orders", async ( SubmitFloorOrderRequest request, HttpContext context, @@ -61,6 +86,25 @@ app.MapPost("/api/waiter/floor/orders", async ( return Results.Ok(await handler.HandleAsync(request, ct)); }); +app.MapPut("/api/waiter/floor/orders/{orderId}", async ( + string orderId, + UpdateFloorOrderBody requestBody, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IUpdateFloorOrderHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + var request = new UpdateFloorOrderRequest(requestBody.ContextId, requestBody.TableId, orderId, requestBody.ItemCount); + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "waiter-floor-bff" })); app.MapGet("/healthz", () => Results.Ok(new { status = "ok", service = "waiter-floor-bff" })); @@ -184,3 +228,4 @@ static IResult ErrorResponse(int statusCode, string code, string message, string } sealed record AuthErrorResponse(string Code, string Message, string CorrelationId); +sealed record UpdateFloorOrderBody(string ContextId, string TableId, int ItemCount); diff --git a/tests/Waiter.Floor.Bff.Application.UnitTests/OperationsWaiterServiceClientTests.cs b/tests/Waiter.Floor.Bff.Application.UnitTests/OperationsWaiterServiceClientTests.cs new file mode 100644 index 0000000..8e7d977 --- /dev/null +++ b/tests/Waiter.Floor.Bff.Application.UnitTests/OperationsWaiterServiceClientTests.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using Waiter.Floor.Bff.Application.Adapters; +using Waiter.Floor.Bff.Contracts.Requests; + +namespace Waiter.Floor.Bff.Application.UnitTests; + +public sealed class OperationsWaiterServiceClientTests +{ + [Fact] + public async Task FetchAssignmentsAsync_MapsAssignmentsAndRecentActivity() + { + var client = CreateClient(""" + { + "contextId": "demo-context", + "locationId": "restaurant-demo", + "summary": "2 active waiter assignments are currently visible.", + "assignments": [ + { "waiterId": "waiter-01", "tableId": "T-12", "status": "serving", "activeOrders": 2 } + ], + "recentActivity": [ + "demo-context: table T-12 requested dessert menus" + ] + } + """); + var adapter = new OperationsWaiterServiceClient(client); + + var response = await adapter.FetchAssignmentsAsync(new GetWaiterAssignmentsRequest("demo-context"), CancellationToken.None); + + Assert.Equal("restaurant-demo", response.LocationId); + Assert.Single(response.Assignments); + Assert.Single(response.RecentActivity); + Assert.Equal("waiter-01", response.Assignments.Single().WaiterId); + } + + [Fact] + public async Task FetchRecentActivityAsync_ProjectsActivityOnlyResponse() + { + var client = CreateClient(""" + { + "contextId": "demo-context", + "locationId": "restaurant-demo", + "summary": "2 active waiter assignments are currently visible.", + "assignments": [], + "recentActivity": [ + "demo-context: table T-08 is waiting for payment capture" + ] + } + """); + var adapter = new OperationsWaiterServiceClient(client); + + var response = await adapter.FetchRecentActivityAsync(new GetWaiterAssignmentsRequest("demo-context"), CancellationToken.None); + + Assert.Equal("demo-context", response.ContextId); + Assert.Single(response.RecentActivity); + } + + [Fact] + public async Task SubmitOrderAsync_MapsOperationsPayloadToSubmitResponse() + { + var client = CreateClient(""" + { + "contextId": "demo-context", + "orderId": "ORD-42", + "accepted": true, + "summary": "Order ORD-42 for table T-12 was accepted with 3 items.", + "status": "queued", + "submittedAtUtc": "2026-03-31T10:15:00Z" + } + """); + var adapter = new OperationsWaiterServiceClient(client); + + var response = await adapter.SubmitOrderAsync(new SubmitFloorOrderRequest("demo-context", "T-12", "ORD-42", 3), CancellationToken.None); + + Assert.True(response.Accepted); + Assert.Equal("queued", response.Status); + Assert.Equal("demo-context", response.ContextId); + } + + [Fact] + public async Task UpdateOrderAsync_PrefixesAcceptedUpdateSummary() + { + var client = CreateClient(""" + { + "contextId": "demo-context", + "orderId": "ORD-42", + "accepted": true, + "summary": "Order ORD-42 for table T-12 was accepted with 4 items.", + "status": "queued", + "submittedAtUtc": "2026-03-31T10:15:00Z" + } + """); + var adapter = new OperationsWaiterServiceClient(client); + + var response = await adapter.UpdateOrderAsync(new UpdateFloorOrderRequest("demo-context", "T-12", "ORD-42", 4), CancellationToken.None); + + Assert.Contains("Updated order ORD-42.", response.Summary); + 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") + }); + } + } +} diff --git a/tests/Waiter.Floor.Bff.Application.UnitTests/Waiter.Floor.Bff.Application.UnitTests.csproj b/tests/Waiter.Floor.Bff.Application.UnitTests/Waiter.Floor.Bff.Application.UnitTests.csproj new file mode 100644 index 0000000..2812800 --- /dev/null +++ b/tests/Waiter.Floor.Bff.Application.UnitTests/Waiter.Floor.Bff.Application.UnitTests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + +