using System.Collections.Concurrent;
using System.Security.Claims;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.HostedServices;
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);
// Registration signs the member in through the authentication service, which
// queues password reset email; the API host registers the same delivery.
services.AddPasswordResetEmailDelivery();
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}");
}
}
}