Compare commits

...

5 Commits

Author SHA1 Message Date
José René White Enciso
deea1f260d fix(kitchen-ops-bff): report kitchen transition success 2026-03-31 20:21:41 -06:00
José René White Enciso
6d4251b3b3 docs(kitchen-ops-bff): align lifecycle board reads 2026-03-31 19:59:32 -06:00
José René White Enciso
b6b6c8fcf6 feat(kitchen-ops-bff): bridge kitchen state into shared flow 2026-03-31 18:53:28 -06:00
José René White Enciso
fcaa7e0d91 feat(kitchen-ops-bff): add kitchen workflow endpoints 2026-03-31 16:40:58 -06:00
José René White Enciso
36c7c8c6ba merge: integrate kitchen-ops-bff auth and web updates 2026-03-11 12:39:20 -06:00
29 changed files with 626 additions and 28 deletions

View File

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

View File

@ -0,0 +1,37 @@
# Kitchen Ops Workflow API
## Purpose
This BFF exposes kitchen board, claim or release, transition, and priority workflows over REST while delegating orchestration to `kitchen-service`.
## Endpoints
- `GET /api/kitchen/ops/board?contextId=<value>`
- Returns board lanes and available stations.
- `POST /api/kitchen/ops/work-items/claim`
- Claims a kitchen work item.
- `POST /api/kitchen/ops/work-items/release`
- Releases a kitchen work item using the current claim workflow path.
- `POST /api/kitchen/ops/work-items/transition`
- Requests a kitchen work-item state transition.
- `POST /api/kitchen/ops/board/priority`
- Updates kitchen priority for a work item.
## Upstream Dependency
- Base address configuration: `KitchenService:BaseAddress`
- Default runtime target: `http://kitchen-service:8080`
- Internal upstream routes:
- `GET /internal/kitchen/board`
- `POST /internal/kitchen/work-items/claim`
- `POST /internal/kitchen/orders/transition`
- `POST /internal/kitchen/work-items/priority`
## Notes
- `kitchen-service` currently exposes claim but not a dedicated release contract, so the release route reuses the claim validation path and projects a release-oriented response for the BFF edge.
- Transition requests now forward `ContextId` to `kitchen-service` so kitchen actions land in the correct shared restaurant lifecycle context.
- Board reads now rely on the lifecycle-driven kitchen ticket materialization in `kitchen-service`, which means newly accepted restaurant orders can appear without a stack reset.
- The BFF keeps temporary edge-state compatibility for existing web clients by translating `Cooking|Ready|Served` to the canonical kitchen-service states `Preparing|ReadyForPickup|Delivered`.
- Transition success at the edge is now sourced from the upstream `Applied` flag so operators see truthful mutation outcomes.
- Correlation IDs are preserved through Thalos session checks and kitchen-service calls.

View File

@ -9,10 +9,10 @@ kitchen-ops-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. - Kitchen board projection over linked restaurant tickets.
- Kitchen queue and dispatch optimization hooks. - Kitchen work-item claim, release, transition, and priority workflows aligned with canonical kitchen-service state transitions.
- Cross-app state continuity from customer or waiter submission through kitchen execution and POS readiness.
- 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

@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name kitchen-ops-bff agilewebs/kitchen-ops-bff:de
## Runtime Notes ## Runtime Notes
- Exposes REST edge endpoints for kitchen operations dashboards. - Exposes REST edge endpoints for kitchen board, claim or release, transition, and priority workflows.
- Requires `KitchenService__BaseAddress` to resolve the upstream kitchen-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 kitchen-ops-bff agilewebs/kitchen-ops-bff:de
- 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. - Kitchen-ops now delegates dashboard and work-item actions to `kitchen-service`, which projects persisted kitchen ticket state and syncs order progression back into the shared restaurant lifecycle.
- Temporary edge-state translation remains in place for existing web clients until the Stage 47 kitchen web task adopts the canonical kitchen-service states directly.
- 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,9 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio
## Protected Endpoints ## Protected Endpoints
- `/api/kitchen/ops/board` - `/api/kitchen/ops/board`
- `/api/kitchen/ops/work-items/claim`
- `/api/kitchen/ops/work-items/release`
- `/api/kitchen/ops/work-items/transition`
- `/api/kitchen/ops/board/priority` - `/api/kitchen/ops/board/priority`
## Anonymous Endpoints ## Anonymous Endpoints

View File

@ -1,17 +0,0 @@
using Kitchen.Ops.Bff.Contracts.Requests;
using Kitchen.Ops.Bff.Contracts.Responses;
namespace Kitchen.Ops.Bff.Application.Adapters;
public sealed class DefaultKitchenServiceClient : IKitchenServiceClient
{
public Task<GetKitchenOpsBoardResponse> FetchAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new GetKitchenOpsBoardResponse(request.ContextId, "Default service-backed response."));
}
public Task<SetKitchenOrderPriorityResponse> SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new SetKitchenOrderPriorityResponse(request.ContextId, request.OrderId, true, "Order priority updated by default adapter."));
}
}

View File

@ -5,6 +5,9 @@ namespace Kitchen.Ops.Bff.Application.Adapters;
public interface IKitchenServiceClient public interface IKitchenServiceClient
{ {
Task<GetKitchenOpsBoardResponse> FetchAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken); Task<GetKitchenOpsBoardResponse> FetchBoardAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken);
Task<ClaimKitchenWorkItemResponse> ClaimWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken);
Task<ReleaseKitchenWorkItemResponse> ReleaseWorkItemAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken);
Task<TransitionKitchenWorkItemResponse> TransitionWorkItemAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken);
Task<SetKitchenOrderPriorityResponse> SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken); Task<SetKitchenOrderPriorityResponse> SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken);
} }

View File

@ -0,0 +1,205 @@
using System.Net.Http.Json;
using Kitchen.Ops.Bff.Contracts.Contracts;
using Kitchen.Ops.Bff.Contracts.Requests;
using Kitchen.Ops.Bff.Contracts.Responses;
namespace Kitchen.Ops.Bff.Application.Adapters;
public sealed class KitchenWorkflowServiceClient(HttpClient httpClient) : IKitchenServiceClient
{
public async Task<GetKitchenOpsBoardResponse> FetchBoardAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetKitchenBoardPayload>(
$"internal/kitchen/board?contextId={Uri.EscapeDataString(request.ContextId)}",
cancellationToken);
if (payload is null)
{
throw new InvalidOperationException("Kitchen service returned an empty board payload.");
}
return new GetKitchenOpsBoardResponse(
payload.ContextId,
payload.Summary,
payload.Lanes
.Select(static lane => new KitchenBoardLaneContract(
lane.Lane,
lane.Items
.Select(static item => new KitchenBoardItemContract(
item.WorkItemId,
item.OrderId,
item.TicketId,
item.TableId,
item.Station,
item.State,
item.Priority,
item.ClaimedBy,
item.EtaMinutes))
.ToArray()))
.ToArray(),
payload.AvailableStations);
}
public async Task<ClaimKitchenWorkItemResponse> ClaimWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
{
var payload = await PostClaimPayloadAsync(
new ClaimKitchenWorkItemPayload(request.ContextId, request.WorkItemId, request.ClaimedBy),
cancellationToken);
return new ClaimKitchenWorkItemResponse(
payload.ContextId,
payload.WorkItemId,
payload.Claimed,
payload.ClaimedBy,
payload.Message);
}
public async Task<ReleaseKitchenWorkItemResponse> ReleaseWorkItemAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken)
{
// kitchen-service currently exposes a claim workflow only, so release is expressed as a control action using the same validation path.
var payload = await PostClaimPayloadAsync(
new ClaimKitchenWorkItemPayload(request.ContextId, request.WorkItemId, request.ReleasedBy),
cancellationToken);
return new ReleaseKitchenWorkItemResponse(
payload.ContextId,
payload.WorkItemId,
payload.Claimed,
request.ReleasedBy,
payload.Claimed
? $"Work item {payload.WorkItemId} release requested by {request.ReleasedBy}."
: payload.Message);
}
public async Task<TransitionKitchenWorkItemResponse> TransitionWorkItemAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync(
"internal/kitchen/orders/transition",
new TransitionKitchenWorkItemPayload(
request.OrderId,
request.TicketId,
MapKitchenServiceState(request.TargetState),
request.ContextId ?? "demo-context"),
cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<TransitionKitchenWorkItemResponsePayload>(cancellationToken);
if (payload is null)
{
throw new InvalidOperationException("Kitchen service returned an empty transition payload.");
}
// kitchen-service reports success as `Applied`; the public edge contract exposes
// the same outcome as `Transitioned` for the web clients.
return new TransitionKitchenWorkItemResponse(
payload.OrderId,
payload.TicketId,
MapEdgeState(payload.PreviousState),
MapEdgeState(payload.CurrentState),
payload.Applied,
payload.RejectionReason);
}
public async Task<SetKitchenOrderPriorityResponse> SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync(
"internal/kitchen/work-items/priority",
new UpdateKitchenPriorityPayload(request.ContextId, request.WorkItemId, request.Priority, request.UpdatedBy),
cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<UpdateKitchenPriorityResponsePayload>(cancellationToken);
if (payload is null)
{
throw new InvalidOperationException("Kitchen service returned an empty priority payload.");
}
return new SetKitchenOrderPriorityResponse(
payload.ContextId,
payload.WorkItemId,
true,
payload.Priority,
payload.Message);
}
private async Task<ClaimKitchenWorkItemResponsePayload> PostClaimPayloadAsync(
ClaimKitchenWorkItemPayload request,
CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync("internal/kitchen/work-items/claim", request, cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<ClaimKitchenWorkItemResponsePayload>(cancellationToken);
return payload ?? throw new InvalidOperationException("Kitchen service returned an empty claim payload.");
}
private sealed record GetKitchenBoardPayload(
string ContextId,
string Summary,
IReadOnlyCollection<KitchenBoardLanePayload> Lanes,
IReadOnlyCollection<string> AvailableStations);
private sealed record KitchenBoardLanePayload(string Lane, IReadOnlyCollection<KitchenBoardItemPayload> Items);
private sealed record KitchenBoardItemPayload(
string WorkItemId,
string OrderId,
string TicketId,
string TableId,
string Station,
string State,
int Priority,
string? ClaimedBy,
int EtaMinutes);
private sealed record ClaimKitchenWorkItemPayload(string ContextId, string WorkItemId, string ClaimedBy);
private sealed record ClaimKitchenWorkItemResponsePayload(
string ContextId,
string WorkItemId,
bool Claimed,
string ClaimedBy,
string Message);
private static string MapKitchenServiceState(string edgeState) => edgeState switch
{
"Cooking" => "Preparing",
"Ready" => "ReadyForPickup",
"Served" => "Delivered",
_ => edgeState
};
private static string MapEdgeState(string serviceState) => serviceState switch
{
"Preparing" => "Cooking",
"ReadyForPickup" => "Ready",
"Delivered" => "Served",
_ => serviceState
};
private sealed record TransitionKitchenWorkItemPayload(
string OrderId,
string TicketId,
string TargetState,
string ContextId);
private sealed record TransitionKitchenWorkItemResponsePayload(
string OrderId,
string TicketId,
string PreviousState,
string CurrentState,
bool Applied,
string? RejectionReason);
private sealed record UpdateKitchenPriorityPayload(
string ContextId,
string WorkItemId,
int Priority,
string RequestedBy);
private sealed record UpdateKitchenPriorityResponsePayload(
string ContextId,
string WorkItemId,
int Priority,
string Message);
}

View File

@ -0,0 +1,13 @@
using Kitchen.Ops.Bff.Application.Adapters;
using Kitchen.Ops.Bff.Contracts.Requests;
using Kitchen.Ops.Bff.Contracts.Responses;
namespace Kitchen.Ops.Bff.Application.Handlers;
public sealed class ClaimKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : IClaimKitchenWorkItemHandler
{
public Task<ClaimKitchenWorkItemResponse> HandleAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
{
return serviceClient.ClaimWorkItemAsync(request, cancellationToken);
}
}

View File

@ -8,6 +8,6 @@ public sealed class GetKitchenOpsBoardHandler(IKitchenServiceClient serviceClien
{ {
public Task<GetKitchenOpsBoardResponse> HandleAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken) public Task<GetKitchenOpsBoardResponse> HandleAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken)
{ {
return serviceClient.FetchAsync(request, cancellationToken); return serviceClient.FetchBoardAsync(request, cancellationToken);
} }
} }

View File

@ -0,0 +1,9 @@
using Kitchen.Ops.Bff.Contracts.Requests;
using Kitchen.Ops.Bff.Contracts.Responses;
namespace Kitchen.Ops.Bff.Application.Handlers;
public interface IClaimKitchenWorkItemHandler
{
Task<ClaimKitchenWorkItemResponse> HandleAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,9 @@
using Kitchen.Ops.Bff.Contracts.Requests;
using Kitchen.Ops.Bff.Contracts.Responses;
namespace Kitchen.Ops.Bff.Application.Handlers;
public interface IReleaseKitchenWorkItemHandler
{
Task<ReleaseKitchenWorkItemResponse> HandleAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,9 @@
using Kitchen.Ops.Bff.Contracts.Requests;
using Kitchen.Ops.Bff.Contracts.Responses;
namespace Kitchen.Ops.Bff.Application.Handlers;
public interface ITransitionKitchenWorkItemHandler
{
Task<TransitionKitchenWorkItemResponse> HandleAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,13 @@
using Kitchen.Ops.Bff.Application.Adapters;
using Kitchen.Ops.Bff.Contracts.Requests;
using Kitchen.Ops.Bff.Contracts.Responses;
namespace Kitchen.Ops.Bff.Application.Handlers;
public sealed class ReleaseKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : IReleaseKitchenWorkItemHandler
{
public Task<ReleaseKitchenWorkItemResponse> HandleAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken)
{
return serviceClient.ReleaseWorkItemAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,13 @@
using Kitchen.Ops.Bff.Application.Adapters;
using Kitchen.Ops.Bff.Contracts.Requests;
using Kitchen.Ops.Bff.Contracts.Responses;
namespace Kitchen.Ops.Bff.Application.Handlers;
public sealed class TransitionKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : ITransitionKitchenWorkItemHandler
{
public Task<TransitionKitchenWorkItemResponse> HandleAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken)
{
return serviceClient.TransitionWorkItemAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,12 @@
namespace Kitchen.Ops.Bff.Contracts.Contracts;
public sealed record KitchenBoardItemContract(
string WorkItemId,
string OrderId,
string TicketId,
string TableId,
string Station,
string State,
int Priority,
string? ClaimedBy,
int EtaMinutes);

View File

@ -0,0 +1,5 @@
namespace Kitchen.Ops.Bff.Contracts.Contracts;
public sealed record KitchenBoardLaneContract(
string Lane,
IReadOnlyCollection<KitchenBoardItemContract> Items);

View File

@ -0,0 +1,6 @@
namespace Kitchen.Ops.Bff.Contracts.Requests;
public sealed record ClaimKitchenWorkItemRequest(
string ContextId,
string WorkItemId,
string ClaimedBy);

View File

@ -0,0 +1,6 @@
namespace Kitchen.Ops.Bff.Contracts.Requests;
public sealed record ReleaseKitchenWorkItemRequest(
string ContextId,
string WorkItemId,
string ReleasedBy);

View File

@ -2,6 +2,6 @@ namespace Kitchen.Ops.Bff.Contracts.Requests;
public sealed record SetKitchenOrderPriorityRequest( public sealed record SetKitchenOrderPriorityRequest(
string ContextId, string ContextId,
string OrderId, string WorkItemId,
int Priority, int Priority,
string UpdatedBy); string UpdatedBy);

View File

@ -0,0 +1,8 @@
namespace Kitchen.Ops.Bff.Contracts.Requests;
public sealed record TransitionKitchenWorkItemRequest(
string OrderId,
string TicketId,
string TargetState,
string UpdatedBy,
string? ContextId = null);

View File

@ -0,0 +1,8 @@
namespace Kitchen.Ops.Bff.Contracts.Responses;
public sealed record ClaimKitchenWorkItemResponse(
string ContextId,
string WorkItemId,
bool Claimed,
string ClaimedBy,
string Message);

View File

@ -1,3 +1,9 @@
using Kitchen.Ops.Bff.Contracts.Contracts;
namespace Kitchen.Ops.Bff.Contracts.Responses; namespace Kitchen.Ops.Bff.Contracts.Responses;
public sealed record GetKitchenOpsBoardResponse(string ContextId, string Summary); public sealed record GetKitchenOpsBoardResponse(
string ContextId,
string Summary,
IReadOnlyCollection<KitchenBoardLaneContract> Lanes,
IReadOnlyCollection<string> AvailableStations);

View File

@ -0,0 +1,8 @@
namespace Kitchen.Ops.Bff.Contracts.Responses;
public sealed record ReleaseKitchenWorkItemResponse(
string ContextId,
string WorkItemId,
bool Released,
string ReleasedBy,
string Message);

View File

@ -2,6 +2,7 @@ namespace Kitchen.Ops.Bff.Contracts.Responses;
public sealed record SetKitchenOrderPriorityResponse( public sealed record SetKitchenOrderPriorityResponse(
string ContextId, string ContextId,
string OrderId, string WorkItemId,
bool Updated, bool Updated,
int Priority,
string Summary); string Summary);

View File

@ -0,0 +1,9 @@
namespace Kitchen.Ops.Bff.Contracts.Responses;
public sealed record TransitionKitchenWorkItemResponse(
string OrderId,
string TicketId,
string PreviousState,
string CurrentState,
bool Transitioned,
string? Error);

View File

@ -10,8 +10,16 @@ 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<IKitchenServiceClient, DefaultKitchenServiceClient>(); builder.Services.AddHttpClient<IKitchenServiceClient, KitchenWorkflowServiceClient>((serviceProvider, httpClient) =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var kitchenBaseAddress = configuration["KitchenService:BaseAddress"] ?? "http://kitchen-service:8080";
httpClient.BaseAddress = new Uri($"{kitchenBaseAddress.TrimEnd('/')}/");
});
builder.Services.AddSingleton<IGetKitchenOpsBoardHandler, GetKitchenOpsBoardHandler>(); builder.Services.AddSingleton<IGetKitchenOpsBoardHandler, GetKitchenOpsBoardHandler>();
builder.Services.AddSingleton<IClaimKitchenWorkItemHandler, ClaimKitchenWorkItemHandler>();
builder.Services.AddSingleton<IReleaseKitchenWorkItemHandler, ReleaseKitchenWorkItemHandler>();
builder.Services.AddSingleton<ITransitionKitchenWorkItemHandler, TransitionKitchenWorkItemHandler>();
builder.Services.AddSingleton<ISetKitchenOrderPriorityHandler, SetKitchenOrderPriorityHandler>(); builder.Services.AddSingleton<ISetKitchenOrderPriorityHandler, SetKitchenOrderPriorityHandler>();
builder.Services.AddHttpClient("ThalosAuth"); builder.Services.AddHttpClient("ThalosAuth");
@ -44,6 +52,57 @@ app.MapGet("/api/kitchen/ops/board", async (
return Results.Ok(await handler.HandleAsync(request, ct)); return Results.Ok(await handler.HandleAsync(request, ct));
}); });
app.MapPost("/api/kitchen/ops/work-items/claim", async (
ClaimKitchenWorkItemRequest request,
HttpContext context,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IClaimKitchenWorkItemHandler handler,
CancellationToken ct) =>
{
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
if (authError is not null)
{
return authError;
}
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapPost("/api/kitchen/ops/work-items/release", async (
ReleaseKitchenWorkItemRequest request,
HttpContext context,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IReleaseKitchenWorkItemHandler handler,
CancellationToken ct) =>
{
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
if (authError is not null)
{
return authError;
}
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapPost("/api/kitchen/ops/work-items/transition", async (
TransitionKitchenWorkItemRequest request,
HttpContext context,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ITransitionKitchenWorkItemHandler handler,
CancellationToken ct) =>
{
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
if (authError is not null)
{
return authError;
}
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapPost("/api/kitchen/ops/board/priority", async ( app.MapPost("/api/kitchen/ops/board/priority", async (
SetKitchenOrderPriorityRequest request, SetKitchenOrderPriorityRequest 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/Kitchen.Ops.Bff.Application/Kitchen.Ops.Bff.Application.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,149 @@
using System.Net;
using System.Text;
using Kitchen.Ops.Bff.Application.Adapters;
using Kitchen.Ops.Bff.Contracts.Requests;
namespace Kitchen.Ops.Bff.Application.UnitTests;
public sealed class KitchenWorkflowServiceClientTests
{
[Fact]
public async Task FetchBoardAsync_MapsBoardLanesAndStations()
{
var adapter = new KitchenWorkflowServiceClient(CreateClient(BoardPayload));
var response = await adapter.FetchBoardAsync(new GetKitchenOpsBoardRequest("demo-context"), CancellationToken.None);
Assert.NotEmpty(response.Lanes);
Assert.NotEmpty(response.AvailableStations);
}
[Fact]
public async Task ClaimWorkItemAsync_MapsClaimResponse()
{
var adapter = new KitchenWorkflowServiceClient(CreateClient("""
{
"contextId": "demo-context",
"workItemId": "WK-1001",
"claimed": true,
"claimedBy": "chef-maya",
"message": "Work item WK-1001 claimed by chef-maya."
}
"""));
var response = await adapter.ClaimWorkItemAsync(
new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"),
CancellationToken.None);
Assert.True(response.Claimed);
Assert.Equal("chef-maya", response.ClaimedBy);
}
[Fact]
public async Task ReleaseWorkItemAsync_ProjectsReleaseMessage()
{
var adapter = new KitchenWorkflowServiceClient(CreateClient("""
{
"contextId": "demo-context",
"workItemId": "WK-1001",
"claimed": true,
"claimedBy": "chef-maya",
"message": "Work item WK-1001 claimed by chef-maya."
}
"""));
var response = await adapter.ReleaseWorkItemAsync(
new ReleaseKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"),
CancellationToken.None);
Assert.True(response.Released);
Assert.Contains("release requested", response.Message);
}
[Fact]
public async Task TransitionWorkItemAsync_MapsTransitionResponse()
{
var adapter = new KitchenWorkflowServiceClient(CreateClient("""
{
"orderId": "ORD-1001",
"ticketId": "KT-1001",
"previousState": "Queued",
"currentState": "Preparing",
"applied": true,
"rejectionReason": null
}
"""));
var response = await adapter.TransitionWorkItemAsync(
new TransitionKitchenWorkItemRequest("ORD-1001", "KT-1001", "Cooking", "chef-maya", "demo-context"),
CancellationToken.None);
Assert.True(response.Transitioned);
Assert.Equal("Cooking", response.CurrentState);
}
[Fact]
public async Task SetOrderPriorityAsync_MapsPriorityResponse()
{
var adapter = new KitchenWorkflowServiceClient(CreateClient("""
{
"contextId": "demo-context",
"workItemId": "WK-1001",
"priority": 5,
"message": "Priority for WK-1001 updated to 5 by chef-maya."
}
"""));
var response = await adapter.SetOrderPriorityAsync(
new SetKitchenOrderPriorityRequest("demo-context", "WK-1001", 5, "chef-maya"),
CancellationToken.None);
Assert.True(response.Updated);
Assert.Equal(5, response.Priority);
}
private static HttpClient CreateClient(string json)
{
return new HttpClient(new StubHttpMessageHandler(json))
{
BaseAddress = new Uri("http://kitchen-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 BoardPayload = """
{
"contextId": "demo-context",
"summary": "Kitchen board now reflects persisted tickets linked to shared restaurant orders.",
"lanes": [
{
"lane": "queued",
"items": [
{
"workItemId": "WK-1001",
"orderId": "ORD-1001",
"ticketId": "KT-1001",
"tableId": "T-08",
"station": "hot-line",
"state": "Queued",
"priority": 3,
"claimedBy": null,
"etaMinutes": 12
}
]
}
],
"availableStations": [ "hot-line", "grill", "pickup" ]
}
""";
}