mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
262 lines
10 KiB
C#
262 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<TeamMemberInviteTestHost> CreateAsync(Action<IServiceCollection>? configure = null)
|
|
{
|
|
var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
|
await connection.OpenAsync();
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>().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<IServiceCollection>? configure)
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["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<ApplicationDbContext>(builder => builder.UseSqlite(connection));
|
|
services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
|
|
new SqliteInviteTestDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
|
|
services.AddSeaHavenIdentity();
|
|
services.AddSingleton<TimeProvider>(fakes.Time);
|
|
services.AddSingleton<IEmailSender>(fakes.Sent);
|
|
services.AddDataServices();
|
|
services.AddBusinessServices(configuration);
|
|
configure?.Invoke(services);
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
public async Task<T> InScopeAsync<T>(Func<IServiceProvider, Task<T>> action)
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
return await action(scope.ServiceProvider);
|
|
}
|
|
|
|
public Task<TeamMemberRegistrationOutcomeDTO> RegistrationAsync(
|
|
Func<ITeamMemberRegistrationService, Task<TeamMemberRegistrationOutcomeDTO>> action) =>
|
|
InScopeAsync(provider => action(provider.GetRequiredService<ITeamMemberRegistrationService>()));
|
|
|
|
/// <summary>Creates a pending member through the admin create path and returns the emailed token.</summary>
|
|
public async Task<(string UserId, string Token)> AddPendingMemberAsync(string email, string name = "Taylor Reed")
|
|
{
|
|
var outcome = await InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().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<string> 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<ApplicationUser> ReloadUserAsync(string userId) =>
|
|
InScopeAsync(async provider => await provider.GetRequiredService<ApplicationDbContext>()
|
|
.Users.AsNoTracking().SingleAsync(user => user.Id == userId));
|
|
|
|
public Task<List<TeamMemberInvite>> InvitesAsync(string userId) =>
|
|
InScopeAsync(provider => provider.GetRequiredService<ApplicationDbContext>()
|
|
.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<ApplicationDbContext> 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<SentEmail> _messages = new();
|
|
|
|
public bool Succeeds { get; set; } = true;
|
|
|
|
public IReadOnlyList<SentEmail> Messages => _messages.ToArray();
|
|
|
|
public Task<bool> 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#(?<token>[A-Za-z0-9_-]+)")]
|
|
public static partial Regex InviteLink();
|
|
|
|
[GeneratedRegex("<strong>(?<code>[0-9]{6})</strong>")]
|
|
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<string> _entries = new();
|
|
|
|
public IReadOnlyList<string> Entries => _entries.ToArray();
|
|
|
|
public ILogger CreateLogger(string categoryName) => new CapturingLogger(_entries);
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private sealed class CapturingLogger : ILogger
|
|
{
|
|
private readonly ConcurrentQueue<string> _entries;
|
|
|
|
public CapturingLogger(ConcurrentQueue<string> entries) => _entries = entries;
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
|
|
{
|
|
_entries.Enqueue(state.ToString() ?? "");
|
|
return null;
|
|
}
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
var values = state is IEnumerable<KeyValuePair<string, object?>> pairs
|
|
? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}"))
|
|
: "";
|
|
_entries.Enqueue($"{formatter(state, exception)} {values} {exception}");
|
|
}
|
|
}
|
|
}
|