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,203 @@
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class DailyReportServiceTests
|
||||
{
|
||||
private static (AppDbContext Db, DailyReportService Service, string CafeId, string BranchId, string ItemId)
|
||||
CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
|
||||
var cafeId = "cafe-report";
|
||||
var branchId = "branch-1";
|
||||
var branch2 = "branch-2";
|
||||
var itemId = "item-espresso";
|
||||
var catId = "cat-1";
|
||||
var userId = "emp-1";
|
||||
|
||||
db.Cafes.Add(new Cafe { Id = cafeId, Name = "R", Slug = "r", PlanTier = PlanTier.Business });
|
||||
db.Branches.Add(new Branch { Id = branchId, CafeId = cafeId, Name = "A", IsActive = true, UpdatedAt = DateTime.UtcNow });
|
||||
db.Branches.Add(new Branch { Id = branch2, CafeId = cafeId, Name = "B", IsActive = true, UpdatedAt = DateTime.UtcNow });
|
||||
db.Employees.Add(new Employee { Id = userId, CafeId = cafeId, BranchId = branchId, Name = "E", Phone = "09120000001", Role = EmployeeRole.Owner });
|
||||
db.MenuCategories.Add(new MenuCategory { Id = catId, CafeId = cafeId, Name = "C", NameEn = "C", SortOrder = 0 });
|
||||
db.MenuItems.Add(new MenuItem { Id = itemId, CafeId = cafeId, CategoryId = catId, Name = "Espresso", NameEn = "Espresso", Price = 50_000m, IsAvailable = true });
|
||||
db.SaveChanges();
|
||||
|
||||
var service = new DailyReportService(db, NullLogger<DailyReportService>.Instance);
|
||||
return (db, service, cafeId, branchId, itemId);
|
||||
}
|
||||
|
||||
private static async Task SeedClosedOrderAsync(
|
||||
AppDbContext db,
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string itemId,
|
||||
DateTime createdAt,
|
||||
bool voidFirstLine,
|
||||
IReadOnlyList<(PaymentMethod Method, decimal Amount)>? payments = null,
|
||||
int activeQty = 1)
|
||||
{
|
||||
var orderId = $"ord_{Guid.NewGuid():N}"[..16];
|
||||
var activeRevenue = 50_000m * activeQty;
|
||||
var order = new Order
|
||||
{
|
||||
Id = orderId,
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Status = OrderStatus.Delivered,
|
||||
OrderType = OrderType.DineIn,
|
||||
Subtotal = activeRevenue,
|
||||
TaxTotal = 0,
|
||||
Total = activeRevenue,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
db.Orders.Add(order);
|
||||
if (voidFirstLine)
|
||||
{
|
||||
db.OrderItems.Add(new OrderItem
|
||||
{
|
||||
OrderId = orderId,
|
||||
MenuItemId = itemId,
|
||||
Quantity = 2,
|
||||
UnitPrice = 50_000m,
|
||||
IsVoided = true,
|
||||
VoidedAt = createdAt
|
||||
});
|
||||
}
|
||||
|
||||
db.OrderItems.Add(new OrderItem
|
||||
{
|
||||
OrderId = orderId,
|
||||
MenuItemId = itemId,
|
||||
Quantity = activeQty,
|
||||
UnitPrice = 50_000m
|
||||
});
|
||||
|
||||
if (payments is not null)
|
||||
{
|
||||
var shiftId = $"shift_{Guid.NewGuid():N}"[..16];
|
||||
db.RegisterShifts.Add(new Shift
|
||||
{
|
||||
Id = shiftId,
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
OpenedByUserId = "emp-1",
|
||||
OpenedAt = createdAt.AddHours(-1),
|
||||
OpeningCash = 0,
|
||||
Status = ShiftStatus.Open,
|
||||
CreatedAt = createdAt.AddHours(-1)
|
||||
});
|
||||
foreach (var p in payments)
|
||||
{
|
||||
db.CashTransactions.Add(new CashTransaction
|
||||
{
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
ShiftId = shiftId,
|
||||
Type = CashTransactionType.OrderPayment,
|
||||
Method = p.Method,
|
||||
Amount = p.Amount,
|
||||
ReferenceId = orderId,
|
||||
CreatedByUserId = "emp-1",
|
||||
CreatedAt = createdAt.AddMinutes(5)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReport_IsIdempotent()
|
||||
{
|
||||
var (db, service, cafeId, branchId, itemId) = CreateFixture();
|
||||
var date = IranCalendar.TodayInIran.AddDays(-1);
|
||||
var (start, _) = IranCalendar.GetUtcRangeForIranDay(date);
|
||||
await SeedClosedOrderAsync(db, cafeId, branchId, itemId, start.AddHours(12), voidFirstLine: false);
|
||||
|
||||
var first = await service.GenerateReportAsync(cafeId, branchId, date);
|
||||
var second = await service.GenerateReportAsync(cafeId, branchId, date);
|
||||
|
||||
Assert.Equal(first.Id, second.Id);
|
||||
Assert.Equal(first.TotalRevenue, second.TotalRevenue);
|
||||
Assert.Equal(1, await db.DailyReports.CountAsync(r => r.CafeId == cafeId && r.BranchId == branchId && r.Date == date));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReport_ExcludesVoidedItemsFromRevenue()
|
||||
{
|
||||
var (db, service, cafeId, branchId, itemId) = CreateFixture();
|
||||
var date = IranCalendar.TodayInIran;
|
||||
var (start, _) = IranCalendar.GetUtcRangeForIranDay(date);
|
||||
await SeedClosedOrderAsync(db, cafeId, branchId, itemId, start.AddHours(10), voidFirstLine: true);
|
||||
|
||||
var report = await service.GenerateReportAsync(cafeId, branchId, date);
|
||||
|
||||
Assert.Equal(50_000m, report.TotalRevenue);
|
||||
Assert.Equal(1, report.TotalVoids);
|
||||
Assert.Equal(100_000m, report.VoidAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReport_SumsPaymentMethods_FromCashTransactions()
|
||||
{
|
||||
var (db, service, cafeId, branchId, itemId) = CreateFixture();
|
||||
var date = IranCalendar.TodayInIran;
|
||||
var (start, _) = IranCalendar.GetUtcRangeForIranDay(date);
|
||||
await SeedClosedOrderAsync(
|
||||
db, cafeId, branchId, itemId, start.AddHours(11), false,
|
||||
payments:
|
||||
[
|
||||
(PaymentMethod.Cash, 60_000m),
|
||||
(PaymentMethod.Card, 40_000m),
|
||||
(PaymentMethod.Credit, 50_000m)
|
||||
]);
|
||||
|
||||
var report = await service.GenerateReportAsync(cafeId, branchId, date);
|
||||
|
||||
Assert.Equal(60_000m, report.CashRevenue);
|
||||
Assert.Equal(40_000m, report.CardRevenue);
|
||||
Assert.Equal(50_000m, report.CreditRevenue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSummary_AggregatesAcrossBranches()
|
||||
{
|
||||
var (db, service, cafeId, branchId, itemId) = CreateFixture();
|
||||
var branch2 = "branch-2";
|
||||
var date = IranCalendar.TodayInIran;
|
||||
var (start, _) = IranCalendar.GetUtcRangeForIranDay(date);
|
||||
|
||||
await SeedClosedOrderAsync(db, cafeId, branchId, itemId, start.AddHours(9), false);
|
||||
await SeedClosedOrderAsync(db, cafeId, branch2, itemId, start.AddHours(10), false);
|
||||
|
||||
await service.GenerateReportAsync(cafeId, branchId, date);
|
||||
await service.GenerateReportAsync(cafeId, branch2, date);
|
||||
|
||||
var summary = await service.GetSummaryAsync(cafeId, 30);
|
||||
|
||||
Assert.Equal(2, summary.ByBranch.Count);
|
||||
Assert.Equal(100_000m, summary.TotalRevenue);
|
||||
Assert.Equal(100_000m, summary.ByBranch.Sum(b => b.TotalRevenue));
|
||||
Assert.Equal(2, summary.TotalOrders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportPlanGate_Free_AllowsEightDayWindow()
|
||||
{
|
||||
var today = new DateOnly(2026, 5, 21);
|
||||
Assert.True(ReportPlanGate.IsDateInRange(PlanTier.Free, today, today));
|
||||
Assert.True(ReportPlanGate.IsDateInRange(PlanTier.Free, today.AddDays(-7), today));
|
||||
Assert.False(ReportPlanGate.IsDateInRange(PlanTier.Free, today.AddDays(-8), today));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user