Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/common/FastFood.Common/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ public static class EventNames
public const string OrderConfirmed = "orderconfirmed";
public const string OrderPaid = "orderpaid";
public const string KitchenItemFinished = "kitchenitemfinished";
public const string KitchenItemInPreparation = "kitcheniteminpreparation";
public const string KitchenOrderStartProcessing = "kitchenorderstartprocessing";
public const string OrderPrepared = "orderprepared";
public const string OrderClosed = "orderclosed";
public const string DeadLetterOrderPaid = "deadletter_orderpaid";
public const string DeadLetterKitchenOrderStartProcessing = "deadletter_kitchenorderstartprocessing";
public const string DeadLetterKitchenItemFinished = "deadletter_kitchenitemfinished";
public const string DeadLetterKitchenItemInPreparation = "deadletter_kitcheniteminpreparation";
}

public static class Services
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using FastFood.Common;
using FrontendKitchenMonitor.Controllers;
using KitchenService.Common.Events;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Moq;

namespace FrontendKitchenMonitor.Unit.Tests.Controllers;

public class FrontendKitchenMonitorEventHandlerControllerTests
{
private readonly Mock<IHubContext<KitchenWorkUpdateHub>> _hubContextMock;
private readonly Mock<ILogger<FrontendKitchenMonitorEventHandlerController>> _loggerMock;
private readonly FrontendKitchenMonitorEventHandlerController _controller;
private readonly Mock<IHubClients> _hubClientsMock;
private readonly Mock<IClientProxy> _clientProxyMock;

public FrontendKitchenMonitorEventHandlerControllerTests()
{
_hubContextMock = new Mock<IHubContext<KitchenWorkUpdateHub>>();
_loggerMock = new Mock<ILogger<FrontendKitchenMonitorEventHandlerController>>();
_hubClientsMock = new Mock<IHubClients>();
_clientProxyMock = new Mock<IClientProxy>();

_hubContextMock.Setup(h => h.Clients).Returns(_hubClientsMock.Object);
_hubClientsMock.Setup(c => c.Group(It.IsAny<string>())).Returns(_clientProxyMock.Object);
_clientProxyMock.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);

_controller = new FrontendKitchenMonitorEventHandlerController(_hubContextMock.Object, _loggerMock.Object);
}

[Fact]
public async Task KitchenItemInPreparation_ValidEvent_BroadcastsSignalR()
{
// Arrange
var orderId = Guid.NewGuid();
var itemId = Guid.NewGuid();
var evt = new KitchenItemInPreparationEvent { OrderId = orderId, ItemId = itemId };

// Act
var result = await _controller.KitchenItemInPreparation(evt);

// Assert
Assert.IsType<OkResult>(result);
_hubClientsMock.Verify(c => c.Group(FrontendKitchenMonitor.Constants.HubGroupKitchenMonitors), Times.Once);
_clientProxyMock.Verify(p => p.SendCoreAsync("kitchenorderupdated", It.IsAny<object[]>(), It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task KitchenItemInPreparation_HubThrowsException_ReturnsInternalServerError()
{
// Arrange
var orderId = Guid.NewGuid();
var itemId = Guid.NewGuid();
var evt = new KitchenItemInPreparationEvent { OrderId = orderId, ItemId = itemId };

_clientProxyMock.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Hub error"));

// Act
var result = await _controller.KitchenItemInPreparation(evt);

// Assert
var statusResult = Assert.IsType<StatusCodeResult>(result);
Assert.Equal(500, statusResult.StatusCode);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,46 @@ public async Task SetItemAsFinished_DaprClientThrowsException_ReturnsInternalSer
var statusResult = Assert.IsType<ObjectResult>(result.Result);
Assert.Equal(500, statusResult.StatusCode);
}

[Fact]
public async Task SetItemAsInPreparation_ValidId_ReturnsItem()
{
// Arrange
var itemId = Guid.NewGuid();
var expectedItem = new KitchenOrderItemDto { Id = itemId };

var request = new HttpRequestMessage();
_daprClientMock.Setup(m => m.CreateInvokeMethodRequest(HttpMethod.Post, FastFoodConstants.Services.KitchenService, $"api/kitchenwork/iteminpreparation/{itemId}"))
.Returns(request);
_daprClientMock.Setup(m => m.InvokeMethodAsync<KitchenOrderItemDto>(request, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedItem);

// Act
var result = await _controller.SetItemAsInPreparation(itemId);

// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var item = Assert.IsType<KitchenOrderItemDto>(okResult.Value);
Assert.Equal(itemId, item.Id);
}

[Fact]
public async Task SetItemAsInPreparation_DaprClientThrowsException_ReturnsInternalServerError()
{
// Arrange
var itemId = Guid.NewGuid();
var request = new HttpRequestMessage();

_daprClientMock.Setup(m => m.CreateInvokeMethodRequest(HttpMethod.Post, FastFoodConstants.Services.KitchenService, $"api/kitchenwork/iteminpreparation/{itemId}"))
.Returns(request);
_daprClientMock.Setup(m => m.InvokeMethodAsync<KitchenOrderItemDto>(request, It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Test exception"));

// Act
var result = await _controller.SetItemAsInPreparation(itemId);

// Assert
var statusResult = Assert.IsType<ObjectResult>(result.Result);
Assert.Equal(500, statusResult.StatusCode);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,24 @@ await _hubContext.Clients.Group(Constants.HubGroupKitchenMonitors)
return StatusCode(500);
}
}

[HttpPost("kitcheniteminpreparation")]
[Topic(FastFoodConstants.PubSubName, FastFoodConstants.EventNames.KitchenItemInPreparation)]
public async Task<ActionResult> KitchenItemInPreparation(KitchenItemInPreparationEvent itemInPreparationEvent)
{
try
{
_logger.LogInformation("Kitchen item in preparation event received: {OrderId}", itemInPreparationEvent.OrderId);
// Broadcast to all clients subscribed to this orderId
await _hubContext.Clients.Group(Constants.HubGroupKitchenMonitors)
.SendAsync("kitchenorderupdated", itemInPreparationEvent.OrderId);

return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing kitchen item in preparation event: {OrderId}/{ItemId}", itemInPreparationEvent.OrderId, itemInPreparationEvent.ItemId);
return StatusCode(500);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ public async Task<ActionResult<IEnumerable<KitchenOrderItemDto>>> GetPendingItem
}
}

// sets an item as in preparation
[HttpPost("iteminpreparation/{id}")]
public async Task<ActionResult<KitchenOrderItemDto>> SetItemAsInPreparation(Guid id)
{
try
{
var item = await _daprClient.InvokeMethodAsync<KitchenOrderItemDto>(HttpMethod.Post, FastFoodConstants.Services.KitchenService, $"{ApiPrefix}/iteminpreparation/{id}");
return Ok(item);
}
catch
{
return StatusCode(500, "Failed to set item as in preparation.");
}
}

// sets an item as finished
[HttpPost("itemfinished/{id}")]
public async Task<ActionResult<KitchenOrderItemDto>> SetItemAsFinished(Guid id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ onMounted(async () => {
await ks.initializeSignalRHub()
})

// Presentational projection with items sorted: unfinished first, finished at the bottom
// Presentational projection with items sorted: AwaitingPreparation first, InPreparation second, Finished last
const pendingOrders = computed(() => ks.pendingOrders.map(x => {
const items = (x.items?.map(y => ({ id: y.id, name: y.productDescription, quantity: y.quantity, state: y.state })) ?? [])
.slice()
.sort((a, b) => {
const aFinished = a.state === 'Finished'
const bFinished = b.state === 'Finished'
if (aFinished === bFinished) return 0
return aFinished ? 1 : -1 // push finished to the bottom
const stateOrder = { 'AwaitingPreparation': 0, 'InPreparation': 1, 'Finished': 2 }
const aOrder = stateOrder[a.state] ?? 0
const bOrder = stateOrder[b.state] ?? 0
return aOrder - bOrder
})
return {
id: x.id,
Expand All @@ -26,6 +26,11 @@ const pendingOrders = computed(() => ks.pendingOrders.map(x => {
}
}))

async function startOrderItem(itemId) {
await ks.startOrderItemPreparation(itemId)
await ks.fetchPendingOrders()
}

async function finishOrderItem(itemId) {
await ks.finishOrderItem(itemId)
await ks.fetchPendingOrders()
Expand All @@ -39,9 +44,9 @@ async function finishOrderItem(itemId) {
<div
v-for="order in pendingOrders"
:key="order.id"
class="p-4 bg-white rounded shadow"
class="p-4 bg-white dark:bg-gray-800 rounded shadow"
:data-testid="`order-card-${order.name.toLowerCase()}`">
<h2 class="text-xl font-semibold mb-2" :data-testid="`order-title-${order.name.toLowerCase()}`">{{ order.name }} ({{ order.id }})</h2>
<h2 class="text-xl font-semibold mb-2 dark:text-white" :data-testid="`order-title-${order.name.toLowerCase()}`">{{ order.name }} ({{ order.id }})</h2>
<ul class="space-y-2" :data-testid="`order-items-${order.name.toLowerCase()}`">
<li
v-for="item in order.orderItems"
Expand All @@ -50,20 +55,29 @@ async function finishOrderItem(itemId) {
:data-testid="`order-item-${item.id}`"
:data-order-ref="order.name.toLowerCase()"
:data-product-name="item.name">
<span>
<span class="dark:text-gray-200">
<span :data-testid="`item-quantity-${item.id}`">{{ item.quantity }}</span>×
<span :data-testid="`item-name-${item.id}`">{{ item.name }}</span>
</span>
<div class="space-x-2">
<div class="space-x-2 flex items-center">
<span
v-if="item.state === 'Finished'"
class="text-green-600 font-semibold"
class="text-green-600 dark:text-green-400 font-semibold"
:data-testid="`item-finished-${item.id}`">Finished</span>
<template v-else-if="item.state === 'InPreparation'">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 animate-pulse"
:data-testid="`item-in-preparation-${item.id}`">In Progress</span>
<button
class="px-3 py-1 bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded"
:data-testid="`finish-item-btn-${item.id}`"
@click="finishOrderItem(item.id)">Finish</button>
</template>
<button
v-else
class="px-3 py-1 bg-blue-500 text-white rounded"
:data-testid="`finish-button-${item.id}`"
@click="finishOrderItem(item.id)">Finish</button>
v-else
class="px-3 py-1 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded"
:data-testid="`start-item-btn-${item.id}`"
@click="startOrderItem(item.id)">Start</button>
</div>
</li>
</ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ export default {
async finishOrderItem(itemId) {
return apiClient.post(`/kitchenwork/itemfinished/${itemId}`);
},
async startOrderItemPreparation(itemId) {
return apiClient.post(`/kitchenwork/iteminpreparation/${itemId}`);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export const useKitchenStore = defineStore('kitchen', {
async finishOrderItem(itemId) {
try { await apiClient.finishOrderItem(itemId) } catch (e) { console.error('Error finishing order item:', e) }
},
async startOrderItemPreparation(itemId) {
try { await apiClient.startOrderItemPreparation(itemId) } catch (e) { console.error('Error starting order item preparation:', e) }
},
async initializeSignalRHub() {
const connection = new signalR.HubConnectionBuilder().withUrl('/kitchenorderupdatehub').build()
// Refresh full list on updates; avoids 404s when an order is removed server-side
Expand Down
Loading
Loading