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:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
+230
View File
@@ -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);
}
}