Files
jobtrackingapp/JobTrackerApi.Tests/BillingControllerTests.cs
T
cesnimda 191de69c48
CI and Deploy / test (pull_request) Successful in 5m27s
CI and Deploy / deploy (pull_request) Has been skipped
test(billing): prove downgrade lifecycle
2026-08-15 20:28:23 +02:00

211 lines
9.8 KiB
C#

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<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()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Stripe:SecretKey"] = "sk_test_fake",
["Stripe:PricePremium"] = "price_fake",
["Stripe:WebhookSecret"] = "whsec_fake",
["App:PublicBaseUrl"] = "https://example.test",
}).Build();
var userStore = new Mock<IUserStore<ApplicationUser>>();
var users = new Mock<UserManager<ApplicationUser>>(
userStore.Object, Options.Create(new IdentityOptions()), new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(), Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, NullLogger<UserManager<ApplicationUser>>.Instance);
var roleStore = new Mock<IRoleStore<IdentityRole>>();
var roles = new Mock<RoleManager<IdentityRole>>(
roleStore.Object, Array.Empty<IRoleValidator<IdentityRole>>(), new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(), NullLogger<RoleManager<IdentityRole>>.Instance);
var controller = new BillingController(configuration, users.Object, roles.Object, NullLogger<BillingController>.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<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" } },
},
},
};
}