diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 06c5e67..290aa08 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -360,6 +360,10 @@ public sealed class BifrostDbContext(DbContextOptions options) .WithMany(x => x.PatronSupportEvents) .HasForeignKey(x => x.UserAccountId) .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Project) + .WithMany(x => x.PatronSupportEvents) + .HasForeignKey(x => x.ProjectId) + .OnDelete(DeleteBehavior.SetNull); }); modelBuilder.Entity(entity => diff --git a/src/Bifrost.Web/Data/Migrations/20260705190000_AddPatronSupportProjectAttribution.cs b/src/Bifrost.Web/Data/Migrations/20260705190000_AddPatronSupportProjectAttribution.cs new file mode 100644 index 0000000..5373d4d --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260705190000_AddPatronSupportProjectAttribution.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddPatronSupportProjectAttribution : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ProjectId", + table: "PatronSupportEvents", + type: "uuid", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_PatronSupportEvents_ProjectId", + table: "PatronSupportEvents", + column: "ProjectId"); + + migrationBuilder.AddForeignKey( + name: "FK_PatronSupportEvents_Projects_ProjectId", + table: "PatronSupportEvents", + column: "ProjectId", + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_PatronSupportEvents_Projects_ProjectId", + table: "PatronSupportEvents"); + + migrationBuilder.DropIndex( + name: "IX_PatronSupportEvents_ProjectId", + table: "PatronSupportEvents"); + + migrationBuilder.DropColumn( + name: "ProjectId", + table: "PatronSupportEvents"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index ef56f07..b12e9dc 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -908,6 +908,9 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(240) .HasColumnType("character varying(240)"); + b.Property("ProjectId") + .HasColumnType("uuid"); + b.Property("RecordedAtUtc") .HasColumnType("timestamp with time zone"); @@ -923,6 +926,8 @@ namespace Bifrost.Web.Data.Migrations .IsUnique() .HasFilter("\"ProviderEventId\" <> ''"); + b.HasIndex("ProjectId"); + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); b.ToTable("PatronSupportEvents"); @@ -1734,12 +1739,19 @@ namespace Bifrost.Web.Data.Migrations modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PatronSupportEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") .WithMany("PatronSupportEvents") .HasForeignKey("UserAccountId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.Navigation("Project"); + b.Navigation("UserAccount"); }); @@ -1998,6 +2010,8 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("Motions"); + b.Navigation("PatronSupportEvents"); + b.Navigation("PointTransactions"); b.Navigation("RevenueEvents"); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index dabfebf..668af3d 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -458,6 +458,8 @@ public sealed class Project public ICollection PointTransactions { get; set; } = []; + public ICollection PatronSupportEvents { get; set; } = []; + public ICollection RevenueEvents { get; set; } = []; public ICollection RevenueShareLines { get; set; } = []; @@ -801,6 +803,8 @@ public sealed class PatronSupportEvent public Guid UserAccountId { get; set; } + public Guid? ProjectId { get; set; } + public string ExternalSupportId { get; set; } = string.Empty; public ExternalPatronProvider Provider { get; set; } = ExternalPatronProvider.Manual; @@ -826,6 +830,8 @@ public sealed class PatronSupportEvent public string Notes { get; set; } = string.Empty; public UserAccount UserAccount { get; set; } = null!; + + public Project? Project { get; set; } } public sealed class DecayRun diff --git a/src/Bifrost.Web/Features/Patronage/PatronageService.cs b/src/Bifrost.Web/Features/Patronage/PatronageService.cs index fe5e09f..8301356 100644 --- a/src/Bifrost.Web/Features/Patronage/PatronageService.cs +++ b/src/Bifrost.Web/Features/Patronage/PatronageService.cs @@ -24,7 +24,8 @@ public sealed class PatronageService( DateTimeOffset supportedAtUtc, bool isCurrentRecurringSupport, string notes, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Guid? projectId = null) { var user = await dbContext.UserAccounts .Include(x => x.Membership) @@ -88,6 +89,7 @@ public sealed class PatronageService( var supportEvent = new PatronSupportEvent { UserAccountId = userAccountId, + ProjectId = projectId, Kind = kind, ExternalSupportId = externalSupportId.Trim(), Provider = provider, @@ -106,6 +108,7 @@ public sealed class PatronageService( dbContext.PointTransactions.Add(new PointTransaction { UserAccountId = userAccountId, + ProjectId = projectId, Type = PointTransactionType.PatronSupport, Amount = amount, IsDecaying = kind == PatronSupportEventKind.OneTimeDonation, diff --git a/src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs b/src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs index 16efb5a..faa0daa 100644 --- a/src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs +++ b/src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs @@ -1,40 +1,33 @@ using System.Net.Http.Headers; using System.Text.Json; using Bifrost.Web.Configuration; +using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Patronage; public sealed class StripeCheckoutService( HttpClient httpClient, + BifrostDbContext dbContext, IOptions options) { - private static readonly IReadOnlyDictionary VelvetTiers = - new Dictionary(StringComparer.OrdinalIgnoreCase) + private static readonly IReadOnlyDictionary ProjectPolicies = + new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["velvet-room"] = new( - "velvet-room", - "GameCult Patronage - Velvet Room", - "General GameCult patronage with Deru supporter access.", - 1900, - "usd"), - ["mirror-test"] = new( - "mirror-test", - "GameCult Patronage - Mirror Test", - "General GameCult patronage with Deru supporter access.", - 1200, - "usd"), - ["after-hours-bundle"] = new( - "after-hours-bundle", - "GameCult Patronage - After Hours Bundle", - "General GameCult patronage with Deru supporter access.", - 3900, - "usd") + ["velvet"] = new( + "velvet", + "Velvet", + "velvet.gamecult.org", + "deru", + "usd", + 100, + 500_00) }; - public async Task CreateVelvetCheckoutAsync( - string tierSlug, + public async Task CreateProjectDonationCheckoutAsync( + StripeDonationCheckoutRequest checkoutRequest, UserAccount patronAccount, CancellationToken cancellationToken) { @@ -44,14 +37,16 @@ public sealed class StripeCheckoutService( return StripeCheckoutResult.NotConfigured("Stripe checkout is not configured."); } - if (!VelvetTiers.TryGetValue(tierSlug.Trim(), out var tier)) + var validation = await ValidateAsync(checkoutRequest, cancellationToken); + if (validation.Status != StripeCheckoutStatus.Created) { - return StripeCheckoutResult.UnknownTier($"Unknown Velvet patronage tier '{tierSlug}'."); + return validation; } + var donation = validation.Donation!; using var request = new HttpRequestMessage(HttpMethod.Post, "v1/checkout/sessions"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", stripeOptions.SecretKey); - request.Content = new FormUrlEncodedContent(BuildCheckoutForm(stripeOptions, tier, patronAccount)); + request.Content = new FormUrlEncodedContent(BuildCheckoutForm(stripeOptions, donation, patronAccount)); using var response = await httpClient.SendAsync(request, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); @@ -74,20 +69,97 @@ public sealed class StripeCheckoutService( return StripeCheckoutResult.Created(new Uri(url)); } + private async Task ValidateAsync( + StripeDonationCheckoutRequest request, + CancellationToken cancellationToken) + { + var projectSlug = request.ProjectSlug.Trim(); + if (string.IsNullOrWhiteSpace(projectSlug)) + { + return StripeCheckoutResult.InvalidRequest("Missing required project path segment."); + } + + if (!ProjectPolicies.TryGetValue(projectSlug, out var policy)) + { + return StripeCheckoutResult.UnknownProject($"Unknown patronage project '{projectSlug}'."); + } + + if (!string.Equals(request.QueryProjectSlug?.Trim(), projectSlug, StringComparison.OrdinalIgnoreCase)) + { + return StripeCheckoutResult.InvalidRequest("Project query parameter must match the checkout project."); + } + + var project = await dbContext.Projects + .AsNoTracking() + .Where(x => x.Slug == policy.ProjectSlug) + .OrderBy(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); + if (project is null || project.Status != ProjectStatus.Active) + { + return StripeCheckoutResult.UnknownProject($"Patronage project '{policy.ProjectSlug}' is not active in Bifrost."); + } + + if (request.AmountInMinorUnits < policy.MinimumAmountInMinorUnits || + request.AmountInMinorUnits > policy.MaximumAmountInMinorUnits) + { + return StripeCheckoutResult.InvalidRequest( + $"Donation amount must be between {policy.MinimumAmountInMinorUnits} and {policy.MaximumAmountInMinorUnits} {policy.Currency} minor units."); + } + + var currency = string.IsNullOrWhiteSpace(request.Currency) + ? policy.Currency + : request.Currency.Trim().ToLowerInvariant(); + if (!string.Equals(currency, policy.Currency, StringComparison.OrdinalIgnoreCase)) + { + return StripeCheckoutResult.InvalidRequest($"Project '{policy.ProjectSlug}' only accepts {policy.Currency.ToUpperInvariant()} donations."); + } + + var itemSlug = request.ItemSlug.Trim(); + if (!IsValidSlugValue(itemSlug, maxLength: 120)) + { + return StripeCheckoutResult.InvalidRequest("Item must be a non-empty slug of 120 characters or fewer."); + } + + var source = string.IsNullOrWhiteSpace(request.Source) + ? policy.Source + : request.Source.Trim(); + if (!string.Equals(source, policy.Source, StringComparison.OrdinalIgnoreCase)) + { + return StripeCheckoutResult.InvalidRequest($"Project '{policy.ProjectSlug}' checkout source must be '{policy.Source}'."); + } + + return StripeCheckoutResult.Validated(new StripeDonationCheckout( + policy, + project.Id, + itemSlug, + request.AmountInMinorUnits, + currency)); + } + + private static bool IsValidSlugValue(string value, int maxLength) => + value.Length is > 0 and <= 120 && + value.Length <= maxLength && + value.All(character => + char.IsAsciiLetterOrDigit(character) || + character is '-' or '_' or '.'); + private static IEnumerable> BuildCheckoutForm( StripeOptions options, - StripePatronageTier tier, + StripeDonationCheckout donation, UserAccount patronAccount) { - var referenceId = $"velvet:{tier.Slug}:{patronAccount.Id:N}:{Guid.NewGuid():N}"; + var policy = donation.Policy; + var referenceId = $"{policy.ProjectSlug}:{donation.ItemSlug}:{patronAccount.Id:N}:{Guid.NewGuid():N}"; var metadata = new Dictionary { - ["source"] = "velvet.gamecult.org", + ["source"] = policy.Source, ["umbrella"] = "GameCult", ["ledger"] = "bifrost", ["purpose"] = "general_patronage", - ["model"] = "deru", - ["tier"] = tier.Slug, + ["project"] = policy.ProjectSlug, + ["project_id"] = donation.ProjectId.ToString("N"), + ["item"] = donation.ItemSlug, + ["model"] = policy.Model, ["bifrost_user_account_id"] = patronAccount.Id.ToString("N"), ["bifrost_github_login"] = patronAccount.GitHubLogin }; @@ -97,10 +169,10 @@ public sealed class StripeCheckoutService( yield return new("cancel_url", options.CancelUrl); yield return new("client_reference_id", referenceId); yield return new("line_items[0][quantity]", "1"); - yield return new("line_items[0][price_data][currency]", tier.Currency); - yield return new("line_items[0][price_data][unit_amount]", tier.AmountInMinorUnits.ToString()); - yield return new("line_items[0][price_data][product_data][name]", tier.Name); - yield return new("line_items[0][price_data][product_data][description]", tier.Description); + yield return new("line_items[0][price_data][currency]", donation.Currency); + yield return new("line_items[0][price_data][unit_amount]", donation.AmountInMinorUnits.ToString()); + yield return new("line_items[0][price_data][product_data][name]", $"{policy.DisplayName} patronage - {donation.ItemSlug}"); + yield return new("line_items[0][price_data][product_data][description]", $"General GameCult patronage attributed to {policy.DisplayName}."); foreach (var (key, value) in metadata) { @@ -110,26 +182,50 @@ public sealed class StripeCheckoutService( } } -public sealed record StripePatronageTier( - string Slug, - string Name, - string Description, +public sealed record StripeDonationCheckoutRequest( + string ProjectSlug, + string QueryProjectSlug, + string ItemSlug, + int AmountInMinorUnits, + string Currency, + string Source); + +public sealed record StripePatronageProjectPolicy( + string ProjectSlug, + string DisplayName, + string Source, + string Model, + string Currency, + int MinimumAmountInMinorUnits, + int MaximumAmountInMinorUnits); + +public sealed record StripeDonationCheckout( + StripePatronageProjectPolicy Policy, + Guid ProjectId, + string ItemSlug, int AmountInMinorUnits, string Currency); public sealed record StripeCheckoutResult( StripeCheckoutStatus Status, Uri? CheckoutUrl, - string Message) + string Message, + StripeDonationCheckout? Donation = null) { public static StripeCheckoutResult Created(Uri checkoutUrl) => new(StripeCheckoutStatus.Created, checkoutUrl, "Stripe checkout session created."); + public static StripeCheckoutResult Validated(StripeDonationCheckout donation) => + new(StripeCheckoutStatus.Created, null, "Stripe checkout request validated.", donation); + public static StripeCheckoutResult NotConfigured(string message) => new(StripeCheckoutStatus.NotConfigured, null, message); - public static StripeCheckoutResult UnknownTier(string message) => - new(StripeCheckoutStatus.UnknownTier, null, message); + public static StripeCheckoutResult InvalidRequest(string message) => + new(StripeCheckoutStatus.InvalidRequest, null, message); + + public static StripeCheckoutResult UnknownProject(string message) => + new(StripeCheckoutStatus.UnknownProject, null, message); public static StripeCheckoutResult ProviderError(string message) => new(StripeCheckoutStatus.ProviderError, null, message); @@ -139,6 +235,7 @@ public enum StripeCheckoutStatus { Created, NotConfigured, - UnknownTier, + InvalidRequest, + UnknownProject, ProviderError } diff --git a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs index 2859e40..4293ffe 100644 --- a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs +++ b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs @@ -72,6 +72,12 @@ public sealed class StripeWebhookService( "Stripe checkout session is not linked to an unsuspended Bifrost patron account."); } + var projectId = await ResolveProjectIdAsync(session, cancellationToken); + if (projectId.Status != StripeWebhookProjectStatus.Resolved) + { + return StripeWebhookResult.BadRequest(projectId.Message); + } + var amount = session.AmountTotal.Value / 100m; var currencyCode = string.IsNullOrWhiteSpace(session.Currency) ? "USD" @@ -91,7 +97,8 @@ public sealed class StripeWebhookService( DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created), isCurrentRecurringSupport: false, BuildNotes(session), - cancellationToken); + cancellationToken, + projectId.ProjectId); return StripeWebhookResult.Processed("Stripe patronage support event recorded."); } @@ -120,10 +127,61 @@ public sealed class StripeWebhookService( return null; } + private async Task ResolveProjectIdAsync( + StripeCheckoutSession session, + CancellationToken cancellationToken) + { + if (session.Metadata.TryGetValue("project_id", out var projectIdText) && + Guid.TryParse(projectIdText, out var projectId)) + { + var project = await dbContext.Projects + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == projectId, cancellationToken); + if (project is null || project.Status != ProjectStatus.Active) + { + return StripeWebhookProjectResolution.Invalid( + $"Stripe checkout session references inactive or unknown project id '{projectIdText}'."); + } + + if (session.Metadata.TryGetValue("project", out var metadataProjectSlug) && + !string.IsNullOrWhiteSpace(metadataProjectSlug) && + !string.Equals(project.Slug, metadataProjectSlug.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return StripeWebhookProjectResolution.Invalid( + $"Stripe checkout session project slug '{metadataProjectSlug}' does not match project id '{projectIdText}'."); + } + + return StripeWebhookProjectResolution.Resolved(projectId); + } + + if (session.Metadata.TryGetValue("project", out var projectSlug) && + !string.IsNullOrWhiteSpace(projectSlug)) + { + var normalizedSlug = projectSlug.Trim(); + var project = await dbContext.Projects + .AsNoTracking() + .Where(x => x.Slug == normalizedSlug) + .OrderBy(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); + return project is not null && project.Status == ProjectStatus.Active + ? StripeWebhookProjectResolution.Resolved(project.Id) + : StripeWebhookProjectResolution.Invalid($"Stripe checkout session references inactive or unknown project '{normalizedSlug}'."); + } + + return StripeWebhookProjectResolution.Resolved(null); + } + private static string BuildNotes(StripeCheckoutSession session) { + session.Metadata.TryGetValue("project", out var project); + session.Metadata.TryGetValue("item", out var item); session.Metadata.TryGetValue("tier", out var tier); session.Metadata.TryGetValue("source", out var source); + if (!string.IsNullOrWhiteSpace(project) || !string.IsNullOrWhiteSpace(item)) + { + return $"Verified Stripe checkout session for {source ?? "unknown source"} project {project ?? "unknown project"} item {item ?? "unknown item"}."; + } + return $"Verified Stripe checkout session for {source ?? "unknown source"} tier {tier ?? "unknown tier"}."; } @@ -182,6 +240,24 @@ public enum StripeWebhookStatus NotConfigured } +internal sealed record StripeWebhookProjectResolution( + StripeWebhookProjectStatus Status, + Guid? ProjectId, + string Message) +{ + public static StripeWebhookProjectResolution Resolved(Guid? projectId) => + new(StripeWebhookProjectStatus.Resolved, projectId, "Project resolved."); + + public static StripeWebhookProjectResolution Invalid(string message) => + new(StripeWebhookProjectStatus.Invalid, null, message); +} + +internal enum StripeWebhookProjectStatus +{ + Resolved, + Invalid +} + public sealed class StripeWebhookEvent { public string Id { get; set; } = string.Empty; diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 007e545..8c9e95b 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -388,16 +388,27 @@ app.MapPost("/heimdall/patron-support/events", async ( }; }).AllowAnonymous(); -app.MapGet("/patronage/velvet/checkout", async ( +app.MapGet("/patronage/{project}/checkout", async ( + string project, HttpRequest request, ICurrentBifrostActorAccessor actorAccessor, StripeCheckoutService stripeCheckoutService, CancellationToken cancellationToken) => { - var tier = request.Query["tier"].ToString(); - if (string.IsNullOrWhiteSpace(tier)) + var amountCentsText = request.Query["amountCents"].ToString(); + if (!int.TryParse(amountCentsText, out var amountCents)) { - return Results.BadRequest("Missing required tier query parameter."); + return Results.Text( + "Missing or invalid required amountCents query parameter.", + statusCode: StatusCodes.Status400BadRequest); + } + + var item = request.Query["item"].ToString(); + if (string.IsNullOrWhiteSpace(item)) + { + return Results.Text( + "Missing required item query parameter.", + statusCode: StatusCodes.Status400BadRequest); } var actor = await actorAccessor.GetAsync(cancellationToken); @@ -407,14 +418,25 @@ app.MapGet("/patronage/velvet/checkout", async ( return Results.Redirect($"/auth/sign-in?returnUrl={returnUrl}"); } - var result = await stripeCheckoutService.CreateVelvetCheckoutAsync( - tier, + var result = await stripeCheckoutService.CreateProjectDonationCheckoutAsync( + new StripeDonationCheckoutRequest( + project, + request.Query["project"].ToString(), + item, + amountCents, + request.Query["currency"].ToString(), + request.Query["source"].ToString()), actor.UserAccount, cancellationToken); return result.Status switch { StripeCheckoutStatus.Created => Results.Redirect(result.CheckoutUrl!.ToString()), - StripeCheckoutStatus.UnknownTier => Results.NotFound(result.Message), + StripeCheckoutStatus.InvalidRequest => Results.Text( + result.Message, + statusCode: StatusCodes.Status400BadRequest), + StripeCheckoutStatus.UnknownProject => Results.Text( + result.Message, + statusCode: StatusCodes.Status404NotFound), StripeCheckoutStatus.NotConfigured => Results.Text( result.Message, statusCode: StatusCodes.Status503ServiceUnavailable), diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index e1fafba..ce4cb0d 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -371,8 +371,9 @@ public sealed class PlatformEndpointsTests : IClassFixture(); patronageAccountId = setupContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN").Id; + projectId = await EnsureVelvetProjectAsync(setupContext, patronageAccountId); } var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -444,8 +462,10 @@ public sealed class PlatformEndpointsTests : IClassFixture x.Action == "patron-support.recorded"); } @@ -1661,6 +1683,49 @@ public sealed class PlatformEndpointsTests : IClassFixture EnsureVelvetProjectAsync(HttpClient client) + { + _ = await client.GetAsync("/App"); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var owner = await dbContext.UserAccounts.SingleAsync(x => x.NormalizedGitHubLogin == "TEST-ADMIN"); + return await EnsureVelvetProjectAsync(dbContext, owner.Id); + } + + private static async Task EnsureVelvetProjectAsync(BifrostDbContext dbContext, Guid ownerUserAccountId) + { + var existing = await dbContext.Projects + .Where(x => x.Slug == "velvet") + .OrderBy(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(); + if (existing is not null) + { + if (existing.Status != ProjectStatus.Active) + { + existing.Status = ProjectStatus.Active; + await dbContext.SaveChangesAsync(); + } + + return existing.Id; + } + + var now = DateTimeOffset.UtcNow; + var project = new Project + { + OwnerUserAccountId = ownerUserAccountId, + Slug = "velvet", + Name = "Velvet", + Summary = "General patronage for Velvet publication.", + Status = ProjectStatus.Active, + CreatedAtUtc = now, + UpdatedAtUtc = now + }; + dbContext.Projects.Add(project); + await dbContext.SaveChangesAsync(); + return project.Id; + } + private static HttpRequestMessage CreateSignedHeimdallRequest(string payload) { var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events"); diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index 86ae0a2..fc8675d 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -84,6 +84,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory return new StripeCheckoutService( client, + serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService>()); }); }); @@ -99,6 +100,8 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory var hasExpectedMetadata = form.Contains("metadata%5Bpurpose%5D=general_patronage", StringComparison.Ordinal) && form.Contains("metadata%5Bledger%5D=bifrost", StringComparison.Ordinal) && + form.Contains("metadata%5Bproject%5D=velvet", StringComparison.Ordinal) && + form.Contains("metadata%5Bitem%5D=velvet-room", StringComparison.Ordinal) && form.Contains("metadata%5Bbifrost_user_account_id%5D=", StringComparison.Ordinal); if (request.RequestUri?.PathAndQuery != "/v1/checkout/sessions" || !hasExpectedMetadata)