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,213 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services;
|
||||
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 ShiftServiceTests
|
||||
{
|
||||
private static (
|
||||
AppDbContext Db,
|
||||
ShiftService Shifts,
|
||||
OrderService Orders,
|
||||
string CafeId,
|
||||
string BranchId,
|
||||
string UserId,
|
||||
string MenuItemId)
|
||||
CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
|
||||
var cafeId = "cafe-shift";
|
||||
var branchId = "branch-main";
|
||||
var userId = "emp-cashier";
|
||||
var menuItemId = "item-1";
|
||||
var categoryId = "cat-1";
|
||||
|
||||
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Shift Cafe", Slug = "shift-cafe", 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.MenuCategories.Add(new MenuCategory
|
||||
{
|
||||
Id = categoryId,
|
||||
CafeId = cafeId,
|
||||
Name = "Menu",
|
||||
NameEn = "Menu",
|
||||
SortOrder = 0
|
||||
});
|
||||
db.MenuItems.Add(new MenuItem
|
||||
{
|
||||
Id = menuItemId,
|
||||
CafeId = cafeId,
|
||||
CategoryId = categoryId,
|
||||
Name = "Tea",
|
||||
NameEn = "Tea",
|
||||
Price = 100_000m,
|
||||
IsAvailable = true
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var shifts = new ShiftService(db);
|
||||
var orders = new OrderService(db, new NoOpKdsNotifier(), new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
||||
return (db, shifts, orders, cafeId, branchId, userId, menuItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenShift_Succeeds()
|
||||
{
|
||||
var (_, shifts, _, cafeId, branchId, userId, _) = CreateFixture();
|
||||
|
||||
var result = await shifts.OpenShiftAsync(cafeId, branchId, 500_000m, userId);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ShiftStatus.Open, result.Data!.Status);
|
||||
Assert.Equal(500_000m, result.Data.OpeningCash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenShift_WhenAlreadyOpen_ReturnsShiftAlreadyOpen()
|
||||
{
|
||||
var (_, shifts, _, cafeId, branchId, userId, _) = CreateFixture();
|
||||
|
||||
await shifts.OpenShiftAsync(cafeId, branchId, 100m, userId);
|
||||
var second = await shifts.OpenShiftAsync(cafeId, branchId, 200m, userId);
|
||||
|
||||
Assert.False(second.Success);
|
||||
Assert.Equal("SHIFT_ALREADY_OPEN", second.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CloseShift_CalculatesDiscrepancy_FromCashPaymentsAndWithdrawals()
|
||||
{
|
||||
var (_, shifts, _, cafeId, branchId, userId, _) = CreateFixture();
|
||||
|
||||
var opened = await shifts.OpenShiftAsync(cafeId, branchId, 1_000_000m, userId);
|
||||
var shiftId = opened.Data!.Id;
|
||||
|
||||
await shifts.RecordTransactionAsync(
|
||||
cafeId, shiftId, CashTransactionType.OrderPayment, PaymentMethod.Cash, 300_000m, userId);
|
||||
await shifts.RecordTransactionAsync(
|
||||
cafeId, shiftId, CashTransactionType.OrderPayment, PaymentMethod.Card, 200_000m, userId);
|
||||
await shifts.RecordTransactionAsync(
|
||||
cafeId, shiftId, CashTransactionType.Withdrawal, PaymentMethod.Cash, 50_000m, userId);
|
||||
|
||||
var closed = await shifts.CloseShiftAsync(cafeId, shiftId, 1_300_000m, userId);
|
||||
|
||||
Assert.True(closed.Success);
|
||||
Assert.Equal(1_250_000m, closed.Data!.ExpectedCash);
|
||||
Assert.Equal(50_000m, closed.Data.Discrepancy);
|
||||
Assert.Equal(ShiftStatus.Closed, closed.Data.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordPayments_WithOpenShift_RecordsCashTransactions()
|
||||
{
|
||||
var (db, shifts, orders, cafeId, branchId, userId, menuItemId) = CreateFixture();
|
||||
await shifts.OpenShiftAsync(cafeId, branchId, 0m, userId);
|
||||
|
||||
var tenant = new TenantContext { CafeId = cafeId, UserId = userId, Role = EmployeeRole.Cashier };
|
||||
var created = await orders.CreateOrderAsync(
|
||||
cafeId,
|
||||
tenant,
|
||||
new CreateOrderRequest(
|
||||
OrderType.DineIn,
|
||||
branchId,
|
||||
null,
|
||||
null,
|
||||
"Guest",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[new CreateOrderItemRequest(menuItemId, 1, null)]));
|
||||
Assert.True(created.Success);
|
||||
|
||||
var pay = await orders.RecordPaymentsAsync(
|
||||
cafeId,
|
||||
created.Data!.Id,
|
||||
new RecordPaymentsRequest(
|
||||
[new CreatePaymentRequest(PaymentMethod.Cash, 100_000m, null),
|
||||
new CreatePaymentRequest(PaymentMethod.Card, 50_000m, null)]),
|
||||
userId);
|
||||
|
||||
Assert.True(pay.Success);
|
||||
|
||||
var shift = await db.RegisterShifts.Include(s => s.Transactions)
|
||||
.FirstAsync(s => s.BranchId == branchId && s.Status == ShiftStatus.Open);
|
||||
Assert.Equal(2, shift.Transactions.Count);
|
||||
Assert.Contains(shift.Transactions, t => t.Method == PaymentMethod.Cash && t.Amount == 100_000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordPayments_WithoutOpenShift_ReturnsNoOpenShift()
|
||||
{
|
||||
var (_, _, orders, cafeId, branchId, userId, menuItemId) = CreateFixture();
|
||||
|
||||
var tenant = new TenantContext { CafeId = cafeId, UserId = userId };
|
||||
var created = await orders.CreateOrderAsync(
|
||||
cafeId,
|
||||
tenant,
|
||||
new CreateOrderRequest(
|
||||
OrderType.DineIn,
|
||||
branchId,
|
||||
null,
|
||||
null,
|
||||
"Guest",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[new CreateOrderItemRequest(menuItemId, 1, null)]));
|
||||
Assert.True(created.Success);
|
||||
|
||||
var pay = await orders.RecordPaymentsAsync(
|
||||
cafeId,
|
||||
created.Data!.Id,
|
||||
new RecordPaymentsRequest([new CreatePaymentRequest(PaymentMethod.Cash, 100_000m, null)]),
|
||||
userId);
|
||||
|
||||
Assert.False(pay.Success);
|
||||
Assert.Equal("NO_OPEN_SHIFT", pay.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user