test(billing): prove downgrade lifecycle
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
using System.Text;
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -8,12 +11,110 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class BillingControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Checkout_uses_the_configured_price_and_stable_user_metadata()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", Email = "person@example.test" };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync(Array.Empty<string>());
|
||||
var gateway = new Mock<IStripeBillingGateway>();
|
||||
Stripe.Checkout.SessionCreateOptions? captured = null;
|
||||
gateway.Setup(item => item.CreateCheckoutAsync("sk_test_fake", It.IsAny<Stripe.Checkout.SessionCreateOptions>(), It.IsAny<CancellationToken>()))
|
||||
.Callback((string _, Stripe.Checkout.SessionCreateOptions options, CancellationToken _) => captured = options)
|
||||
.ReturnsAsync(new Stripe.Checkout.Session { Url = "https://checkout.stripe.test/session" });
|
||||
var controller = Controller(Configuration(), users, CreateRoleManager(), gateway);
|
||||
Authenticate(controller, user.Id);
|
||||
|
||||
var action = await controller.Checkout(default);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(action.Result);
|
||||
Assert.Equal("https://checkout.stripe.test/session", Assert.IsType<BillingController.BillingRedirectDto>(result.Value).Url);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal("price_fake", Assert.Single(captured.LineItems).Price);
|
||||
Assert.Equal(user.Id, captured.ClientReferenceId);
|
||||
Assert.Equal(user.Id, captured.Metadata["jobtracker_user_id"]);
|
||||
Assert.Equal(user.Id, captured.SubscriptionData.Metadata["jobtracker_user_id"]);
|
||||
Assert.Equal("https://example.test/settings?billing=success", captured.SuccessUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Product_identifier_cannot_enable_checkout_as_a_price()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync(Array.Empty<string>());
|
||||
var configuration = Configuration(new Dictionary<string, string?> { ["Stripe:PricePremium"] = "prod_wrong_kind" });
|
||||
var controller = Controller(configuration, users, CreateRoleManager(), new Mock<IStripeBillingGateway>());
|
||||
Authenticate(controller, user.Id);
|
||||
|
||||
var status = Assert.IsType<OkObjectResult>((await controller.Status(default)).Result);
|
||||
Assert.False(Assert.IsType<BillingController.BillingStatusDto>(status.Value).Enabled);
|
||||
Assert.IsType<ObjectResult>((await controller.Checkout(default)).Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signed_subscription_lifecycle_grants_then_revokes_on_expiry_idempotently()
|
||||
{
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
AiEnabled = true,
|
||||
ProfileCvText = "non-AI profile data must survive",
|
||||
};
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(item => item.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var hasPremium = false;
|
||||
users.Setup(item => item.IsInRoleAsync(user, "Premium")).ReturnsAsync(() => hasPremium);
|
||||
users.Setup(item => item.AddToRoleAsync(user, "Premium"))
|
||||
.Callback(() => hasPremium = true)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
users.Setup(item => item.RemoveFromRoleAsync(user, "Premium"))
|
||||
.Callback(() => hasPremium = false)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var roles = CreateRoleManager();
|
||||
roles.Setup(item => item.RoleExistsAsync("Premium")).ReturnsAsync(true);
|
||||
var webhookEvent = new Event
|
||||
{
|
||||
Type = EventTypes.CustomerSubscriptionUpdated,
|
||||
Created = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc),
|
||||
Data = new EventData { Object = new Subscription { Id = "sub_lifecycle" } },
|
||||
};
|
||||
var gateway = new Mock<IStripeBillingGateway>();
|
||||
gateway.Setup(item => item.ConstructEvent(It.IsAny<string>(), "signed", "whsec_fake")).Returns(webhookEvent);
|
||||
gateway.SetupSequence(item => item.GetSubscriptionAsync("sk_test_fake", "sub_lifecycle", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Subscription("active"))
|
||||
.ReturnsAsync(Subscription("past_due"))
|
||||
.ReturnsAsync(Subscription("canceled"));
|
||||
var controller = Controller(Configuration(), users, roles, gateway);
|
||||
|
||||
SetWebhookRequest(controller);
|
||||
Assert.IsType<OkResult>(await controller.Webhook(default));
|
||||
Assert.True(hasPremium);
|
||||
Assert.Equal("active", user.StripeSubscriptionStatus);
|
||||
|
||||
SetWebhookRequest(controller);
|
||||
Assert.IsType<OkResult>(await controller.Webhook(default));
|
||||
Assert.False(hasPremium);
|
||||
Assert.Equal("past_due", user.StripeSubscriptionStatus);
|
||||
Assert.Equal("non-AI profile data must survive", user.ProfileCvText);
|
||||
|
||||
SetWebhookRequest(controller);
|
||||
Assert.IsType<OkResult>(await controller.Webhook(default));
|
||||
Assert.False(hasPremium);
|
||||
Assert.Equal("canceled", user.StripeSubscriptionStatus);
|
||||
users.Verify(item => item.AddToRoleAsync(user, "Premium"), Times.Once);
|
||||
users.Verify(item => item.RemoveFromRoleAsync(user, "Premium"), Times.Once);
|
||||
users.Verify(item => item.UpdateAsync(user), Times.Exactly(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Webhook_rejects_an_invalid_Stripe_signature()
|
||||
{
|
||||
@@ -46,4 +147,64 @@ public sealed class BillingControllerTests
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
private static IConfiguration Configuration(Dictionary<string, string?>? overrides = null)
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["Stripe:SecretKey"] = "sk_test_fake",
|
||||
["Stripe:PricePremium"] = "price_fake",
|
||||
["Stripe:WebhookSecret"] = "whsec_fake",
|
||||
["App:PublicBaseUrl"] = "https://example.test",
|
||||
};
|
||||
if (overrides is not null)
|
||||
foreach (var (key, value) in overrides) values[key] = value;
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
}
|
||||
|
||||
private static Mock<RoleManager<IdentityRole>> CreateRoleManager()
|
||||
{
|
||||
var store = new Mock<IRoleStore<IdentityRole>>();
|
||||
return new Mock<RoleManager<IdentityRole>>(
|
||||
store.Object,
|
||||
Array.Empty<IRoleValidator<IdentityRole>>(),
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
NullLogger<RoleManager<IdentityRole>>.Instance);
|
||||
}
|
||||
|
||||
private static BillingController Controller(
|
||||
IConfiguration configuration,
|
||||
Mock<UserManager<ApplicationUser>> users,
|
||||
Mock<RoleManager<IdentityRole>> roles,
|
||||
Mock<IStripeBillingGateway> gateway)
|
||||
=> new(configuration, users.Object, roles.Object, NullLogger<BillingController>.Instance, stripe: gateway.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
|
||||
private static void Authenticate(BillingController controller, string userId)
|
||||
=> controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "local"));
|
||||
|
||||
private static void SetWebhookRequest(BillingController controller)
|
||||
{
|
||||
controller.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}"));
|
||||
controller.Request.Headers["Stripe-Signature"] = "signed";
|
||||
}
|
||||
|
||||
private static Subscription Subscription(string status) => new()
|
||||
{
|
||||
Id = "sub_lifecycle",
|
||||
CustomerId = "cus_lifecycle",
|
||||
Status = status,
|
||||
Metadata = new Dictionary<string, string> { ["jobtracker_user_id"] = "user-1" },
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new() { Price = new Price { Id = "price_fake" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user