using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Meezi.API.Middleware; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Infrastructure.Data; using Xunit; namespace Meezi.API.Tests; public class IdempotencyMiddlewareTests { private sealed class TestTenant(string? cafeId) : ITenantContext { public string? UserId => "user-1"; public string? CafeId => cafeId; 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; } /// A scope factory whose scopes share one in-memory database, mirroring how the /// middleware opens isolated DI scopes against the same store in production. private static IServiceScopeFactory BuildScopeFactory() { var dbName = Guid.NewGuid().ToString(); var services = new ServiceCollection(); services.AddDbContext(o => o.UseInMemoryDatabase(dbName)); services.AddLogging(); return services.BuildServiceProvider().GetRequiredService(); } private static DefaultHttpContext NewPost(string? key) { var ctx = new DefaultHttpContext(); ctx.Request.Method = "POST"; ctx.Request.Path = "/api/test"; if (key is not null) ctx.Request.Headers["Idempotency-Key"] = key; ctx.Response.Body = new MemoryStream(); return ctx; } private static string ReadBody(HttpContext ctx) { ctx.Response.Body.Position = 0; return new StreamReader(ctx.Response.Body).ReadToEnd(); } [Fact] public async Task SameKey_ExecutesOnce_AndReplaysStoredResponse() { var scopeFactory = BuildScopeFactory(); var tenant = new TestTenant("cafe-1"); var calls = 0; RequestDelegate next = async ctx => { calls++; ctx.Response.StatusCode = 200; await ctx.Response.WriteAsync($"{{\"v\":\"{Guid.NewGuid():N}\"}}"); }; var mw = new IdempotencyMiddleware(next, NullLogger.Instance); var c1 = NewPost("KEY-1"); await mw.InvokeAsync(c1, tenant, scopeFactory); var body1 = ReadBody(c1); var c2 = NewPost("KEY-1"); await mw.InvokeAsync(c2, tenant, scopeFactory); var body2 = ReadBody(c2); Assert.Equal(1, calls); // executed exactly once Assert.Equal(body1, body2); // second call replays the stored body verbatim Assert.Equal(200, c2.Response.StatusCode); Assert.Equal("true", c2.Response.Headers["Idempotent-Replay"].ToString()); } [Fact] public async Task DifferentKey_ExecutesAgain() { var scopeFactory = BuildScopeFactory(); var tenant = new TestTenant("cafe-1"); var calls = 0; RequestDelegate next = async ctx => { calls++; ctx.Response.StatusCode = 200; await ctx.Response.WriteAsync("{\"ok\":true}"); }; var mw = new IdempotencyMiddleware(next, NullLogger.Instance); await mw.InvokeAsync(NewPost("A"), tenant, scopeFactory); await mw.InvokeAsync(NewPost("B"), tenant, scopeFactory); Assert.Equal(2, calls); } [Fact] public async Task NoKey_PassesThrough_NoIdempotency() { var scopeFactory = BuildScopeFactory(); var tenant = new TestTenant("cafe-1"); var calls = 0; RequestDelegate next = ctx => { calls++; ctx.Response.StatusCode = 200; return Task.CompletedTask; }; var mw = new IdempotencyMiddleware(next, NullLogger.Instance); await mw.InvokeAsync(NewPost(null), tenant, scopeFactory); await mw.InvokeAsync(NewPost(null), tenant, scopeFactory); Assert.Equal(2, calls); } [Fact] public async Task SameKey_DifferentTenant_IsNotReplayed() { var scopeFactory = BuildScopeFactory(); var calls = 0; RequestDelegate next = async ctx => { calls++; ctx.Response.StatusCode = 200; await ctx.Response.WriteAsync("{\"ok\":true}"); }; var mw = new IdempotencyMiddleware(next, NullLogger.Instance); await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-A"), scopeFactory); await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-B"), scopeFactory); Assert.Equal(2, calls); // keys are scoped per café — no cross-tenant collision } [Fact] public async Task ServerError_IsNotCached_SoRetryReexecutes() { var scopeFactory = BuildScopeFactory(); var tenant = new TestTenant("cafe-1"); var calls = 0; RequestDelegate next = async ctx => { calls++; ctx.Response.StatusCode = 500; await ctx.Response.WriteAsync("{\"error\":\"boom\"}"); }; var mw = new IdempotencyMiddleware(next, NullLogger.Instance); await mw.InvokeAsync(NewPost("KEY-5XX"), tenant, scopeFactory); var c2 = NewPost("KEY-5XX"); await mw.InvokeAsync(c2, tenant, scopeFactory); Assert.Equal(2, calls); // 5xx is transient → reservation released, retry runs again Assert.NotEqual("true", c2.Response.Headers["Idempotent-Replay"].ToString()); } }