From 30b153d284def69d956cbdafc448f22c1788a4c8 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Tue, 21 Apr 2026 16:10:33 -0400 Subject: [PATCH 1/2] fix(api): add remove from queue endpoint and enforce max 5 queue size AB#29 --- .../Controllers/SupportChatController.cs | 18 ++++++++++++++++++ .../ServiceInterfaces/ISupportChatService.cs | 1 + .../Services/SupportChatService.cs | 16 ++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/JobFlow.API/Controllers/SupportChatController.cs b/JobFlow.API/Controllers/SupportChatController.cs index 8fa26be..18d33a9 100644 --- a/JobFlow.API/Controllers/SupportChatController.cs +++ b/JobFlow.API/Controllers/SupportChatController.cs @@ -51,6 +51,10 @@ public async Task JoinQueue([FromBody] SupportChatJoinQueueRequest requ ?? HttpContext.User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; } + var queueResult = await _chatService.GetQueueAsync(); + if (queueResult.IsSuccess && queueResult.Value.Count >= 5) + return Results.BadRequest(new { error = "Queue is full. Please try again later." }); + var result = await _chatService.JoinQueueAsync(customerName ?? string.Empty, customerEmail ?? string.Empty, customerId); if (result.IsFailure) return result.ToProblemDetails(); @@ -91,6 +95,20 @@ public async Task PickCustomer(Guid sessionId) return Results.Ok(result.Value); } + /// Removes a customer from the queue and notifies participants. + [HttpDelete("sessions/{sessionId}/queue")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task RemoveFromQueue(Guid sessionId) + { + var result = await _chatService.RemoveFromQueueAsync(sessionId); + if (result.IsFailure) return result.ToProblemDetails(); + + await _hubContext.Clients.Group("reps").SendAsync("QueueUpdated"); + await _hubContext.Clients.Group($"session-{sessionId}").SendAsync("SessionClosed"); + + return Results.Ok(); + } + /// Returns all messages for a session. Accessible without authentication (customer view). [HttpGet("sessions/{sessionId}/messages")] [AllowAnonymous] diff --git a/JobFlow.Business/Services/ServiceInterfaces/ISupportChatService.cs b/JobFlow.Business/Services/ServiceInterfaces/ISupportChatService.cs index 36eeaac..5a1936e 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/ISupportChatService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/ISupportChatService.cs @@ -14,4 +14,5 @@ public interface ISupportChatService Task> UploadFileAsync(Stream stream, string fileName, string contentType); Task> ValidateCustomerAsync(string email); Task> GetSessionAsync(Guid sessionId); + Task RemoveFromQueueAsync(Guid sessionId); } diff --git a/JobFlow.Business/Services/SupportChatService.cs b/JobFlow.Business/Services/SupportChatService.cs index 7ecb503..d691ca1 100644 --- a/JobFlow.Business/Services/SupportChatService.cs +++ b/JobFlow.Business/Services/SupportChatService.cs @@ -229,6 +229,22 @@ public async Task> GetSessionAsync(Guid sessionId) return Result.Success(MapToSessionDto(session, position)); } + public async Task RemoveFromQueueAsync(Guid sessionId) + { + var session = await _sessions.Query() + .FirstOrDefaultAsync(s => s.Id == sessionId && s.Status == SupportChatSessionStatus.Queued); + + if (session is null) + return Result.Failure(Error.NotFound("SupportChat.SessionNotFound", "Session not found or not in queue.")); + + session.Status = SupportChatSessionStatus.Closed; + session.ClosedAt = DateTime.UtcNow; + session.UpdatedAt = DateTime.UtcNow; + + await _unitOfWork.SaveChangesAsync(); + return Result.Success(); + } + private async Task> AssignSession( SupportChatSession session, Guid repId, string repName) { From 3e3426316f8865d17387b5f107a829975d613689 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Tue, 21 Apr 2026 16:45:50 -0400 Subject: [PATCH 2/2] fix(api): add remove from queue endpoint and enforce max 5 queue size AB#29 --- JobFlow.Tests/SupportChatControllerTests.cs | 151 ++++++++++++++++++++ JobFlow.Tests/SupportChatServiceTests.cs | 130 +++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 JobFlow.Tests/SupportChatControllerTests.cs create mode 100644 JobFlow.Tests/SupportChatServiceTests.cs diff --git a/JobFlow.Tests/SupportChatControllerTests.cs b/JobFlow.Tests/SupportChatControllerTests.cs new file mode 100644 index 0000000..2361e0f --- /dev/null +++ b/JobFlow.Tests/SupportChatControllerTests.cs @@ -0,0 +1,151 @@ +using JobFlow.API.Controllers; +using JobFlow.API.Hubs; +using JobFlow.Business; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; + +namespace JobFlow.Tests; + +public class SupportChatControllerTests +{ + // ── Stubs ───────────────────────────────────────────────────────────────── + + private sealed class StubChatService : ISupportChatService + { + private readonly int _queueSize; + private readonly bool _removeSucceeds; + + public StubChatService(int queueSize = 0, bool removeSucceeds = true) + { + _queueSize = queueSize; + _removeSucceeds = removeSucceeds; + } + + public Task>> GetQueueAsync() + { + var items = Enumerable.Range(1, _queueSize) + .Select(i => new SupportChatQueueItemDto( + Guid.NewGuid(), $"Customer {i}", $"cust{i}@test.com", i, 60 * i, DateTime.UtcNow)) + .ToList(); + return Task.FromResult(Result.Success(items)); + } + + public Task> JoinQueueAsync( + string customerName, string customerEmail, Guid? customerId = null) => + Task.FromResult(Result.Success(new SupportChatJoinQueueResponse(Guid.NewGuid(), 1, 60))); + + public Task RemoveFromQueueAsync(Guid sessionId) => + Task.FromResult(_removeSucceeds + ? Result.Success() + : Result.Failure(Error.NotFound("SupportChat.SessionNotFound", "Session not found or not in queue."))); + + public Task> PickNextCustomerAsync(Guid repId, string repName) => throw new NotImplementedException(); + public Task> PickCustomerAsync(Guid sessionId, Guid repId, string repName) => throw new NotImplementedException(); + public Task> SendMessageAsync(SupportChatSendMessageRequest request) => throw new NotImplementedException(); + public Task>> GetSessionMessagesAsync(Guid sessionId) => throw new NotImplementedException(); + public Task CloseSessionAsync(Guid sessionId) => throw new NotImplementedException(); + public Task> UploadFileAsync(Stream stream, string fileName, string contentType) => throw new NotImplementedException(); + public Task> ValidateCustomerAsync(string email) => throw new NotImplementedException(); + public Task> GetSessionAsync(Guid sessionId) => throw new NotImplementedException(); + } + + private sealed class NullUserService : IUserService + { + public Task>> GetAllUsers() => throw new NotImplementedException(); + public Task> GetUserById(Guid userId) => throw new NotImplementedException(); + public Task> GetUserByFirebaseUid(string uid) => throw new NotImplementedException(); + public Task> UpsertUser(User model) => throw new NotImplementedException(); + public Task DeleteUser(Guid userId) => throw new NotImplementedException(); + public Task> GetUserByEmail(string email) => throw new NotImplementedException(); + public Task AssignRole(Guid userId, string role) => throw new NotImplementedException(); + public Task> GetProfileByFirebaseUid(string uid) => throw new NotImplementedException(); + public Task> UpdateProfile(string uid, UserProfileUpdateRequest request) => throw new NotImplementedException(); + } + + private sealed class NullHubContext : IHubContext + { + public IHubClients Clients { get; } = new NullHubClients(); + public IGroupManager Groups => throw new NotImplementedException(); + + private sealed class NullHubClients : IHubClients + { + private static readonly NullClientProxy Proxy = new(); + public IClientProxy All => Proxy; + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy; + public IClientProxy Client(string connectionId) => Proxy; + public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; + public IClientProxy Group(string groupName) => Proxy; + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; + public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; + public IClientProxy User(string userId) => Proxy; + public IClientProxy Users(IReadOnlyList userIds) => Proxy; + } + + private sealed class NullClientProxy : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + } + + private static SupportChatController CreateController(ISupportChatService chatService) + { + var controller = new SupportChatController(chatService, new NullUserService(), new NullHubContext()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + return controller; + } + + // ── RemoveFromQueue ─────────────────────────────────────────────────────── + + [Fact] + public async Task RemoveFromQueue_Returns200_WhenSessionRemovedSuccessfully() + { + var controller = CreateController(new StubChatService(removeSucceeds: true)); + + var result = await controller.RemoveFromQueue(Guid.NewGuid()); + + Assert.Equal(200, ((IStatusCodeHttpResult)result).StatusCode); + } + + [Fact] + public async Task RemoveFromQueue_Returns404_WhenSessionNotFound() + { + var controller = CreateController(new StubChatService(removeSucceeds: false)); + + var result = await controller.RemoveFromQueue(Guid.NewGuid()); + + Assert.Equal(404, ((IStatusCodeHttpResult)result).StatusCode); + } + + // ── JoinQueue ───────────────────────────────────────────────────────────── + + [Fact] + public async Task JoinQueue_Returns400_WhenQueueHasFiveOrMoreSessions() + { + var controller = CreateController(new StubChatService(queueSize: 5)); + var request = new SupportChatJoinQueueRequest("Test User", "test@example.com"); + + var result = await controller.JoinQueue(request); + + Assert.Equal(400, ((IStatusCodeHttpResult)result).StatusCode); + } + + [Fact] + public async Task JoinQueue_Returns200_WhenQueueHasFewerThanFiveSessions() + { + var controller = CreateController(new StubChatService(queueSize: 4)); + var request = new SupportChatJoinQueueRequest("Test User", "test@example.com"); + + var result = await controller.JoinQueue(request); + + Assert.Equal(200, ((IStatusCodeHttpResult)result).StatusCode); + } +} diff --git a/JobFlow.Tests/SupportChatServiceTests.cs b/JobFlow.Tests/SupportChatServiceTests.cs new file mode 100644 index 0000000..39438a7 --- /dev/null +++ b/JobFlow.Tests/SupportChatServiceTests.cs @@ -0,0 +1,130 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JobFlow.Tests; + +public class SupportChatServiceTests +{ + // ── Helpers ────────────────────────────────────────────────────────────── + + private static JobFlowUnitOfWork CreateUnitOfWork(string databaseName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + + var factory = new TestDbContextFactory(options); + return new JobFlowUnitOfWork(NullLogger.Instance, factory); + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + public TestDbContextFactory(DbContextOptions options) => _options = options; + public JobFlowDbContext CreateDbContext() => new JobFlowDbContext(_options); + } + + private sealed class TestWebHostEnvironmentAccessor : IWebHostEnvironmentAccessor + { + public string WebRootPath => Path.GetTempPath(); + public string ContentRootPath => Path.GetTempPath(); + } + + private static SupportChatService CreateService(IUnitOfWork uow) => + new SupportChatService(uow, new TestWebHostEnvironmentAccessor()); + + // ── RemoveFromQueueAsync ───────────────────────────────────────────────── + + [Fact] + public async Task RemoveFromQueueAsync_SetsStatusToClosed_WhenSessionIsQueued() + { + var uow = CreateUnitOfWork(nameof(RemoveFromQueueAsync_SetsStatusToClosed_WhenSessionIsQueued)); + var svc = CreateService(uow); + var joinResult = await svc.JoinQueueAsync("Alice Smith", "alice@example.com"); + var sessionId = joinResult.Value.SessionId; + + var result = await svc.RemoveFromQueueAsync(sessionId); + + Assert.True(result.IsSuccess); + var sessionResult = await svc.GetSessionAsync(sessionId); + Assert.True(sessionResult.IsSuccess); + Assert.Equal(SupportChatSessionStatus.Closed, sessionResult.Value.Status); + } + + [Fact] + public async Task RemoveFromQueueAsync_ReturnsNotFound_WhenSessionDoesNotExist() + { + var uow = CreateUnitOfWork(nameof(RemoveFromQueueAsync_ReturnsNotFound_WhenSessionDoesNotExist)); + var svc = CreateService(uow); + + var result = await svc.RemoveFromQueueAsync(Guid.NewGuid()); + + Assert.True(result.IsFailure); + Assert.Equal("SupportChat.SessionNotFound", result.Error.Code); + } + + [Fact] + public async Task RemoveFromQueueAsync_ReturnsNotFound_WhenSessionIsAlreadyClosed() + { + var uow = CreateUnitOfWork(nameof(RemoveFromQueueAsync_ReturnsNotFound_WhenSessionIsAlreadyClosed)); + var svc = CreateService(uow); + var joinResult = await svc.JoinQueueAsync("Bob Jones", "bob@example.com"); + var sessionId = joinResult.Value.SessionId; + await svc.RemoveFromQueueAsync(sessionId); // close once + + var result = await svc.RemoveFromQueueAsync(sessionId); // attempt again on closed session + + Assert.True(result.IsFailure); + Assert.Equal("SupportChat.SessionNotFound", result.Error.Code); + } + + [Fact] + public async Task RemoveFromQueueAsync_SetsClosedAt_WhenSessionIsRemoved() + { + var uow = CreateUnitOfWork(nameof(RemoveFromQueueAsync_SetsClosedAt_WhenSessionIsRemoved)); + var svc = CreateService(uow); + var joinResult = await svc.JoinQueueAsync("Carol White", "carol@example.com"); + var sessionId = joinResult.Value.SessionId; + + await svc.RemoveFromQueueAsync(sessionId); + + var sessionResult = await svc.GetSessionAsync(sessionId); + Assert.NotNull(sessionResult.Value.ClosedAt); + } + + [Fact] + public async Task RemoveFromQueueAsync_DoesNotAffectOtherQueuedSessions() + { + var uow = CreateUnitOfWork(nameof(RemoveFromQueueAsync_DoesNotAffectOtherQueuedSessions)); + var svc = CreateService(uow); + var join1 = await svc.JoinQueueAsync("Dave One", "dave@example.com"); + var join2 = await svc.JoinQueueAsync("Eve Two", "eve@example.com"); + + await svc.RemoveFromQueueAsync(join1.Value.SessionId); + + var queue = await svc.GetQueueAsync(); + Assert.True(queue.IsSuccess); + Assert.Single(queue.Value); + Assert.Equal(join2.Value.SessionId, queue.Value[0].SessionId); + } + + // ── JoinQueueAsync queue-full guard (exercised through GetQueueAsync) ──── + + [Fact] + public async Task JoinQueueAsync_ReturnsSuccessWithPosition_WhenQueueHasFewer5Sessions() + { + var uow = CreateUnitOfWork(nameof(JoinQueueAsync_ReturnsSuccessWithPosition_WhenQueueHasFewer5Sessions)); + var svc = CreateService(uow); + + var result = await svc.JoinQueueAsync("Frank New", "frank@example.com"); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.QueuePosition); + } +}