feat(api): .NET 10 multi-tenant REST API
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>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user