feat(api): .NET 10 multi-tenant REST API

Full backend implementation:
- Multi-tenant cafe/restaurant management (menus, orders, tables, staff)
- POS order flow with ZarinPal and Snappfood payment integration
- OTP authentication via Kavenegar SMS
- QR digital menu with public discover/finder endpoints
- Customer loyalty, coupons, CRM
- PostgreSQL via EF Core, Redis for caching/sessions
- Background jobs, webhook handlers
- Full migration history

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
+189
View File
@@ -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"));
}
}
+143
View File
@@ -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;
}
+226
View File
@@ -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));
}
}
+44
View File
@@ -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));
}
}
+120
View File
@@ -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);
}
}
+230
View File
@@ -0,0 +1,230 @@
using Meezi.API.Models.Menu;
using Meezi.API.Models.Orders;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Xunit;
using LiveOrderDto = Meezi.API.Models.Orders.LiveOrderDto;
namespace Meezi.API.Tests;
public class QrMenuTests
{
private sealed class NoOpKds : IKdsNotifier
{
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken ct = default) =>
Task.CompletedTask;
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken ct = default) =>
Task.CompletedTask;
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken ct = default) =>
Task.CompletedTask;
}
private static (
AppDbContext Db,
TableService Tables,
PublicService Public,
string CafeId,
string BranchId,
string TableId,
string ItemA,
string ItemB,
string QrCode
) CreateFixture()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new AppDbContext(options);
var cafeId = "cafe-qr";
var branchId = "branch-qr";
var catId = "cat-qr";
var itemA = "item-qr-a";
var itemB = "item-qr-b";
var tableId = "table-qr-1";
const string qrCode = "demo_table_01";
db.Cafes.Add(new Cafe
{
Id = cafeId,
Name = "Demo",
Slug = "demo",
PlanTier = PlanTier.Pro,
DefaultTaxRate = 9m
});
db.Branches.Add(new Branch
{
Id = branchId,
CafeId = cafeId,
Name = "Main",
IsActive = true,
WelcomeText = "خوش آمدید",
UpdatedAt = DateTime.UtcNow
});
db.MenuCategories.Add(new MenuCategory
{
Id = catId,
CafeId = cafeId,
Name = "Drinks",
SortOrder = 0,
IsActive = true
});
db.MenuItems.AddRange(
new MenuItem
{
Id = itemA,
CafeId = cafeId,
CategoryId = catId,
Name = "Espresso",
Price = 100_000m,
IsAvailable = true
},
new MenuItem
{
Id = itemB,
CafeId = cafeId,
CategoryId = catId,
Name = "Latte",
Price = 150_000m,
IsAvailable = true
});
db.Tables.Add(new Table
{
Id = tableId,
CafeId = cafeId,
BranchId = branchId,
Number = "1",
QrCode = qrCode,
IsActive = true
});
db.SaveChanges();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["App:QrPublicBaseUrl"] = "https://meezi.test" })
.Build();
var kds = new NoOpKds();
var branchMenu = new BranchMenuService(db);
var identity = new BranchIdentityService(db);
var tables = new TableService(db, config, kds, identity);
var shifts = new ShiftService(db);
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
var publicSvc = new PublicService(db, orders, new ReviewService(db), kds, branchMenu, identity);
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
}
[Fact]
public async Task ResolveQr_ValidCode_ReturnsBranchInfo()
{
var (_, tables, _, _, branchId, tableId, _, _, qrCode) = CreateFixture();
var result = await tables.ResolveQrAsync(qrCode);
Assert.NotNull(result);
Assert.Equal(branchId, result!.BranchId);
Assert.Equal(tableId, result.TableId);
Assert.False(result.IsCleaning);
}
[Fact]
public async Task ResolveQr_InvalidCode_ReturnsNull()
{
var (_, tables, _, _, _, _, _, _, _) = CreateFixture();
Assert.Null(await tables.ResolveQrAsync("missing_code"));
}
[Fact]
public async Task GetBranchMenu_ExcludesUnavailableItems()
{
var (db, _, pub, cafeId, branchId, _, itemA, itemB, _) = CreateFixture();
var menuSvc = new BranchMenuService(db);
await menuSvc.UpsertOverrideAsync(
cafeId, branchId, itemB,
new UpsertBranchMenuOverrideRequest(false, null),
PlanTier.Pro, EmployeeRole.Manager, "u1");
var menu = await pub.GetBranchMenuAsync(cafeId, branchId);
var ids = menu!.Categories.SelectMany(c => c.Items).Select(i => i.Id).ToList();
Assert.Contains(itemA, ids);
Assert.DoesNotContain(itemB, ids);
}
[Fact]
public async Task GetBranchMenu_AppliesBranchPriceOverride()
{
var (db, _, pub, cafeId, branchId, _, itemA, _, _) = CreateFixture();
await new BranchMenuService(db).UpsertOverrideAsync(
cafeId, branchId, itemA,
new UpsertBranchMenuOverrideRequest(true, 88_000m),
PlanTier.Pro, EmployeeRole.Manager, "u1");
var item = (await pub.GetBranchMenuAsync(cafeId, branchId))!
.Categories.SelectMany(c => c.Items)
.First(i => i.Id == itemA);
Assert.Equal(88_000m, item.Price);
}
[Fact]
public async Task PlaceGuestOrder_ValidItems_CreatesOrderWithGuestQrSource()
{
var (db, _, pub, cafeId, branchId, tableId, itemA, _, _) = CreateFixture();
var (data, code, _) = await pub.PlaceBranchGuestOrderAsync(
cafeId,
branchId,
new PlaceGuestOrderRequest(tableId, "Guest", null, [new CreateOrderItemRequest(itemA, 2, null)]));
Assert.Null(code);
Assert.NotNull(data);
var order = await db.Orders.Include(o => o.Items).FirstAsync(o => o.Id == data!.OrderId);
Assert.Equal(OrderSource.GuestQr, order.Source);
Assert.Equal(2, order.Items.Sum(i => i.Quantity));
}
[Fact]
public async Task PlaceGuestOrder_MergesWithExistingOpenOrder()
{
var (db, _, pub, cafeId, branchId, tableId, itemA, _, _) = CreateFixture();
await pub.PlaceBranchGuestOrderAsync(
cafeId, branchId,
new PlaceGuestOrderRequest(tableId, null, null, [new CreateOrderItemRequest(itemA, 1, null)]));
var (second, code, _) = await pub.PlaceBranchGuestOrderAsync(
cafeId, branchId,
new PlaceGuestOrderRequest(tableId, null, null, [new CreateOrderItemRequest(itemA, 1, null)]));
Assert.Null(code);
Assert.Equal(1, await db.Orders.CountAsync(o => o.TableId == tableId));
var order = await db.Orders.Include(o => o.Items).FirstAsync(o => o.Id == second!.OrderId);
Assert.Equal(2, order.Items.Sum(i => i.Quantity));
}
[Fact]
public async Task PlaceGuestOrder_CleaningTable_ReturnsTableCleaning()
{
var (db, _, pub, cafeId, branchId, tableId, itemA, _, _) = CreateFixture();
var table = await db.Tables.FirstAsync(t => t.Id == tableId);
table.IsCleaning = true;
await db.SaveChangesAsync();
var (_, code, _) = await pub.PlaceBranchGuestOrderAsync(
cafeId, branchId,
new PlaceGuestOrderRequest(tableId, null, null, [new CreateOrderItemRequest(itemA, 1, null)]));
Assert.Equal("TABLE_CLEANING", code);
}
[Fact]
public async Task PlaceGuestOrder_InvalidMenuItem_ReturnsInvalidMenuItems()
{
var (_, _, pub, cafeId, branchId, tableId, _, _, _) = CreateFixture();
var (_, code, _) = await pub.PlaceBranchGuestOrderAsync(
cafeId, branchId,
new PlaceGuestOrderRequest(tableId, null, null, [new CreateOrderItemRequest("missing", 1, null)]));
Assert.Equal("INVALID_MENU_ITEMS", code);
}
}
+213
View File
@@ -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>();
}
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": false
}
+29
View File
@@ -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.
+40
View File
@@ -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);
}