ef15fd6247
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
213 lines
8.5 KiB
C#
213 lines
8.5 KiB
C#
using Meezi.API.Models.Orders;
|
|
using Meezi.API.Models.Shifts;
|
|
using Meezi.API.Services;
|
|
using Meezi.Core.Entities;
|
|
using Meezi.Core.Enums;
|
|
using Meezi.Core.Interfaces;
|
|
using Meezi.Infrastructure.Data;
|
|
using TenantContext = Meezi.Infrastructure.Data.TenantContext;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Xunit;
|
|
|
|
namespace Meezi.API.Tests;
|
|
|
|
public class OrderVoidTransferTests
|
|
{
|
|
private static (
|
|
AppDbContext Db,
|
|
OrderService Orders,
|
|
string CafeId,
|
|
string Table1Id,
|
|
string Table2Id,
|
|
string MenuItemId,
|
|
TenantContext Tenant)
|
|
CreateFixture()
|
|
{
|
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
var db = new AppDbContext(options);
|
|
|
|
var cafeId = "cafe-void";
|
|
var table1 = "table-1";
|
|
var table2 = "table-2";
|
|
var menuItemId = "item-1";
|
|
var categoryId = "cat-1";
|
|
var managerId = "emp-manager";
|
|
|
|
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Test", Slug = "test-void", PlanTier = PlanTier.Pro });
|
|
db.Tables.Add(new Table { Id = table1, CafeId = cafeId, Number = "1", QrCode = "qr1", Capacity = 4 });
|
|
db.Tables.Add(new Table { Id = table2, CafeId = cafeId, Number = "2", QrCode = "qr2", Capacity = 4 });
|
|
db.MenuCategories.Add(new MenuCategory
|
|
{
|
|
Id = categoryId,
|
|
CafeId = cafeId,
|
|
Name = "Drinks",
|
|
NameEn = "Drinks",
|
|
SortOrder = 0
|
|
});
|
|
db.MenuItems.Add(new MenuItem
|
|
{
|
|
Id = menuItemId,
|
|
CafeId = cafeId,
|
|
CategoryId = categoryId,
|
|
Name = "Tea",
|
|
NameEn = "Tea",
|
|
Price = 50_000m,
|
|
IsAvailable = true
|
|
});
|
|
db.Employees.Add(new Employee
|
|
{
|
|
Id = managerId,
|
|
CafeId = cafeId,
|
|
Name = "Manager",
|
|
Phone = "09120000000",
|
|
Role = EmployeeRole.Manager
|
|
});
|
|
db.SaveChanges();
|
|
|
|
var tenant = new TenantContext
|
|
{
|
|
CafeId = cafeId,
|
|
UserId = managerId,
|
|
Role = EmployeeRole.Manager,
|
|
PlanTier = PlanTier.Pro,
|
|
Language = "fa"
|
|
};
|
|
|
|
var orders = new OrderService(db, new NoOpKdsNotifier(), new NoOpSnappfood(), new NoOpDeliverySync(), new NoOpShiftService(), TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
|
return (db, orders, cafeId, table1, table2, menuItemId, tenant);
|
|
}
|
|
|
|
private static async Task<OrderDto> CreateOpenOrderAsync(
|
|
OrderService orders,
|
|
string cafeId,
|
|
TenantContext tenant,
|
|
string tableId,
|
|
string menuItemId,
|
|
int qty = 1)
|
|
{
|
|
var result = await orders.CreateOrderAsync(
|
|
cafeId,
|
|
tenant,
|
|
new CreateOrderRequest(
|
|
OrderType.DineIn,
|
|
null,
|
|
tableId,
|
|
null,
|
|
"Guest",
|
|
null,
|
|
null,
|
|
null,
|
|
[new CreateOrderItemRequest(menuItemId, qty, null)]));
|
|
Assert.True(result.Success);
|
|
return result.Data!;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VoidItem_ReducesOrderTotal()
|
|
{
|
|
var (_, orders, cafeId, table1, _, menuItemId, tenant) = CreateFixture();
|
|
var order = await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId, 2);
|
|
var itemId = order.Items[0].Id;
|
|
var before = order.Total;
|
|
|
|
var result = await orders.VoidOrderItemAsync(cafeId, order.Id, itemId, tenant.UserId!);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.True(result.Data!.Items.First(i => i.Id == itemId).IsVoided);
|
|
Assert.True(result.Data.Total < before);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VoidItem_AlreadyVoided_ReturnsError()
|
|
{
|
|
var (_, orders, cafeId, table1, _, menuItemId, tenant) = CreateFixture();
|
|
var order = await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId);
|
|
var itemId = order.Items[0].Id;
|
|
|
|
await orders.VoidOrderItemAsync(cafeId, order.Id, itemId, tenant.UserId!);
|
|
var second = await orders.VoidOrderItemAsync(cafeId, order.Id, itemId, tenant.UserId!);
|
|
|
|
Assert.False(second.Success);
|
|
Assert.Equal("ITEM_ALREADY_VOIDED", second.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TransferTable_MovesOrderToFreeTable()
|
|
{
|
|
var (_, orders, cafeId, table1, table2, menuItemId, tenant) = CreateFixture();
|
|
var order = await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId);
|
|
|
|
var result = await orders.TransferTableAsync(cafeId, order.Id, table2);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal(table2, result.Data!.TableId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TransferTable_ToOccupiedTable_ReturnsTableOccupied()
|
|
{
|
|
var (_, orders, cafeId, table1, table2, menuItemId, tenant) = CreateFixture();
|
|
await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId);
|
|
var order2 = await CreateOpenOrderAsync(orders, cafeId, tenant, table2, menuItemId);
|
|
|
|
var result = await orders.TransferTableAsync(cafeId, order2.Id, table1);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal("TABLE_OCCUPIED", result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TransferTable_ToCleaningTable_ReturnsTableCleaning()
|
|
{
|
|
var (db, orders, cafeId, table1, table2, menuItemId, tenant) = CreateFixture();
|
|
var order = await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId);
|
|
|
|
var table = await db.Tables.FirstAsync(t => t.Id == table2);
|
|
table.IsCleaning = true;
|
|
await db.SaveChangesAsync();
|
|
|
|
var result = await orders.TransferTableAsync(cafeId, order.Id, table2);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal("TABLE_CLEANING", result.ErrorCode);
|
|
}
|
|
|
|
private sealed class NoOpKdsNotifier : IKdsNotifier
|
|
{
|
|
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken cancellationToken = default) =>
|
|
Task.CompletedTask;
|
|
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default) =>
|
|
Task.CompletedTask;
|
|
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken cancellationToken = default) =>
|
|
Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class NoOpSnappfood : ISnappfoodClient
|
|
{
|
|
public Task AcknowledgeOrderAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
|
Task.CompletedTask;
|
|
public Task NotifyOrderDeliveredAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
|
Task.CompletedTask;
|
|
public Task NotifyOrderStatusAsync(string snappfoodOrderId, string status, CancellationToken cancellationToken = default) =>
|
|
Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class NoOpShiftService : IShiftService
|
|
{
|
|
public Task<ShiftServiceResult<ShiftDto>> OpenShiftAsync(string cafeId, string branchId, decimal openingCash, string userId, CancellationToken cancellationToken = default) =>
|
|
throw new NotImplementedException();
|
|
public Task<ShiftServiceResult<ShiftDto>> CloseShiftAsync(string cafeId, string shiftId, decimal closingCash, string userId, CancellationToken cancellationToken = default) =>
|
|
throw new NotImplementedException();
|
|
public Task<ShiftDto?> GetCurrentShiftAsync(string cafeId, string branchId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult<ShiftDto?>(null);
|
|
public Task<IReadOnlyList<CashTransactionDto>?> GetTransactionsAsync(string cafeId, string shiftId, CancellationToken cancellationToken = default) =>
|
|
throw new NotImplementedException();
|
|
public Task<ShiftServiceResult<CashTransactionDto>> RecordTransactionAsync(string cafeId, string shiftId, CashTransactionType type, PaymentMethod method, decimal amount, string createdByUserId, string? referenceId = null, string? note = null, CancellationToken cancellationToken = default) =>
|
|
throw new NotImplementedException();
|
|
public Task<ShiftServiceResult<Shift>> RequireOpenShiftForBranchAsync(string cafeId, string branchId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new ShiftServiceResult<Shift>(true, new Shift { Id = "shift-test", CafeId = cafeId, BranchId = branchId }));
|
|
}
|
|
}
|