From 8029c42b482e271536b0f4dec39cd281b60efb32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Tue, 31 Mar 2026 16:37:06 -0600 Subject: [PATCH] feat(restaurant-admin-bff): add admin workflow endpoints --- Restaurant.Admin.Bff.slnx | 3 + docs/api/restaurant-admin-workflows.md | 27 ++++ docs/architecture/ownership-boundary.md | 2 +- docs/roadmap/feature-epics.md | 1 + docs/runbooks/containerization.md | 5 +- docs/security/auth-enforcement.md | 1 + .../DefaultRestaurantAdminServiceClient.cs | 17 --- .../Adapters/IRestaurantAdminServiceClient.cs | 3 +- .../OperationsRestaurantAdminServiceClient.cs | 128 ++++++++++++++++++ .../Handlers/GetRecentAdminChangesHandler.cs | 13 ++ .../GetRestaurantAdminConfigHandler.cs | 2 +- .../Handlers/IGetRecentAdminChangesHandler.cs | 9 ++ .../Contracts/ConfigChangeContract.cs | 8 ++ .../Contracts/FeatureFlagStateContract.cs | 3 + .../Contracts/ServiceWindowContract.cs | 7 + .../GetRecentAdminChangesResponse.cs | 9 ++ .../GetRestaurantAdminConfigResponse.cs | 10 +- .../Responses/SetServiceWindowResponse.cs | 5 +- src/Restaurant.Admin.Bff.Rest/Program.cs | 26 +++- ...ationsRestaurantAdminServiceClientTests.cs | 108 +++++++++++++++ ...ant.Admin.Bff.Application.UnitTests.csproj | 19 +++ 21 files changed, 381 insertions(+), 25 deletions(-) create mode 100644 docs/api/restaurant-admin-workflows.md delete mode 100644 src/Restaurant.Admin.Bff.Application/Adapters/DefaultRestaurantAdminServiceClient.cs create mode 100644 src/Restaurant.Admin.Bff.Application/Adapters/OperationsRestaurantAdminServiceClient.cs create mode 100644 src/Restaurant.Admin.Bff.Application/Handlers/GetRecentAdminChangesHandler.cs create mode 100644 src/Restaurant.Admin.Bff.Application/Handlers/IGetRecentAdminChangesHandler.cs create mode 100644 src/Restaurant.Admin.Bff.Contracts/Contracts/ConfigChangeContract.cs create mode 100644 src/Restaurant.Admin.Bff.Contracts/Contracts/FeatureFlagStateContract.cs create mode 100644 src/Restaurant.Admin.Bff.Contracts/Contracts/ServiceWindowContract.cs create mode 100644 src/Restaurant.Admin.Bff.Contracts/Responses/GetRecentAdminChangesResponse.cs create mode 100644 tests/Restaurant.Admin.Bff.Application.UnitTests/OperationsRestaurantAdminServiceClientTests.cs create mode 100644 tests/Restaurant.Admin.Bff.Application.UnitTests/Restaurant.Admin.Bff.Application.UnitTests.csproj diff --git a/Restaurant.Admin.Bff.slnx b/Restaurant.Admin.Bff.slnx index 0044d68..67a41cc 100644 --- a/Restaurant.Admin.Bff.slnx +++ b/Restaurant.Admin.Bff.slnx @@ -4,4 +4,7 @@ + + + diff --git a/docs/api/restaurant-admin-workflows.md b/docs/api/restaurant-admin-workflows.md new file mode 100644 index 0000000..aca5abf --- /dev/null +++ b/docs/api/restaurant-admin-workflows.md @@ -0,0 +1,27 @@ +# Restaurant Admin Workflow API + +## Purpose + +This BFF exposes restaurant-admin control-plane workflows over REST while delegating orchestration to `operations-service`. + +## Endpoints + +- `GET /api/restaurant/admin/config?contextId=` + - Returns configuration snapshot, feature flags, service windows, and recent changes. +- `GET /api/restaurant/admin/changes?contextId=` + - Returns recent configuration change history. +- `POST /api/restaurant/admin/service-window` + - Updates a service window and returns the applied snapshot. + +## Upstream Dependency + +- Base address configuration: `OperationsService:BaseAddress` +- Default runtime target: `http://operations-service:8080` +- Internal upstream routes: + - `GET /internal/operations/admin/config` + - `POST /internal/operations/admin/service-window` + +## Notes + +- The explicit change-history route is projected from the same admin config snapshot returned by `operations-service`. +- 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 358db0c..093a1e2 100644 --- a/docs/architecture/ownership-boundary.md +++ b/docs/architecture/ownership-boundary.md @@ -1,6 +1,6 @@ # Restaurant Admin Ownership Boundary - Control-plane-facing BFF for policy, schedule, and operational configuration. -- Owns admin edge contracts for feature flags, service windows, and overrides. +- Owns admin edge contracts for feature flags, service windows, overrides, and recent change visibility. - Does not own transactional order execution. - Consumes service APIs only; no direct DAL access. diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index 1f3ec0c..de86199 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -13,6 +13,7 @@ restaurant-admin-bff - Kitchen queue and dispatch optimization hooks. - Operations control-plane policies (flags, service windows, overrides). - POS closeout and settlement summary alignment. +- Admin change history and configuration snapshot workflows that stay aligned with operations-service. ## Documentation Contract Any code change in this repository must include docs updates in the same branch. diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index a83ae5a..f853461 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name restaurant-admin-bff agilewebs/restaurant-ad ## Runtime Notes -- Exposes REST control-plane endpoints for admin configuration updates. +- Exposes REST control-plane endpoints for admin configuration snapshots, recent change history, and service-window updates. +- 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 restaurant-admin-bff agilewebs/restaurant-ad - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` ## Known Limitations -- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. +- Restaurant-admin now delegates control-plane 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 be6edad..df25a20 100644 --- a/docs/security/auth-enforcement.md +++ b/docs/security/auth-enforcement.md @@ -7,6 +7,7 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio ## Protected Endpoints - `/api/restaurant/admin/config` +- `/api/restaurant/admin/changes` - `/api/restaurant/admin/service-window` ## Anonymous Endpoints diff --git a/src/Restaurant.Admin.Bff.Application/Adapters/DefaultRestaurantAdminServiceClient.cs b/src/Restaurant.Admin.Bff.Application/Adapters/DefaultRestaurantAdminServiceClient.cs deleted file mode 100644 index 954a424..0000000 --- a/src/Restaurant.Admin.Bff.Application/Adapters/DefaultRestaurantAdminServiceClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Restaurant.Admin.Bff.Contracts.Requests; -using Restaurant.Admin.Bff.Contracts.Responses; - -namespace Restaurant.Admin.Bff.Application.Adapters; - -public sealed class DefaultRestaurantAdminServiceClient : IRestaurantAdminServiceClient -{ - public Task FetchAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new GetRestaurantAdminConfigResponse(request.ContextId, "Default service-backed response.")); - } - - public Task SetServiceWindowAsync(SetServiceWindowRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new SetServiceWindowResponse(request.ContextId, true, "Service window updated by default adapter.")); - } -} diff --git a/src/Restaurant.Admin.Bff.Application/Adapters/IRestaurantAdminServiceClient.cs b/src/Restaurant.Admin.Bff.Application/Adapters/IRestaurantAdminServiceClient.cs index ec3f670..c85c73a 100644 --- a/src/Restaurant.Admin.Bff.Application/Adapters/IRestaurantAdminServiceClient.cs +++ b/src/Restaurant.Admin.Bff.Application/Adapters/IRestaurantAdminServiceClient.cs @@ -5,6 +5,7 @@ namespace Restaurant.Admin.Bff.Application.Adapters; public interface IRestaurantAdminServiceClient { - Task FetchAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken); + Task FetchConfigAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken); + Task FetchRecentChangesAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken); Task SetServiceWindowAsync(SetServiceWindowRequest request, CancellationToken cancellationToken); } diff --git a/src/Restaurant.Admin.Bff.Application/Adapters/OperationsRestaurantAdminServiceClient.cs b/src/Restaurant.Admin.Bff.Application/Adapters/OperationsRestaurantAdminServiceClient.cs new file mode 100644 index 0000000..866c491 --- /dev/null +++ b/src/Restaurant.Admin.Bff.Application/Adapters/OperationsRestaurantAdminServiceClient.cs @@ -0,0 +1,128 @@ +using System.Net.Http.Json; +using Restaurant.Admin.Bff.Contracts.Contracts; +using Restaurant.Admin.Bff.Contracts.Requests; +using Restaurant.Admin.Bff.Contracts.Responses; + +namespace Restaurant.Admin.Bff.Application.Adapters; + +public sealed class OperationsRestaurantAdminServiceClient(HttpClient httpClient) : IRestaurantAdminServiceClient +{ + public async Task FetchConfigAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken) + { + var payload = await GetConfigPayloadAsync(request.ContextId, cancellationToken); + + return new GetRestaurantAdminConfigResponse( + payload.ContextId, + payload.Summary, + payload.Version, + payload.FeatureFlags + .Select(static flag => new FeatureFlagStateContract(flag.Key, flag.Enabled)) + .ToArray(), + payload.ServiceWindows.Select(MapServiceWindow).ToArray(), + payload.RecentChanges.Select(MapConfigChange).ToArray()); + } + + public async Task FetchRecentChangesAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken) + { + var payload = await GetConfigPayloadAsync(request.ContextId, cancellationToken); + + return new GetRecentAdminChangesResponse( + payload.ContextId, + payload.Summary, + payload.Version, + payload.RecentChanges.Select(MapConfigChange).ToArray()); + } + + public async Task SetServiceWindowAsync(SetServiceWindowRequest request, CancellationToken cancellationToken) + { + var payload = await SetServiceWindowPayloadAsync( + new SetServiceWindowPayload( + request.ContextId, + (int)request.Day, + request.OpenAt.ToString("HH:mm:ss"), + request.CloseAt.ToString("HH:mm:ss"), + request.UpdatedBy), + cancellationToken); + + return new SetServiceWindowResponse( + payload.ContextId, + payload.Applied, + payload.Message, + MapServiceWindow(payload.ServiceWindow)); + } + + private async Task GetConfigPayloadAsync(string contextId, CancellationToken cancellationToken) + { + var payload = await httpClient.GetFromJsonAsync( + $"internal/operations/admin/config?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return payload ?? throw new InvalidOperationException("Operations service returned an empty restaurant admin config payload."); + } + + private async Task SetServiceWindowPayloadAsync( + SetServiceWindowPayload request, + CancellationToken cancellationToken) + { + using var response = await httpClient.PostAsJsonAsync("internal/operations/admin/service-window", request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + return payload ?? throw new InvalidOperationException("Operations service returned an empty service-window payload."); + } + + private static ServiceWindowContract MapServiceWindow(ServiceWindowPayload serviceWindow) + { + return new ServiceWindowContract( + (DayOfWeek)serviceWindow.DayOfWeek, + TimeOnly.Parse(serviceWindow.OpenAt), + TimeOnly.Parse(serviceWindow.CloseAt), + serviceWindow.IsClosed); + } + + private static ConfigChangeContract MapConfigChange(ConfigChangePayload change) + { + return new ConfigChangeContract( + change.ChangeId, + change.Category, + change.Description, + change.UpdatedBy, + change.UpdatedAtUtc); + } + + private sealed record GetRestaurantAdminConfigPayload( + string ContextId, + string Summary, + string Version, + IReadOnlyCollection FeatureFlags, + IReadOnlyCollection ServiceWindows, + IReadOnlyCollection RecentChanges); + + private sealed record FeatureFlagPayload(string Key, bool Enabled); + + private sealed record ServiceWindowPayload( + int DayOfWeek, + string OpenAt, + string CloseAt, + bool IsClosed); + + private sealed record ConfigChangePayload( + string ChangeId, + string Category, + string Description, + string UpdatedBy, + DateTime UpdatedAtUtc); + + private sealed record SetServiceWindowPayload( + string ContextId, + int DayOfWeek, + string OpenAt, + string CloseAt, + string UpdatedBy); + + private sealed record SetServiceWindowResponsePayload( + string ContextId, + bool Applied, + string Message, + ServiceWindowPayload ServiceWindow); +} diff --git a/src/Restaurant.Admin.Bff.Application/Handlers/GetRecentAdminChangesHandler.cs b/src/Restaurant.Admin.Bff.Application/Handlers/GetRecentAdminChangesHandler.cs new file mode 100644 index 0000000..695b5f7 --- /dev/null +++ b/src/Restaurant.Admin.Bff.Application/Handlers/GetRecentAdminChangesHandler.cs @@ -0,0 +1,13 @@ +using Restaurant.Admin.Bff.Application.Adapters; +using Restaurant.Admin.Bff.Contracts.Requests; +using Restaurant.Admin.Bff.Contracts.Responses; + +namespace Restaurant.Admin.Bff.Application.Handlers; + +public sealed class GetRecentAdminChangesHandler(IRestaurantAdminServiceClient serviceClient) : IGetRecentAdminChangesHandler +{ + public Task HandleAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken) + { + return serviceClient.FetchRecentChangesAsync(request, cancellationToken); + } +} diff --git a/src/Restaurant.Admin.Bff.Application/Handlers/GetRestaurantAdminConfigHandler.cs b/src/Restaurant.Admin.Bff.Application/Handlers/GetRestaurantAdminConfigHandler.cs index 2daffd8..9dc3f23 100644 --- a/src/Restaurant.Admin.Bff.Application/Handlers/GetRestaurantAdminConfigHandler.cs +++ b/src/Restaurant.Admin.Bff.Application/Handlers/GetRestaurantAdminConfigHandler.cs @@ -8,6 +8,6 @@ public sealed class GetRestaurantAdminConfigHandler(IRestaurantAdminServiceClien { public Task HandleAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken) { - return serviceClient.FetchAsync(request, cancellationToken); + return serviceClient.FetchConfigAsync(request, cancellationToken); } } diff --git a/src/Restaurant.Admin.Bff.Application/Handlers/IGetRecentAdminChangesHandler.cs b/src/Restaurant.Admin.Bff.Application/Handlers/IGetRecentAdminChangesHandler.cs new file mode 100644 index 0000000..32550a7 --- /dev/null +++ b/src/Restaurant.Admin.Bff.Application/Handlers/IGetRecentAdminChangesHandler.cs @@ -0,0 +1,9 @@ +using Restaurant.Admin.Bff.Contracts.Requests; +using Restaurant.Admin.Bff.Contracts.Responses; + +namespace Restaurant.Admin.Bff.Application.Handlers; + +public interface IGetRecentAdminChangesHandler +{ + Task HandleAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken); +} diff --git a/src/Restaurant.Admin.Bff.Contracts/Contracts/ConfigChangeContract.cs b/src/Restaurant.Admin.Bff.Contracts/Contracts/ConfigChangeContract.cs new file mode 100644 index 0000000..a5c00a0 --- /dev/null +++ b/src/Restaurant.Admin.Bff.Contracts/Contracts/ConfigChangeContract.cs @@ -0,0 +1,8 @@ +namespace Restaurant.Admin.Bff.Contracts.Contracts; + +public sealed record ConfigChangeContract( + string ChangeId, + string Category, + string Description, + string UpdatedBy, + DateTime UpdatedAtUtc); diff --git a/src/Restaurant.Admin.Bff.Contracts/Contracts/FeatureFlagStateContract.cs b/src/Restaurant.Admin.Bff.Contracts/Contracts/FeatureFlagStateContract.cs new file mode 100644 index 0000000..f014505 --- /dev/null +++ b/src/Restaurant.Admin.Bff.Contracts/Contracts/FeatureFlagStateContract.cs @@ -0,0 +1,3 @@ +namespace Restaurant.Admin.Bff.Contracts.Contracts; + +public sealed record FeatureFlagStateContract(string Key, bool Enabled); diff --git a/src/Restaurant.Admin.Bff.Contracts/Contracts/ServiceWindowContract.cs b/src/Restaurant.Admin.Bff.Contracts/Contracts/ServiceWindowContract.cs new file mode 100644 index 0000000..c6d2e03 --- /dev/null +++ b/src/Restaurant.Admin.Bff.Contracts/Contracts/ServiceWindowContract.cs @@ -0,0 +1,7 @@ +namespace Restaurant.Admin.Bff.Contracts.Contracts; + +public sealed record ServiceWindowContract( + DayOfWeek Day, + TimeOnly OpenAt, + TimeOnly CloseAt, + bool IsClosed); diff --git a/src/Restaurant.Admin.Bff.Contracts/Responses/GetRecentAdminChangesResponse.cs b/src/Restaurant.Admin.Bff.Contracts/Responses/GetRecentAdminChangesResponse.cs new file mode 100644 index 0000000..d309326 --- /dev/null +++ b/src/Restaurant.Admin.Bff.Contracts/Responses/GetRecentAdminChangesResponse.cs @@ -0,0 +1,9 @@ +using Restaurant.Admin.Bff.Contracts.Contracts; + +namespace Restaurant.Admin.Bff.Contracts.Responses; + +public sealed record GetRecentAdminChangesResponse( + string ContextId, + string Summary, + string Version, + IReadOnlyCollection RecentChanges); diff --git a/src/Restaurant.Admin.Bff.Contracts/Responses/GetRestaurantAdminConfigResponse.cs b/src/Restaurant.Admin.Bff.Contracts/Responses/GetRestaurantAdminConfigResponse.cs index 1e1cfdd..f46e064 100644 --- a/src/Restaurant.Admin.Bff.Contracts/Responses/GetRestaurantAdminConfigResponse.cs +++ b/src/Restaurant.Admin.Bff.Contracts/Responses/GetRestaurantAdminConfigResponse.cs @@ -1,3 +1,11 @@ +using Restaurant.Admin.Bff.Contracts.Contracts; + namespace Restaurant.Admin.Bff.Contracts.Responses; -public sealed record GetRestaurantAdminConfigResponse(string ContextId, string Summary); +public sealed record GetRestaurantAdminConfigResponse( + string ContextId, + string Summary, + string Version, + IReadOnlyCollection FeatureFlags, + IReadOnlyCollection ServiceWindows, + IReadOnlyCollection RecentChanges); diff --git a/src/Restaurant.Admin.Bff.Contracts/Responses/SetServiceWindowResponse.cs b/src/Restaurant.Admin.Bff.Contracts/Responses/SetServiceWindowResponse.cs index 78dcb34..4aed9bc 100644 --- a/src/Restaurant.Admin.Bff.Contracts/Responses/SetServiceWindowResponse.cs +++ b/src/Restaurant.Admin.Bff.Contracts/Responses/SetServiceWindowResponse.cs @@ -1,6 +1,9 @@ +using Restaurant.Admin.Bff.Contracts.Contracts; + namespace Restaurant.Admin.Bff.Contracts.Responses; public sealed record SetServiceWindowResponse( string ContextId, bool Applied, - string Message); + string Message, + ServiceWindowContract ServiceWindow); diff --git a/src/Restaurant.Admin.Bff.Rest/Program.cs b/src/Restaurant.Admin.Bff.Rest/Program.cs index 6a9c16d..2d6fd94 100644 --- a/src/Restaurant.Admin.Bff.Rest/Program.cs +++ b/src/Restaurant.Admin.Bff.Rest/Program.cs @@ -10,8 +10,14 @@ 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.AddHttpClient("ThalosAuth"); @@ -44,6 +50,24 @@ app.MapGet("/api/restaurant/admin/config", async ( return Results.Ok(await handler.HandleAsync(request, ct)); }); +app.MapGet("/api/restaurant/admin/changes", async ( + string contextId, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IGetRecentAdminChangesHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + var request = new GetRestaurantAdminConfigRequest(contextId); + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + app.MapPost("/api/restaurant/admin/service-window", async ( SetServiceWindowRequest request, HttpContext context, diff --git a/tests/Restaurant.Admin.Bff.Application.UnitTests/OperationsRestaurantAdminServiceClientTests.cs b/tests/Restaurant.Admin.Bff.Application.UnitTests/OperationsRestaurantAdminServiceClientTests.cs new file mode 100644 index 0000000..8c96592 --- /dev/null +++ b/tests/Restaurant.Admin.Bff.Application.UnitTests/OperationsRestaurantAdminServiceClientTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Text; +using Restaurant.Admin.Bff.Application.Adapters; +using Restaurant.Admin.Bff.Contracts.Requests; + +namespace Restaurant.Admin.Bff.Application.UnitTests; + +public sealed class OperationsRestaurantAdminServiceClientTests +{ + [Fact] + public async Task FetchConfigAsync_MapsFlagsWindowsAndChanges() + { + var adapter = new OperationsRestaurantAdminServiceClient(CreateClient(ConfigPayload)); + + var response = await adapter.FetchConfigAsync(new GetRestaurantAdminConfigRequest("demo-context"), CancellationToken.None); + + Assert.Equal("v2", response.Version); + Assert.NotEmpty(response.FeatureFlags); + Assert.NotEmpty(response.ServiceWindows); + Assert.NotEmpty(response.RecentChanges); + } + + [Fact] + public async Task FetchRecentChangesAsync_ProjectsChangeHistory() + { + var adapter = new OperationsRestaurantAdminServiceClient(CreateClient(ConfigPayload)); + + var response = await adapter.FetchRecentChangesAsync(new GetRestaurantAdminConfigRequest("demo-context"), CancellationToken.None); + + Assert.Equal(2, response.RecentChanges.Count); + } + + [Fact] + public async Task SetServiceWindowAsync_MapsUpdatedServiceWindow() + { + var adapter = new OperationsRestaurantAdminServiceClient(CreateClient(""" + { + "contextId": "demo-context", + "applied": true, + "message": "Service window updated by admin-operator.", + "serviceWindow": { + "dayOfWeek": 1, + "openAt": "08:00:00", + "closeAt": "22:00:00", + "isClosed": false + } + } + """)); + + var response = await adapter.SetServiceWindowAsync( + new SetServiceWindowRequest("demo-context", DayOfWeek.Monday, new TimeOnly(8, 0), new TimeOnly(22, 0), "admin-operator"), + CancellationToken.None); + + Assert.True(response.Applied); + Assert.Equal(DayOfWeek.Monday, response.ServiceWindow.Day); + } + + 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 ConfigPayload = """ + { + "contextId": "demo-context", + "summary": "Restaurant admin snapshot includes current flags, service windows, and recent control-plane changes.", + "version": "v2", + "featureFlags": [ + { "key": "kitchen.dispatch.enabled", "enabled": true }, + { "key": "pos.closeout.preview", "enabled": true } + ], + "serviceWindows": [ + { "dayOfWeek": 1, "openAt": "08:00:00", "closeAt": "22:00:00", "isClosed": false }, + { "dayOfWeek": 5, "openAt": "08:00:00", "closeAt": "23:30:00", "isClosed": false } + ], + "recentChanges": [ + { + "changeId": "CFG-100", + "category": "service-window", + "description": "Extended Friday dinner service window.", + "updatedBy": "admin-operator", + "updatedAtUtc": "2026-03-31T10:00:00Z" + }, + { + "changeId": "CFG-101", + "category": "feature-flag", + "description": "Enabled POS closeout preview mode.", + "updatedBy": "ops-lead", + "updatedAtUtc": "2026-03-31T12:00:00Z" + } + ] + } + """; +} diff --git a/tests/Restaurant.Admin.Bff.Application.UnitTests/Restaurant.Admin.Bff.Application.UnitTests.csproj b/tests/Restaurant.Admin.Bff.Application.UnitTests/Restaurant.Admin.Bff.Application.UnitTests.csproj new file mode 100644 index 0000000..bedc03e --- /dev/null +++ b/tests/Restaurant.Admin.Bff.Application.UnitTests/Restaurant.Admin.Bff.Application.UnitTests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + +