Files
soroush.asadi ef15fd6247 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>
2026-05-27 21:33:48 +03:30

227 lines
8.4 KiB
C#

using Meezi.API.Models.Orders;
using Meezi.API.Services;
using LiveOrderDto = Meezi.API.Models.Orders.LiveOrderDto;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace Meezi.API.Tests;
public class OrderSessionTests
{
private sealed class NullKdsNotifier : 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 static (AppDbContext Db, OrderService Orders, TableService Tables, string CafeId, string TableId, string MenuItemId)
CreateFixture()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new AppDbContext(options);
var cafeId = "cafe-1";
var branchId = "branch-1";
var tableId = "table-1";
var menuItemId = "item-1";
var categoryId = "cat-1";
var userId = "user-1";
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Test", Slug = "test", PlanTier = PlanTier.Pro });
db.Branches.Add(new Branch
{
Id = branchId,
CafeId = cafeId,
Name = "Main",
IsActive = true,
UpdatedAt = DateTime.UtcNow
});
db.Employees.Add(new Employee
{
Id = userId,
CafeId = cafeId,
BranchId = branchId,
Name = "Cashier",
Phone = "09121111111",
Role = EmployeeRole.Cashier
});
db.Tables.Add(new Table
{
Id = tableId,
CafeId = cafeId,
BranchId = branchId,
Number = "5",
QrCode = "qr1",
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 = "Espresso",
NameEn = "Espresso",
Price = 100_000m,
IsAvailable = true
});
db.SaveChanges();
var shifts = new ShiftService(db);
shifts.OpenShiftAsync(cafeId, branchId, 0m, userId).GetAwaiter().GetResult();
var kds = new NoOpKdsNotifier();
var snapp = new NoOpSnappfood();
var orders = new OrderService(db, kds, snapp, new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
var tables = new TableService(
db,
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
new NullKdsNotifier(),
new BranchIdentityService(db));
return (db, orders, tables, cafeId, tableId, menuItemId);
}
private static CreateOrderRequest DineInRequest(string tableId, string menuItemId, int qty = 1) =>
new(
OrderType.DineIn,
"branch-1",
tableId,
null,
"Ali",
"09121111111",
null,
null,
[new CreateOrderItemRequest(menuItemId, qty, null)]);
[Fact]
public async Task CreateOrder_SameTable_MergesIntoSingleOpenOrder()
{
var (_, orders, _, cafeId, tableId, menuItemId) = CreateFixture();
var tenant = new TestTenant(cafeId, "user-1");
var first = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
var second = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId, 2), default);
Assert.True(first.Success);
Assert.True(second.Success);
Assert.Equal(first.Data!.Id, second.Data!.Id);
Assert.Equal(3, second.Data!.Items.Sum(i => i.Quantity));
}
[Fact]
public async Task AppendItems_IncreasesTotal()
{
var (_, orders, _, cafeId, tableId, menuItemId) = CreateFixture();
var tenant = new TestTenant(cafeId, "user-1");
var created = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
var append = await orders.AppendOrderItemsAsync(
cafeId,
created.Data!.Id,
new AppendOrderItemsRequest([new CreateOrderItemRequest(menuItemId, 1, null)]),
default);
Assert.True(append.Success);
Assert.Equal(200_000m, append.Data!.Subtotal);
}
[Fact]
public async Task GetOpenOrders_SearchByPhone_FindsOrder()
{
var (_, orders, _, cafeId, tableId, menuItemId) = CreateFixture();
var tenant = new TestTenant(cafeId, "user-1");
await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
var found = await orders.GetOpenOrdersAsync(cafeId, "09121111111", default);
Assert.Single(found);
Assert.Equal("09121111111", found[0].GuestPhone);
}
[Fact]
public async Task Payment_CompletesOrder_TableBoardShowsFree()
{
var (_, orders, tables, cafeId, tableId, menuItemId) = CreateFixture();
var tenant = new TestTenant(cafeId, "user-1");
var created = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
var order = created.Data!;
var paid = await orders.RecordPaymentsAsync(
cafeId,
order.Id,
new RecordPaymentsRequest([new CreatePaymentRequest(PaymentMethod.Cash, order.Total, null)]),
"user-1",
default);
Assert.True(paid.Success);
var board = await tables.GetTableBoardAsync(cafeId, cancellationToken: default);
var tile = board.First(t => t.Id == tableId);
Assert.Equal(TableBoardStatus.Free, tile.Status);
}
[Fact]
public async Task CleaningTable_BlocksNewOrder()
{
var (db, orders, tables, cafeId, tableId, menuItemId) = CreateFixture();
var table = await db.Tables.FindAsync(tableId);
table!.IsCleaning = true;
await db.SaveChangesAsync();
var tenant = new TestTenant(cafeId, "user-1");
var result = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
Assert.False(result.Success);
Assert.Equal("TABLE_NOT_AVAILABLE", result.ErrorCode);
}
private sealed class TestTenant(string cafeId, string userId) : ITenantContext
{
public string? CafeId => cafeId;
public string? UserId => userId;
public EmployeeRole? Role => EmployeeRole.Owner;
public PlanTier? PlanTier => Core.Enums.PlanTier.Pro;
public string? Language => "fa";
public string? BranchId => null;
public bool IsSystemAdmin => false;
public bool IsAuthenticated => true;
}
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;
}
}