ef15fd6247
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>
231 lines
8.2 KiB
C#
231 lines
8.2 KiB
C#
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);
|
|
}
|
|
}
|