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:
hamid
2026-07-02 22:21:53 +03:30
parent 4b4243c451
commit f77a23cb25
84 changed files with 8229 additions and 4 deletions
@@ -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);
}
}