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,159 @@
|
||||
using Meezi.API.Models.Expenses;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class ExpenseServiceTests
|
||||
{
|
||||
private static (
|
||||
AppDbContext Db,
|
||||
ExpenseService Expenses,
|
||||
ShiftService Shifts,
|
||||
DailyReportService Reports,
|
||||
string CafeId,
|
||||
string BranchId,
|
||||
string UserId)
|
||||
CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
|
||||
var cafeId = "cafe-exp";
|
||||
var branchId = "branch-exp";
|
||||
var userId = "mgr-1";
|
||||
|
||||
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Exp Cafe", Slug = "exp-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 = "Manager",
|
||||
Phone = "09123333333",
|
||||
Role = EmployeeRole.Manager
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var shifts = new ShiftService(db);
|
||||
var expenses = new ExpenseService(db, shifts);
|
||||
var reports = new DailyReportService(db, Microsoft.Extensions.Logging.Abstractions.NullLogger<DailyReportService>.Instance);
|
||||
return (db, expenses, shifts, reports, cafeId, branchId, userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateExpense_WithOpenShift_RecordsWithdrawal_AndReducesExpectedCash()
|
||||
{
|
||||
var (_, expenses, shifts, _, cafeId, branchId, userId) = CreateFixture();
|
||||
|
||||
var opened = await shifts.OpenShiftAsync(cafeId, branchId, 1_000_000m, userId);
|
||||
var shiftId = opened.Data!.Id;
|
||||
|
||||
var created = await expenses.CreateExpenseAsync(
|
||||
cafeId,
|
||||
new CreateExpenseRequest(branchId, shiftId, ExpenseCategory.Supplies, 150_000m, "خرید شیر", null),
|
||||
userId);
|
||||
|
||||
Assert.True(created.Success);
|
||||
Assert.Equal(150_000m, created.Data!.Amount);
|
||||
|
||||
var shift = await shifts.CloseShiftAsync(cafeId, shiftId, 850_000m, userId);
|
||||
Assert.True(shift.Success);
|
||||
Assert.Equal(850_000m, shift.Data!.ExpectedCash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteExpense_SoftDeletesExpenseAndWithdrawal()
|
||||
{
|
||||
var (db, expenses, shifts, _, cafeId, branchId, userId) = CreateFixture();
|
||||
|
||||
var opened = await shifts.OpenShiftAsync(cafeId, branchId, 500_000m, userId);
|
||||
var shiftId = opened.Data!.Id;
|
||||
|
||||
var created = await expenses.CreateExpenseAsync(
|
||||
cafeId,
|
||||
new CreateExpenseRequest(branchId, shiftId, ExpenseCategory.Other, 50_000m, null, null),
|
||||
userId);
|
||||
Assert.True(created.Success);
|
||||
|
||||
var deleted = await expenses.DeleteExpenseAsync(cafeId, created.Data!.Id);
|
||||
Assert.True(deleted.Success);
|
||||
|
||||
var expenseRow = await db.Expenses.IgnoreQueryFilters()
|
||||
.FirstAsync(e => e.Id == created.Data.Id);
|
||||
Assert.NotNull(expenseRow.DeletedAt);
|
||||
|
||||
var tx = await db.CashTransactions.IgnoreQueryFilters()
|
||||
.FirstAsync(t => t.ReferenceId == created.Data.Id);
|
||||
Assert.NotNull(tx.DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DailyReport_Expense_ReducesNetIncome_NotRevenue()
|
||||
{
|
||||
var (db, expenses, shifts, reports, cafeId, branchId, userId) = CreateFixture();
|
||||
var date = IranCalendar.TodayInIran;
|
||||
var (start, _) = IranCalendar.GetUtcRangeForIranDay(date);
|
||||
|
||||
var catId = "cat-1";
|
||||
var itemId = "item-1";
|
||||
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 = "Tea",
|
||||
NameEn = "Tea",
|
||||
Price = 100_000m,
|
||||
IsAvailable = true
|
||||
});
|
||||
|
||||
var orderId = "ord-1";
|
||||
db.Orders.Add(new Order
|
||||
{
|
||||
Id = orderId,
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Status = OrderStatus.Delivered,
|
||||
OrderType = OrderType.DineIn,
|
||||
Subtotal = 100_000m,
|
||||
TaxTotal = 0,
|
||||
Total = 100_000m,
|
||||
CreatedAt = start.AddHours(10)
|
||||
});
|
||||
db.OrderItems.Add(new OrderItem
|
||||
{
|
||||
OrderId = orderId,
|
||||
MenuItemId = itemId,
|
||||
Quantity = 1,
|
||||
UnitPrice = 100_000m
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await expenses.CreateExpenseAsync(
|
||||
cafeId,
|
||||
new CreateExpenseRequest(branchId, null, ExpenseCategory.Utilities, 30_000m, "برق", null),
|
||||
userId);
|
||||
|
||||
var report = await reports.GenerateReportAsync(cafeId, branchId, date);
|
||||
|
||||
Assert.Equal(100_000m, report.TotalRevenue);
|
||||
Assert.Equal(30_000m, report.TotalExpenses);
|
||||
Assert.Equal(70_000m, report.NetIncome);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user