Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions JobFlow.API/Controllers/SupportChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public async Task<IResult> 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();

Expand Down Expand Up @@ -91,6 +95,20 @@ public async Task<IResult> PickCustomer(Guid sessionId)
return Results.Ok(result.Value);
}

/// <summary>Removes a customer from the queue and notifies participants.</summary>
[HttpDelete("sessions/{sessionId}/queue")]
[Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")]
public async Task<IResult> 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();
}

/// <summary>Returns all messages for a session. Accessible without authentication (customer view).</summary>
[HttpGet("sessions/{sessionId}/messages")]
[AllowAnonymous]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ public interface ISupportChatService
Task<Result<SupportChatFileUploadResponse>> UploadFileAsync(Stream stream, string fileName, string contentType);
Task<Result<SupportChatValidateCustomerResponse>> ValidateCustomerAsync(string email);
Task<Result<SupportChatSessionDto>> GetSessionAsync(Guid sessionId);
Task<Result> RemoveFromQueueAsync(Guid sessionId);
}
16 changes: 16 additions & 0 deletions JobFlow.Business/Services/SupportChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,22 @@ public async Task<Result<SupportChatSessionDto>> GetSessionAsync(Guid sessionId)
return Result.Success(MapToSessionDto(session, position));
}

public async Task<Result> 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<Result<SupportChatSessionDto>> AssignSession(
SupportChatSession session, Guid repId, string repName)
{
Expand Down
151 changes: 151 additions & 0 deletions JobFlow.Tests/SupportChatControllerTests.cs
Original file line number Diff line number Diff line change
@@ -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<Result<List<SupportChatQueueItemDto>>> 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<Result<SupportChatJoinQueueResponse>> JoinQueueAsync(
string customerName, string customerEmail, Guid? customerId = null) =>
Task.FromResult(Result.Success(new SupportChatJoinQueueResponse(Guid.NewGuid(), 1, 60)));

public Task<Result> RemoveFromQueueAsync(Guid sessionId) =>
Task.FromResult(_removeSucceeds
? Result.Success()
: Result.Failure(Error.NotFound("SupportChat.SessionNotFound", "Session not found or not in queue.")));

public Task<Result<SupportChatSessionDto>> PickNextCustomerAsync(Guid repId, string repName) => throw new NotImplementedException();
public Task<Result<SupportChatSessionDto>> PickCustomerAsync(Guid sessionId, Guid repId, string repName) => throw new NotImplementedException();
public Task<Result<SupportChatMessageDto>> SendMessageAsync(SupportChatSendMessageRequest request) => throw new NotImplementedException();
public Task<Result<List<SupportChatMessageDto>>> GetSessionMessagesAsync(Guid sessionId) => throw new NotImplementedException();
public Task<Result> CloseSessionAsync(Guid sessionId) => throw new NotImplementedException();
public Task<Result<SupportChatFileUploadResponse>> UploadFileAsync(Stream stream, string fileName, string contentType) => throw new NotImplementedException();
public Task<Result<SupportChatValidateCustomerResponse>> ValidateCustomerAsync(string email) => throw new NotImplementedException();
public Task<Result<SupportChatSessionDto>> GetSessionAsync(Guid sessionId) => throw new NotImplementedException();
}

private sealed class NullUserService : IUserService
{
public Task<Result<IEnumerable<User>>> GetAllUsers() => throw new NotImplementedException();
public Task<Result<User>> GetUserById(Guid userId) => throw new NotImplementedException();
public Task<Result<User>> GetUserByFirebaseUid(string uid) => throw new NotImplementedException();
public Task<Result<User>> UpsertUser(User model) => throw new NotImplementedException();
public Task<Result> DeleteUser(Guid userId) => throw new NotImplementedException();
public Task<Result<User>> GetUserByEmail(string email) => throw new NotImplementedException();
public Task<Result> AssignRole(Guid userId, string role) => throw new NotImplementedException();
public Task<Result<UserProfileDto>> GetProfileByFirebaseUid(string uid) => throw new NotImplementedException();
public Task<Result<UserProfileDto>> UpdateProfile(string uid, UserProfileUpdateRequest request) => throw new NotImplementedException();
}

private sealed class NullHubContext : IHubContext<SupportChatHub>
{
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<string> excludedConnectionIds) => Proxy;
public IClientProxy Client(string connectionId) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
public IClientProxy Group(string groupName) => Proxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
public IClientProxy User(string userId) => Proxy;
public IClientProxy Users(IReadOnlyList<string> 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);
}
}
130 changes: 130 additions & 0 deletions JobFlow.Tests/SupportChatServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<JobFlowDbContext>()
.UseInMemoryDatabase(databaseName)
.Options;

var factory = new TestDbContextFactory(options);
return new JobFlowUnitOfWork(NullLogger<JobFlowUnitOfWork>.Instance, factory);
}

private sealed class TestDbContextFactory : IDbContextFactory<JobFlowDbContext>
{
private readonly DbContextOptions<JobFlowDbContext> _options;
public TestDbContextFactory(DbContextOptions<JobFlowDbContext> 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);
}
}
Loading