f4583f5169
Backend half of offline Phase 1. Lets the offline outbox replay a write after a
lost response without executing it twice (e.g. an order whose POST reached the
server but whose reply never came back).
- IdempotencyRecord entity + table (unique index on (Scope, Key)); migration
AddIdempotencyRecords. Standalone POCO — no tenant/soft-delete filters.
- IdempotencyMiddleware (after TenantMiddleware, before plan-limit/controllers):
opt-in via `Idempotency-Key` header on POST/PUT/PATCH/DELETE.
* Completed key → replays stored status+body with `Idempotent-Replay: true`.
* In-progress key → 409 IDEMPOTENCY_IN_PROGRESS; the unique index serializes
racing first requests; stale (>60s) reservations are recovered after a crash.
* Only <500 responses are cached; 5xx is released so the client can retry.
Bookkeeping runs in isolated DI scopes so it never contaminates the controller's
unit of work. Keys are scoped per café — no cross-tenant collisions.
- 5 middleware tests (replay/execute-once, distinct key, pass-through, tenant
isolation, 5xx-not-cached). Full suite 86 passing.
Next in Phase 1: generalize the POS order queue into a generic client outbox that
sends these keys and remaps client→server ids.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
5.6 KiB
C#
163 lines
5.6 KiB
C#
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;
|
|
}
|
|
|
|
/// <summary>A scope factory whose scopes share one in-memory database, mirroring how the
|
|
/// middleware opens isolated DI scopes against the same store in production.</summary>
|
|
private static IServiceScopeFactory BuildScopeFactory()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
var services = new ServiceCollection();
|
|
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase(dbName));
|
|
services.AddLogging();
|
|
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
|
}
|
|
|
|
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<IdempotencyMiddleware>.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<IdempotencyMiddleware>.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<IdempotencyMiddleware>.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<IdempotencyMiddleware>.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<IdempotencyMiddleware>.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());
|
|
}
|
|
}
|