backend phase 5: service catalog & nurse pricing variants
Two-tier service model the marketplace is priced and searched on. Admin catalog skeleton (categories + EAV option groups/values, addable as data not migrations; NULL category = cross-category) and the nurse pricing layer (nurse_service_variants — the atomic bookable unit: category + one value per required dimension at the nurse's own IRR price and price unit). - New `catalog` schema via one additive migration; Price BIGINT (no floats), on the wire as a string of digits; total = price + unit + session_count. - Duplicate-listing guard: deterministic option_set_hash + filtered UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL + friendly 409 pre-check. One value per dimension; required groups (incl. cross-category) enforced; deactivate, never delete. - Public catalog browse cached behind a CatalogCache generation token, invalidated on any admin write. IVariantSnapshotSerializer shipped for b8. - Contract (catalog.md) + handoff + report published; swagger refreshed. 122 tests green; zero new build warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Catalog.Commands.CreateServiceCategory;
|
||||
using Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup;
|
||||
using Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Catalog;
|
||||
|
||||
public class CatalogAdminHandlerTests
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly ICatalogRepository _catalog = Substitute.For<ICatalogRepository>();
|
||||
private readonly ICacheService _cache = Substitute.For<ICacheService>();
|
||||
|
||||
public CatalogAdminHandlerTests()
|
||||
{
|
||||
_unitOfWork.CatalogRepository.Returns(_catalog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCategory_Succeeds_AndInvalidatesCache()
|
||||
{
|
||||
var handler = new CreateServiceCategoryCommandHandler(_unitOfWork, _cache);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateServiceCategoryCommand("مراقبت از سالمند", "Elderly Care", null, null, null, 1), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Elderly Care", result.Result.NameEn);
|
||||
Assert.True(result.Result.IsActive);
|
||||
await _catalog.Received(1).AddCategoryAsync(Arg.Any<ServiceCategory>(), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
// Invalidation bumps the generation-token key so every cached catalog page refreshes.
|
||||
await _cache.Received().SetAsync("catalog:version", Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOptionGroup_UnknownCategory_Fails()
|
||||
{
|
||||
_catalog.CategoryExistsAsync(99L, Arg.Any<CancellationToken>()).Returns(false);
|
||||
var handler = new CreateServiceOptionGroupCommandHandler(_unitOfWork, _cache);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateServiceOptionGroupCommand(99L, "نوع شیفت", "Shift type", true, 1), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _catalog.DidNotReceive().AddGroupAsync(Arg.Any<ServiceOptionGroup>(), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOptionGroup_CrossCategoryNull_Succeeds()
|
||||
{
|
||||
_catalog.GetGroupDtoAsync(Arg.Any<long>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new OptionGroupDto(5, null, "نوع شیفت", "Shift type", true, 1, true, []));
|
||||
var handler = new CreateServiceOptionGroupCommandHandler(_unitOfWork, _cache);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateServiceOptionGroupCommand(null, "نوع شیفت", "Shift type", true, 1), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(result.Result.ServiceCategoryId);
|
||||
await _catalog.Received(1).AddGroupAsync(
|
||||
Arg.Is<ServiceOptionGroup>(g => g.ServiceCategoryId == null && g.IsRequired), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOptionValue_UnknownGroup_Fails()
|
||||
{
|
||||
_catalog.GroupExistsAsync(77L, Arg.Any<CancellationToken>()).Returns(false);
|
||||
var handler = new CreateServiceOptionValueCommandHandler(_unitOfWork, _cache);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateServiceOptionValueCommand(77L, "شبانهروزی", "Live-in", 1), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _catalog.DidNotReceive().AddValueAsync(Arg.Any<ServiceOptionValue>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Variants.Commands.CreateVariant;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Catalog;
|
||||
|
||||
public class CreateVariantHandlerTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly ICatalogRepository _catalog = Substitute.For<ICatalogRepository>();
|
||||
private readonly INurseServiceVariantRepository _variants = Substitute.For<INurseServiceVariantRepository>();
|
||||
|
||||
private const long CategoryId = 1;
|
||||
private const long ShiftGroupId = 10;
|
||||
private const long LiveInValueId = 100;
|
||||
private const long DaytimeValueId = 101;
|
||||
|
||||
public CreateVariantHandlerTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(7);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_nurses);
|
||||
_unitOfWork.CatalogRepository.Returns(_catalog);
|
||||
_unitOfWork.NurseServiceVariantRepository.Returns(_variants);
|
||||
|
||||
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
|
||||
_catalog.GetActiveCategoryAsync(CategoryId, Arg.Any<CancellationToken>())
|
||||
.Returns(new ServiceCategoryDto(CategoryId, "مراقبت از سالمند", "Elderly Care", null, null, null, 1, true));
|
||||
|
||||
_catalog.GetApplicableGroupsAsync(CategoryId, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<OptionGroupDto>
|
||||
{
|
||||
new(ShiftGroupId, CategoryId, "نوع شیفت", "Shift type", IsRequired: true, SortOrder: 1, IsActive: true,
|
||||
Values:
|
||||
[
|
||||
new OptionValueDto(LiveInValueId, "شبانهروزی", "Live-in", 1, true),
|
||||
new OptionValueDto(DaytimeValueId, "روزانه", "Daytime", 2, true)
|
||||
])
|
||||
});
|
||||
|
||||
_variants.DuplicateHashExistsAsync(Arg.Any<long>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<long?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
}
|
||||
|
||||
private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork);
|
||||
|
||||
private static CreateVariantCommand Command(IReadOnlyList<VariantOptionSelection> options, string? displayName = null)
|
||||
=> new(CategoryId, options, "8000000", "per_24h", null, displayName);
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ValidVariant_SucceedsWithGeneratedDisplayName()
|
||||
{
|
||||
var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.IsActive);
|
||||
Assert.Equal("8000000", result.Result.Price);
|
||||
Assert.Contains("مراقبت از سالمند", result.Result.DisplayName);
|
||||
Assert.Contains("شبانهروزی", result.Result.DisplayName);
|
||||
Assert.Single(result.Result.Options);
|
||||
await _variants.Received(1).AddAsync(
|
||||
Arg.Is<NurseServiceVariant>(v =>
|
||||
v.NurseId == 42L && v.ServiceCategoryId == CategoryId && v.PriceUnit == "per_24h"
|
||||
&& v.Price == 8000000 && !string.IsNullOrEmpty(v.OptionSetHash) && v.Options.Count == 1),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_MissingRequiredGroup_FailsValidationNotConflict()
|
||||
{
|
||||
var result = await Handler().Handle(Command([]), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.False(result.IsConflict);
|
||||
Assert.False(result.IsNotFound);
|
||||
await _variants.DidNotReceive().AddAsync(Arg.Any<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DuplicateOptionSet_ReturnsConflict()
|
||||
{
|
||||
_variants.DuplicateHashExistsAsync(42L, CategoryId, Arg.Any<string>(), null, Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsConflict);
|
||||
await _variants.DidNotReceive().AddAsync(Arg.Any<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SameGroupTwice_FailsOneValuePerDimension()
|
||||
{
|
||||
var result = await Handler().Handle(
|
||||
Command([new(ShiftGroupId, LiveInValueId), new(ShiftGroupId, DaytimeValueId)]), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _variants.DidNotReceive().AddAsync(Arg.Any<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ValueNotBelongingToGroup_Fails()
|
||||
{
|
||||
var result = await Handler().Handle(Command([new(ShiftGroupId, 999L)]), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _variants.DidNotReceive().AddAsync(Arg.Any<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_InactiveOrMissingCategory_Fails()
|
||||
{
|
||||
_catalog.GetActiveCategoryAsync(CategoryId, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
|
||||
var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _variants.DidNotReceive().AddAsync(Arg.Any<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NonNurse_IsForbidden()
|
||||
{
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
|
||||
var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsForbidden);
|
||||
await _variants.DidNotReceive().AddAsync(Arg.Any<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NurseOverridesDisplayName_UsesOverride()
|
||||
{
|
||||
var result = await Handler().Handle(
|
||||
Command([new(ShiftGroupId, LiveInValueId)], displayName: "My live-in package"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("My live-in package", result.Result.DisplayName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Models.Catalog;
|
||||
|
||||
namespace Baya.Test.Foundation.Catalog;
|
||||
|
||||
public class VariantSnapshotSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_CarriesCategoryAndOptionLabels_PriceAsString_UnitAndSession()
|
||||
{
|
||||
var serializer = new VariantSnapshotSerializer();
|
||||
|
||||
var snapshot = new VariantSnapshot(
|
||||
VariantId: 12,
|
||||
ServiceCategoryId: 1,
|
||||
CategoryNameFa: "مراقبت از سالمند",
|
||||
CategoryNameEn: "Elderly Care",
|
||||
Price: 8000000,
|
||||
PriceUnit: "per_24h",
|
||||
SessionCount: 3,
|
||||
DisplayName: "مراقبت از سالمند · شبانهروزی",
|
||||
Options: [new VariantOptionDto(10, "نوع شیفت", "Shift type", 100, "شبانهروزی", "Live-in")]);
|
||||
|
||||
var json = serializer.Serialize(snapshot);
|
||||
|
||||
// Money is a string of IRR-Rial digits (no floats, no numeric literal).
|
||||
Assert.Contains("\"price\":\"8000000\"", json);
|
||||
Assert.Contains("\"priceUnit\":\"per_24h\"", json);
|
||||
Assert.Contains("\"sessionCount\":3", json);
|
||||
Assert.Contains("\"variantId\":12", json);
|
||||
// Category labels (both) — Persian lands as readable text, not \uXXXX.
|
||||
Assert.Contains("مراقبت از سالمند", json);
|
||||
Assert.Contains("Elderly Care", json);
|
||||
// Each option label (group + value).
|
||||
Assert.Contains("نوع شیفت", json);
|
||||
Assert.Contains("شبانهروزی", json);
|
||||
Assert.Contains("Live-in", json);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user