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,230 @@
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
using LiveOrderDto = Meezi.API.Models.Orders.LiveOrderDto;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class QrMenuTests
|
||||
{
|
||||
private sealed class NoOpKds : IKdsNotifier
|
||||
{
|
||||
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static (
|
||||
AppDbContext Db,
|
||||
TableService Tables,
|
||||
PublicService Public,
|
||||
string CafeId,
|
||||
string BranchId,
|
||||
string TableId,
|
||||
string ItemA,
|
||||
string ItemB,
|
||||
string QrCode
|
||||
) CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
|
||||
var cafeId = "cafe-qr";
|
||||
var branchId = "branch-qr";
|
||||
var catId = "cat-qr";
|
||||
var itemA = "item-qr-a";
|
||||
var itemB = "item-qr-b";
|
||||
var tableId = "table-qr-1";
|
||||
const string qrCode = "demo_table_01";
|
||||
|
||||
db.Cafes.Add(new Cafe
|
||||
{
|
||||
Id = cafeId,
|
||||
Name = "Demo",
|
||||
Slug = "demo",
|
||||
PlanTier = PlanTier.Pro,
|
||||
DefaultTaxRate = 9m
|
||||
});
|
||||
db.Branches.Add(new Branch
|
||||
{
|
||||
Id = branchId,
|
||||
CafeId = cafeId,
|
||||
Name = "Main",
|
||||
IsActive = true,
|
||||
WelcomeText = "خوش آمدید",
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
db.MenuCategories.Add(new MenuCategory
|
||||
{
|
||||
Id = catId,
|
||||
CafeId = cafeId,
|
||||
Name = "Drinks",
|
||||
SortOrder = 0,
|
||||
IsActive = true
|
||||
});
|
||||
db.MenuItems.AddRange(
|
||||
new MenuItem
|
||||
{
|
||||
Id = itemA,
|
||||
CafeId = cafeId,
|
||||
CategoryId = catId,
|
||||
Name = "Espresso",
|
||||
Price = 100_000m,
|
||||
IsAvailable = true
|
||||
},
|
||||
new MenuItem
|
||||
{
|
||||
Id = itemB,
|
||||
CafeId = cafeId,
|
||||
CategoryId = catId,
|
||||
Name = "Latte",
|
||||
Price = 150_000m,
|
||||
IsAvailable = true
|
||||
});
|
||||
db.Tables.Add(new Table
|
||||
{
|
||||
Id = tableId,
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Number = "1",
|
||||
QrCode = qrCode,
|
||||
IsActive = true
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["App:QrPublicBaseUrl"] = "https://meezi.test" })
|
||||
.Build();
|
||||
var kds = new NoOpKds();
|
||||
var branchMenu = new BranchMenuService(db);
|
||||
var identity = new BranchIdentityService(db);
|
||||
var tables = new TableService(db, config, kds, identity);
|
||||
var shifts = new ShiftService(db);
|
||||
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
||||
var publicSvc = new PublicService(db, orders, new ReviewService(db), kds, branchMenu, identity);
|
||||
|
||||
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveQr_ValidCode_ReturnsBranchInfo()
|
||||
{
|
||||
var (_, tables, _, _, branchId, tableId, _, _, qrCode) = CreateFixture();
|
||||
var result = await tables.ResolveQrAsync(qrCode);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(branchId, result!.BranchId);
|
||||
Assert.Equal(tableId, result.TableId);
|
||||
Assert.False(result.IsCleaning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveQr_InvalidCode_ReturnsNull()
|
||||
{
|
||||
var (_, tables, _, _, _, _, _, _, _) = CreateFixture();
|
||||
Assert.Null(await tables.ResolveQrAsync("missing_code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBranchMenu_ExcludesUnavailableItems()
|
||||
{
|
||||
var (db, _, pub, cafeId, branchId, _, itemA, itemB, _) = CreateFixture();
|
||||
var menuSvc = new BranchMenuService(db);
|
||||
await menuSvc.UpsertOverrideAsync(
|
||||
cafeId, branchId, itemB,
|
||||
new UpsertBranchMenuOverrideRequest(false, null),
|
||||
PlanTier.Pro, EmployeeRole.Manager, "u1");
|
||||
|
||||
var menu = await pub.GetBranchMenuAsync(cafeId, branchId);
|
||||
var ids = menu!.Categories.SelectMany(c => c.Items).Select(i => i.Id).ToList();
|
||||
Assert.Contains(itemA, ids);
|
||||
Assert.DoesNotContain(itemB, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBranchMenu_AppliesBranchPriceOverride()
|
||||
{
|
||||
var (db, _, pub, cafeId, branchId, _, itemA, _, _) = CreateFixture();
|
||||
await new BranchMenuService(db).UpsertOverrideAsync(
|
||||
cafeId, branchId, itemA,
|
||||
new UpsertBranchMenuOverrideRequest(true, 88_000m),
|
||||
PlanTier.Pro, EmployeeRole.Manager, "u1");
|
||||
|
||||
var item = (await pub.GetBranchMenuAsync(cafeId, branchId))!
|
||||
.Categories.SelectMany(c => c.Items)
|
||||
.First(i => i.Id == itemA);
|
||||
Assert.Equal(88_000m, item.Price);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaceGuestOrder_ValidItems_CreatesOrderWithGuestQrSource()
|
||||
{
|
||||
var (db, _, pub, cafeId, branchId, tableId, itemA, _, _) = CreateFixture();
|
||||
var (data, code, _) = await pub.PlaceBranchGuestOrderAsync(
|
||||
cafeId,
|
||||
branchId,
|
||||
new PlaceGuestOrderRequest(tableId, "Guest", null, [new CreateOrderItemRequest(itemA, 2, null)]));
|
||||
|
||||
Assert.Null(code);
|
||||
Assert.NotNull(data);
|
||||
var order = await db.Orders.Include(o => o.Items).FirstAsync(o => o.Id == data!.OrderId);
|
||||
Assert.Equal(OrderSource.GuestQr, order.Source);
|
||||
Assert.Equal(2, order.Items.Sum(i => i.Quantity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaceGuestOrder_MergesWithExistingOpenOrder()
|
||||
{
|
||||
var (db, _, pub, cafeId, branchId, tableId, itemA, _, _) = CreateFixture();
|
||||
await pub.PlaceBranchGuestOrderAsync(
|
||||
cafeId, branchId,
|
||||
new PlaceGuestOrderRequest(tableId, null, null, [new CreateOrderItemRequest(itemA, 1, null)]));
|
||||
|
||||
var (second, code, _) = await pub.PlaceBranchGuestOrderAsync(
|
||||
cafeId, branchId,
|
||||
new PlaceGuestOrderRequest(tableId, null, null, [new CreateOrderItemRequest(itemA, 1, null)]));
|
||||
|
||||
Assert.Null(code);
|
||||
Assert.Equal(1, await db.Orders.CountAsync(o => o.TableId == tableId));
|
||||
var order = await db.Orders.Include(o => o.Items).FirstAsync(o => o.Id == second!.OrderId);
|
||||
Assert.Equal(2, order.Items.Sum(i => i.Quantity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaceGuestOrder_CleaningTable_ReturnsTableCleaning()
|
||||
{
|
||||
var (db, _, pub, cafeId, branchId, tableId, itemA, _, _) = CreateFixture();
|
||||
var table = await db.Tables.FirstAsync(t => t.Id == tableId);
|
||||
table.IsCleaning = true;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var (_, code, _) = await pub.PlaceBranchGuestOrderAsync(
|
||||
cafeId, branchId,
|
||||
new PlaceGuestOrderRequest(tableId, null, null, [new CreateOrderItemRequest(itemA, 1, null)]));
|
||||
|
||||
Assert.Equal("TABLE_CLEANING", code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaceGuestOrder_InvalidMenuItem_ReturnsInvalidMenuItems()
|
||||
{
|
||||
var (_, _, pub, cafeId, branchId, tableId, _, _, _) = CreateFixture();
|
||||
var (_, code, _) = await pub.PlaceBranchGuestOrderAsync(
|
||||
cafeId, branchId,
|
||||
new PlaceGuestOrderRequest(tableId, null, null, [new CreateOrderItemRequest("missing", 1, null)]));
|
||||
|
||||
Assert.Equal("INVALID_MENU_ITEMS", code);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user