using System.Collections.Concurrent; using System.Security.Claims; using System.Text.RegularExpressions; using Api.SeaHavenIndustries.Infrastructure; using Data.SeaHavenIndustries; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; /// /// A SQLite-backed composition of the real team member, invite and registration /// services, with Identity registered exactly as the API host registers it. Email /// goes to an in-memory sender and every log line is captured. /// internal sealed class TeamMemberInviteTestHost : IAsyncDisposable { public const string FrontendBaseUrl = "https://shoc.test"; private readonly SqliteConnection _connection; private ServiceProvider _provider = null!; private TeamMemberInviteTestHost(SqliteConnection connection) { _connection = connection; } public CapturingEmailSender Sent { get; } = new(); public ManualTimeProvider Time { get; } = new(); public CapturingLoggerProvider Logged { get; } = new(); public static async Task CreateAsync(Action? configure = null) { var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; await using (var setup = new SqliteInviteTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); var host = new TeamMemberInviteTestHost(connection); host._provider = BuildProvider(connection, host, configure); return host; } private static ServiceProvider BuildProvider( SqliteConnection connection, TeamMemberInviteTestHost fakes, Action? configure) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["FrontendBaseUrl"] = FrontendBaseUrl, ["JWT:Secret"] = new string('k', 64), ["JWT:ValidIssuer"] = "issuer", ["JWT:ValidAudience"] = "audience" }) .Build(); var services = new ServiceCollection(); services.AddLogging(logging => logging .SetMinimumLevel(LogLevel.Trace) .AddProvider(fakes.Logged)); services.AddDbContext(builder => builder.UseSqlite(connection)); services.Replace(ServiceDescriptor.Scoped(provider => new SqliteInviteTestDbContext(provider.GetRequiredService>()))); services.AddSeaHavenIdentity(); services.AddSingleton(fakes.Time); services.AddSingleton(fakes.Sent); services.AddDataServices(); services.AddBusinessServices(configuration); configure?.Invoke(services); return services.BuildServiceProvider(); } public async Task InScopeAsync(Func> action) { await using var scope = _provider.CreateAsyncScope(); return await action(scope.ServiceProvider); } public Task RegistrationAsync( Func> action) => InScopeAsync(provider => action(provider.GetRequiredService())); /// Creates a pending member through the admin create path and returns the emailed token. public async Task<(string UserId, string Token)> AddPendingMemberAsync(string email, string name = "Taylor Reed") { var outcome = await InScopeAsync(provider => provider.GetRequiredService().CreateAsync( new CreateTeamMemberRequestDTO { Name = name, Role = "dispatcher", Color = "#0D9488", Email = email, Phone = "555-0100", ServiceAreas = new[] { "East" } }, Admin(), CancellationToken.None)); Assert.True(outcome.Success, outcome.Error); return (outcome.Member!.Id, Sent.LatestTokenFor(email)); } public async Task SendCodeAsync(string token, string email) { var outcome = await RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); Assert.Equal(TeamMemberRegistrationStatus.Ok, outcome.Status); return Sent.LatestCodeFor(email); } public async Task ConfirmEmailAsync(string token, string email) { var code = await SendCodeAsync(token, email); var verified = await RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None)); Assert.Equal(TeamMemberRegistrationStatus.Ok, verified.Status); } public Task ReloadUserAsync(string userId) => InScopeAsync(async provider => await provider.GetRequiredService() .Users.AsNoTracking().SingleAsync(user => user.Id == userId)); public Task> InvitesAsync(string userId) => InScopeAsync(provider => provider.GetRequiredService() .TeamMemberInvites.AsNoTracking().Where(invite => invite.UserId == userId) .OrderBy(invite => invite.Id).ToListAsync()); public static ClaimsPrincipal Admin() => new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test")); public static ClaimsPrincipal Dispatcher() => new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Dispatcher") }, "Test")); public async ValueTask DisposeAsync() { await _provider.DisposeAsync(); await _connection.DisposeAsync(); } private sealed class SqliteInviteTestDbContext : ApplicationDbContext { public SqliteInviteTestDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); // Keep filtered unique indexes filtered: translate SQL Server identifier quoting. foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) { if (index.GetFilter() is { } filter) index.SetFilter(filter.Replace('[', '"').Replace(']', '"')); } foreach (var property in builder.Model.GetEntityTypes() .SelectMany(entity => entity.GetProperties()) .Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[]))) { property.ValueGenerated = ValueGenerated.Never; property.IsConcurrencyToken = false; } } } } internal sealed partial class CapturingEmailSender : IEmailSender { private readonly ConcurrentQueue _messages = new(); public bool Succeeds { get; set; } = true; public IReadOnlyList Messages => _messages.ToArray(); public Task SendEmailAsync(string emailTo, string subject, string body) { _messages.Enqueue(new SentEmail(emailTo, subject, body)); return Task.FromResult(Succeeds); } public string LatestTokenFor(string email) { var message = Messages.Last(sent => sent.To == email && sent.Subject == "You're invited to Seahaven"); return InviteLink().Match(message.Body).Groups["token"].Value; } public string LatestCodeFor(string email) { var message = Messages.Last(sent => sent.To == email && sent.Subject == "Your Seahaven confirmation code"); return Code().Match(message.Body).Groups["code"].Value; } [GeneratedRegex("https://shoc\\.test/invite#(?[A-Za-z0-9_-]+)")] public static partial Regex InviteLink(); [GeneratedRegex("(?[0-9]{6})")] private static partial Regex Code(); } internal sealed record SentEmail(string To, string Subject, string Body); internal sealed class ManualTimeProvider : TimeProvider { public DateTimeOffset Now { get; private set; } = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => Now; public void Advance(TimeSpan by) => Now = Now.Add(by); } internal sealed class CapturingLoggerProvider : ILoggerProvider { private readonly ConcurrentQueue _entries = new(); public IReadOnlyList Entries => _entries.ToArray(); public ILogger CreateLogger(string categoryName) => new CapturingLogger(_entries); public void Dispose() { } private sealed class CapturingLogger : ILogger { private readonly ConcurrentQueue _entries; public CapturingLogger(ConcurrentQueue entries) => _entries = entries; public IDisposable? BeginScope(TState state) where TState : notnull { _entries.Enqueue(state.ToString() ?? ""); return null; } public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var values = state is IEnumerable> pairs ? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}")) : ""; _entries.Enqueue($"{formatter(state, exception)} {values} {exception}"); } } }