mirror of
https://github.com/bitwarden/server
synced 2026-02-18 18:33:29 +00:00
[PM-30108] import discount from stripe (#6982)
* [PM-30108] import discount from stripe * fix repo tests * pr feedback * wrap discounts in feature flag * claude pr feedback
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
using Bit.Admin.Billing.Controllers;
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Entities;
|
||||
using Bit.Core.Billing.Subscriptions.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Stripe;
|
||||
|
||||
namespace Admin.Test.Billing.Controllers;
|
||||
|
||||
[ControllerCustomize(typeof(SubscriptionDiscountsController))]
|
||||
[SutProviderCustomize]
|
||||
public class SubscriptionDiscountsControllerTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task Index_DefaultParameters_ReturnsViewWithDiscounts(
|
||||
List<SubscriptionDiscount> discounts,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.ListAsync(0, 25)
|
||||
.Returns(discounts);
|
||||
|
||||
var result = await sutProvider.Sut.Index();
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
var viewResult = (ViewResult)result;
|
||||
var model = Assert.IsType<SubscriptionDiscountPagedModel>(viewResult.Model);
|
||||
Assert.Equal(25, model.Count);
|
||||
Assert.Equal(1, model.Page);
|
||||
Assert.Equal(discounts.Count, model.Items.Count);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Index_WithPagination_CalculatesCorrectSkip(
|
||||
List<SubscriptionDiscount> discounts,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.ListAsync(50, 25)
|
||||
.Returns(discounts);
|
||||
|
||||
var result = await sutProvider.Sut.Index(page: 3, count: 25);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.Received(1)
|
||||
.ListAsync(50, 25);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Index_WithInvalidPage_DefaultsToPage1(
|
||||
List<SubscriptionDiscount> discounts,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.ListAsync(0, 25)
|
||||
.Returns(discounts);
|
||||
|
||||
var result = await sutProvider.Sut.Index(page: -1, count: 25);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.Received(1)
|
||||
.ListAsync(0, 25);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void Create_Get_ReturnsViewWithEmptyModel(
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var result = sutProvider.Sut.Create();
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
var viewResult = (ViewResult)result;
|
||||
var model = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
|
||||
Assert.False(model.IsImported);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportCoupon_ValidCoupon_ReturnsViewWithStripeProperties(
|
||||
CreateSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var stripeCoupon = new Stripe.Coupon
|
||||
{
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25,
|
||||
Duration = "once"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
|
||||
.Returns(stripeCoupon);
|
||||
|
||||
var result = await sutProvider.Sut.ImportCoupon(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
var viewResult = (ViewResult)result;
|
||||
Assert.Equal("Create", viewResult.ViewName);
|
||||
var returnedModel = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
|
||||
Assert.Equal(stripeCoupon.Name, returnedModel.Name);
|
||||
Assert.Equal(stripeCoupon.PercentOff, returnedModel.PercentOff);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportCoupon_ValidCoupon_SetsIsImportedToTrue(
|
||||
CreateSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var stripeCoupon = new Stripe.Coupon
|
||||
{
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25,
|
||||
Duration = "once"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
|
||||
.Returns(stripeCoupon);
|
||||
|
||||
// Ensure IsImported starts as false
|
||||
model.IsImported = false;
|
||||
|
||||
var result = await sutProvider.Sut.ImportCoupon(model);
|
||||
|
||||
var viewResult = Assert.IsType<ViewResult>(result);
|
||||
var returnedModel = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
|
||||
Assert.True(returnedModel.IsImported, "IsImported should be set to true after successful import");
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportCoupon_CouponWithProductRestrictions_MapsProductIds(
|
||||
CreateSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var productIds = new List<string> { "prod_test1", "prod_test2", "prod_test3" };
|
||||
var stripeCoupon = new Stripe.Coupon
|
||||
{
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25,
|
||||
Duration = "once",
|
||||
AppliesTo = new Stripe.CouponAppliesTo
|
||||
{
|
||||
Products = productIds
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
|
||||
.Returns(stripeCoupon);
|
||||
|
||||
var products = new List<Stripe.Product>
|
||||
{
|
||||
new() { Id = "prod_test1", Name = "Test Product 1" },
|
||||
new() { Id = "prod_test2", Name = "Test Product 2" },
|
||||
new() { Id = "prod_test3", Name = "Test Product 3" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.ListProductsAsync(Arg.Is<ProductListOptions>(o =>
|
||||
o.Ids != null &&
|
||||
o.Ids.Count == 3 &&
|
||||
o.Ids.Contains("prod_test1") &&
|
||||
o.Ids.Contains("prod_test2") &&
|
||||
o.Ids.Contains("prod_test3")))
|
||||
.Returns(products);
|
||||
|
||||
var result = await sutProvider.Sut.ImportCoupon(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
var viewResult = (ViewResult)result;
|
||||
var returnedModel = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
|
||||
Assert.NotNull(returnedModel.AppliesToProducts);
|
||||
Assert.Equal(3, returnedModel.AppliesToProducts.Count);
|
||||
Assert.Equal("Test Product 1", returnedModel.AppliesToProducts["prod_test1"]);
|
||||
Assert.Equal("Test Product 2", returnedModel.AppliesToProducts["prod_test2"]);
|
||||
Assert.Equal("Test Product 3", returnedModel.AppliesToProducts["prod_test3"]);
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.ListProductsAsync(Arg.Is<ProductListOptions>(o => o.Ids != null && o.Ids.Count == 3));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportCoupon_EmptyCouponId_ReturnsViewWithError(
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel { StripeCouponId = "" };
|
||||
sutProvider.Sut.ModelState.AddModelError(nameof(model.StripeCouponId), "The Stripe Coupon ID field is required.");
|
||||
|
||||
var result = await sutProvider.Sut.ImportCoupon(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
var viewResult = (ViewResult)result;
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("required", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportCoupon_DuplicateCoupon_ReturnsViewWithError(
|
||||
CreateSubscriptionDiscountModel model,
|
||||
SubscriptionDiscount existingDiscount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns(existingDiscount);
|
||||
|
||||
var result = await sutProvider.Sut.ImportCoupon(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
var viewResult = (ViewResult)result;
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("already been imported", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportCoupon_StripeApiError_ReturnsViewWithError(
|
||||
CreateSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
|
||||
.Throws(new StripeException());
|
||||
|
||||
var result = await sutProvider.Sut.ImportCoupon(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("error occurred", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportCoupon_StripeResourceMissingError_ReturnsViewWithSpecificError(
|
||||
CreateSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
var stripeError = new StripeError { Code = "resource_missing" };
|
||||
var stripeException = new StripeException { StripeError = stripeError };
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
|
||||
.Throws(stripeException);
|
||||
|
||||
var result = await sutProvider.Sut.ImportCoupon(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("not found in Stripe", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportCoupon_WithDurationInMonths_ConvertsToInt(
|
||||
CreateSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var stripeCoupon = new Stripe.Coupon
|
||||
{
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25,
|
||||
Duration = "repeating",
|
||||
DurationInMonths = 12L
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
|
||||
.Returns(stripeCoupon);
|
||||
|
||||
var result = await sutProvider.Sut.ImportCoupon(model);
|
||||
|
||||
var viewResult = (ViewResult)result;
|
||||
var returnedModel = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
|
||||
Assert.Equal(12, returnedModel.DurationInMonths);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Create_ValidModel_CreatesDiscountAndRedirects(
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
StripeCouponId = "TEST123",
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25,
|
||||
Duration = "once",
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddMonths(1),
|
||||
RestrictToNewUsersOnly = false,
|
||||
IsImported = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
sutProvider.Sut.ModelState.Clear();
|
||||
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
sutProvider.Sut.TempData = tempData;
|
||||
|
||||
var result = await sutProvider.Sut.Create(model);
|
||||
|
||||
Assert.IsType<RedirectToActionResult>(result);
|
||||
var redirectResult = (RedirectToActionResult)result;
|
||||
Assert.Equal(nameof(SubscriptionDiscountsController.Index), redirectResult.ActionName);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(Arg.Is<SubscriptionDiscount>(d =>
|
||||
d.StripeCouponId == model.StripeCouponId &&
|
||||
d.Name == model.Name &&
|
||||
d.StartDate == model.StartDate &&
|
||||
d.EndDate == model.EndDate &&
|
||||
d.AudienceType == DiscountAudienceType.AllUsers));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Create_WithRestrictToNewUsersOnly_SetsCorrectAudienceType(
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
StripeCouponId = "TEST123",
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25,
|
||||
Duration = "once",
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddMonths(1),
|
||||
RestrictToNewUsersOnly = true,
|
||||
IsImported = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
sutProvider.Sut.ModelState.Clear();
|
||||
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
sutProvider.Sut.TempData = tempData;
|
||||
|
||||
var result = await sutProvider.Sut.Create(model);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(Arg.Is<SubscriptionDiscount>(d =>
|
||||
d.AudienceType == DiscountAudienceType.UserHasNoPreviousSubscriptions));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Create_NotImported_ReturnsViewWithError(
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
StripeCouponId = "TEST123",
|
||||
Name = null
|
||||
};
|
||||
|
||||
var result = await sutProvider.Sut.Create(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("import the coupon", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Create_DuplicateCoupon_ReturnsViewWithError(
|
||||
SubscriptionDiscount existingDiscount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
StripeCouponId = "TEST123",
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25,
|
||||
Duration = "once",
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddMonths(1),
|
||||
IsImported = true
|
||||
};
|
||||
|
||||
// Simulate race condition: another admin imported the same coupon between import and save
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByStripeCouponIdAsync(model.StripeCouponId)
|
||||
.Returns(existingDiscount);
|
||||
|
||||
sutProvider.Sut.ModelState.Clear();
|
||||
|
||||
var result = await sutProvider.Sut.Create(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("already been imported", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
|
||||
|
||||
// Verify CreateAsync was NOT called since we detected the duplicate
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.DidNotReceive()
|
||||
.CreateAsync(Arg.Any<SubscriptionDiscount>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Create_RepositoryThrowsException_ReturnsViewWithError(
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
StripeCouponId = "TEST123",
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25,
|
||||
Duration = "once",
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddMonths(1),
|
||||
IsImported = true
|
||||
};
|
||||
|
||||
sutProvider.Sut.ModelState.Clear();
|
||||
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
sutProvider.Sut.TempData = tempData;
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.CreateAsync(Arg.Any<SubscriptionDiscount>())
|
||||
.Throws(new Exception("Database error"));
|
||||
|
||||
var result = await sutProvider.Sut.Create(model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("error occurred", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Admin.Test.Billing.Models;
|
||||
|
||||
public class CreateSubscriptionDiscountModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void AudienceType_WhenCheckboxUnchecked_ReturnsAllUsers()
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
RestrictToNewUsersOnly = false
|
||||
};
|
||||
|
||||
Assert.Equal(DiscountAudienceType.AllUsers, model.AudienceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudienceType_WhenCheckboxChecked_ReturnsUserHasNoPreviousSubscriptions()
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
RestrictToNewUsersOnly = true
|
||||
};
|
||||
|
||||
Assert.Equal(DiscountAudienceType.UserHasNoPreviousSubscriptions, model.AudienceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEndDateBeforeStartDate_ReturnsError()
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
StartDate = DateTime.UtcNow.Date.AddDays(10),
|
||||
EndDate = DateTime.UtcNow.Date
|
||||
};
|
||||
|
||||
var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(model);
|
||||
var results = model.Validate(validationContext).ToList();
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Contains("End Date must be on or after Start Date", results[0].ErrorMessage);
|
||||
Assert.Contains(nameof(model.EndDate), results[0].MemberNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEndDateEqualsStartDate_NoError()
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date
|
||||
};
|
||||
|
||||
var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(model);
|
||||
var results = model.Validate(validationContext).ToList();
|
||||
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEndDateAfterStartDate_NoError()
|
||||
{
|
||||
var model = new CreateSubscriptionDiscountModel
|
||||
{
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddDays(10)
|
||||
};
|
||||
|
||||
var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(model);
|
||||
var results = model.Validate(validationContext).ToList();
|
||||
|
||||
Assert.Empty(results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Admin.Test.Billing.Models;
|
||||
|
||||
public class SubscriptionDiscountViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void DiscountDisplay_WithPercentOff_ReturnsFormattedPercent()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
PercentOff = 25m
|
||||
};
|
||||
|
||||
Assert.Equal("25% off", model.DiscountDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscountDisplay_WithDecimalPercentOff_ReturnsFormattedPercentWithDecimals()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
PercentOff = 33.5m
|
||||
};
|
||||
|
||||
Assert.Equal("33.5% off", model.DiscountDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscountDisplay_WithWholeNumberPercentOff_ReturnsFormattedPercentWithoutDecimals()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
PercentOff = 50.00m
|
||||
};
|
||||
|
||||
Assert.Equal("50% off", model.DiscountDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscountDisplay_WithAmountOff_ReturnsFormattedDollar()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
AmountOff = 1000
|
||||
};
|
||||
|
||||
Assert.Equal("$10 off", model.DiscountDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscountDisplay_WithZeroAmountOff_ReturnsZero()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
AmountOff = 0
|
||||
};
|
||||
|
||||
Assert.Equal("$0 off", model.DiscountDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRestrictedToNewUsersOnly_WithMatchingAudienceType_ReturnsTrue()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
|
||||
};
|
||||
|
||||
Assert.True(model.IsRestrictedToNewUsersOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRestrictedToNewUsersOnly_WithAllUsersAudienceType_ReturnsFalse()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
AudienceType = DiscountAudienceType.AllUsers
|
||||
};
|
||||
|
||||
Assert.False(model.IsRestrictedToNewUsersOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAvailableToAllUsers_WithAllUsersAudienceType_ReturnsTrue()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
AudienceType = DiscountAudienceType.AllUsers
|
||||
};
|
||||
|
||||
Assert.True(model.IsAvailableToAllUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAvailableToAllUsers_WithRestrictedAudienceType_ReturnsFalse()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
|
||||
};
|
||||
|
||||
Assert.False(model.IsAvailableToAllUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsActive_WhenWithinDateRange_ReturnsTrue()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
StartDate = DateTime.UtcNow.AddDays(-1),
|
||||
EndDate = DateTime.UtcNow.AddDays(1)
|
||||
};
|
||||
|
||||
Assert.True(model.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsActive_WhenBeforeStartDate_ReturnsFalse()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
StartDate = DateTime.UtcNow.AddDays(1),
|
||||
EndDate = DateTime.UtcNow.AddDays(2)
|
||||
};
|
||||
|
||||
Assert.False(model.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsActive_WhenAfterEndDate_ReturnsFalse()
|
||||
{
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
StartDate = DateTime.UtcNow.AddDays(-2),
|
||||
EndDate = DateTime.UtcNow.AddDays(-1)
|
||||
};
|
||||
|
||||
Assert.False(model.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsActive_WhenExactlyOnStartDate_ReturnsTrue()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
StartDate = now,
|
||||
EndDate = now.AddDays(1)
|
||||
};
|
||||
|
||||
Assert.True(model.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsActive_WhenCurrentTimeIsOnEndDate_ReturnsTrue()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var model = new SubscriptionDiscountViewModel
|
||||
{
|
||||
StartDate = now.AddDays(-1),
|
||||
EndDate = now.AddSeconds(1)
|
||||
};
|
||||
|
||||
Assert.True(model.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ public class SubscriptionDiscountRepositoryTests
|
||||
Assert.Contains(activeDiscounts, d => d.Id == activeDiscount.Id);
|
||||
Assert.DoesNotContain(activeDiscounts, d => d.Id == expiredDiscount.Id);
|
||||
Assert.DoesNotContain(activeDiscounts, d => d.Id == futureDiscount.Id);
|
||||
|
||||
// Cleanup
|
||||
await subscriptionDiscountRepository.DeleteAsync(activeDiscount);
|
||||
await subscriptionDiscountRepository.DeleteAsync(expiredDiscount);
|
||||
await subscriptionDiscountRepository.DeleteAsync(futureDiscount);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
@@ -106,6 +111,9 @@ public class SubscriptionDiscountRepositoryTests
|
||||
Assert.Equal(couponId, result.StripeCouponId);
|
||||
Assert.Equal(20.00m, result.PercentOff);
|
||||
Assert.Equal(3, result.DurationInMonths);
|
||||
|
||||
// Cleanup
|
||||
await subscriptionDiscountRepository.DeleteAsync(discount);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
@@ -140,6 +148,9 @@ public class SubscriptionDiscountRepositoryTests
|
||||
Assert.Equal(discount.StripeCouponId, createdDiscount.StripeCouponId);
|
||||
Assert.Equal(500, createdDiscount.AmountOff);
|
||||
Assert.Equal("usd", createdDiscount.Currency);
|
||||
|
||||
// Cleanup
|
||||
await subscriptionDiscountRepository.DeleteAsync(createdDiscount);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
@@ -164,6 +175,9 @@ public class SubscriptionDiscountRepositoryTests
|
||||
Assert.NotNull(updatedDiscount);
|
||||
Assert.Equal("Updated Name", updatedDiscount.Name);
|
||||
Assert.Equal(15.00m, updatedDiscount.PercentOff);
|
||||
|
||||
// Cleanup
|
||||
await subscriptionDiscountRepository.DeleteAsync(updatedDiscount);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
@@ -184,4 +198,123 @@ public class SubscriptionDiscountRepositoryTests
|
||||
var deletedDiscount = await subscriptionDiscountRepository.GetByIdAsync(discount.Id);
|
||||
Assert.Null(deletedDiscount);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task ListAsync_ReturnsPagedResults_OrderedByCreationDateDescending(
|
||||
ISubscriptionDiscountRepository subscriptionDiscountRepository)
|
||||
{
|
||||
// Arrange - create discounts with future timestamps (should be at top)
|
||||
var farFuture = new DateTime(2500, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var discount1 = await subscriptionDiscountRepository.CreateAsync(
|
||||
CreateTestDiscount(
|
||||
stripeCouponId: $"test-search-1-{Guid.NewGuid()}",
|
||||
percentOff: 10.00m,
|
||||
name: "First Discount",
|
||||
creationDate: farFuture.AddSeconds(-3)));
|
||||
|
||||
var discount2 = await subscriptionDiscountRepository.CreateAsync(
|
||||
CreateTestDiscount(
|
||||
stripeCouponId: $"test-search-2-{Guid.NewGuid()}",
|
||||
percentOff: 20.00m,
|
||||
name: "Second Discount",
|
||||
creationDate: farFuture.AddSeconds(-2)));
|
||||
|
||||
var discount3 = await subscriptionDiscountRepository.CreateAsync(
|
||||
CreateTestDiscount(
|
||||
stripeCouponId: $"test-search-3-{Guid.NewGuid()}",
|
||||
percentOff: 30.00m,
|
||||
name: "Third Discount",
|
||||
creationDate: farFuture.AddSeconds(-1)));
|
||||
|
||||
// Act - get first page
|
||||
var result = await subscriptionDiscountRepository.ListAsync(0, 100);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result);
|
||||
var resultList = result.ToList();
|
||||
|
||||
// Our discounts should be the first 3 in the result
|
||||
Assert.Equal(discount3.Id, resultList[0].Id);
|
||||
Assert.Equal(discount2.Id, resultList[1].Id);
|
||||
Assert.Equal(discount1.Id, resultList[2].Id);
|
||||
|
||||
// Cleanup
|
||||
await subscriptionDiscountRepository.DeleteAsync(discount1);
|
||||
await subscriptionDiscountRepository.DeleteAsync(discount2);
|
||||
await subscriptionDiscountRepository.DeleteAsync(discount3);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task ListAsync_WithSkip_ReturnsCorrectPage(
|
||||
ISubscriptionDiscountRepository subscriptionDiscountRepository)
|
||||
{
|
||||
// Arrange - create several discounts with future timestamps (should be at top)
|
||||
var farFuture = new DateTime(2500, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var discounts = new List<SubscriptionDiscount>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var discount = await subscriptionDiscountRepository.CreateAsync(
|
||||
CreateTestDiscount(
|
||||
stripeCouponId: $"test-skip-{i}-{Guid.NewGuid()}",
|
||||
percentOff: 10.00m + i,
|
||||
name: $"Discount {i}",
|
||||
creationDate: farFuture.AddSeconds(-i)));
|
||||
discounts.Add(discount);
|
||||
}
|
||||
|
||||
// Act - get first page to find where our discounts are
|
||||
var allResults = await subscriptionDiscountRepository.ListAsync(0, 100);
|
||||
var allResultsList = allResults.ToList();
|
||||
|
||||
// Find the indices of our created discounts
|
||||
var indices = discounts.Select(d => allResultsList.FindIndex(r => r.Id == d.Id)).Where(i => i >= 0).OrderBy(i => i).ToList();
|
||||
|
||||
// Act - skip the first 2 of OUR discounts, take 2
|
||||
var result = await subscriptionDiscountRepository.ListAsync(indices[2], 2);
|
||||
|
||||
// Assert
|
||||
var resultList = result.ToList();
|
||||
Assert.True(resultList.Count == 2);
|
||||
|
||||
// Verify we got discounts[2] and discounts[3]
|
||||
Assert.Contains(resultList, d => d.Id == discounts[2].Id);
|
||||
Assert.Contains(resultList, d => d.Id == discounts[3].Id);
|
||||
|
||||
// Cleanup
|
||||
foreach (var discount in discounts)
|
||||
{
|
||||
await subscriptionDiscountRepository.DeleteAsync(discount);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task ListAsync_WithTake_LimitsResults(
|
||||
ISubscriptionDiscountRepository subscriptionDiscountRepository)
|
||||
{
|
||||
// Arrange - create 5 discounts
|
||||
var discounts = new List<SubscriptionDiscount>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var discount = await subscriptionDiscountRepository.CreateAsync(
|
||||
CreateTestDiscount(
|
||||
stripeCouponId: $"test-limit-{i}-{Guid.NewGuid()}",
|
||||
percentOff: 10.00m,
|
||||
name: $"Discount {i}",
|
||||
creationDate: DateTime.UtcNow.AddMinutes(-i)));
|
||||
discounts.Add(discount);
|
||||
}
|
||||
|
||||
// Act - get only 3 results
|
||||
var result = await subscriptionDiscountRepository.ListAsync(0, 3);
|
||||
|
||||
// Assert
|
||||
var resultList = result.ToList();
|
||||
Assert.True(resultList.Count == 3);
|
||||
|
||||
// Cleanup
|
||||
foreach (var discount in discounts)
|
||||
{
|
||||
await subscriptionDiscountRepository.DeleteAsync(discount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user