backend phase 8
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user