mirror of
https://github.com/bitwarden/server
synced 2026-02-24 00:23:05 +00:00
[PM-30109] edit discounts in bitwarden portal (#7032)
* [PM-30109] edit discounts in bitwarden portal * forgot model error * dotnet format * pr feedback * pr feedback
This commit is contained in:
@@ -19,6 +19,7 @@ public class SubscriptionDiscountsController(
|
||||
ILogger<SubscriptionDiscountsController> logger) : Controller
|
||||
{
|
||||
private const string SuccessKey = "Success";
|
||||
private const string ErrorKey = "Error";
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(Permission.Tools_CreateEditTransaction)]
|
||||
@@ -205,5 +206,100 @@ public class SubscriptionDiscountsController(
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[RequirePermission(Permission.Tools_CreateEditTransaction)]
|
||||
public async Task<IActionResult> Edit(Guid id)
|
||||
{
|
||||
var discount = await subscriptionDiscountRepository.GetByIdAsync(id);
|
||||
if (discount == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var model = new EditSubscriptionDiscountModel(discount);
|
||||
|
||||
if (model.StripeProductIds is { Count: > 0 })
|
||||
{
|
||||
try
|
||||
{
|
||||
var products = await stripeAdapter.ListProductsAsync(new ProductListOptions
|
||||
{
|
||||
Ids = model.StripeProductIds.ToList()
|
||||
});
|
||||
model.AppliesToProducts = products.ToDictionary(p => p.Id, p => p.Name);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to fetch the coupon's associated products from Stripe. Coupon ID: {CouponId}", model.StripeCouponId);
|
||||
ModelState.AddModelError(string.Empty, "Failed to fetch the coupon's associated products from Stripe. However, editing is still possible.");
|
||||
}
|
||||
}
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpPost("{id}")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Tools_CreateEditTransaction)]
|
||||
public async Task<IActionResult> Edit(Guid id, EditSubscriptionDiscountModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var discount = await subscriptionDiscountRepository.GetByIdAsync(id);
|
||||
if (discount == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
discount.StartDate = model.StartDate;
|
||||
discount.EndDate = model.EndDate;
|
||||
discount.AudienceType = model.AudienceType;
|
||||
discount.RevisionDate = DateTime.UtcNow;
|
||||
|
||||
await subscriptionDiscountRepository.ReplaceAsync(discount);
|
||||
|
||||
PersistSuccessMessage($"Discount '{discount.StripeCouponId}' updated successfully.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error updating subscription discount. Coupon ID: {CouponId}", discount.StripeCouponId);
|
||||
ModelState.AddModelError(string.Empty, "An error occurred while updating the discount.");
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Tools_CreateEditTransaction)]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var discount = await subscriptionDiscountRepository.GetByIdAsync(id);
|
||||
if (discount == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await subscriptionDiscountRepository.DeleteAsync(discount);
|
||||
|
||||
PersistSuccessMessage($"Discount '{discount.StripeCouponId}' deleted successfully.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting subscription discount. Coupon ID: {CouponId}", discount.StripeCouponId);
|
||||
PersistErrorMessage("An error occurred while attempting to delete the discount.");
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistSuccessMessage(string message) => TempData[SuccessKey] = message;
|
||||
private void PersistErrorMessage(string message) => TempData[ErrorKey] = message;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Subscriptions.Entities;
|
||||
|
||||
namespace Bit.Admin.Billing.Models;
|
||||
|
||||
public class EditSubscriptionDiscountModel : IValidatableObject
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string StripeCouponId { get; set; } = null!;
|
||||
public string? Name { get; set; }
|
||||
public decimal? PercentOff { get; set; }
|
||||
public long? AmountOff { get; set; }
|
||||
public string? Currency { get; set; }
|
||||
public string Duration { get; set; } = string.Empty;
|
||||
public int? DurationInMonths { get; set; }
|
||||
public ICollection<string>? StripeProductIds { get; set; }
|
||||
public Dictionary<string, string>? AppliesToProducts { get; set; } // Key: ProductId, Value: ProductName
|
||||
|
||||
[Required]
|
||||
[Display(Name = "Start Date")]
|
||||
public DateTime StartDate { get; set; }
|
||||
|
||||
[Required]
|
||||
[Display(Name = "End Date")]
|
||||
public DateTime EndDate { get; set; }
|
||||
|
||||
[Display(Name = "Restrict to users with no previous subscriptions?")]
|
||||
public bool RestrictToNewUsersOnly { get; set; }
|
||||
|
||||
public DiscountAudienceType AudienceType => RestrictToNewUsersOnly
|
||||
? DiscountAudienceType.UserHasNoPreviousSubscriptions
|
||||
: DiscountAudienceType.AllUsers;
|
||||
|
||||
public EditSubscriptionDiscountModel() { }
|
||||
|
||||
public EditSubscriptionDiscountModel(SubscriptionDiscount discount)
|
||||
{
|
||||
Id = discount.Id;
|
||||
StripeCouponId = discount.StripeCouponId;
|
||||
Name = discount.Name;
|
||||
PercentOff = discount.PercentOff;
|
||||
AmountOff = discount.AmountOff;
|
||||
Currency = discount.Currency;
|
||||
Duration = discount.Duration;
|
||||
DurationInMonths = discount.DurationInMonths;
|
||||
StripeProductIds = discount.StripeProductIds;
|
||||
StartDate = discount.StartDate;
|
||||
EndDate = discount.EndDate;
|
||||
RestrictToNewUsersOnly = discount.AudienceType == DiscountAudienceType.UserHasNoPreviousSubscriptions;
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (EndDate < StartDate)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"End Date must be on or after Start Date.",
|
||||
new[] { nameof(EndDate) });
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/Admin/Billing/Views/SubscriptionDiscounts/Edit.cshtml
Normal file
133
src/Admin/Billing/Views/SubscriptionDiscounts/Edit.cshtml
Normal file
@@ -0,0 +1,133 @@
|
||||
@model EditSubscriptionDiscountModel
|
||||
@{
|
||||
ViewData["Title"] = "Edit Discount";
|
||||
}
|
||||
|
||||
<h1>Edit Discount</h1>
|
||||
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
|
||||
<form method="post" asp-action="Edit" asp-route-id="@Model.Id" id="edit-form">
|
||||
<input type="hidden" asp-for="StripeCouponId" />
|
||||
<input type="hidden" asp-for="Name" />
|
||||
<input type="hidden" asp-for="PercentOff" />
|
||||
<input type="hidden" asp-for="AmountOff" />
|
||||
<input type="hidden" asp-for="Currency" />
|
||||
<input type="hidden" asp-for="Duration" />
|
||||
<input type="hidden" asp-for="DurationInMonths" />
|
||||
@if (Model.AppliesToProducts != null)
|
||||
{
|
||||
var index = 0;
|
||||
@foreach (var product in Model.AppliesToProducts)
|
||||
{
|
||||
<input type="hidden" name="AppliesToProducts[@index].Key" value="@product.Key" />
|
||||
<input type="hidden" name="AppliesToProducts[@index].Value" value="@product.Value" />
|
||||
index++;
|
||||
}
|
||||
}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5>Stripe Coupon Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Coupon ID:</dt>
|
||||
<dd class="col-sm-9"><code>@Model.StripeCouponId</code></dd>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Name))
|
||||
{
|
||||
<dt class="col-sm-3">Name:</dt>
|
||||
<dd class="col-sm-9">@Model.Name</dd>
|
||||
}
|
||||
|
||||
<dt class="col-sm-3">Discount:</dt>
|
||||
<dd class="col-sm-9">
|
||||
@if (Model.PercentOff.HasValue)
|
||||
{
|
||||
<text>@Model.PercentOff% off</text>
|
||||
}
|
||||
else if (Model.AmountOff.HasValue)
|
||||
{
|
||||
<text>$@(Model.AmountOff / 100m) off</text>
|
||||
@if (!string.IsNullOrEmpty(Model.Currency))
|
||||
{
|
||||
<text> (@Model.Currency.ToUpper())</text>
|
||||
}
|
||||
}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Duration:</dt>
|
||||
<dd class="col-sm-9">
|
||||
@Model.Duration
|
||||
@if (Model.DurationInMonths.HasValue)
|
||||
{
|
||||
<text>(@Model.DurationInMonths months)</text>
|
||||
}
|
||||
</dd>
|
||||
|
||||
@if (Model.AppliesToProducts != null && Model.AppliesToProducts.Count != 0)
|
||||
{
|
||||
<dt class="col-sm-3">Applies To Products:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<ul class="mb-0">
|
||||
@foreach (var product in Model.AppliesToProducts)
|
||||
{
|
||||
<li>@product.Value</li>
|
||||
}
|
||||
</ul>
|
||||
</dd>
|
||||
}
|
||||
else if (Model.StripeProductIds != null && Model.StripeProductIds.Count != 0)
|
||||
{
|
||||
<dt class="col-sm-3">Applies To Products:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<ul class="mb-0">
|
||||
@foreach (var productId in Model.StripeProductIds)
|
||||
{
|
||||
<li><code>@productId</code></li>
|
||||
}
|
||||
</ul>
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5>Bitwarden Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="StartDate" class="form-label"></label>
|
||||
<input asp-for="StartDate" type="date" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="EndDate" class="form-label"></label>
|
||||
<input asp-for="EndDate" type="date" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input asp-for="RestrictToNewUsersOnly" type="checkbox" class="form-check-input" />
|
||||
<label asp-for="RestrictToNewUsersOnly" class="form-check-label"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="d-flex mt-4">
|
||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||
<a asp-action="Index" class="btn btn-secondary ms-2">Cancel</a>
|
||||
<div class="ms-auto d-flex">
|
||||
<form method="post" asp-action="Delete" asp-route-id="@Model.Id"
|
||||
onsubmit="return confirm('Are you sure you want to delete this discount?')">
|
||||
<button class="btn btn-danger" type="submit">Delete Discount</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,4 +456,276 @@ public class SubscriptionDiscountsControllerTests
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("error occurred", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Get_ReturnsViewWithModel(
|
||||
SubscriptionDiscount discount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(discount.Id)
|
||||
.Returns(discount);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.ListProductsAsync(Arg.Any<ProductListOptions>())
|
||||
.Returns(new List<Stripe.Product>());
|
||||
|
||||
var result = await sutProvider.Sut.Edit(discount.Id);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
var viewResult = (ViewResult)result;
|
||||
var model = Assert.IsType<EditSubscriptionDiscountModel>(viewResult.Model);
|
||||
Assert.Equal(discount.Id, model.Id);
|
||||
Assert.Equal(discount.StripeCouponId, model.StripeCouponId);
|
||||
Assert.Equal(discount.StartDate, model.StartDate);
|
||||
Assert.Equal(discount.EndDate, model.EndDate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Get_WithStripeProducts_PopulatesAppliesToProducts(
|
||||
SubscriptionDiscount discount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
discount.StripeProductIds = new List<string> { "prod_1", "prod_2" };
|
||||
var stripeProducts = new List<Stripe.Product>
|
||||
{
|
||||
new() { Id = "prod_1", Name = "Product One" },
|
||||
new() { Id = "prod_2", Name = "Product Two" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(discount.Id)
|
||||
.Returns(discount);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.ListProductsAsync(Arg.Any<ProductListOptions>())
|
||||
.Returns(stripeProducts);
|
||||
|
||||
var result = await sutProvider.Sut.Edit(discount.Id);
|
||||
|
||||
var viewResult = Assert.IsType<ViewResult>(result);
|
||||
var model = Assert.IsType<EditSubscriptionDiscountModel>(viewResult.Model);
|
||||
Assert.NotNull(model.AppliesToProducts);
|
||||
Assert.Equal(2, model.AppliesToProducts.Count);
|
||||
Assert.Equal("Product One", model.AppliesToProducts["prod_1"]);
|
||||
Assert.Equal("Product Two", model.AppliesToProducts["prod_2"]);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Get_WhenStripeProductLookupFails_StillReturnsView(
|
||||
SubscriptionDiscount discount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
discount.StripeProductIds = new List<string> { "prod_1" };
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(discount.Id)
|
||||
.Returns(discount);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.ListProductsAsync(Arg.Any<ProductListOptions>())
|
||||
.Throws(new StripeException());
|
||||
|
||||
var result = await sutProvider.Sut.Edit(discount.Id);
|
||||
|
||||
var viewResult = Assert.IsType<ViewResult>(result);
|
||||
var model = Assert.IsType<EditSubscriptionDiscountModel>(viewResult.Model);
|
||||
Assert.Null(model.AppliesToProducts);
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("Failed to fetch", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Get_WhenNotFound_ReturnsNotFound(
|
||||
Guid id,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(id)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
var result = await sutProvider.Sut.Edit(id);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Post_ValidModel_UpdatesBitwardenFieldsAndRedirects(
|
||||
SubscriptionDiscount discount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var model = new EditSubscriptionDiscountModel
|
||||
{
|
||||
StartDate = DateTime.UtcNow.Date.AddDays(1),
|
||||
EndDate = DateTime.UtcNow.Date.AddMonths(2),
|
||||
RestrictToNewUsersOnly = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(discount.Id)
|
||||
.Returns(discount);
|
||||
|
||||
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
sutProvider.Sut.TempData = tempData;
|
||||
|
||||
var result = await sutProvider.Sut.Edit(discount.Id, model);
|
||||
|
||||
Assert.IsType<RedirectToActionResult>(result);
|
||||
var redirectResult = (RedirectToActionResult)result;
|
||||
Assert.Equal(nameof(SubscriptionDiscountsController.Index), redirectResult.ActionName);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.Received(1)
|
||||
.ReplaceAsync(Arg.Is<SubscriptionDiscount>(d =>
|
||||
d.StartDate == model.StartDate &&
|
||||
d.EndDate == model.EndDate &&
|
||||
d.AudienceType == DiscountAudienceType.UserHasNoPreviousSubscriptions));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Post_ValidModel_DoesNotUpdateStripeFields(
|
||||
SubscriptionDiscount discount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
var originalStripeCouponId = discount.StripeCouponId;
|
||||
var originalPercentOff = discount.PercentOff;
|
||||
var originalAmountOff = discount.AmountOff;
|
||||
var originalDuration = discount.Duration;
|
||||
|
||||
var model = new EditSubscriptionDiscountModel
|
||||
{
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddMonths(1),
|
||||
RestrictToNewUsersOnly = false
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(discount.Id)
|
||||
.Returns(discount);
|
||||
|
||||
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
sutProvider.Sut.TempData = tempData;
|
||||
|
||||
await sutProvider.Sut.Edit(discount.Id, model);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.Received(1)
|
||||
.ReplaceAsync(Arg.Is<SubscriptionDiscount>(d =>
|
||||
d.StripeCouponId == originalStripeCouponId &&
|
||||
d.PercentOff == originalPercentOff &&
|
||||
d.AmountOff == originalAmountOff &&
|
||||
d.Duration == originalDuration));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Post_InvalidModelState_ReturnsView(
|
||||
Guid id,
|
||||
EditSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.Sut.ModelState.AddModelError(nameof(model.EndDate), "End Date must be on or after Start Date.");
|
||||
|
||||
var result = await sutProvider.Sut.Edit(id, model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.DidNotReceive()
|
||||
.ReplaceAsync(Arg.Any<SubscriptionDiscount>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Post_RepositoryThrowsException_ReturnsViewWithError(
|
||||
SubscriptionDiscount discount,
|
||||
EditSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(discount.Id)
|
||||
.Returns(discount);
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.ReplaceAsync(Arg.Any<SubscriptionDiscount>())
|
||||
.Throws(new Exception("Database error"));
|
||||
|
||||
var result = await sutProvider.Sut.Edit(discount.Id, model);
|
||||
|
||||
Assert.IsType<ViewResult>(result);
|
||||
Assert.False(sutProvider.Sut.ModelState.IsValid);
|
||||
Assert.Contains("error occurred", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Edit_Post_WhenNotFound_ReturnsNotFound(
|
||||
Guid id,
|
||||
EditSubscriptionDiscountModel model,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(id)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
var result = await sutProvider.Sut.Edit(id, model);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Delete_Post_DeletesDiscountAndRedirectsToIndex(
|
||||
SubscriptionDiscount discount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(discount.Id)
|
||||
.Returns(discount);
|
||||
|
||||
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
sutProvider.Sut.TempData = tempData;
|
||||
|
||||
var result = await sutProvider.Sut.Delete(discount.Id);
|
||||
|
||||
Assert.IsType<RedirectToActionResult>(result);
|
||||
var redirectResult = (RedirectToActionResult)result;
|
||||
Assert.Equal(nameof(SubscriptionDiscountsController.Index), redirectResult.ActionName);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.Received(1)
|
||||
.DeleteAsync(discount);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Delete_Post_RepositoryThrowsException_RedirectsToEditWithError(
|
||||
SubscriptionDiscount discount,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(discount.Id)
|
||||
.Returns(discount);
|
||||
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.DeleteAsync(discount)
|
||||
.Throws(new Exception("Database error"));
|
||||
|
||||
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
sutProvider.Sut.TempData = tempData;
|
||||
|
||||
var result = await sutProvider.Sut.Delete(discount.Id);
|
||||
|
||||
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
|
||||
Assert.Equal(nameof(SubscriptionDiscountsController.Edit), redirectResult.ActionName);
|
||||
Assert.Contains("attempting to delete", sutProvider.Sut.TempData["Error"]!.ToString());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Delete_Post_WhenNotFound_ReturnsNotFound(
|
||||
Guid id,
|
||||
SutProvider<SubscriptionDiscountsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
|
||||
.GetByIdAsync(id)
|
||||
.Returns((SubscriptionDiscount?)null);
|
||||
|
||||
var result = await sutProvider.Sut.Delete(id);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Subscriptions.Entities;
|
||||
|
||||
namespace Admin.Test.Billing.Models;
|
||||
|
||||
public class EditSubscriptionDiscountModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void AudienceType_WhenRestrictToNewUsersOnly_ReturnsUserHasNoPreviousSubscriptions()
|
||||
{
|
||||
var model = new EditSubscriptionDiscountModel
|
||||
{
|
||||
RestrictToNewUsersOnly = true
|
||||
};
|
||||
|
||||
Assert.Equal(DiscountAudienceType.UserHasNoPreviousSubscriptions, model.AudienceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudienceType_WhenNotRestricted_ReturnsAllUsers()
|
||||
{
|
||||
var model = new EditSubscriptionDiscountModel
|
||||
{
|
||||
RestrictToNewUsersOnly = false
|
||||
};
|
||||
|
||||
Assert.Equal(DiscountAudienceType.AllUsers, model.AudienceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEndDateBeforeStartDate_ReturnsError()
|
||||
{
|
||||
var model = new EditSubscriptionDiscountModel
|
||||
{
|
||||
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 EditSubscriptionDiscountModel
|
||||
{
|
||||
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 EditSubscriptionDiscountModel
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromEntity_MapsAllProperties()
|
||||
{
|
||||
var discount = new SubscriptionDiscount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
StripeCouponId = "COUPON123",
|
||||
Name = "Test Coupon",
|
||||
PercentOff = 25m,
|
||||
AmountOff = null,
|
||||
Currency = "usd",
|
||||
Duration = "once",
|
||||
DurationInMonths = null,
|
||||
StripeProductIds = new List<string> { "prod_1", "prod_2" },
|
||||
StartDate = new DateTime(2025, 1, 1),
|
||||
EndDate = new DateTime(2025, 12, 31),
|
||||
AudienceType = DiscountAudienceType.AllUsers
|
||||
};
|
||||
|
||||
var model = new EditSubscriptionDiscountModel(discount);
|
||||
|
||||
Assert.Equal(discount.Id, model.Id);
|
||||
Assert.Equal(discount.StripeCouponId, model.StripeCouponId);
|
||||
Assert.Equal(discount.Name, model.Name);
|
||||
Assert.Equal(discount.PercentOff, model.PercentOff);
|
||||
Assert.Equal(discount.AmountOff, model.AmountOff);
|
||||
Assert.Equal(discount.Currency, model.Currency);
|
||||
Assert.Equal(discount.Duration, model.Duration);
|
||||
Assert.Equal(discount.DurationInMonths, model.DurationInMonths);
|
||||
Assert.Equal(discount.StripeProductIds, model.StripeProductIds);
|
||||
Assert.Equal(discount.StartDate, model.StartDate);
|
||||
Assert.Equal(discount.EndDate, model.EndDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromEntity_WhenAudienceTypeIsUserHasNoPreviousSubscriptions_SetsRestrictToNewUsersOnlyTrue()
|
||||
{
|
||||
var discount = new SubscriptionDiscount
|
||||
{
|
||||
StripeCouponId = "COUPON123",
|
||||
Duration = "once",
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddMonths(1),
|
||||
AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
|
||||
};
|
||||
|
||||
var model = new EditSubscriptionDiscountModel(discount);
|
||||
|
||||
Assert.True(model.RestrictToNewUsersOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromEntity_WhenAudienceTypeIsAllUsers_SetsRestrictToNewUsersOnlyFalse()
|
||||
{
|
||||
var discount = new SubscriptionDiscount
|
||||
{
|
||||
StripeCouponId = "COUPON123",
|
||||
Duration = "once",
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddMonths(1),
|
||||
AudienceType = DiscountAudienceType.AllUsers
|
||||
};
|
||||
|
||||
var model = new EditSubscriptionDiscountModel(discount);
|
||||
|
||||
Assert.False(model.RestrictToNewUsersOnly);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user