yavsc/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs

500 lines
17 KiB
C#
Raw Normal View History

2026-08-31 02:14:04 +01:00
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
2026-09-13 03:49:07 +01:00
using Microsoft.EntityFrameworkCore.Metadata;
2026-08-31 02:14:04 +01:00
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
2026-09-13 03:09:12 +01:00
using Npgsql;
2026-08-31 02:14:04 +01:00
using Yavsc.Controllers;
2026-09-12 13:57:25 +01:00
using Yavsc.Interfaces.Workflow;
2026-08-31 02:14:04 +01:00
using Yavsc.Models;
2026-09-13 23:29:06 +01:00
using Yavsc.Models.Billing;
2026-09-12 13:57:25 +01:00
using Yavsc.Models.Google.Messaging;
2026-08-31 04:15:10 +01:00
using Yavsc.Models.Haircut;
2026-09-12 13:57:25 +01:00
using Yavsc.Models.Messaging;
2026-08-31 02:14:04 +01:00
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
2026-09-12 13:57:25 +01:00
using Yavsc.Services;
2026-08-31 02:14:04 +01:00
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test.Fixtures;
public sealed class ApiWebServerFixture : WebHostFixture
{
2026-09-13 03:09:12 +01:00
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
2026-09-13 03:49:07 +01:00
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
2026-09-13 03:09:12 +01:00
2026-08-31 02:14:04 +01:00
protected override int HttpsPort => 5104;
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
2026-09-13 03:09:12 +01:00
private static readonly object _npgsqlLock = new();
private static string? _sharedNpgsqlConnectionString;
2026-08-31 02:14:04 +01:00
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
2026-09-13 03:09:12 +01:00
if (UseNpgsqlProvider())
{
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
2026-09-13 23:29:06 +01:00
opt.UseNpgsql(npgsqlConnectionString,
x => x.MigrationsAssembly("Yavsc.Org")));
2026-09-13 03:09:12 +01:00
}
else
2026-08-31 02:14:04 +01:00
{
2026-09-13 03:09:12 +01:00
SqliteConnection sharedConnection;
lock (_sqliteLock)
2026-08-31 02:14:04 +01:00
{
2026-09-13 03:09:12 +01:00
if (_sharedSqliteConnection is null)
{
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscApiTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
2026-08-31 02:14:04 +01:00
}
2026-09-13 03:09:12 +01:00
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseSqlite(sharedConnection));
}
2026-08-31 02:14:04 +01:00
builder.Services.AddControllers()
.AddApplicationPart(typeof(ActivityApiController).Assembly);
2026-09-12 13:57:25 +01:00
builder.Services.AddLocalization();
builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.AddTransient<IBillingService, BillingService>();
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
2026-08-31 02:14:04 +01:00
builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.IncludeErrorDetails = true;
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenIssuer.SigningKey,
NameClaimType = "sub",
RoleClaimType = Yavsc.Constants.RoleClaimType,
};
});
return builder.Build();
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
app.UseDeveloperExceptionPage();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
2026-09-13 23:29:06 +01:00
if (UseNpgsqlProvider())
{
// Apply the EF Core migrations (Yavsc.Org assembly) so the
// test database schema matches the production provider.
// EnsureCreated must not be used here: it would create the
// schema without the migrations history and break Migrate().
db.Database.Migrate();
}
else
{
db.Database.EnsureCreated();
}
2026-08-31 02:14:04 +01:00
}
await Task.CompletedTask;
return app;
}
public string BaseAddress => Addresses.First(a => a.StartsWith("https://", StringComparison.Ordinal));
2026-09-13 03:09:12 +01:00
public static bool UseNpgsqlProvider()
=> string.Equals(
Environment.GetEnvironmentVariable(DbProviderEnvVar),
"npgsql",
StringComparison.OrdinalIgnoreCase);
private static string EnsureNpgsqlDatabaseCreated()
{
lock (_npgsqlLock)
{
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
{
return _sharedNpgsqlConnectionString;
}
var adminConnectionString = BuildAdminConnectionString();
2026-09-13 03:49:07 +01:00
var databaseName = DedicatedNpgsqlDatabaseName;
2026-09-13 03:09:12 +01:00
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
{
adminConnection.Open();
2026-09-13 03:49:07 +01:00
using var existsCommand = adminConnection.CreateCommand();
existsCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @databaseName";
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
if (existsCommand.ExecuteScalar() is null)
{
using var createCommand = adminConnection.CreateCommand();
createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\"";
createCommand.ExecuteNonQuery();
}
2026-09-13 03:09:12 +01:00
}
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
{
Database = databaseName,
Pooling = false,
IncludeErrorDetail = true
};
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
return _sharedNpgsqlConnectionString;
}
}
private static string BuildAdminConnectionString()
{
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
2026-09-13 03:09:12 +01:00
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
if (string.IsNullOrWhiteSpace(configured))
throw new InvalidOperationException(
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(configured)
2026-09-13 03:09:12 +01:00
{
Pooling = false,
IncludeErrorDetail = true
};
// The admin connection targets the maintenance database
// (postgres) so we can CREATE/DROP the dedicated test database;
// whatever database the env var names is overridden here.
if (string.IsNullOrWhiteSpace(builder.Database))
2026-09-13 03:09:12 +01:00
builder.Database = "postgres";
return builder.ToString();
}
2026-09-12 13:57:25 +01:00
private sealed class NoopMessageSender : IYavscMessageSender
{
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyEstimateAsync(IEnumerable<string> connectionIds, EstimationEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyHairCutQueryAsync(IEnumerable<string> connectionIds, HairCutQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> connectionIds, IEvent yaev)
=> Task.FromResult(new MessageWithPayloadResponse());
}
2026-08-31 02:14:04 +01:00
public void ResetAndSeedActivityGraph()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
2026-09-13 03:49:07 +01:00
ResetDatabase(db);
2026-08-31 02:14:04 +01:00
db.Database.EnsureCreated();
var user = new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.test",
EmailConfirmed = true,
FullName = "Alice",
};
db.Users.Add(user);
var location = new Location
{
Address = "1 rue du Test",
Latitude = 48.8566,
Longitude = 2.3522,
};
db.Add(location);
db.SaveChanges();
var activity = new Activity
{
Code = "dev",
Name = "Dev",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.Activities.Add(activity);
db.Activities.Add(new Activity
{
Code = "ghost",
Name = "Ghost",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
var performer = new PerformerProfile
{
PerformerId = "alice",
SIREN = "123456789",
OrganizationAddressId = location.Id,
AcceptNotifications = true,
AcceptPublicContact = true,
Active = true,
Rate = 5,
WebSite = "https://alice.dev",
};
db.Performers.Add(performer);
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "dev",
Weight = 100,
});
db.Users.Add(new ApplicationUser
{
Id = "bob",
UserName = "bob",
Email = "bob@example.test",
EmailConfirmed = true,
FullName = "Bob",
});
db.Activities.Add(new Activity
{
Code = "declared-only",
Name = "Declared Only",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
db.Performers.Add(new PerformerProfile
{
PerformerId = "bob",
SIREN = "987654321",
OrganizationAddressId = location.Id,
AcceptNotifications = false,
AcceptPublicContact = false,
Active = false,
Rate = 0,
WebSite = "",
});
db.UserActivities.Add(new UserActivity
{
UserId = "bob",
DoesCode = "declared-only",
Weight = 10,
});
db.SaveChanges();
}
2026-09-13 03:49:07 +01:00
private static void ResetDatabase(ApplicationDbContext db)
{
if (UseNpgsqlProvider())
{
2026-09-13 23:29:06 +01:00
// Purge only the tables of the test graph, children before
// parents, so no DELETE violates a foreign key. Estimate and
// CommandLine reference NominativeServiceCommand (CommandId) and
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
db.Set<Estimate>().RemoveRange(db.Set<Estimate>());
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
db.Set<HairMultiCutQuery>().RemoveRange(db.Set<HairMultiCutQuery>());
db.Set<HairPrestationCollectionItem>().RemoveRange(db.Set<HairPrestationCollectionItem>());
db.Set<HairPrestation>().RemoveRange(db.Set<HairPrestation>());
db.Set<UserActivity>().RemoveRange(db.Set<UserActivity>());
db.Set<PerformerProfile>().RemoveRange(db.Set<PerformerProfile>());
db.Set<Activity>().RemoveRange(db.Set<Activity>());
db.Set<ApplicationUser>().RemoveRange(db.Set<ApplicationUser>());
db.Set<Location>().RemoveRange(db.Set<Location>());
2026-09-13 03:49:07 +01:00
db.SaveChanges();
return;
}
db.Database.EnsureDeleted();
}
2026-08-31 03:37:09 +01:00
public void ResetAndSeedRdvQueryGraph()
{
ResetAndSeedActivityGraph();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
db.RdvQueries.Add(new RdvQuery
{
ActivityCode = "dev",
ClientId = "alice",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
2026-08-31 03:37:09 +01:00
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Initial rendez-vous",
Status = Yavsc.QueryStatus.Inserted,
});
db.SaveChanges();
}
2026-08-31 04:15:10 +01:00
public void ResetAndSeedHaircutGraph()
{
ResetAndSeedActivityGraph();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
if (!db.Activities.Any(a => a.Code == "brush"))
{
db.Activities.Add(new Activity
{
Code = "brush",
Name = "Brush",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
}
if (!db.Activities.Any(a => a.Code == "mbrush"))
{
db.Activities.Add(new Activity
{
Code = "mbrush",
Name = "MBrush",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
}
if (!db.BrusherProfile.Any(p => p.UserId == "alice"))
{
db.BrusherProfile.Add(new BrusherProfile
{
UserId = "alice",
ActionDistance = 25,
WomenLongCutPrice = 50m,
WomenHalfCutPrice = 40m,
WomenShortCutPrice = 30m,
ManCutPrice = 20m,
KidCutPrice = 15m,
ShampooPrice = 5m,
});
}
var prestation1 = new HairPrestation
{
Gender = HairCutGenders.Women,
Length = HairLength.HalfLong,
Cut = true,
Shampoo = true,
Dressing = HairDressings.Brushing,
Tech = HairTechnos.NoTech,
Cares = false,
Taints = new List<HairTaintInstance>(),
};
var prestation2 = new HairPrestation
{
Gender = HairCutGenders.Man,
Length = HairLength.Short,
Cut = true,
Shampoo = false,
Dressing = HairDressings.Brushing,
Tech = HairTechnos.NoTech,
Cares = false,
Taints = new List<HairTaintInstance>(),
};
db.HairPrestation.AddRange(prestation1, prestation2);
db.SaveChanges();
db.HairCutQueries.Add(new HairCutQuery
{
ActivityCode = "brush",
ClientId = "alice",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
2026-08-31 04:15:10 +01:00
EventDate = DateTime.UtcNow.AddDays(3),
Location = location,
PrestationId = prestation1.Id,
Prestation = prestation1,
AdditionalInfo = "Coupe test",
Status = Yavsc.QueryStatus.Inserted,
Description = "Haircut seed",
});
db.HairMultiCutQueries.Add(new HairMultiCutQuery
{
ActivityCode = "mbrush",
ClientId = "alice",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
2026-08-31 04:15:10 +01:00
EventDate = DateTime.UtcNow.AddDays(4),
Location = location,
Prestations = new List<HairPrestationCollectionItem>
{
new() { PrestationId = prestation1.Id, Prestation = prestation1 },
new() { PrestationId = prestation2.Id, Prestation = prestation2 },
},
Status = Yavsc.QueryStatus.Inserted,
});
db.SaveChanges();
}
2026-08-31 02:14:04 +01:00
public override void Dispose()
{
2026-09-12 17:31:18 +01:00
// Keep the shared in-memory SQLite connection alive for the
// whole test process. Closing it from one fixture instance can
// drop the schema while other tests are still running.
base.Dispose();
2026-08-31 02:14:04 +01:00
}
}