backend phase 8

This commit is contained in:
hamid
2026-07-06 02:48:56 +03:30
parent 99ebf5d881
commit 2cfc082a04
55 changed files with 7480 additions and 7 deletions
@@ -0,0 +1,59 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Baya.Domain.Entities.Booking;
using NSubstitute;
namespace Baya.Test.Foundation.Booking;
public class BookingRequestExpiryTests : IDisposable
{
private readonly BookingTestHost _host = new();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
private static readonly DateTimeOffset Now = new(2026, 7, 6, 12, 0, 0, TimeSpan.Zero);
private static readonly DateTime NowUtc = Now.UtcDateTime;
private ExpireBookingRequestsCommandHandler Handler()
{
_clock.UtcNow.Returns(Now);
return new ExpireBookingRequestsCommandHandler(_host.UnitOfWork, _clock, _notifications);
}
[Fact]
public async Task Sweep_TransitionsStalePendingAndAcceptedAndNotifies()
{
var stalePending = _host.AddPendingRequest(responseDeadline: NowUtc.AddHours(-1));
var freshPending = _host.AddPendingRequest(responseDeadline: NowUtc.AddHours(5));
var staleAccepted = _host.AddAcceptedRequest(paymentDeadline: NowUtc.AddMinutes(-1));
var freshAccepted = _host.AddAcceptedRequest(paymentDeadline: NowUtc.AddMinutes(20));
var result = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Result.ExpiredNoResponse);
Assert.Equal(1, result.Result.PaymentDeadlineExpired);
Assert.Equal(BookingRequestStatus.ExpiredNoResponse, _host.StatusOf(stalePending.Id));
Assert.Equal(BookingRequestStatus.PendingNurseResponse, _host.StatusOf(freshPending.Id));
Assert.Equal(BookingRequestStatus.PaymentDeadlineExpired, _host.StatusOf(staleAccepted.Id));
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, _host.StatusOf(freshAccepted.Id));
await _notifications.Received(2).DispatchAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Sweep_IsIdempotent_SecondRunIsNoOp()
{
_host.AddPendingRequest(responseDeadline: NowUtc.AddHours(-1));
var first = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
var second = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
Assert.Equal(1, first.Result.ExpiredNoResponse);
Assert.Equal(0, second.Result.ExpiredNoResponse);
Assert.Equal(0, second.Result.PaymentDeadlineExpired);
}
public void Dispose() => _host.Dispose();
}
@@ -0,0 +1,97 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Booking.Queries.GetBookingRequest;
using Baya.Application.Features.Booking.Queries.ListBookingRequests;
using Baya.Domain.Entities.User;
using NSubstitute;
namespace Baya.Test.Foundation.Booking;
public class BookingRequestQueryTests : IDisposable
{
private readonly BookingTestHost _host = new();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private void SignInAs(int userId, params string[] roles)
{
_currentUser.UserId.Returns(userId);
_currentUser.Roles.Returns(roles);
}
[Fact]
public async Task List_NurseInbox_ShowsPatientCounterpartyAndCustomerNotesOnly()
{
_host.AddPendingRequest();
SignInAs(_host.NurseUserId, RoleNames.Nurse);
var handler = new ListBookingRequestsQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new ListBookingRequestsQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Result.Total);
var item = result.Result.Items[0];
Assert.Contains("پدر", item.CounterpartyName);
Assert.Equal("Careful with the IV.", item.CustomerNotes);
Assert.Null(item.NurseRating);
}
[Fact]
public async Task List_CustomerInbox_ShowsNurseCounterpartyWithoutCustomerNotes()
{
_host.AddPendingRequest();
SignInAs(_host.CustomerUserId, RoleNames.Customer);
var handler = new ListBookingRequestsQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new ListBookingRequestsQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Result.Total);
var item = result.Result.Items[0];
Assert.Contains("زهرا", item.CounterpartyName);
Assert.Null(item.CustomerNotes);
Assert.NotNull(item.NurseRating);
}
[Fact]
public async Task Get_AsNurse_MasksFullAddress()
{
var request = _host.AddPendingRequest();
SignInAs(_host.NurseUserId, RoleNames.Nurse);
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Null(result.Result.AddressLine);
Assert.Null(result.Result.PostalCode);
// Coarse location + stage-1 notes are still available to the nurse.
Assert.Equal("Careful with the IV.", result.Result.CustomerNotes);
Assert.False(string.IsNullOrEmpty(result.Result.CityNameFa));
}
[Fact]
public async Task Get_AsOwningCustomer_ReturnsFullAddress()
{
var request = _host.AddPendingRequest();
SignInAs(_host.CustomerUserId, RoleNames.Customer);
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(string.IsNullOrEmpty(result.Result.AddressLine));
}
[Fact]
public async Task Get_AsThirdParty_IsNotFound()
{
var request = _host.AddPendingRequest();
SignInAs(999999, RoleNames.Customer);
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
Assert.True(result.IsNotFound);
}
public void Dispose() => _host.Dispose();
}
@@ -0,0 +1,49 @@
using Baya.Domain.Entities.Booking;
namespace Baya.Test.Foundation.Booking;
public class BookingRequestTransitionsTests
{
[Theory]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.AcceptedAwaitingPayment, true)]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.RejectedByNurse, true)]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.ExpiredNoResponse, true)]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.CancelledByCustomer, true)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.Converted, true)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.PaymentDeadlineExpired, true)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.CancelledByCustomer, true)]
// Illegal edges.
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.Converted, false)]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.PaymentDeadlineExpired, false)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.RejectedByNurse, false)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.ExpiredNoResponse, false)]
// Terminals have no outgoing edges.
[InlineData(BookingRequestStatus.Converted, BookingRequestStatus.CancelledByCustomer, false)]
[InlineData(BookingRequestStatus.RejectedByNurse, BookingRequestStatus.AcceptedAwaitingPayment, false)]
[InlineData(BookingRequestStatus.ExpiredNoResponse, BookingRequestStatus.AcceptedAwaitingPayment, false)]
[InlineData(BookingRequestStatus.PaymentDeadlineExpired, BookingRequestStatus.CancelledByCustomer, false)]
[InlineData(BookingRequestStatus.CancelledByCustomer, BookingRequestStatus.AcceptedAwaitingPayment, false)]
public void CanTransition_MatchesForwardOnlyMachine(string from, string to, bool expected)
=> Assert.Equal(expected, BookingRequestTransitions.CanTransition(from, to));
[Fact]
public void Accept_FromPending_SetsPaymentDeadlineAndStatus()
{
var request = new BookingRequest { NurseResponseDeadlineAt = DateTime.UnixEpoch.AddHours(24) };
var deadline = DateTime.UnixEpoch.AddMinutes(30);
request.Accept(deadline);
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, request.Status);
Assert.Equal(deadline, request.PaymentDeadlineAt);
}
[Fact]
public void Accept_FromTerminal_Throws()
{
var request = new BookingRequest { NurseResponseDeadlineAt = DateTime.UnixEpoch };
request.Reject("no");
Assert.Throws<InvalidOperationException>(() => request.Accept(DateTime.UnixEpoch));
}
}
@@ -0,0 +1,144 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Baya.Test.Foundation.Booking;
/// <summary>
/// A self-contained SQLite host exercising the real EF model (schema, filtered indexes, query filters) for
/// the booking-request repository, expiry sweep, and queries end-to-end. Seeds one customer (user + profile
/// + patient + address) and one bookable nurse (user + profile + active variant), and lets a test create
/// requests in a chosen state and drive the real <see cref="UnitOfWork"/>.
/// </summary>
public sealed class BookingTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public UnitOfWork UnitOfWork { get; }
public long CustomerId { get; }
public int CustomerUserId { get; }
public long NurseId { get; }
public int NurseUserId { get; }
public long PatientId { get; }
public long AddressId { get; }
public long VariantId { get; }
public BookingTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_connection)
.Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
UnitOfWork = new UnitOfWork(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
Db.SaveChanges();
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
Db.Set<ServiceCategory>().Add(category);
Db.SaveChanges();
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
Db.Users.Add(customerUser);
Db.SaveChanges();
CustomerUserId = customerUser.Id;
var customer = new CustomerProfile { UserId = customerUser.Id };
Db.Set<CustomerProfile>().Add(customer);
Db.SaveChanges();
CustomerId = customer.Id;
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
Db.Set<Patient>().Add(patient);
Db.SaveChanges();
PatientId = patient.Id;
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خانه",
AddressLine = "خیابان اول", PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001", IsPrimary = true
};
Db.Set<CustomerAddress>().Add(address);
Db.SaveChanges();
AddressId = address.Id;
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
Db.Users.Add(nurseUser);
Db.SaveChanges();
NurseUserId = nurseUser.Id;
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
NurseId = nurse.Id;
var variant = new NurseServiceVariant
{
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = 5_000_000, PriceUnit = "per_day",
DisplayName = "مراقبت روزانه", OptionSetHash = "hash", IsActive = true
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
VariantId = variant.Id;
}
/// <summary>Inserts a pending request with the given response deadline (defaults far in the future).</summary>
public BookingRequest AddPendingRequest(DateTime? responseDeadline = null)
{
var request = new BookingRequest
{
CustomerId = CustomerId,
NurseId = NurseId,
PatientId = PatientId,
VariantId = VariantId,
CustomerAddressId = AddressId,
RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1),
RequestedTimeStart = new TimeOnly(9, 0),
RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "Careful with the IV.",
NurseResponseDeadlineAt = responseDeadline ?? new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
Db.Set<BookingRequest>().Add(request);
Db.SaveChanges();
return request;
}
/// <summary>Inserts an accepted request whose payment window closes at <paramref name="paymentDeadline"/>.</summary>
public BookingRequest AddAcceptedRequest(DateTime paymentDeadline)
{
var request = AddPendingRequest();
request.Accept(paymentDeadline);
Db.SaveChanges();
return request;
}
public string StatusOf(long id)
=> Db.Set<BookingRequest>().AsNoTracking().Where(r => r.Id == id).Select(r => r.Status).Single();
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
}
@@ -0,0 +1,192 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Booking.Commands.CreateBookingRequest;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
namespace Baya.Test.Foundation.Booking;
public class CreateBookingRequestHandlerTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IPlatformConfig _config = Substitute.For<IPlatformConfig>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
private readonly IPatientRepository _patients = Substitute.For<IPatientRepository>();
private readonly ICustomerAddressRepository _addresses = Substitute.For<ICustomerAddressRepository>();
private readonly INurseServiceVariantRepository _variants = Substitute.For<INurseServiceVariantRepository>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly IBookingRequestRepository _requests = Substitute.For<IBookingRequestRepository>();
private const int CustomerUserId = 7;
private const long CustomerId = 100;
private const long NurseId = 42;
private const int NurseUserId = 9;
private const long PatientId = 5;
private const long AddressId = 6;
private const long VariantId = 8;
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
public CreateBookingRequestHandlerTests()
{
_currentUser.UserId.Returns(CustomerUserId);
_currentUser.Roles.Returns([RoleNames.Customer]);
_clock.UtcNow.Returns(Now);
_unitOfWork.CustomerProfileRepository.Returns(_customers);
_unitOfWork.PatientRepository.Returns(_patients);
_unitOfWork.CustomerAddressRepository.Returns(_addresses);
_unitOfWork.NurseServiceVariantRepository.Returns(_variants);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_unitOfWork.BookingRequestRepository.Returns(_requests);
_customers.GetProfileIdByUserIdAsync(CustomerUserId, Arg.Any<CancellationToken>()).Returns(CustomerId);
_patients.GetOwnedAsync(PatientId, CustomerId, Arg.Any<CancellationToken>())
.Returns(new Patient { CustomerId = CustomerId, DisplayName = "پدر", Gender = "male" });
_addresses.GetOwnedAsync(AddressId, CustomerId, Arg.Any<CancellationToken>())
.Returns(new CustomerAddress { CustomerId = CustomerId });
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>())
.Returns(new NurseServiceVariant { NurseId = NurseId, IsActive = true });
_nurses.GetBookingContextByIdAsync(NurseId, Arg.Any<CancellationToken>())
.Returns(new NurseBookingContext(NurseUserId, "female", IsVerified: true, IsAcceptingBookings: true));
_config.GetConfig<int>("nurse_response_deadline_hours", Arg.Any<CancellationToken>()).Returns(24);
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(DetailStub());
}
private CreateBookingRequestCommandHandler Handler()
=> new(_currentUser, _unitOfWork, _config, _clock, _notifications);
private static CreateBookingRequestCommand Command(string gender = "female")
=> new(NurseId, VariantId, PatientId, AddressId,
new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0), gender, "Careful with the IV.");
[Fact]
public async Task Create_Valid_FreezesResponseDeadlineFromConfigAndNotifiesNurse()
{
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsSuccess);
await _requests.Received(1).AddAsync(
Arg.Is<BookingRequest>(r =>
r.CustomerId == CustomerId && r.NurseId == NurseId && r.PatientId == PatientId
&& r.Status == BookingRequestStatus.PendingNurseResponse
&& r.PaymentDeadlineAt == null
&& r.NurseResponseDeadlineAt == Now.AddHours(24).UtcDateTime),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
await _notifications.Received(1).DispatchAsync(
Arg.Is<Notification>(n => n.RecipientUserId == NurseUserId && n.Type == "booking_request_received"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_AnyGender_MatchesRegardlessOfNurseGender()
{
var result = await Handler().Handle(Command("any"), CancellationToken.None);
Assert.True(result.IsSuccess);
}
[Fact]
public async Task Create_SameGenderMismatch_FailsNamingTheConflict()
{
// Nurse is female; a male requirement must not match.
var result = await Handler().Handle(Command("male"), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.False(result.IsNotFound);
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_CrossCustomerPatient_IsNotFoundAndCreatesNothing()
{
_patients.GetOwnedAsync(PatientId, CustomerId, Arg.Any<CancellationToken>()).Returns((Patient)null);
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsNotFound);
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_AddressNotOwned_IsNotFound()
{
_addresses.GetOwnedAsync(AddressId, CustomerId, Arg.Any<CancellationToken>()).Returns((CustomerAddress)null);
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsNotFound);
}
[Fact]
public async Task Create_VariantNotThisNurses_IsNotFound()
{
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>()).Returns((NurseServiceVariant)null);
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsNotFound);
}
[Fact]
public async Task Create_InactiveVariant_Fails()
{
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>())
.Returns(new NurseServiceVariant { NurseId = NurseId, IsActive = false });
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.False(result.IsSuccess);
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_NurseNotAcceptingOrUnverified_Fails()
{
_nurses.GetBookingContextByIdAsync(NurseId, Arg.Any<CancellationToken>())
.Returns(new NurseBookingContext(NurseUserId, "female", IsVerified: true, IsAcceptingBookings: false));
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Create_PastDate_Fails()
{
var command = Command() with { RequestedDate = new DateOnly(2026, 7, 5) };
var result = await Handler().Handle(command, CancellationToken.None);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Create_NonCustomer_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Nurse]);
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsForbidden);
}
private static BookingRequestDetailProjection DetailStub()
=> new(
1, BookingRequestStatus.PendingNurseResponse, CustomerId, NurseId,
"پرستار", 0m, 0, PatientId, "پدر", VariantId, "خدمت", "per_day",
AddressId, "خانه", 1, "تهران", "Tehran", null, null, null,
"line", "postal", "recipient", "phone",
"female", new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0),
"notes", Now.AddHours(24).UtcDateTime, null, null, Now);
}
@@ -0,0 +1,199 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
using Baya.Application.Features.Booking.Commands.CancelBookingRequest;
using Baya.Application.Features.Booking.Commands.RejectBookingRequest;
using Baya.Application.Models.Booking;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
namespace Baya.Test.Foundation.Booking;
public class RespondBookingRequestHandlerTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IPlatformConfig _config = Substitute.For<IPlatformConfig>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
private readonly IBookingRequestRepository _requests = Substitute.For<IBookingRequestRepository>();
private const int NurseUserId = 9;
private const long NurseId = 42;
private const int CustomerUserId = 7;
private const long CustomerId = 100;
private const int CustomerAccountUserId = 5;
private const long RequestId = 1;
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
public RespondBookingRequestHandlerTests()
{
_clock.UtcNow.Returns(Now);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_unitOfWork.CustomerProfileRepository.Returns(_customers);
_unitOfWork.BookingRequestRepository.Returns(_requests);
_nurses.GetProfileIdByUserIdAsync(NurseUserId, Arg.Any<CancellationToken>()).Returns(NurseId);
_customers.GetProfileIdByUserIdAsync(CustomerUserId, Arg.Any<CancellationToken>()).Returns(CustomerId);
_config.GetConfig<int>("booking_payment_deadline_minutes", Arg.Any<CancellationToken>()).Returns(30);
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(DetailStub(BookingRequestStatus.PendingNurseResponse));
}
private static BookingRequest PendingRequest(DateTimeOffset responseDeadline)
=> new()
{
CustomerId = CustomerId,
NurseId = NurseId,
NurseResponseDeadlineAt = responseDeadline.UtcDateTime,
Customer = new CustomerProfile { UserId = CustomerAccountUserId }
};
// ---- Accept ----
[Fact]
public async Task Accept_Pending_SetsThirtyMinuteWindowAndNotifiesCustomer()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(2));
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, request.Status);
Assert.Equal(Now.AddMinutes(30).UtcDateTime, request.PaymentDeadlineAt);
await _unitOfWork.Received(1).CommitAsync();
await _notifications.Received(1).DispatchAsync(
Arg.Is<Notification>(n => n.RecipientUserId == CustomerAccountUserId && n.Type == "booking_request_accepted"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Accept_AfterResponseDeadline_IsConflict()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(-1));
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsConflict);
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task Accept_NonPending_IsConflict()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(2));
request.Reject("already declined");
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsConflict);
}
[Fact]
public async Task Accept_NotAssignedNurse_IsNotFound()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns((BookingRequest)null);
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsNotFound);
}
// ---- Reject ----
[Fact]
public async Task Reject_Pending_StoresReasonAndNotifies()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(2));
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new RejectBookingRequestCommandHandler(_currentUser, _unitOfWork, _notifications);
var result = await handler.Handle(new RejectBookingRequestCommand("Fully booked", RequestId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(BookingRequestStatus.RejectedByNurse, request.Status);
Assert.Equal("Fully booked", request.NurseRejectionReason);
await _notifications.Received(1).DispatchAsync(
Arg.Is<Notification>(n => n.Type == "booking_request_rejected"), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Reject_NonPending_IsConflict()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(2));
request.Accept(Now.AddMinutes(30).UtcDateTime);
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new RejectBookingRequestCommandHandler(_currentUser, _unitOfWork, _notifications);
var result = await handler.Handle(new RejectBookingRequestCommand("late", RequestId), CancellationToken.None);
Assert.True(result.IsConflict);
}
// ---- Cancel ----
[Fact]
public async Task Cancel_FromAcceptedAwaitingPayment_Succeeds()
{
_currentUser.UserId.Returns(CustomerUserId);
_currentUser.Roles.Returns([RoleNames.Customer]);
var request = PendingRequest(Now.AddHours(2));
request.Accept(Now.AddMinutes(30).UtcDateTime);
_requests.GetTrackedForCustomerAsync(RequestId, CustomerId, Arg.Any<CancellationToken>()).Returns(request);
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns(DetailStub(BookingRequestStatus.CancelledByCustomer));
var handler = new CancelBookingRequestCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new CancelBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(BookingRequestStatus.CancelledByCustomer, request.Status);
}
[Fact]
public async Task Cancel_FromTerminal_IsConflict()
{
_currentUser.UserId.Returns(CustomerUserId);
_currentUser.Roles.Returns([RoleNames.Customer]);
var request = PendingRequest(Now.AddHours(2));
request.Reject("declined");
_requests.GetTrackedForCustomerAsync(RequestId, CustomerId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new CancelBookingRequestCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new CancelBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsConflict);
await _unitOfWork.DidNotReceive().CommitAsync();
}
private static BookingRequestDetailProjection DetailStub(string status)
=> new(
RequestId, status, CustomerId, NurseId,
"پرستار", 0m, 0, 5, "پدر", 8, "خدمت", "per_day",
6, "خانه", 1, "تهران", "Tehran", null, null, null,
"line", "postal", "recipient", "phone",
"any", new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0),
null, Now.AddHours(24).UtcDateTime, null, null, Now);
}