using Microsoft.EntityFrameworkCore; using Tau.Acuvim.Portal.Data; using Tau.Acuvim.Portal.DTOs; using Tau.Acuvim.Portal.Services; namespace Tau.Acuvim.Portal.Tests; public class CustomerTokenGraceTests { private static AdminDbContext NewDb() { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: "grace-" + Guid.NewGuid()) .Options; var rls = new RlsContext(); rls.SetAll(); // Tests bypass RLS scoping; CustomerService isn't customer-scoped anyway. return new AdminDbContext(opts, rls); } private static async Task<(CustomerService svc, Guid id, string originalToken)> SeedAsync() { var db = NewDb(); var svc = new CustomerService(db); var created = await svc.CreateAsync(new CreateCustomerRequest("ABC0001", "Test")); return (svc, created.Customer.Id, created.Token); } [Fact] public async Task Create_DoesNotSetPreviousToken() { var (svc, id, _) = await SeedAsync(); var list = await svc.ListAsync(); var c = list.Single(x => x.Id == id); Assert.Null(c.PreviousTokenExpiresAt); } [Fact] public async Task Rotate_MovesCurrentTokenIntoPreviousSlot() { var (svc, id, originalToken) = await SeedAsync(); var rotated = await svc.RotateTokenAsync(id); Assert.NotNull(rotated); // Original token still accepted within grace window. var found = await svc.FindByTokenAsync(originalToken); Assert.NotNull(found); // New token also accepted. var newToken = rotated!.Token; Assert.NotEqual(originalToken, newToken); var foundNew = await svc.FindByTokenAsync(newToken); Assert.NotNull(foundNew); // PreviousTokenExpiresAt populated and in the near future. var listed = (await svc.ListAsync()).Single(x => x.Id == id); Assert.NotNull(listed.PreviousTokenExpiresAt); Assert.True(listed.PreviousTokenExpiresAt > DateTime.UtcNow); Assert.True(listed.PreviousTokenExpiresAt <= DateTime.UtcNow + CustomerService.DefaultTokenGracePeriod + TimeSpan.FromMinutes(1)); } [Fact] public async Task Rotate_WithZeroGrace_RejectsOriginalImmediately() { var (svc, id, originalToken) = await SeedAsync(); await svc.RotateTokenAsync(id, graceWindow: TimeSpan.Zero); // PreviousExpiresAt is now-ish; the strict-greater-than check in FindByTokenAsync // rejects it. So the original is dead. var found = await svc.FindByTokenAsync(originalToken); Assert.Null(found); } [Fact] public async Task SecondRotation_OverwritesPreviousSlot_OriginalDies() { var (svc, id, originalToken) = await SeedAsync(); var firstRotation = await svc.RotateTokenAsync(id); var secondRotation = await svc.RotateTokenAsync(id); Assert.NotNull(firstRotation); Assert.NotNull(secondRotation); // Original is gone — the previous slot now holds the first-rotation token. Assert.Null(await svc.FindByTokenAsync(originalToken)); // First-rotation token is still accepted (it's the new previous). Assert.NotNull(await svc.FindByTokenAsync(firstRotation!.Token)); // Second-rotation token is accepted (current). Assert.NotNull(await svc.FindByTokenAsync(secondRotation!.Token)); } [Fact] public async Task InactiveCustomer_RejectsBothCurrentAndPrevious() { var (svc, id, originalToken) = await SeedAsync(); var rotated = await svc.RotateTokenAsync(id); await svc.UpdateAsync(id, new UpdateCustomerRequest("Test", IsActive: false)); Assert.Null(await svc.FindByTokenAsync(originalToken)); Assert.Null(await svc.FindByTokenAsync(rotated!.Token)); } }