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,189 @@
|
||||
using Meezi.API.Models.Menu;
|
||||
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 BranchMenuTests
|
||||
{
|
||||
private static (AppDbContext Db, BranchMenuService Menu, string CafeId, string BranchId, string ItemA, string ItemB)
|
||||
CreateFixture(PlanTier tier = PlanTier.Pro)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
|
||||
var cafeId = "cafe-1";
|
||||
var branchId = "branch-1";
|
||||
var catId = "cat-1";
|
||||
var itemA = "item-a";
|
||||
var itemB = "item-b";
|
||||
|
||||
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Test", Slug = "test", PlanTier = tier });
|
||||
db.Branches.Add(new Branch
|
||||
{
|
||||
Id = branchId,
|
||||
CafeId = cafeId,
|
||||
Name = "Main",
|
||||
IsActive = true,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
db.MenuCategories.Add(new MenuCategory
|
||||
{
|
||||
Id = catId,
|
||||
CafeId = cafeId,
|
||||
Name = "Drinks",
|
||||
NameEn = "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.SaveChanges();
|
||||
|
||||
return (db, new BranchMenuService(db), cafeId, branchId, itemA, itemB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBranchMenu_ExcludesUnavailableItems()
|
||||
{
|
||||
var (_, menu, cafeId, branchId, itemA, itemB) = CreateFixture();
|
||||
await menu.UpsertOverrideAsync(
|
||||
cafeId, branchId, itemB,
|
||||
new UpsertBranchMenuOverrideRequest(false, null),
|
||||
PlanTier.Pro, EmployeeRole.Manager, "user-1");
|
||||
|
||||
var result = await menu.GetBranchMenuAsync(cafeId, branchId, includeUnavailable: false);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result!);
|
||||
Assert.Equal(itemA, result![0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBranchMenu_AppliesPriceOverride_WhenSet()
|
||||
{
|
||||
var (_, menu, cafeId, branchId, itemA, _) = CreateFixture();
|
||||
await menu.UpsertOverrideAsync(
|
||||
cafeId, branchId, itemA,
|
||||
new UpsertBranchMenuOverrideRequest(true, 88_000m),
|
||||
PlanTier.Pro, EmployeeRole.Manager, "user-1");
|
||||
|
||||
var result = await menu.GetBranchMenuAsync(cafeId, branchId, includeUnavailable: false);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var row = Assert.Single(result!, r => r.Id == itemA);
|
||||
Assert.Equal(100_000m, row.MasterPrice);
|
||||
Assert.Equal(88_000m, row.EffectivePrice);
|
||||
Assert.True(row.HasPriceOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBranchMenu_UsesMasterPrice_WhenNoOverride()
|
||||
{
|
||||
var (_, menu, cafeId, branchId, itemA, _) = CreateFixture();
|
||||
|
||||
var result = await menu.GetBranchMenuAsync(cafeId, branchId, includeUnavailable: false);
|
||||
|
||||
var row = Assert.Single(result!, r => r.Id == itemA);
|
||||
Assert.Equal(100_000m, row.MasterPrice);
|
||||
Assert.Equal(100_000m, row.EffectivePrice);
|
||||
Assert.False(row.IsOverridden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertOverride_FreePlan_PriceOverride_ReturnsPlanLimitReached()
|
||||
{
|
||||
var (_, menu, cafeId, branchId, itemA, _) = CreateFixture(PlanTier.Free);
|
||||
|
||||
var result = await menu.UpsertOverrideAsync(
|
||||
cafeId, branchId, itemA,
|
||||
new UpsertBranchMenuOverrideRequest(true, 90_000m),
|
||||
PlanTier.Free,
|
||||
EmployeeRole.Manager,
|
||||
"user-1");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("PLAN_LIMIT_REACHED", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertOverride_ProPlan_PriceOverride_Succeeds()
|
||||
{
|
||||
var (_, menu, cafeId, branchId, itemA, _) = CreateFixture(PlanTier.Pro);
|
||||
|
||||
var result = await menu.UpsertOverrideAsync(
|
||||
cafeId, branchId, itemA,
|
||||
new UpsertBranchMenuOverrideRequest(true, 90_000m),
|
||||
PlanTier.Pro,
|
||||
EmployeeRole.Manager,
|
||||
"user-1");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(90_000m, result.Data!.PriceOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteOverride_ResetsToMasterPrice()
|
||||
{
|
||||
var (_, menu, cafeId, branchId, itemA, _) = CreateFixture();
|
||||
await menu.UpsertOverrideAsync(
|
||||
cafeId, branchId, itemA,
|
||||
new UpsertBranchMenuOverrideRequest(true, 90_000m),
|
||||
PlanTier.Pro, EmployeeRole.Owner, "owner-1");
|
||||
|
||||
var deleted = await menu.DeleteOverrideAsync(cafeId, branchId, itemA);
|
||||
Assert.True(deleted);
|
||||
|
||||
var menuRows = await menu.GetBranchMenuAsync(cafeId, branchId, includeUnavailable: false);
|
||||
var row = Assert.Single(menuRows!, r => r.Id == itemA);
|
||||
Assert.Equal(100_000m, row.EffectivePrice);
|
||||
Assert.False(row.IsOverridden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_UniqueConstraint_UpsertNotDuplicate()
|
||||
{
|
||||
var (db, menu, cafeId, branchId, itemA, _) = CreateFixture();
|
||||
await menu.UpsertOverrideAsync(
|
||||
cafeId, branchId, itemA,
|
||||
new UpsertBranchMenuOverrideRequest(true, 80_000m),
|
||||
PlanTier.Pro, EmployeeRole.Manager, "user-1");
|
||||
await menu.UpsertOverrideAsync(
|
||||
cafeId, branchId, itemA,
|
||||
new UpsertBranchMenuOverrideRequest(false, 85_000m),
|
||||
PlanTier.Pro, EmployeeRole.Manager, "user-1");
|
||||
|
||||
var count = await db.BranchMenuItemOverrides
|
||||
.CountAsync(o => o.BranchId == branchId && o.MenuItemId == itemA);
|
||||
Assert.Equal(1, count);
|
||||
|
||||
var rows = await menu.GetBranchMenuAsync(cafeId, branchId, includeUnavailable: true);
|
||||
Assert.Equal(2, rows!.Count);
|
||||
var hidden = Assert.Single(rows, r => r.Id == itemA);
|
||||
Assert.False(hidden.IsAvailable);
|
||||
Assert.Equal(85_000m, hidden.EffectivePrice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Meezi.API.Models.Branches;
|
||||
using Meezi.API.Validators;
|
||||
using Meezi.Core.Utilities;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class BranchPhaseATests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateBranchRequestValidator_requires_valid_login_phone()
|
||||
{
|
||||
var validator = new CreateBranchRequestValidator();
|
||||
var invalid = validator.Validate(new CreateBranchRequest("شعبه", "123", null, null, null, null));
|
||||
Assert.False(invalid.IsValid);
|
||||
|
||||
var valid = validator.Validate(new CreateBranchRequest("شعبه", "09121234567", "علی", null, null, null));
|
||||
Assert.True(valid.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhoneNormalizer_aligns_branch_login_phones()
|
||||
{
|
||||
Assert.Equal("09121234567", PhoneNormalizer.Normalize("0912 123 4567"));
|
||||
Assert.Equal("09121234567", PhoneNormalizer.Normalize("+989121234567"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Tables;
|
||||
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 BranchTableTests
|
||||
{
|
||||
private sealed class NullKdsNotifier : IKdsNotifier
|
||||
{
|
||||
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static (AppDbContext Db, TableService Tables, string CafeId, string BranchA, string BranchB, string ManagerId)
|
||||
CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build();
|
||||
var tables = new TableService(db, config, new NullKdsNotifier(), new BranchIdentityService(db));
|
||||
|
||||
var cafeId = "cafe-1";
|
||||
var branchA = "branch-a";
|
||||
var branchB = "branch-b";
|
||||
var managerId = "mgr-1";
|
||||
|
||||
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Test", Slug = "test", PlanTier = PlanTier.Pro });
|
||||
db.Branches.AddRange(
|
||||
new Branch { Id = branchA, CafeId = cafeId, Name = "A", IsActive = true, UpdatedAt = DateTime.UtcNow },
|
||||
new Branch { Id = branchB, CafeId = cafeId, Name = "B", IsActive = true, UpdatedAt = DateTime.UtcNow });
|
||||
db.Employees.Add(new Employee
|
||||
{
|
||||
Id = managerId,
|
||||
CafeId = cafeId,
|
||||
BranchId = branchA,
|
||||
Name = "Manager",
|
||||
Phone = "09121111111",
|
||||
Role = EmployeeRole.Manager
|
||||
});
|
||||
db.Tables.AddRange(
|
||||
new Table { Id = "t-a", CafeId = cafeId, BranchId = branchA, Number = "1", QrCode = "qr-a" },
|
||||
new Table { Id = "t-b", CafeId = cafeId, BranchId = branchB, Number = "2", QrCode = "qr-b" });
|
||||
db.SaveChanges();
|
||||
|
||||
return (db, tables, cafeId, branchA, branchB, managerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTables_ReturnsBranchTablesOnly()
|
||||
{
|
||||
var (_, tables, cafeId, branchA, _, _) = CreateFixture();
|
||||
var result = await tables.GetBranchTablesAsync(cafeId, branchA);
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("t-a", result[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTables_DoesNotReturnOtherBranchTables()
|
||||
{
|
||||
var (_, tables, cafeId, branchA, _, _) = CreateFixture();
|
||||
var result = await tables.GetBranchTablesAsync(cafeId, branchA);
|
||||
Assert.NotNull(result);
|
||||
Assert.DoesNotContain(result, t => t.Id == "t-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTable_AssignsToBranch()
|
||||
{
|
||||
var (db, tables, cafeId, branchA, _, _) = CreateFixture();
|
||||
var created = await tables.CreateBranchTableAsync(
|
||||
cafeId,
|
||||
branchA,
|
||||
new CreateBranchTableRequest("9", 6));
|
||||
Assert.True(created.Success);
|
||||
Assert.Equal(branchA, created.Data!.BranchId);
|
||||
Assert.Equal(1, await db.Tables.CountAsync(t => t.BranchId == branchA && t.Number == "9"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTable_WithOpenOrder_ReturnsTableHasOpenOrder()
|
||||
{
|
||||
var (db, tables, cafeId, branchA, _, _) = CreateFixture();
|
||||
db.Orders.Add(new Order
|
||||
{
|
||||
Id = "ord-1",
|
||||
CafeId = cafeId,
|
||||
BranchId = branchA,
|
||||
TableId = "t-a",
|
||||
Status = OrderStatus.Preparing,
|
||||
OrderType = OrderType.DineIn,
|
||||
Subtotal = 100,
|
||||
Total = 100
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await tables.DeleteBranchTableAsync(cafeId, branchA, "t-a");
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("TABLE_HAS_OPEN_ORDER", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSection_WithTables_ReturnsTableSectionHasTables()
|
||||
{
|
||||
var (db, tables, cafeId, branchA, _, _) = CreateFixture();
|
||||
var section = new TableSection
|
||||
{
|
||||
Id = "sec-1",
|
||||
CafeId = cafeId,
|
||||
BranchId = branchA,
|
||||
Name = "Hall"
|
||||
};
|
||||
db.TableSections.Add(section);
|
||||
var table = await db.Tables.FirstAsync(t => t.Id == "t-a");
|
||||
table.SectionId = section.Id;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await tables.DeleteBranchSectionAsync(cafeId, branchA, section.Id);
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("TABLE_SECTION_HAS_TABLES", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManagerCannotAccessOtherBranchTables()
|
||||
{
|
||||
var (_, tables, cafeId, _, branchB, managerId) = CreateFixture();
|
||||
var allowed = await tables.CanAccessBranchAsync(
|
||||
cafeId, branchB, managerId, EmployeeRole.Manager);
|
||||
Assert.False(allowed);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Meezi.API.Configuration;
|
||||
using Meezi.API.Models.Snappfood;
|
||||
using Meezi.API.Models.Tap30;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class DeliveryIntegrationTests
|
||||
{
|
||||
private static DeliveryPlatformsOptions TestOptions() => new()
|
||||
{
|
||||
DefaultSnappfoodCommissionPercent = 18,
|
||||
DefaultTap30CommissionPercent = 15
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OrderNormalizer_Snappfood_MapsToUnifiedOrder()
|
||||
{
|
||||
var normalizer = new OrderNormalizer();
|
||||
var unified = normalizer.FromSnappfood(new SnappfoodWebhookOrder(
|
||||
"sf-100",
|
||||
"vendor-1",
|
||||
"Ali",
|
||||
"09121234567",
|
||||
250_000m,
|
||||
[new SnappfoodWebhookItem("Espresso", 2, 100_000m)]));
|
||||
|
||||
Assert.NotNull(unified);
|
||||
Assert.Equal("sf-100", unified!.ExternalId);
|
||||
Assert.Equal(DeliveryPlatform.Snappfood, unified.Platform);
|
||||
Assert.Equal(2, unified.Items[0].Quantity);
|
||||
Assert.Equal(UnifiedDeliveryStatus.Confirmed, unified.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderNormalizer_Tap30_MapsToUnifiedOrder()
|
||||
{
|
||||
var normalizer = new OrderNormalizer();
|
||||
var unified = normalizer.FromTap30(new Tap30WebhookOrder(
|
||||
"t30-55",
|
||||
"tap-vendor",
|
||||
new Tap30Customer("Sara", "09129876543", "Tehran", null, null),
|
||||
180_000m,
|
||||
"online",
|
||||
true,
|
||||
null,
|
||||
"delivery",
|
||||
35,
|
||||
null,
|
||||
null,
|
||||
"confirmed",
|
||||
[new Tap30WebhookItem("latte-1", "Latte", 1, 180_000m, null)]));
|
||||
|
||||
Assert.NotNull(unified);
|
||||
Assert.Equal(DeliveryPlatform.Tap30, unified!.Platform);
|
||||
Assert.Equal("Sara", unified.Customer.Name);
|
||||
Assert.Equal(35, unified.Delivery.EstimatedMinutes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommissionCalculator_SnappfoodDefault_Is18Percent()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
var calc = new CommissionCalculator(db, Options.Create(TestOptions()));
|
||||
|
||||
var commission = await calc.CalculateForOrderAsync(
|
||||
"cafe-1",
|
||||
new UnifiedDeliveryOrder(
|
||||
"x1",
|
||||
DeliveryPlatform.Snappfood,
|
||||
"v1",
|
||||
DateTime.UtcNow,
|
||||
new UnifiedDeliveryCustomer("A", "09"),
|
||||
[new UnifiedDeliveryItem("1", "Coffee", 1, 100_000m)],
|
||||
new UnifiedDeliveryPayment(100_000m, "online", true),
|
||||
new UnifiedDeliveryInfo("delivery"),
|
||||
UnifiedDeliveryStatus.Confirmed));
|
||||
|
||||
Assert.Equal(18_000m, commission);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommissionCalculator_Tap30Default_Is15Percent()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
var calc = new CommissionCalculator(db, Options.Create(TestOptions()));
|
||||
|
||||
var rate = await calc.ResolveRatePercentAsync("cafe-1", DeliveryPlatform.Tap30);
|
||||
Assert.Equal(15m, rate);
|
||||
Assert.Equal(15_000m, calc.CalculateCommission(100_000m, rate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebhookSignature_ValidHmac_Accepts()
|
||||
{
|
||||
const string secret = "test-secret";
|
||||
const string body = """{"orderId":"1"}""";
|
||||
var expected = WebhookSignatureService.ComputeHmacSha256Hex(body, secret);
|
||||
|
||||
var svc = new WebhookSignatureService(Options.Create(new DeliveryPlatformsOptions
|
||||
{
|
||||
Snappfood = new SnappfoodPlatformOptions { WebhookSecret = secret }
|
||||
}));
|
||||
|
||||
Assert.True(svc.Verify(DeliveryPlatform.Snappfood, body, expected));
|
||||
Assert.True(svc.Verify(DeliveryPlatform.Snappfood, body, $"sha256={expected}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
internal sealed class NoOpSnappfood : ISnappfoodClient
|
||||
{
|
||||
public Task AcknowledgeOrderAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderDeliveredAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusAsync(string snappfoodOrderId, string status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class NoOpDeliverySync : IDeliveryStatusSyncService
|
||||
{
|
||||
public Task<bool> SyncInternalStatusAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
OrderStatus newStatus,
|
||||
CancellationToken ct = default) => Task.FromResult(false);
|
||||
|
||||
public Task<bool> ApplyPlatformStatusAsync(
|
||||
DeliveryPlatform platform,
|
||||
string externalOrderId,
|
||||
string platformStatus,
|
||||
CancellationToken ct = default) => Task.FromResult(false);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Discover;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class DiscoverFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Matches_WhenThemeOverlaps()
|
||||
{
|
||||
var profile = new CafeDiscoverProfile { Themes = ["modern", "cozy"] };
|
||||
var filters = new DiscoverFilterParams(Themes: ["modern"]);
|
||||
Assert.True(DiscoverProfileMatcher.Matches(profile, filters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_WhenThemeMissing()
|
||||
{
|
||||
var profile = new CafeDiscoverProfile { Themes = ["vintage"] };
|
||||
var filters = new DiscoverFilterParams(Themes: ["modern"]);
|
||||
Assert.False(DiscoverProfileMatcher.Matches(profile, filters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireProfile_RejectsEmptyProfile()
|
||||
{
|
||||
var profile = new CafeDiscoverProfile();
|
||||
var filters = new DiscoverFilterParams(RequireProfile: true);
|
||||
Assert.False(DiscoverProfileMatcher.Matches(profile, filters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromQuery_ParsesCommaSeparated()
|
||||
{
|
||||
var f = DiscoverFilterParams.FromQuery(
|
||||
city: "تهران",
|
||||
q: null,
|
||||
minRating: 4,
|
||||
sort: "rating",
|
||||
themes: "modern, cozy",
|
||||
vibes: "romantic",
|
||||
occasions: null,
|
||||
spaceFeatures: "outdoor",
|
||||
noise: "quiet",
|
||||
priceTier: "mid",
|
||||
size: null,
|
||||
requireProfile: true);
|
||||
Assert.Equal("تهران", f.City);
|
||||
Assert.Equal(4, f.MinRating);
|
||||
Assert.Contains("modern", f.Themes!);
|
||||
Assert.Contains("cozy", f.Themes!);
|
||||
Assert.Equal("quiet", f.NoiseLevel);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests.Integration;
|
||||
|
||||
[Collection(nameof(MeeziIntegrationCollection))]
|
||||
public class HealthIntegrationTests
|
||||
{
|
||||
private readonly MeeziWebApplicationFactory _factory;
|
||||
|
||||
public HealthIntegrationTests(MeeziWebApplicationFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Health_ReturnsOk()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/health");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests.Integration;
|
||||
|
||||
[CollectionDefinition(nameof(MeeziIntegrationCollection))]
|
||||
public class MeeziIntegrationCollection : ICollectionFixture<MeeziWebApplicationFactory>;
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Meezi.API;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Tests.Integration;
|
||||
|
||||
public class MeeziWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
var app = Program.BuildWebApplication(
|
||||
Array.Empty<string>(),
|
||||
ConfigureTestingBeforeServices,
|
||||
ConfigureTestingAfterServices);
|
||||
app.StartAsync().GetAwaiter().GetResult();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void ConfigureTestingBeforeServices(WebApplicationBuilder webBuilder)
|
||||
{
|
||||
webBuilder.Environment.EnvironmentName = "Testing";
|
||||
webBuilder.WebHost.UseTestServer();
|
||||
webBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Testing:Enabled"] = "true",
|
||||
["Testing:SkipSeed"] = "true",
|
||||
["RUN_MIGRATIONS"] = "false",
|
||||
["ConnectionStrings:Redis"] = "127.0.0.1:6379"
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureTestingAfterServices(WebApplicationBuilder webBuilder)
|
||||
{
|
||||
var dbDescriptor = webBuilder.Services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
||||
if (dbDescriptor is not null)
|
||||
webBuilder.Services.Remove(dbDescriptor);
|
||||
|
||||
webBuilder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseInMemoryDatabase("meezi_test_db"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Meezi.API\Meezi.API.csproj" />
|
||||
<ProjectReference Include="..\..\src\Meezi.Core\Meezi.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,34 @@
|
||||
using Meezi.API.Services;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
internal sealed class NoOpInventoryService : IInventoryService
|
||||
{
|
||||
public Task<IReadOnlyList<IngredientDto>> ListAsync(string cafeId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<IngredientDto>>([]);
|
||||
|
||||
public Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<IngredientDto>>([]);
|
||||
|
||||
public Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default) =>
|
||||
Task.FromResult<IngredientDto?>(null);
|
||||
|
||||
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
|
||||
Task.FromResult<IngredientDto?>(null);
|
||||
|
||||
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, CancellationToken ct = default) =>
|
||||
Task.FromResult<IngredientDto?>(null);
|
||||
|
||||
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
|
||||
Task.FromResult<MenuItemRecipeDto?>(null);
|
||||
|
||||
public Task<MenuItemRecipeDto?> SetRecipeAsync(string cafeId, string menuItemId, SetMenuItemRecipeRequest request, CancellationToken ct = default) =>
|
||||
Task.FromResult<MenuItemRecipeDto?>(null);
|
||||
|
||||
public Task<OrderDeductionResult> DeductForOrderAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
IReadOnlyList<(string MenuItemId, int Quantity)> lines,
|
||||
CancellationToken ct = default) =>
|
||||
Task.FromResult(new OrderDeductionResult(false, []));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Meezi.API.Services;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
internal sealed class NoOpLoyaltyService : ILoyaltyService
|
||||
{
|
||||
public Task ApplyEarnOnOrderPaidAsync(
|
||||
string cafeId,
|
||||
string? customerId,
|
||||
decimal paidAmount,
|
||||
CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Entities;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
internal sealed class NoOpOrderNotificationService : IOrderNotificationService
|
||||
{
|
||||
public Task NotifyGuestOrderPlacedAsync(Order order, LiveOrderDto live, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services;
|
||||
using LiveOrderDto = Meezi.API.Models.Orders.LiveOrderDto;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class OrderSessionTests
|
||||
{
|
||||
private sealed class NullKdsNotifier : IKdsNotifier
|
||||
{
|
||||
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static (AppDbContext Db, OrderService Orders, TableService Tables, string CafeId, string TableId, string MenuItemId)
|
||||
CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
|
||||
var cafeId = "cafe-1";
|
||||
var branchId = "branch-1";
|
||||
var tableId = "table-1";
|
||||
var menuItemId = "item-1";
|
||||
var categoryId = "cat-1";
|
||||
var userId = "user-1";
|
||||
|
||||
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Test", Slug = "test", 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 = "Cashier",
|
||||
Phone = "09121111111",
|
||||
Role = EmployeeRole.Cashier
|
||||
});
|
||||
db.Tables.Add(new Table
|
||||
{
|
||||
Id = tableId,
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Number = "5",
|
||||
QrCode = "qr1",
|
||||
Capacity = 4
|
||||
});
|
||||
db.MenuCategories.Add(new MenuCategory
|
||||
{
|
||||
Id = categoryId,
|
||||
CafeId = cafeId,
|
||||
Name = "Drinks",
|
||||
NameEn = "Drinks",
|
||||
SortOrder = 0
|
||||
});
|
||||
db.MenuItems.Add(new MenuItem
|
||||
{
|
||||
Id = menuItemId,
|
||||
CafeId = cafeId,
|
||||
CategoryId = categoryId,
|
||||
Name = "Espresso",
|
||||
NameEn = "Espresso",
|
||||
Price = 100_000m,
|
||||
IsAvailable = true
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var shifts = new ShiftService(db);
|
||||
shifts.OpenShiftAsync(cafeId, branchId, 0m, userId).GetAwaiter().GetResult();
|
||||
|
||||
var kds = new NoOpKdsNotifier();
|
||||
var snapp = new NoOpSnappfood();
|
||||
var orders = new OrderService(db, kds, snapp, new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
||||
var tables = new TableService(
|
||||
db,
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
new NullKdsNotifier(),
|
||||
new BranchIdentityService(db));
|
||||
|
||||
return (db, orders, tables, cafeId, tableId, menuItemId);
|
||||
}
|
||||
|
||||
private static CreateOrderRequest DineInRequest(string tableId, string menuItemId, int qty = 1) =>
|
||||
new(
|
||||
OrderType.DineIn,
|
||||
"branch-1",
|
||||
tableId,
|
||||
null,
|
||||
"Ali",
|
||||
"09121111111",
|
||||
null,
|
||||
null,
|
||||
[new CreateOrderItemRequest(menuItemId, qty, null)]);
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrder_SameTable_MergesIntoSingleOpenOrder()
|
||||
{
|
||||
var (_, orders, _, cafeId, tableId, menuItemId) = CreateFixture();
|
||||
var tenant = new TestTenant(cafeId, "user-1");
|
||||
|
||||
var first = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
|
||||
var second = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId, 2), default);
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.True(second.Success);
|
||||
Assert.Equal(first.Data!.Id, second.Data!.Id);
|
||||
Assert.Equal(3, second.Data!.Items.Sum(i => i.Quantity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendItems_IncreasesTotal()
|
||||
{
|
||||
var (_, orders, _, cafeId, tableId, menuItemId) = CreateFixture();
|
||||
var tenant = new TestTenant(cafeId, "user-1");
|
||||
|
||||
var created = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
|
||||
var append = await orders.AppendOrderItemsAsync(
|
||||
cafeId,
|
||||
created.Data!.Id,
|
||||
new AppendOrderItemsRequest([new CreateOrderItemRequest(menuItemId, 1, null)]),
|
||||
default);
|
||||
|
||||
Assert.True(append.Success);
|
||||
Assert.Equal(200_000m, append.Data!.Subtotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOpenOrders_SearchByPhone_FindsOrder()
|
||||
{
|
||||
var (_, orders, _, cafeId, tableId, menuItemId) = CreateFixture();
|
||||
var tenant = new TestTenant(cafeId, "user-1");
|
||||
await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
|
||||
|
||||
var found = await orders.GetOpenOrdersAsync(cafeId, "09121111111", default);
|
||||
Assert.Single(found);
|
||||
Assert.Equal("09121111111", found[0].GuestPhone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Payment_CompletesOrder_TableBoardShowsFree()
|
||||
{
|
||||
var (_, orders, tables, cafeId, tableId, menuItemId) = CreateFixture();
|
||||
var tenant = new TestTenant(cafeId, "user-1");
|
||||
|
||||
var created = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
|
||||
var order = created.Data!;
|
||||
|
||||
var paid = await orders.RecordPaymentsAsync(
|
||||
cafeId,
|
||||
order.Id,
|
||||
new RecordPaymentsRequest([new CreatePaymentRequest(PaymentMethod.Cash, order.Total, null)]),
|
||||
"user-1",
|
||||
default);
|
||||
Assert.True(paid.Success);
|
||||
|
||||
var board = await tables.GetTableBoardAsync(cafeId, cancellationToken: default);
|
||||
var tile = board.First(t => t.Id == tableId);
|
||||
Assert.Equal(TableBoardStatus.Free, tile.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleaningTable_BlocksNewOrder()
|
||||
{
|
||||
var (db, orders, tables, cafeId, tableId, menuItemId) = CreateFixture();
|
||||
var table = await db.Tables.FindAsync(tableId);
|
||||
table!.IsCleaning = true;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var tenant = new TestTenant(cafeId, "user-1");
|
||||
var result = await orders.CreateOrderAsync(cafeId, tenant, DineInRequest(tableId, menuItemId), default);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("TABLE_NOT_AVAILABLE", result.ErrorCode);
|
||||
}
|
||||
|
||||
private sealed class TestTenant(string cafeId, string userId) : ITenantContext
|
||||
{
|
||||
public string? CafeId => cafeId;
|
||||
public string? UserId => userId;
|
||||
public EmployeeRole? Role => EmployeeRole.Owner;
|
||||
public PlanTier? PlanTier => Core.Enums.PlanTier.Pro;
|
||||
public string? Language => "fa";
|
||||
public string? BranchId => null;
|
||||
public bool IsSystemAdmin => false;
|
||||
public bool IsAuthenticated => true;
|
||||
}
|
||||
|
||||
private sealed class NoOpKdsNotifier : IKdsNotifier
|
||||
{
|
||||
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NoOpSnappfood : ISnappfoodClient
|
||||
{
|
||||
public Task AcknowledgeOrderAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderDeliveredAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusAsync(string snappfoodOrderId, string status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Shifts;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using TenantContext = Meezi.Infrastructure.Data.TenantContext;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class OrderVoidTransferTests
|
||||
{
|
||||
private static (
|
||||
AppDbContext Db,
|
||||
OrderService Orders,
|
||||
string CafeId,
|
||||
string Table1Id,
|
||||
string Table2Id,
|
||||
string MenuItemId,
|
||||
TenantContext Tenant)
|
||||
CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
|
||||
var cafeId = "cafe-void";
|
||||
var table1 = "table-1";
|
||||
var table2 = "table-2";
|
||||
var menuItemId = "item-1";
|
||||
var categoryId = "cat-1";
|
||||
var managerId = "emp-manager";
|
||||
|
||||
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Test", Slug = "test-void", PlanTier = PlanTier.Pro });
|
||||
db.Tables.Add(new Table { Id = table1, CafeId = cafeId, Number = "1", QrCode = "qr1", Capacity = 4 });
|
||||
db.Tables.Add(new Table { Id = table2, CafeId = cafeId, Number = "2", QrCode = "qr2", Capacity = 4 });
|
||||
db.MenuCategories.Add(new MenuCategory
|
||||
{
|
||||
Id = categoryId,
|
||||
CafeId = cafeId,
|
||||
Name = "Drinks",
|
||||
NameEn = "Drinks",
|
||||
SortOrder = 0
|
||||
});
|
||||
db.MenuItems.Add(new MenuItem
|
||||
{
|
||||
Id = menuItemId,
|
||||
CafeId = cafeId,
|
||||
CategoryId = categoryId,
|
||||
Name = "Tea",
|
||||
NameEn = "Tea",
|
||||
Price = 50_000m,
|
||||
IsAvailable = true
|
||||
});
|
||||
db.Employees.Add(new Employee
|
||||
{
|
||||
Id = managerId,
|
||||
CafeId = cafeId,
|
||||
Name = "Manager",
|
||||
Phone = "09120000000",
|
||||
Role = EmployeeRole.Manager
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var tenant = new TenantContext
|
||||
{
|
||||
CafeId = cafeId,
|
||||
UserId = managerId,
|
||||
Role = EmployeeRole.Manager,
|
||||
PlanTier = PlanTier.Pro,
|
||||
Language = "fa"
|
||||
};
|
||||
|
||||
var orders = new OrderService(db, new NoOpKdsNotifier(), new NoOpSnappfood(), new NoOpDeliverySync(), new NoOpShiftService(), TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
||||
return (db, orders, cafeId, table1, table2, menuItemId, tenant);
|
||||
}
|
||||
|
||||
private static async Task<OrderDto> CreateOpenOrderAsync(
|
||||
OrderService orders,
|
||||
string cafeId,
|
||||
TenantContext tenant,
|
||||
string tableId,
|
||||
string menuItemId,
|
||||
int qty = 1)
|
||||
{
|
||||
var result = await orders.CreateOrderAsync(
|
||||
cafeId,
|
||||
tenant,
|
||||
new CreateOrderRequest(
|
||||
OrderType.DineIn,
|
||||
null,
|
||||
tableId,
|
||||
null,
|
||||
"Guest",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[new CreateOrderItemRequest(menuItemId, qty, null)]));
|
||||
Assert.True(result.Success);
|
||||
return result.Data!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VoidItem_ReducesOrderTotal()
|
||||
{
|
||||
var (_, orders, cafeId, table1, _, menuItemId, tenant) = CreateFixture();
|
||||
var order = await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId, 2);
|
||||
var itemId = order.Items[0].Id;
|
||||
var before = order.Total;
|
||||
|
||||
var result = await orders.VoidOrderItemAsync(cafeId, order.Id, itemId, tenant.UserId!);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Data!.Items.First(i => i.Id == itemId).IsVoided);
|
||||
Assert.True(result.Data.Total < before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VoidItem_AlreadyVoided_ReturnsError()
|
||||
{
|
||||
var (_, orders, cafeId, table1, _, menuItemId, tenant) = CreateFixture();
|
||||
var order = await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId);
|
||||
var itemId = order.Items[0].Id;
|
||||
|
||||
await orders.VoidOrderItemAsync(cafeId, order.Id, itemId, tenant.UserId!);
|
||||
var second = await orders.VoidOrderItemAsync(cafeId, order.Id, itemId, tenant.UserId!);
|
||||
|
||||
Assert.False(second.Success);
|
||||
Assert.Equal("ITEM_ALREADY_VOIDED", second.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransferTable_MovesOrderToFreeTable()
|
||||
{
|
||||
var (_, orders, cafeId, table1, table2, menuItemId, tenant) = CreateFixture();
|
||||
var order = await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId);
|
||||
|
||||
var result = await orders.TransferTableAsync(cafeId, order.Id, table2);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(table2, result.Data!.TableId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransferTable_ToOccupiedTable_ReturnsTableOccupied()
|
||||
{
|
||||
var (_, orders, cafeId, table1, table2, menuItemId, tenant) = CreateFixture();
|
||||
await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId);
|
||||
var order2 = await CreateOpenOrderAsync(orders, cafeId, tenant, table2, menuItemId);
|
||||
|
||||
var result = await orders.TransferTableAsync(cafeId, order2.Id, table1);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("TABLE_OCCUPIED", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransferTable_ToCleaningTable_ReturnsTableCleaning()
|
||||
{
|
||||
var (db, orders, cafeId, table1, table2, menuItemId, tenant) = CreateFixture();
|
||||
var order = await CreateOpenOrderAsync(orders, cafeId, tenant, table1, menuItemId);
|
||||
|
||||
var table = await db.Tables.FirstAsync(t => t.Id == table2);
|
||||
table.IsCleaning = true;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await orders.TransferTableAsync(cafeId, order.Id, table2);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("TABLE_CLEANING", result.ErrorCode);
|
||||
}
|
||||
|
||||
private sealed class NoOpKdsNotifier : IKdsNotifier
|
||||
{
|
||||
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NoOpSnappfood : ISnappfoodClient
|
||||
{
|
||||
public Task AcknowledgeOrderAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderDeliveredAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusAsync(string snappfoodOrderId, string status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NoOpShiftService : IShiftService
|
||||
{
|
||||
public Task<ShiftServiceResult<ShiftDto>> OpenShiftAsync(string cafeId, string branchId, decimal openingCash, string userId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
public Task<ShiftServiceResult<ShiftDto>> CloseShiftAsync(string cafeId, string shiftId, decimal closingCash, string userId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
public Task<ShiftDto?> GetCurrentShiftAsync(string cafeId, string branchId, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<ShiftDto?>(null);
|
||||
public Task<IReadOnlyList<CashTransactionDto>?> GetTransactionsAsync(string cafeId, string shiftId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
public Task<ShiftServiceResult<CashTransactionDto>> RecordTransactionAsync(string cafeId, string shiftId, CashTransactionType type, PaymentMethod method, decimal amount, string createdByUserId, string? referenceId = null, string? note = null, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
public Task<ShiftServiceResult<Shift>> RequireOpenShiftForBranchAsync(string cafeId, string branchId, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new ShiftServiceResult<Shift>(true, new Shift { Id = "shift-test", CafeId = cafeId, BranchId = branchId }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Meezi.Core.Utilities;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class OtpNormalizerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("916581", "916581")]
|
||||
[InlineData(" 916581 ", "916581")]
|
||||
[InlineData("۹۱۶۵۸۱", "916581")]
|
||||
[InlineData("٩١٦٥٨١", "916581")]
|
||||
public void Normalize_maps_to_ascii_digits(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, OtpNormalizer.Normalize(input));
|
||||
Assert.True(OtpNormalizer.IsValidSixDigitCode(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("12345")]
|
||||
[InlineData("1234567")]
|
||||
public void IsValid_rejects_invalid(string input)
|
||||
{
|
||||
Assert.False(OtpNormalizer.IsValidSixDigitCode(input));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class PlanLimitsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(PlanTier.Free, 1)]
|
||||
[InlineData(PlanTier.Pro, 3)]
|
||||
public void MaxBranches_MatchesTier(PlanTier tier, int expected)
|
||||
{
|
||||
Assert.Equal(expected, PlanLimits.MaxBranches(tier));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxBranches_Business_IsUnlimited()
|
||||
{
|
||||
Assert.Equal(int.MaxValue, PlanLimits.MaxBranches(PlanTier.Business));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PlanTier.Free, 8)]
|
||||
[InlineData(PlanTier.Pro, 90)]
|
||||
public void MaxReportHistoryDays_MatchesTier(PlanTier tier, int expected)
|
||||
{
|
||||
Assert.Equal(expected, PlanLimits.MaxReportHistoryDays(tier));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxReportHistoryDays_Business_IsUnlimited()
|
||||
{
|
||||
Assert.Equal(int.MaxValue, PlanLimits.MaxReportHistoryDays(PlanTier.Business));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PlanTier.Free, 1)]
|
||||
[InlineData(PlanTier.Pro, 3)]
|
||||
public void MaxTerminals_MatchesTier(PlanTier tier, int expected)
|
||||
{
|
||||
Assert.Equal(expected, PlanLimits.MaxTerminals(tier));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class PrintingTests
|
||||
{
|
||||
private static OrderDto SampleOrder() => new(
|
||||
"order-abc12345",
|
||||
"cafe-1",
|
||||
"branch-1",
|
||||
"table-1",
|
||||
"5",
|
||||
"Ali",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
OrderType.DineIn,
|
||||
OrderSource.Pos,
|
||||
OrderStatus.Pending,
|
||||
200_000m,
|
||||
18_000m,
|
||||
0m,
|
||||
218_000m,
|
||||
0m,
|
||||
DateTime.UtcNow,
|
||||
[
|
||||
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
|
||||
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
|
||||
],
|
||||
[new PaymentDto("p1", PaymentMethod.Cash, 218_000m, PaymentStatus.Completed, null)]);
|
||||
|
||||
private static ReceiptPrintContext Ctx(int paper = 80) => new(
|
||||
SampleOrder(),
|
||||
"کافه دمو",
|
||||
"شعبه اصلی",
|
||||
"خوش آمدید",
|
||||
"با تشکر",
|
||||
"wifi1234",
|
||||
paper,
|
||||
true);
|
||||
|
||||
[Fact]
|
||||
public void ReceiptBuilder_ExcludesVoidedItems()
|
||||
{
|
||||
var bytes = new ReceiptBuilder().BuildReceipt(Ctx());
|
||||
var text = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
Assert.Contains("Espresso", text);
|
||||
Assert.DoesNotContain("Void Latte", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptBuilder_AppliesPersianCalendarDate()
|
||||
{
|
||||
var bytes = new ReceiptBuilder().BuildReceipt(Ctx());
|
||||
var text = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
Assert.Contains("تاریخ:", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptBuilder_ShowsTaxLine_WhenTaxNonZero()
|
||||
{
|
||||
var bytes = new ReceiptBuilder().BuildReceipt(Ctx());
|
||||
var text = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
Assert.Contains("مالیات", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptBuilder_80mm_Uses48CharWidth()
|
||||
{
|
||||
var sep = new EscPosBuilder().Separator(48).Build();
|
||||
var receipt = new ReceiptBuilder().BuildReceipt(Ctx(80));
|
||||
Assert.NotEmpty(receipt);
|
||||
Assert.Contains(sep, receipt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptBuilder_58mm_Uses32CharWidth()
|
||||
{
|
||||
var sep = new EscPosBuilder().Separator(32).Build();
|
||||
var receipt = new ReceiptBuilder().BuildReceipt(Ctx(58));
|
||||
Assert.Contains(sep, receipt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KitchenTicket_IncludesItemNotes()
|
||||
{
|
||||
var order = SampleOrder() with
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new OrderItemDto("i1", "m1", "Burger", 1, 100_000m, "بدون پیاز")
|
||||
]
|
||||
};
|
||||
var ctx = Ctx() with { Order = order };
|
||||
var text = System.Text.Encoding.UTF8.GetString(new ReceiptBuilder().BuildKitchenTicket(ctx));
|
||||
Assert.Contains("بدون پیاز", text);
|
||||
Assert.Contains("آشپزخانه", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscPosBuilder_Cut_AppendsCorrectBytes()
|
||||
{
|
||||
var bytes = new EscPosBuilder().Cut().Build();
|
||||
Assert.Equal([0x1D, 0x56, 0x42, 0x03], bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscPosBuilder_TwoColumns_PadsCorrectly()
|
||||
{
|
||||
var bytes = new EscPosBuilder().TwoColumns("کالا", "1000", 10).Build();
|
||||
var line = System.Text.Encoding.UTF8.GetString(bytes).TrimEnd('\n');
|
||||
Assert.Equal(10, line.Length);
|
||||
Assert.EndsWith("1000", line);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class ShiftServiceTests
|
||||
{
|
||||
private static (
|
||||
AppDbContext Db,
|
||||
ShiftService Shifts,
|
||||
OrderService Orders,
|
||||
string CafeId,
|
||||
string BranchId,
|
||||
string UserId,
|
||||
string MenuItemId)
|
||||
CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
|
||||
var cafeId = "cafe-shift";
|
||||
var branchId = "branch-main";
|
||||
var userId = "emp-cashier";
|
||||
var menuItemId = "item-1";
|
||||
var categoryId = "cat-1";
|
||||
|
||||
db.Cafes.Add(new Cafe { Id = cafeId, Name = "Shift Cafe", Slug = "shift-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 = "Cashier",
|
||||
Phone = "09121111111",
|
||||
Role = EmployeeRole.Cashier
|
||||
});
|
||||
db.MenuCategories.Add(new MenuCategory
|
||||
{
|
||||
Id = categoryId,
|
||||
CafeId = cafeId,
|
||||
Name = "Menu",
|
||||
NameEn = "Menu",
|
||||
SortOrder = 0
|
||||
});
|
||||
db.MenuItems.Add(new MenuItem
|
||||
{
|
||||
Id = menuItemId,
|
||||
CafeId = cafeId,
|
||||
CategoryId = categoryId,
|
||||
Name = "Tea",
|
||||
NameEn = "Tea",
|
||||
Price = 100_000m,
|
||||
IsAvailable = true
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var shifts = new ShiftService(db);
|
||||
var orders = new OrderService(db, new NoOpKdsNotifier(), new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
||||
return (db, shifts, orders, cafeId, branchId, userId, menuItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenShift_Succeeds()
|
||||
{
|
||||
var (_, shifts, _, cafeId, branchId, userId, _) = CreateFixture();
|
||||
|
||||
var result = await shifts.OpenShiftAsync(cafeId, branchId, 500_000m, userId);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ShiftStatus.Open, result.Data!.Status);
|
||||
Assert.Equal(500_000m, result.Data.OpeningCash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenShift_WhenAlreadyOpen_ReturnsShiftAlreadyOpen()
|
||||
{
|
||||
var (_, shifts, _, cafeId, branchId, userId, _) = CreateFixture();
|
||||
|
||||
await shifts.OpenShiftAsync(cafeId, branchId, 100m, userId);
|
||||
var second = await shifts.OpenShiftAsync(cafeId, branchId, 200m, userId);
|
||||
|
||||
Assert.False(second.Success);
|
||||
Assert.Equal("SHIFT_ALREADY_OPEN", second.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CloseShift_CalculatesDiscrepancy_FromCashPaymentsAndWithdrawals()
|
||||
{
|
||||
var (_, shifts, _, cafeId, branchId, userId, _) = CreateFixture();
|
||||
|
||||
var opened = await shifts.OpenShiftAsync(cafeId, branchId, 1_000_000m, userId);
|
||||
var shiftId = opened.Data!.Id;
|
||||
|
||||
await shifts.RecordTransactionAsync(
|
||||
cafeId, shiftId, CashTransactionType.OrderPayment, PaymentMethod.Cash, 300_000m, userId);
|
||||
await shifts.RecordTransactionAsync(
|
||||
cafeId, shiftId, CashTransactionType.OrderPayment, PaymentMethod.Card, 200_000m, userId);
|
||||
await shifts.RecordTransactionAsync(
|
||||
cafeId, shiftId, CashTransactionType.Withdrawal, PaymentMethod.Cash, 50_000m, userId);
|
||||
|
||||
var closed = await shifts.CloseShiftAsync(cafeId, shiftId, 1_300_000m, userId);
|
||||
|
||||
Assert.True(closed.Success);
|
||||
Assert.Equal(1_250_000m, closed.Data!.ExpectedCash);
|
||||
Assert.Equal(50_000m, closed.Data.Discrepancy);
|
||||
Assert.Equal(ShiftStatus.Closed, closed.Data.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordPayments_WithOpenShift_RecordsCashTransactions()
|
||||
{
|
||||
var (db, shifts, orders, cafeId, branchId, userId, menuItemId) = CreateFixture();
|
||||
await shifts.OpenShiftAsync(cafeId, branchId, 0m, userId);
|
||||
|
||||
var tenant = new TenantContext { CafeId = cafeId, UserId = userId, Role = EmployeeRole.Cashier };
|
||||
var created = await orders.CreateOrderAsync(
|
||||
cafeId,
|
||||
tenant,
|
||||
new CreateOrderRequest(
|
||||
OrderType.DineIn,
|
||||
branchId,
|
||||
null,
|
||||
null,
|
||||
"Guest",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[new CreateOrderItemRequest(menuItemId, 1, null)]));
|
||||
Assert.True(created.Success);
|
||||
|
||||
var pay = await orders.RecordPaymentsAsync(
|
||||
cafeId,
|
||||
created.Data!.Id,
|
||||
new RecordPaymentsRequest(
|
||||
[new CreatePaymentRequest(PaymentMethod.Cash, 100_000m, null),
|
||||
new CreatePaymentRequest(PaymentMethod.Card, 50_000m, null)]),
|
||||
userId);
|
||||
|
||||
Assert.True(pay.Success);
|
||||
|
||||
var shift = await db.RegisterShifts.Include(s => s.Transactions)
|
||||
.FirstAsync(s => s.BranchId == branchId && s.Status == ShiftStatus.Open);
|
||||
Assert.Equal(2, shift.Transactions.Count);
|
||||
Assert.Contains(shift.Transactions, t => t.Method == PaymentMethod.Cash && t.Amount == 100_000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordPayments_WithoutOpenShift_ReturnsNoOpenShift()
|
||||
{
|
||||
var (_, _, orders, cafeId, branchId, userId, menuItemId) = CreateFixture();
|
||||
|
||||
var tenant = new TenantContext { CafeId = cafeId, UserId = userId };
|
||||
var created = await orders.CreateOrderAsync(
|
||||
cafeId,
|
||||
tenant,
|
||||
new CreateOrderRequest(
|
||||
OrderType.DineIn,
|
||||
branchId,
|
||||
null,
|
||||
null,
|
||||
"Guest",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[new CreateOrderItemRequest(menuItemId, 1, null)]));
|
||||
Assert.True(created.Success);
|
||||
|
||||
var pay = await orders.RecordPaymentsAsync(
|
||||
cafeId,
|
||||
created.Data!.Id,
|
||||
new RecordPaymentsRequest([new CreatePaymentRequest(PaymentMethod.Cash, 100_000m, null)]),
|
||||
userId);
|
||||
|
||||
Assert.False(pay.Success);
|
||||
Assert.Equal("NO_OPEN_SHIFT", pay.ErrorCode);
|
||||
}
|
||||
|
||||
private sealed class NoOpKdsNotifier : IKdsNotifier
|
||||
{
|
||||
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NoOpSnappfood : ISnappfoodClient
|
||||
{
|
||||
public Task AcknowledgeOrderAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderDeliveredAsync(string snappfoodOrderId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task NotifyOrderStatusAsync(string snappfoodOrderId, string status, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Meezi.Core.Constants;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class TableContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlanPricing_Pro_HasExpectedToman()
|
||||
{
|
||||
Assert.Equal(1_490_000m, PlanPricing.MonthlyToman(Core.Enums.PlanTier.Pro));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanPricing_ToRials_ConvertsToman()
|
||||
{
|
||||
Assert.Equal(14_900_000L, PlanPricing.ToRials(1_490_000m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Meezi.API.Services.Printing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
internal sealed class NoOpPrinterService : IPrinterService
|
||||
{
|
||||
public Task<PrintResult> PrintReceiptAsync(string cafeId, string orderId, CancellationToken ct = default) =>
|
||||
Task.FromResult(PrintResult.Ok());
|
||||
|
||||
public Task<PrintResult> PrintKitchenTicketAsync(string cafeId, string orderId, CancellationToken ct = default) =>
|
||||
Task.FromResult(PrintResult.Ok());
|
||||
|
||||
public Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default) =>
|
||||
Task.FromResult(PrintResult.Ok());
|
||||
}
|
||||
|
||||
internal static class TestServiceScopeFactory
|
||||
{
|
||||
public static IServiceScopeFactory Create() =>
|
||||
new ServiceCollection()
|
||||
.AddSingleton<IPrinterService, NoOpPrinterService>()
|
||||
.BuildServiceProvider()
|
||||
.GetRequiredService<IServiceScopeFactory>();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Meezi load tests (k6)
|
||||
|
||||
Smoke-test public endpoints and verify rate limiting under abuse.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install [k6](https://k6.io/docs/get-started/installation/).
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
# API must be running (e.g. docker compose or dotnet run)
|
||||
$env:BASE_URL = "http://localhost:5080"
|
||||
k6 run tests/load/public-abuse.js
|
||||
```
|
||||
|
||||
## Variables
|
||||
|
||||
| Env | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `BASE_URL` | `http://localhost:5080` | API root |
|
||||
| `QR_CODE` | `demo_table_01` | QR resolver code |
|
||||
|
||||
## Expected
|
||||
|
||||
- Most requests succeed under normal VUs.
|
||||
- At high rate, some responses return **429** with `RATE_LIMITED` (ASP.NET or Redis limits).
|
||||
|
||||
See `docs/SECURITY.md` for limit values.
|
||||
@@ -0,0 +1,40 @@
|
||||
import http from "k6/http";
|
||||
import { check, sleep } from "k6";
|
||||
|
||||
const baseUrl = __ENV.BASE_URL || "http://localhost:5080";
|
||||
const qrCode = __ENV.QR_CODE || "demo_table_01";
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: "15s", target: 5 },
|
||||
{ duration: "30s", target: 20 },
|
||||
{ duration: "15s", target: 0 },
|
||||
],
|
||||
thresholds: {
|
||||
http_req_failed: ["rate<0.15"],
|
||||
http_req_duration: ["p(95)<3000"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const health = http.get(`${baseUrl}/health`);
|
||||
check(health, { "health ok": (r) => r.status === 200 });
|
||||
|
||||
const discover = http.get(
|
||||
`${baseUrl}/api/public/discover?city=${encodeURIComponent("تهران")}&requireProfile=false`
|
||||
);
|
||||
check(discover, {
|
||||
"discover ok": (r) => r.status === 200,
|
||||
"discover json": (r) => r.json("success") === true,
|
||||
});
|
||||
|
||||
const qr = http.get(`${baseUrl}/api/q/${qrCode}`);
|
||||
check(qr, {
|
||||
"qr ok or not found": (r) => r.status === 200 || r.status === 404,
|
||||
});
|
||||
|
||||
const security = http.get(`${baseUrl}/api/public/security-config`);
|
||||
check(security, { "security-config ok": (r) => r.status === 200 });
|
||||
|
||||
sleep(0.3);
|
||||
}
|
||||
Reference in New Issue
Block a user