feat(kitchen-ops-bff): add kitchen workflow endpoints

This commit is contained in:
José René White Enciso 2026-03-31 16:40:58 -06:00
parent 36c7c8c6ba
commit fcaa7e0d91
29 changed files with 592 additions and 25 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.Rest/Kitchen.Ops.Bff.Rest.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Kitchen.Ops.Bff.Application.UnitTests/Kitchen.Ops.Bff.Application.UnitTests.csproj" />
</Folder>
</Solution>

View File

@ -0,0 +1,33 @@
# 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.
- Correlation IDs are preserved through Thalos session checks and kitchen-service calls.

View File

@ -11,6 +11,7 @@ kitchen-ops-bff
## Domain-Specific Candidate Features
- Order lifecycle consistency and state transitions.
- Kitchen queue and dispatch optimization hooks.
- Kitchen work-item claim, release, transition, and priority workflows aligned with kitchen-service.
- Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment.

View File

@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name kitchen-ops-bff agilewebs/kitchen-ops-bff:de
## 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.
- Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation.
@ -38,5 +39,5 @@ docker run --rm -p 8080:8080 --name kitchen-ops-bff agilewebs/kitchen-ops-bff:de
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## 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`, but the upstream kitchen workflow 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.

View File

@ -7,6 +7,9 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio
## Protected Endpoints
- `/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`
## 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
{
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);
}

View File

@ -0,0 +1,179 @@
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, request.TargetState),
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.");
}
return new TransitionKitchenWorkItemResponse(
payload.OrderId,
payload.TicketId,
payload.PreviousState,
payload.CurrentState,
payload.Transitioned,
payload.Error);
}
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 sealed record TransitionKitchenWorkItemPayload(string OrderId, string TicketId, string TargetState);
private sealed record TransitionKitchenWorkItemResponsePayload(
string OrderId,
string TicketId,
string PreviousState,
string CurrentState,
bool Transitioned,
string? Error);
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)
{
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(
string ContextId,
string OrderId,
string WorkItemId,
int Priority,
string UpdatedBy);

View File

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

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;
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(
string ContextId,
string OrderId,
string WorkItemId,
bool Updated,
int Priority,
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";
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<IClaimKitchenWorkItemHandler, ClaimKitchenWorkItemHandler>();
builder.Services.AddSingleton<IReleaseKitchenWorkItemHandler, ReleaseKitchenWorkItemHandler>();
builder.Services.AddSingleton<ITransitionKitchenWorkItemHandler, TransitionKitchenWorkItemHandler>();
builder.Services.AddSingleton<ISetKitchenOrderPriorityHandler, SetKitchenOrderPriorityHandler>();
builder.Services.AddHttpClient("ThalosAuth");
@ -44,6 +52,57 @@ app.MapGet("/api/kitchen/ops/board", async (
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 (
SetKitchenOrderPriorityRequest request,
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": "CO-1001",
"ticketId": "KT-1001",
"previousState": "Queued",
"currentState": "Preparing",
"transitioned": true,
"error": null
}
"""));
var response = await adapter.TransitionWorkItemAsync(
new TransitionKitchenWorkItemRequest("CO-1001", "KT-1001", "Preparing", "chef-maya"),
CancellationToken.None);
Assert.True(response.Transitioned);
Assert.Equal("Preparing", 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 shows queued, preparing, and ready lanes for the current service context.",
"lanes": [
{
"lane": "queued",
"items": [
{
"workItemId": "WK-1001",
"orderId": "CO-1001",
"ticketId": "KT-1001",
"tableId": "T-08",
"station": "hot-line",
"state": "Queued",
"priority": 3,
"claimedBy": null,
"etaMinutes": 12
}
]
}
],
"availableStations": [ "hot-line", "grill", "pickup" ]
}
""";
}