From d1120253785c139d29f533e37f362ae6765087ca Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 2 Jun 2026 13:41:56 -0700 Subject: [PATCH 1/2] Add bot-event status consumer + tests + IaC for order status/history (#41, #42) #41 Get Order Status: status previously never advanced past placement. Add an Event Hub BackgroundService (OrderStatusConsumer) that reads bot events from the simulator's robot-output hub and advances order status: - RobotOrderAssignmentResponse: Accepted->InTransit, Queued->Assigned, Rejected->Failed - RobotStatusUpdated: OnDelivery->InTransit, DeliveryCompleted->Delivered - RobotDeliveryCompleted->Delivered Mapping is a pure, forward-only function (out-of-order/duplicate events can't regress status). Correlates by the order GUID we emit in RobotOrderAssignment; verified against the merged simulator source that it echoes that id back. Idle-with-warning when StatusConsumer config is absent so the API still runs. #41/#42 read endpoints (GetOrder, GetOrderHistory) already existed but were untested; add 22 unit tests covering the read paths, the pure status mapping, and persisted status transitions. IaC (Iac/order-service): dedicated 'order-service' consumer group on robot-output + StatusConsumer env vars, reusing the namespace-level eventhub-connection-string secret (has Listen on robot-output). terraform validate passes. --- Iac/order-service/README.md | 9 +- Iac/order-service/main.tf | 22 +- Iac/order-service/variables.tf | 20 +- .../OrderService.Tests/OrderServiceTests.cs | 254 ++++++++++++++++++ .../Events/OrderStatusConsumer.cs | 104 +++++++ .../OrderService/Events/OrderStatusMapping.cs | 84 ++++++ .../OrderService/Events/RobotEventEnvelope.cs | 49 ++++ OrderService/OrderService/Program.cs | 3 + .../OrderService/Services/IOrderService.cs | 5 +- .../OrderService/Services/OrderService.cs | 31 +++ OrderService/OrderService/appsettings.json | 5 + 11 files changed, 580 insertions(+), 6 deletions(-) create mode 100644 OrderService/OrderService/Events/OrderStatusConsumer.cs create mode 100644 OrderService/OrderService/Events/OrderStatusMapping.cs create mode 100644 OrderService/OrderService/Events/RobotEventEnvelope.cs diff --git a/Iac/order-service/README.md b/Iac/order-service/README.md index f6ed65b..d5c6375 100644 --- a/Iac/order-service/README.md +++ b/Iac/order-service/README.md @@ -25,9 +25,14 @@ order-service/ - a **system-assigned managed identity** - **ACR pull** via the `acr-password` secret + `registry` block (admin creds) - external **ingress** on port 8080 - - env vars: `ASPNETCORE_ENVIRONMENT`, `BotNetApi__BaseUrl` + - env vars: `ASPNETCORE_ENVIRONMENT`, `BotNetApi__BaseUrl`, + `StatusConsumer__EventHubName` (`robot-output`), + `StatusConsumer__ConsumerGroup` (`order-service`) - secret-backed env vars: `ConnectionStrings__DefaultConnection`, - `EventHub__ConnectionString` + `EventHub__ConnectionString`, `StatusConsumer__ConnectionString` +- `azurerm_eventhub_consumer_group.order_service_status` — a dedicated + `order-service` consumer group on the simulator's `robot-output` hub, which the + app's status consumer reads to advance order status (#41). The **image tag is owned by the CD pipeline**, not Terraform — the module sets an initial `:latest` image and `ignore_changes` on it so `terraform apply` diff --git a/Iac/order-service/main.tf b/Iac/order-service/main.tf index b9eeafe..b249bf0 100644 --- a/Iac/order-service/main.tf +++ b/Iac/order-service/main.tf @@ -18,6 +18,18 @@ data "azurerm_container_registry" "acr" { resource_group_name = data.azurerm_resource_group.rg.name } +# Dedicated consumer group for the Order Service's status consumer (#41). +# The namespace and the robot-output hub pre-exist on the shared simulator +# namespace (created outside this stack); we only add our own consumer group so +# our read offsets stay isolated from $Default and other features +# (e.g. readable-bot-network-dev). +resource "azurerm_eventhub_consumer_group" "order_service_status" { + name = var.status_consumer_group_name + namespace_name = var.event_hub_namespace_name + eventhub_name = var.status_event_hub_name + resource_group_name = data.azurerm_resource_group.rg.name +} + module "order_service_app" { source = "./modules/container-app" @@ -42,13 +54,19 @@ module "order_service_app" { } env_vars = { - "ASPNETCORE_ENVIRONMENT" = "Production" - "BotNetApi__BaseUrl" = var.botnet_api_url + "ASPNETCORE_ENVIRONMENT" = "Production" + "BotNetApi__BaseUrl" = var.botnet_api_url + "StatusConsumer__EventHubName" = var.status_event_hub_name + "StatusConsumer__ConsumerGroup" = azurerm_eventhub_consumer_group.order_service_status.name } secret_env_vars = { "ConnectionStrings__DefaultConnection" = "sql-connection-string" "EventHub__ConnectionString" = "eventhub-connection-string" + # Reuse the namespace-level Event Hub connection string — it has Listen on + # all hubs in the namespace, incl. robot-output. If it is later scoped to a + # Listen-only SAS, introduce a separate secret/variable for this. + "StatusConsumer__ConnectionString" = "eventhub-connection-string" } tags = var.tags diff --git a/Iac/order-service/variables.tf b/Iac/order-service/variables.tf index 3e6f765..7b0e048 100644 --- a/Iac/order-service/variables.tf +++ b/Iac/order-service/variables.tf @@ -41,11 +41,29 @@ variable "sql_connection_string" { } variable "eventhub_connection_string" { - description = "Connection string for the robot-input Event Hub — passed in from the CD pipeline, never committed." + description = "Namespace-level Event Hub connection string — passed in from the CD pipeline, never committed. Used to publish to robot-input AND to consume robot-output (Listen) for order status updates." type = string sensitive = true } +variable "event_hub_namespace_name" { + description = "Event Hub namespace hosting the simulator's robot-input/robot-output hubs." + type = string + default = "DeliverybotSimulator-EVHNS" +} + +variable "status_event_hub_name" { + description = "Hub the simulator publishes bot status events to; the Order Service consumes it to advance order status (#41)." + type = string + default = "robot-output" +} + +variable "status_consumer_group_name" { + description = "Dedicated consumer group the Order Service reads status events with — kept separate from $Default and other features." + type = string + default = "order-service" +} + variable "tags" { description = "Common tags applied to Order Service resources." type = map(string) diff --git a/OrderService/OrderService.Tests/OrderServiceTests.cs b/OrderService/OrderService.Tests/OrderServiceTests.cs index d0e6637..0dcfdd7 100644 --- a/OrderService/OrderService.Tests/OrderServiceTests.cs +++ b/OrderService/OrderService.Tests/OrderServiceTests.cs @@ -8,6 +8,8 @@ using Microsoft.Extensions.Logging.Abstractions; using OrderService.Data; using OrderService.DTOs; +using OrderService.Events; +using OrderService.Models; namespace OrderService.Tests; @@ -192,6 +194,258 @@ public async Task PlaceOrder_UsesGeocodedCoords_WhenGeocodingSucceeds() Assert.Equal(-117.4100, result.Destination!.Longitude, precision: 4); } + // ── Seed / event helpers ────────────────────────────────────────────────── + + private static Order SeedOrder( + OrderDbContext db, + string customerId, + OrderStatus status, + Guid? id = null, + DateTime? createdAt = null, + string itemId = "food") + { + var order = new Order + { + Id = id ?? Guid.NewGuid(), + CustomerId = customerId, + Status = status, + DeliveryAddress = "123 Main St, Spokane WA", + DestinationLatitude = 47.6, + DestinationLongitude = -117.4, + CreatedAt = createdAt ?? DateTime.UtcNow, + Items = { new OrderItem { ItemId = itemId, Quantity = 1 } } + }; + db.Orders.Add(order); + db.SaveChanges(); + return order; + } + + private static RobotEventEnvelope Envelope( + string eventType, + string? source = "robot-simulator", + string? orderId = null, + string? activeOrderId = null, + string? previousOrderId = null, + string? result = null, + string? currentStatus = null, + string? reason = null) => new() + { + EventType = eventType, + Source = source, + Data = new RobotEventData + { + OrderId = orderId, + ActiveOrderId = activeOrderId, + PreviousOrderId = previousOrderId, + Result = result, + CurrentStatus = currentStatus, + Reason = reason + } + }; + + // ── GetOrderAsync (status by id) — #41 ───────────────────────────────────── + + [Fact] + public async Task GetOrder_ReturnsNull_WhenOrderDoesNotExist() + { + var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); + + var result = await svc.GetOrderAsync(Guid.NewGuid()); + + Assert.Null(result); + } + + [Fact] + public async Task GetOrder_ReturnsOrderWithStatusAndItems_WhenExists() + { + var (svc, db) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var order = SeedOrder(db, "Jane:555", OrderStatus.InTransit); + + var result = await svc.GetOrderAsync(order.Id); + + Assert.NotNull(result); + Assert.Equal(order.Id, result!.Id); + Assert.Equal("InTransit", result.Status); + Assert.Contains(result.Items, i => i.ItemId == "food"); + } + + // ── GetOrderHistoryAsync — #42 ───────────────────────────────────────────── + + [Fact] + public async Task GetOrderHistory_ReturnsOnlyMatchingCustomersOrders() + { + var (svc, db) = CreateService(_ => Json("[]"), Config(botUrl: "")); + SeedOrder(db, "Jane:555", OrderStatus.Delivered); + SeedOrder(db, "Jane:555", OrderStatus.Pending); + SeedOrder(db, "Bob:999", OrderStatus.Delivered); + + var history = (await svc.GetOrderHistoryAsync("Jane:555")).ToList(); + + Assert.Equal(2, history.Count); + Assert.All(history, o => Assert.Equal("Jane:555", o.CustomerId)); + } + + [Fact] + public async Task GetOrderHistory_IsOrderedByMostRecentFirst() + { + var (svc, db) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var older = SeedOrder(db, "Jane:555", OrderStatus.Delivered, createdAt: DateTime.UtcNow.AddHours(-2)); + var newer = SeedOrder(db, "Jane:555", OrderStatus.Pending, createdAt: DateTime.UtcNow); + + var history = (await svc.GetOrderHistoryAsync("Jane:555")).ToList(); + + Assert.Equal(newer.Id, history[0].Id); + Assert.Equal(older.Id, history[1].Id); + } + + [Fact] + public async Task GetOrderHistory_EachOrderHasItemsDestinationTimestampAndStatus() + { + var (svc, db) = CreateService(_ => Json("[]"), Config(botUrl: "")); + SeedOrder(db, "Jane:555", OrderStatus.Delivered); + + var order = (await svc.GetOrderHistoryAsync("Jane:555")).Single(); + + Assert.NotEmpty(order.Items); // items ordered + Assert.NotNull(order.Destination); // destination + Assert.NotEqual(default, order.CreatedAt); // timestamp + Assert.Equal("Delivered", order.Status); // final status + } + + [Fact] + public async Task GetOrderHistory_ReturnsEmpty_ForUnknownCustomer() + { + var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); + + var history = await svc.GetOrderHistoryAsync("Nobody:000"); + + Assert.Empty(history); + } + + // ── OrderStatusMapping (pure event → status) — #41 ───────────────────────── + + [Theory] + [InlineData("Accepted", OrderStatus.InTransit)] + [InlineData("Queued", OrderStatus.Assigned)] + [InlineData("Rejected", OrderStatus.Failed)] + public void Map_AssignmentResponse_MapsResultToStatus(string result, OrderStatus expected) + { + var orderId = Guid.NewGuid().ToString(); + var change = OrderStatusMapping + .Map(Envelope("RobotOrderAssignmentResponse", orderId: orderId, result: result)) + .Single(); + + Assert.Equal(orderId, change.OrderId); + Assert.Equal(expected, change.Status); + } + + [Fact] + public void Map_StatusUpdated_OnDelivery_MarksActiveOrderInTransit() + { + var orderId = Guid.NewGuid().ToString(); + var change = OrderStatusMapping + .Map(Envelope("RobotStatusUpdated", activeOrderId: orderId, + currentStatus: "OnDelivery", reason: "OrderAcceptedDeliveryStarted")) + .Single(); + + Assert.Equal(orderId, change.OrderId); + Assert.Equal(OrderStatus.InTransit, change.Status); + } + + [Fact] + public void Map_StatusUpdated_DeliveryCompleted_MarksPreviousOrderDelivered() + { + var orderId = Guid.NewGuid().ToString(); + var change = OrderStatusMapping + .Map(Envelope("RobotStatusUpdated", previousOrderId: orderId, + currentStatus: "Available", reason: "DeliveryCompletedNoQueuedOrders")) + .Single(); + + Assert.Equal(orderId, change.OrderId); + Assert.Equal(OrderStatus.Delivered, change.Status); + } + + [Fact] + public void Map_DeliveryCompleted_MarksOrderDelivered() + { + var orderId = Guid.NewGuid().ToString(); + var change = OrderStatusMapping + .Map(Envelope("RobotDeliveryCompleted", orderId: orderId)) + .Single(); + + Assert.Equal(OrderStatus.Delivered, change.Status); + } + + [Fact] + public void Map_UnknownEventType_ProducesNoChanges() + { + Assert.Empty(OrderStatusMapping.Map(Envelope("RobotTelemetryUpdated", orderId: "x"))); + } + + [Theory] + [InlineData(OrderStatus.Pending, OrderStatus.Assigned, true)] + [InlineData(OrderStatus.Assigned, OrderStatus.InTransit, true)] + [InlineData(OrderStatus.InTransit, OrderStatus.Assigned, false)] + [InlineData(OrderStatus.Delivered, OrderStatus.InTransit, false)] + [InlineData(OrderStatus.Delivered, OrderStatus.Failed, false)] + public void IsForward_OnlyAllowsForwardProgression(OrderStatus current, OrderStatus next, bool expected) + { + Assert.Equal(expected, OrderStatusMapping.IsForward(current, next)); + } + + // ── ApplyStatusEventAsync (persisted) — #41 ──────────────────────────────── + + [Fact] + public async Task ApplyStatusEvent_AdvancesPersistedStatus() + { + var (svc, db) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var order = SeedOrder(db, "Jane:555", OrderStatus.Assigned); + + await svc.ApplyStatusEventAsync( + Envelope("RobotOrderAssignmentResponse", orderId: order.Id.ToString(), result: "Accepted")); + + Assert.Equal(OrderStatus.InTransit, db.Orders.Single().Status); + } + + [Fact] + public async Task ApplyStatusEvent_DoesNotRegressTerminalStatus() + { + var (svc, db) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var order = SeedOrder(db, "Jane:555", OrderStatus.Delivered); + + await svc.ApplyStatusEventAsync( + Envelope("RobotStatusUpdated", activeOrderId: order.Id.ToString(), + currentStatus: "OnDelivery", reason: "OrderAcceptedDeliveryStarted")); + + Assert.Equal(OrderStatus.Delivered, db.Orders.Single().Status); + } + + [Fact] + public async Task ApplyStatusEvent_IgnoresEventsWePublished() + { + var (svc, db) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var order = SeedOrder(db, "Jane:555", OrderStatus.Assigned); + + await svc.ApplyStatusEventAsync( + Envelope("RobotOrderAssignmentResponse", source: "order-service", + orderId: order.Id.ToString(), result: "Rejected")); + + Assert.Equal(OrderStatus.Assigned, db.Orders.Single().Status); + } + + [Fact] + public async Task ApplyStatusEvent_IgnoresUnknownOrderId() + { + var (svc, db) = CreateService(_ => Json("[]"), Config(botUrl: "")); + SeedOrder(db, "Jane:555", OrderStatus.Assigned); + + // Should not throw and should not touch the existing order. + await svc.ApplyStatusEventAsync( + Envelope("RobotDeliveryCompleted", orderId: Guid.NewGuid().ToString())); + + Assert.Equal(OrderStatus.Assigned, db.Orders.Single().Status); + } + // ── Fakes ───────────────────────────────────────────────────────────────── private sealed class FakeHttpMessageHandler(Func respond) diff --git a/OrderService/OrderService/Events/OrderStatusConsumer.cs b/OrderService/OrderService/Events/OrderStatusConsumer.cs new file mode 100644 index 0000000..5850528 --- /dev/null +++ b/OrderService/OrderService/Events/OrderStatusConsumer.cs @@ -0,0 +1,104 @@ +// Background service that reads bot events from Azure Event Hub and advances order +// status accordingly (#41). Mirrors the consumer pattern used by the RobotSimulator +// (AzureRobotEventConsumer) but runs as an ASP.NET Core hosted service. +// +// CONFIG (StatusConsumer section) — must point at the hub the simulator PUBLISHES to +// (RobotStatusUpdated / RobotOrderAssignmentResponse / RobotDeliveryCompleted). This is +// the simulator's OUTPUT hub, NOT the "robot-input" hub we publish assignments to. +// ASSUMPTION to confirm with the simulator team: hub name + consumer group below. +// If unconfigured, the consumer logs a warning and stays idle so the API still runs. +using System.Text.Json; +using Azure.Messaging.EventHubs.Consumer; +using OrderService.Services; + +namespace OrderService.Events; + +public sealed class OrderStatusConsumer : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public OrderStatusConsumer( + IServiceScopeFactory scopeFactory, + IConfiguration config, + ILogger logger) + { + _scopeFactory = scopeFactory; + _config = config; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var connectionString = _config["StatusConsumer:ConnectionString"]; + var hubName = _config["StatusConsumer:EventHubName"]; + var consumerGroup = _config["StatusConsumer:ConsumerGroup"] + ?? EventHubConsumerClient.DefaultConsumerGroupName; + + if (string.IsNullOrWhiteSpace(connectionString) || string.IsNullOrWhiteSpace(hubName)) + { + _logger.LogWarning( + "StatusConsumer is not configured (StatusConsumer:ConnectionString / EventHubName). " + + "Order status will NOT auto-update from bot events."); + return; + } + + _logger.LogInformation( + "Starting order-status consumer. EventHubName={EventHubName} ConsumerGroup={ConsumerGroup}", + hubName, consumerGroup); + + await using var client = new EventHubConsumerClient(consumerGroup, connectionString, hubName); + + try + { + // Read only events arriving from now on; status is event-driven and forward-only. + await foreach (var partitionEvent in client.ReadEventsAsync( + startReadingAtEarliestEvent: false, cancellationToken: stoppingToken)) + { + if (partitionEvent.Data is null) + continue; + + await HandleMessageAsync(partitionEvent.Data.EventBody.ToString(), stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown. + } + catch (Exception ex) + { + _logger.LogError(ex, "Order-status consumer stopped unexpectedly."); + } + } + + private async Task HandleMessageAsync(string body, CancellationToken ct) + { + RobotEventEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize(body, RobotEventJson.Options); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize bot event. Skipping."); + return; + } + + if (envelope is null || string.IsNullOrWhiteSpace(envelope.EventType)) + return; + + try + { + // DbContext is scoped — create a scope per message since this service is a singleton. + using var scope = _scopeFactory.CreateScope(); + var orderService = scope.ServiceProvider.GetRequiredService(); + await orderService.ApplyStatusEventAsync(envelope, ct); + } + catch (Exception ex) + { + // Don't let one bad message kill the consumer loop. + _logger.LogError(ex, "Failed to apply bot event. EventType={EventType}", envelope.EventType); + } + } +} diff --git a/OrderService/OrderService/Events/OrderStatusMapping.cs b/OrderService/OrderService/Events/OrderStatusMapping.cs new file mode 100644 index 0000000..fbf863b --- /dev/null +++ b/OrderService/OrderService/Events/OrderStatusMapping.cs @@ -0,0 +1,84 @@ +// Pure translation from simulator bot events to order status changes. +// Kept free of database/IO so it can be unit-tested in isolation. +// +// Mapping is derived from docs/simulator-events.md. ASSUMPTIONS flagged for the +// simulator team to confirm: +// 1. The simulator echoes back the SAME orderId we sent in RobotOrderAssignment +// (we send Order.Id as a GUID string) in its response/status events. +// 2. Event/data field names match the doc examples (camelCase). +using OrderService.Models; + +namespace OrderService.Events; + +public static class OrderStatusMapping +{ + // A status change to apply: which order, and the new status. + public readonly record struct StatusChange(string OrderId, OrderStatus Status); + + // Translate one envelope into zero or more status changes. + public static IEnumerable Map(RobotEventEnvelope evt) + { + var data = evt.Data; + if (data is null) + yield break; + + switch (evt.EventType) + { + // Bot accepted/queued/rejected the assignment we sent it. + case "RobotOrderAssignmentResponse": + if (!string.IsNullOrWhiteSpace(data.OrderId) && !string.IsNullOrWhiteSpace(data.Result)) + { + var mapped = data.Result switch + { + "Accepted" => OrderStatus.InTransit, // doc: "Order accepted and delivery started." + "Queued" => OrderStatus.Assigned, // accepted but waiting behind another order + "Rejected" => OrderStatus.Failed, + _ => (OrderStatus?)null + }; + if (mapped is { } s) + yield return new StatusChange(data.OrderId, s); + } + break; + + // Bot status / active-delivery state changed. + case "RobotStatusUpdated": + // A completed delivery references the finished order via previousOrderId. + if (data.Reason is { } reason && + reason.StartsWith("DeliveryCompleted", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(data.PreviousOrderId)) + { + yield return new StatusChange(data.PreviousOrderId, OrderStatus.Delivered); + } + // The order the bot is actively delivering is in transit. + if (string.Equals(data.CurrentStatus, "OnDelivery", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(data.ActiveOrderId)) + { + yield return new StatusChange(data.ActiveOrderId, OrderStatus.InTransit); + } + break; + + // Explicit delivery-completed event. + case "RobotDeliveryCompleted": + if (!string.IsNullOrWhiteSpace(data.OrderId)) + yield return new StatusChange(data.OrderId, OrderStatus.Delivered); + break; + } + } + + // Forward-only guard so out-of-order or duplicate events can't regress an order. + // Pending < Assigned < InTransit < terminal (Delivered/Cancelled/Failed). + public static bool IsForward(OrderStatus current, OrderStatus next) => + Rank(next) > Rank(current); + + private static int Rank(OrderStatus status) => status switch + { + OrderStatus.Pending => 0, + OrderStatus.Assigned => 1, + OrderStatus.InTransit => 2, + // Terminal states share the top rank — once terminal, status no longer moves. + OrderStatus.Delivered => 3, + OrderStatus.Cancelled => 3, + OrderStatus.Failed => 3, + _ => 0 + }; +} diff --git a/OrderService/OrderService/Events/RobotEventEnvelope.cs b/OrderService/OrderService/Events/RobotEventEnvelope.cs new file mode 100644 index 0000000..ce3c0cd --- /dev/null +++ b/OrderService/OrderService/Events/RobotEventEnvelope.cs @@ -0,0 +1,49 @@ +// Shape of the events the simulator publishes about a bot/delivery. +// Mirrors the shared envelope documented in docs/simulator-events.md. +// We only model the fields the Order Service needs to advance order status. +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OrderService.Events; + +public sealed class RobotEventEnvelope +{ + public string EventType { get; set; } = string.Empty; + + // Producing system, e.g. "robot-simulator". We ignore events we published ourselves. + public string? Source { get; set; } + + // Event-specific payload. Fields used vary by EventType. + public RobotEventData? Data { get; set; } +} + +// Union of the data fields we read across the event types we consume. +// Any given event populates only the subset relevant to it; the rest stay null. +public sealed class RobotEventData +{ + // RobotOrderAssignmentResponse / RobotDeliveryCompleted + public string? OrderId { get; set; } + + // RobotStatusUpdated + public string? ActiveOrderId { get; set; } + public string? PreviousOrderId { get; set; } + + // RobotOrderAssignmentResponse: "Accepted" | "Queued" | "Rejected" + public string? Result { get; set; } + + // RobotStatusUpdated: e.g. "OnDelivery" | "Available" + public string? CurrentStatus { get; set; } + + // RobotStatusUpdated: e.g. "OrderAcceptedDeliveryStarted" | "DeliveryCompletedNoQueuedOrders" + public string? Reason { get; set; } +} + +public static class RobotEventJson +{ + // Simulator emits camelCase; be lenient about casing and ignore unknown fields. + public static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} diff --git a/OrderService/OrderService/Program.cs b/OrderService/OrderService/Program.cs index 92f3db6..ba9ce42 100644 --- a/OrderService/OrderService/Program.cs +++ b/OrderService/OrderService/Program.cs @@ -2,6 +2,7 @@ // Runs EF Core migrations automatically on startup so tables are always up to date. using Microsoft.EntityFrameworkCore; using OrderService.Data; +using OrderService.Events; using OrderService.Services; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +14,8 @@ // ── Services ─────────────────────────────────────────────────────────────────── builder.Services.AddScoped(); +// Consumes bot events from Event Hub and advances order status (#41). +builder.Services.AddHostedService(); builder.Services.AddHttpClient(); // Nominatim requires a User-Agent header or it rejects requests builder.Services.AddHttpClient("Nominatim", client => diff --git a/OrderService/OrderService/Services/IOrderService.cs b/OrderService/OrderService/Services/IOrderService.cs index 21734e3..7a182da 100644 --- a/OrderService/OrderService/Services/IOrderService.cs +++ b/OrderService/OrderService/Services/IOrderService.cs @@ -1,6 +1,6 @@ // Defines the contract for the Order Service. -// Any class implementing this interface must provide these three methods. using OrderService.DTOs; +using OrderService.Events; namespace OrderService.Services; @@ -9,4 +9,7 @@ public interface IOrderService Task PlaceOrderAsync(PlaceOrderDto dto); Task GetOrderAsync(Guid id); Task> GetOrderHistoryAsync(string customerId); + + // Advances order status in response to a bot event from the simulator (#41). + Task ApplyStatusEventAsync(RobotEventEnvelope evt, CancellationToken ct = default); } diff --git a/OrderService/OrderService/Services/OrderService.cs b/OrderService/OrderService/Services/OrderService.cs index 38574cc..a1a3b27 100644 --- a/OrderService/OrderService/Services/OrderService.cs +++ b/OrderService/OrderService/Services/OrderService.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using OrderService.Data; using OrderService.DTOs; +using OrderService.Events; using OrderService.Models; namespace OrderService.Services; @@ -88,6 +89,36 @@ public async Task> GetOrderHistoryAsync(string cus return orders.Select(ToResponseDto); } + public async Task ApplyStatusEventAsync(RobotEventEnvelope evt, CancellationToken ct = default) + { + // Never react to events we published ourselves (RobotOrderAssignment). + if (string.Equals(evt.Source, "order-service", StringComparison.OrdinalIgnoreCase)) + return; + + foreach (var change in OrderStatusMapping.Map(evt)) + { + if (!Guid.TryParse(change.OrderId, out var orderId)) + continue; + + var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct); + if (order is null) + continue; // event for an order we don't own (or not yet persisted) + + // Forward-only: ignore duplicate/out-of-order events that would regress status. + if (!OrderStatusMapping.IsForward(order.Status, change.Status)) + continue; + + var previous = order.Status; + order.Status = change.Status; + order.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + _logger.LogInformation( + "Order status updated from bot event. OrderId={OrderId} {From}->{To} EventType={EventType}", + order.Id, previous, change.Status, evt.EventType); + } + } + // Calls OpenStreetMap Nominatim to convert a text address to GPS coordinates. // Falls back to downtown Spokane if geocoding fails so orders still go through. private async Task<(double Latitude, double Longitude)> GeocodeAddressAsync(string address) diff --git a/OrderService/OrderService/appsettings.json b/OrderService/OrderService/appsettings.json index 4973150..9f24dba 100644 --- a/OrderService/OrderService/appsettings.json +++ b/OrderService/OrderService/appsettings.json @@ -9,6 +9,11 @@ "ConnectionString": "", "Name": "robot-input" }, + "StatusConsumer": { + "ConnectionString": "", + "EventHubName": "", + "ConsumerGroup": "$Default" + }, "Logging": { "LogLevel": { "Default": "Information", From 531c0d8af285cc2768e67d2d8e3b044fce0f21e7 Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 2 Jun 2026 14:20:12 -0700 Subject: [PATCH 2/2] Align order items to the simulator's stock catalog Orders were rejected by the simulator ('Item beverage is not stocked by this bot') because OrderService emitted abstract item ids (food/beverage/package) while the simulated bots stock water/soda/chips/sandwich (RobotSimulator BotFleet). Map order options directly to the simulator catalog instead; default unrecognized values to water (always stocked) so orders aren't rejected. Verified live against the deployed simulator: it echoes our order GUID back in RobotOrderAssignmentResponse, confirming the status-consumer correlation. Note: this is Place Order (#40) contract logic, folded into this PR per request. Frontend dropdown (customer-webapp, crawfordkid2) still lists the old types and should be updated to water/soda/chips/sandwich as a follow-up. --- .../OrderService.Tests/OrderServiceTests.cs | 41 +++++++------------ .../OrderService/DTOs/PlaceOrderDto.cs | 4 +- .../OrderService/Services/OrderService.cs | 14 ++++--- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/OrderService/OrderService.Tests/OrderServiceTests.cs b/OrderService/OrderService.Tests/OrderServiceTests.cs index 0dcfdd7..6204435 100644 --- a/OrderService/OrderService.Tests/OrderServiceTests.cs +++ b/OrderService/OrderService.Tests/OrderServiceTests.cs @@ -36,7 +36,7 @@ private static (Services.OrderService svc, OrderDbContext db) CreateService( return (new Services.OrderService(db, factory, config, logger), db); } - private static PlaceOrderDto MakeOrder(string orderType = "Food Order") => new() + private static PlaceOrderDto MakeOrder(string orderType = "water") => new() { CustomerName = "Jane", Phone = "555-1234", @@ -71,42 +71,31 @@ private static HttpResponseMessage DispatchByUrl( : BotListJson(botAvailable); } - // ── MapOrderTypeToItems tests (verified through PlaceOrderAsync result) ──── + // ── MapOrderTypeToItems tests — items mirror the simulator catalog ───────── + // (water/soda/chips/sandwich; verified through PlaceOrderAsync result) - [Fact] - public async Task PlaceOrder_FoodOrder_CreatesFoodItem() - { - var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); - var result = await svc.PlaceOrderAsync(MakeOrder("Food Order")); - - Assert.Contains(result.Items, i => i.ItemId == "food"); - } - - [Fact] - public async Task PlaceOrder_BeverageOrder_CreatesBeverageItem() - { - var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); - var result = await svc.PlaceOrderAsync(MakeOrder("Beverage Order")); - - Assert.Contains(result.Items, i => i.ItemId == "beverage"); - } - - [Fact] - public async Task PlaceOrder_SmallPackage_CreatesPackageItem() + [Theory] + [InlineData("water", "water")] + [InlineData("soda", "soda")] + [InlineData("chips", "chips")] + [InlineData("sandwich", "sandwich")] + [InlineData("Sandwich", "sandwich")] // display-name / casing tolerated + public async Task PlaceOrder_MapsCatalogItem(string orderType, string expectedItemId) { var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); - var result = await svc.PlaceOrderAsync(MakeOrder("Small Package")); + var result = await svc.PlaceOrderAsync(MakeOrder(orderType)); - Assert.Contains(result.Items, i => i.ItemId == "package"); + Assert.Contains(result.Items, i => i.ItemId == expectedItemId); } [Fact] - public async Task PlaceOrder_UnknownOrderType_DefaultsToFoodItem() + public async Task PlaceOrder_UnknownOrderType_DefaultsToWater() { var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); var result = await svc.PlaceOrderAsync(MakeOrder("Mystery Order")); - Assert.Contains(result.Items, i => i.ItemId == "food"); + // Falls back to water, which bots always stock, so the order isn't rejected. + Assert.Contains(result.Items, i => i.ItemId == "water"); } // ── Bot selection / order status tests ──────────────────────────────────── diff --git a/OrderService/OrderService/DTOs/PlaceOrderDto.cs b/OrderService/OrderService/DTOs/PlaceOrderDto.cs index bbe49ce..1a31a2a 100644 --- a/OrderService/OrderService/DTOs/PlaceOrderDto.cs +++ b/OrderService/OrderService/DTOs/PlaceOrderDto.cs @@ -17,8 +17,10 @@ public class PlaceOrderDto [Required] public string DeliveryAddress { get; set; } = string.Empty; + // The catalog item being ordered — mirrors the simulator's bot stock: + // water | soda | chips | sandwich (item id or display name, case-insensitive). [Required] - public string OrderType { get; set; } = "Food Order"; + public string OrderType { get; set; } = "water"; public string DeliveryNotes { get; set; } = string.Empty; } diff --git a/OrderService/OrderService/Services/OrderService.cs b/OrderService/OrderService/Services/OrderService.cs index a1a3b27..076a137 100644 --- a/OrderService/OrderService/Services/OrderService.cs +++ b/OrderService/OrderService/Services/OrderService.cs @@ -156,13 +156,17 @@ public async Task ApplyStatusEventAsync(RobotEventEnvelope evt, CancellationToke } } - // Maps the UI order type dropdown to item IDs the simulator recognizes + // Order options mirror the simulator's bot stock catalog (RobotSimulator BotFleet): + // water, soda, chips, sandwich. Bots reject anything they don't stock, so the + // Order Service follows the simulator's catalog directly rather than mapping + // abstract order types. Accepts the item id ("water") or display name ("Water"). private static List<(string ItemId, int Quantity)> MapOrderTypeToItems(string orderType) => - orderType switch + orderType?.Trim().ToLowerInvariant() switch { - "Beverage Order" => [("beverage", 1)], - "Small Package" => [("package", 1)], - _ => [("food", 1)] // "Food Order" and any unknown type + "soda" => [("soda", 1)], + "chips" => [("chips", 1)], + "sandwich" => [("sandwich", 1)], + _ => [("water", 1)] // "water" and any unrecognized value → water (always stocked) }; // Calls BotNetApi and returns the Name of the first available bot