feat(kitchen-ops-bff): add kitchen workflow endpoints
This commit is contained in:
parent
36c7c8c6ba
commit
fcaa7e0d91
@ -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>
|
||||
|
||||
33
docs/api/kitchen-ops-workflows.md
Normal file
33
docs/api/kitchen-ops-workflows.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -0,0 +1,5 @@
|
||||
namespace Kitchen.Ops.Bff.Contracts.Contracts;
|
||||
|
||||
public sealed record KitchenBoardLaneContract(
|
||||
string Lane,
|
||||
IReadOnlyCollection<KitchenBoardItemContract> Items);
|
||||
@ -0,0 +1,6 @@
|
||||
namespace Kitchen.Ops.Bff.Contracts.Requests;
|
||||
|
||||
public sealed record ClaimKitchenWorkItemRequest(
|
||||
string ContextId,
|
||||
string WorkItemId,
|
||||
string ClaimedBy);
|
||||
@ -0,0 +1,6 @@
|
||||
namespace Kitchen.Ops.Bff.Contracts.Requests;
|
||||
|
||||
public sealed record ReleaseKitchenWorkItemRequest(
|
||||
string ContextId,
|
||||
string WorkItemId,
|
||||
string ReleasedBy);
|
||||
@ -2,6 +2,6 @@ namespace Kitchen.Ops.Bff.Contracts.Requests;
|
||||
|
||||
public sealed record SetKitchenOrderPriorityRequest(
|
||||
string ContextId,
|
||||
string OrderId,
|
||||
string WorkItemId,
|
||||
int Priority,
|
||||
string UpdatedBy);
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
namespace Kitchen.Ops.Bff.Contracts.Requests;
|
||||
|
||||
public sealed record TransitionKitchenWorkItemRequest(
|
||||
string OrderId,
|
||||
string TicketId,
|
||||
string TargetState,
|
||||
string UpdatedBy);
|
||||
@ -0,0 +1,8 @@
|
||||
namespace Kitchen.Ops.Bff.Contracts.Responses;
|
||||
|
||||
public sealed record ClaimKitchenWorkItemResponse(
|
||||
string ContextId,
|
||||
string WorkItemId,
|
||||
bool Claimed,
|
||||
string ClaimedBy,
|
||||
string Message);
|
||||
@ -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);
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace Kitchen.Ops.Bff.Contracts.Responses;
|
||||
|
||||
public sealed record ReleaseKitchenWorkItemResponse(
|
||||
string ContextId,
|
||||
string WorkItemId,
|
||||
bool Released,
|
||||
string ReleasedBy,
|
||||
string Message);
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
@ -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" ]
|
||||
}
|
||||
""";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user