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; 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()); var gateway = new Mock(); Stripe.Checkout.SessionCreateOptions? captured = null; gateway.Setup(item => item.CreateCheckoutAsync("sk_test_fake", It.IsAny(), It.IsAny())) .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(action.Result); Assert.Equal("https://checkout.stripe.test/session", Assert.IsType(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()); var configuration = Configuration(new Dictionary { ["Stripe:PricePremium"] = "prod_wrong_kind" }); var controller = Controller(configuration, users, CreateRoleManager(), new Mock()); Authenticate(controller, user.Id); var status = Assert.IsType((await controller.Status(default)).Result); Assert.False(Assert.IsType(status.Value).Enabled); Assert.IsType((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(); gateway.Setup(item => item.ConstructEvent(It.IsAny(), "signed", "whsec_fake")).Returns(webhookEvent); gateway.SetupSequence(item => item.GetSubscriptionAsync("sk_test_fake", "sub_lifecycle", It.IsAny())) .ReturnsAsync(Subscription("active")) .ReturnsAsync(Subscription("past_due")) .ReturnsAsync(Subscription("canceled")); var controller = Controller(Configuration(), users, roles, gateway); SetWebhookRequest(controller); Assert.IsType(await controller.Webhook(default)); Assert.True(hasPremium); Assert.Equal("active", user.StripeSubscriptionStatus); SetWebhookRequest(controller); Assert.IsType(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(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() { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Stripe:SecretKey"] = "sk_test_fake", ["Stripe:PricePremium"] = "price_fake", ["Stripe:WebhookSecret"] = "whsec_fake", ["App:PublicBaseUrl"] = "https://example.test", }).Build(); var userStore = new Mock>(); var users = new Mock>( userStore.Object, Options.Create(new IdentityOptions()), new PasswordHasher(), Array.Empty>(), Array.Empty>(), new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, NullLogger>.Instance); var roleStore = new Mock>(); var roles = new Mock>( roleStore.Object, Array.Empty>(), new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), NullLogger>.Instance); var controller = new BillingController(configuration, users.Object, roles.Object, NullLogger.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, }; controller.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}")); controller.Request.Headers["Stripe-Signature"] = "invalid"; var result = await controller.Webhook(CancellationToken.None); Assert.IsType(result); } private static IConfiguration Configuration(Dictionary? overrides = null) { var values = new Dictionary { ["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> CreateRoleManager() { var store = new Mock>(); return new Mock>( store.Object, Array.Empty>(), new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), NullLogger>.Instance); } private static BillingController Controller( IConfiguration configuration, Mock> users, Mock> roles, Mock gateway) => new(configuration, users.Object, roles.Object, NullLogger.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 { ["jobtracker_user_id"] = "user-1" }, Items = new StripeList { Data = new List { new() { Price = new Price { Id = "price_fake" } }, }, }, }; }