using System.Linq.Expressions; using System.Text.Json; using FluentAssertions; using Hangfire; using Hangfire.Common; using Hangfire.States; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using TrueCV.Application.DTOs; using TrueCV.Application.Interfaces; using TrueCV.Application.Models; using TrueCV.Domain.Entities; using TrueCV.Domain.Enums; using TrueCV.Infrastructure.Data; using TrueCV.Infrastructure.Jobs; using TrueCV.Infrastructure.Services; namespace TrueCV.Tests.Services; public sealed class CVCheckServiceTests : IDisposable { private readonly ApplicationDbContext _dbContext; private readonly Mock _fileStorageServiceMock; private readonly Mock _backgroundJobClientMock; private readonly Mock _auditServiceMock; private readonly Mock> _loggerMock; private readonly CVCheckService _sut; public CVCheckServiceTests() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; _dbContext = new ApplicationDbContext(options); _fileStorageServiceMock = new Mock(); _backgroundJobClientMock = new Mock(); _auditServiceMock = new Mock(); _loggerMock = new Mock>(); _sut = new CVCheckService( _dbContext, _fileStorageServiceMock.Object, _backgroundJobClientMock.Object, _auditServiceMock.Object, _loggerMock.Object); } public void Dispose() { _dbContext.Dispose(); } #region CreateCheckAsync Tests [Fact] public async Task CreateCheckAsync_CreatesRecordWithCorrectData() { // Arrange var userId = Guid.NewGuid(); var fileName = "test-cv.pdf"; var expectedBlobUrl = "https://storage.blob.core.windows.net/cvs/test-cv.pdf"; using var stream = new MemoryStream(); _fileStorageServiceMock .Setup(x => x.UploadAsync(It.IsAny(), fileName)) .ReturnsAsync(expectedBlobUrl); // Act var checkId = await _sut.CreateCheckAsync(userId, stream, fileName); // Assert var savedCheck = await _dbContext.CVChecks.FirstOrDefaultAsync(c => c.Id == checkId); savedCheck.Should().NotBeNull(); savedCheck!.UserId.Should().Be(userId); savedCheck.OriginalFileName.Should().Be(fileName); savedCheck.BlobUrl.Should().Be(expectedBlobUrl); savedCheck.Status.Should().Be(CheckStatus.Pending); savedCheck.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); } [Fact] public async Task CreateCheckAsync_UploadsFileToBlobStorage() { // Arrange var userId = Guid.NewGuid(); var fileName = "resume.docx"; var expectedBlobUrl = "https://storage.blob.core.windows.net/cvs/resume.docx"; using var stream = new MemoryStream(new byte[] { 0x01, 0x02, 0x03 }); _fileStorageServiceMock .Setup(x => x.UploadAsync(stream, fileName)) .ReturnsAsync(expectedBlobUrl); // Act await _sut.CreateCheckAsync(userId, stream, fileName); // Assert _fileStorageServiceMock.Verify( x => x.UploadAsync(stream, fileName), Times.Once); } [Fact] public async Task CreateCheckAsync_QueuesHangfireBackgroundJob() { // Arrange var userId = Guid.NewGuid(); var fileName = "cv.pdf"; using var stream = new MemoryStream(); _fileStorageServiceMock .Setup(x => x.UploadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync("https://blob.url/cv.pdf"); // Act var checkId = await _sut.CreateCheckAsync(userId, stream, fileName); // Assert _backgroundJobClientMock.Verify( x => x.Create( It.Is(job => job.Type == typeof(ProcessCVCheckJob) && job.Method.Name == nameof(ProcessCVCheckJob.ExecuteAsync)), It.IsAny()), Times.Once); } [Fact] public async Task CreateCheckAsync_ReturnsNewCheckId() { // Arrange var userId = Guid.NewGuid(); var fileName = "my-cv.pdf"; using var stream = new MemoryStream(); _fileStorageServiceMock .Setup(x => x.UploadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync("https://blob.url/my-cv.pdf"); // Act var checkId = await _sut.CreateCheckAsync(userId, stream, fileName); // Assert checkId.Should().NotBe(Guid.Empty); var savedCheck = await _dbContext.CVChecks.FirstOrDefaultAsync(c => c.Id == checkId); savedCheck.Should().NotBeNull(); } [Fact] public async Task CreateCheckAsync_ThrowsArgumentNullException_WhenFileIsNull() { // Arrange var userId = Guid.NewGuid(); var fileName = "test.pdf"; // Act var act = () => _sut.CreateCheckAsync(userId, null!, fileName); // Assert await act.Should().ThrowAsync() .WithParameterName("file"); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task CreateCheckAsync_ThrowsArgumentException_WhenFileNameIsNullOrWhitespace(string? fileName) { // Arrange var userId = Guid.NewGuid(); using var stream = new MemoryStream(); // Act var act = () => _sut.CreateCheckAsync(userId, stream, fileName!); // Assert await act.Should().ThrowAsync() .WithParameterName("fileName"); } #endregion #region GetCheckAsync Tests [Fact] public async Task GetCheckAsync_ReturnsNull_WhenCheckDoesNotExist() { // Arrange var nonExistentId = Guid.NewGuid(); // Act var result = await _sut.GetCheckAsync(nonExistentId); // Assert result.Should().BeNull(); } [Fact] public async Task GetCheckAsync_ReturnsCorrectDto_WhenCheckExists() { // Arrange var checkId = Guid.NewGuid(); var userId = Guid.NewGuid(); var createdAt = DateTime.UtcNow.AddHours(-1); var completedAt = DateTime.UtcNow.AddMinutes(-30); var cvCheck = new CVCheck { Id = checkId, UserId = userId, OriginalFileName = "professional-cv.pdf", BlobUrl = "https://blob.url/professional-cv.pdf", Status = CheckStatus.Completed, VeracityScore = 85, CreatedAt = createdAt, CompletedAt = completedAt }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetCheckAsync(checkId); // Assert result.Should().NotBeNull(); result!.Id.Should().Be(checkId); result.OriginalFileName.Should().Be("professional-cv.pdf"); result.Status.Should().Be("Completed"); result.VeracityScore.Should().Be(85); result.CompletedAt.Should().Be(completedAt); } [Fact] public async Task GetCheckAsync_ReturnsPendingStatus_ForNewCheck() { // Arrange var checkId = Guid.NewGuid(); var cvCheck = new CVCheck { Id = checkId, UserId = Guid.NewGuid(), OriginalFileName = "new-cv.pdf", BlobUrl = "https://blob.url/new-cv.pdf", Status = CheckStatus.Pending, CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetCheckAsync(checkId); // Assert result.Should().NotBeNull(); result!.Status.Should().Be("Pending"); result.VeracityScore.Should().BeNull(); result.CompletedAt.Should().BeNull(); } #endregion #region GetUserChecksAsync Tests [Fact] public async Task GetUserChecksAsync_ReturnsEmptyList_WhenUserHasNoChecks() { // Arrange var userId = Guid.NewGuid(); // Act var result = await _sut.GetUserChecksAsync(userId); // Assert result.Should().NotBeNull(); result.Should().BeEmpty(); } [Fact] public async Task GetUserChecksAsync_ReturnsAllChecksForUser() { // Arrange var userId = Guid.NewGuid(); var check1 = new CVCheck { Id = Guid.NewGuid(), UserId = userId, OriginalFileName = "cv1.pdf", BlobUrl = "https://blob.url/cv1.pdf", Status = CheckStatus.Completed, VeracityScore = 90, CreatedAt = DateTime.UtcNow.AddDays(-2) }; var check2 = new CVCheck { Id = Guid.NewGuid(), UserId = userId, OriginalFileName = "cv2.pdf", BlobUrl = "https://blob.url/cv2.pdf", Status = CheckStatus.Pending, CreatedAt = DateTime.UtcNow.AddDays(-1) }; var check3 = new CVCheck { Id = Guid.NewGuid(), UserId = userId, OriginalFileName = "cv3.pdf", BlobUrl = "https://blob.url/cv3.pdf", Status = CheckStatus.Processing, CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.AddRange(check1, check2, check3); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetUserChecksAsync(userId); // Assert result.Should().HaveCount(3); result.Select(c => c.OriginalFileName) .Should().Contain(new[] { "cv1.pdf", "cv2.pdf", "cv3.pdf" }); } [Fact] public async Task GetUserChecksAsync_DoesNotReturnOtherUsersChecks() { // Arrange var userId1 = Guid.NewGuid(); var userId2 = Guid.NewGuid(); var user1Check = new CVCheck { Id = Guid.NewGuid(), UserId = userId1, OriginalFileName = "user1-cv.pdf", BlobUrl = "https://blob.url/user1-cv.pdf", Status = CheckStatus.Completed, CreatedAt = DateTime.UtcNow.AddHours(-1) }; var user2Check1 = new CVCheck { Id = Guid.NewGuid(), UserId = userId2, OriginalFileName = "user2-cv-a.pdf", BlobUrl = "https://blob.url/user2-cv-a.pdf", Status = CheckStatus.Pending, CreatedAt = DateTime.UtcNow.AddHours(-2) }; var user2Check2 = new CVCheck { Id = Guid.NewGuid(), UserId = userId2, OriginalFileName = "user2-cv-b.pdf", BlobUrl = "https://blob.url/user2-cv-b.pdf", Status = CheckStatus.Completed, CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.AddRange(user1Check, user2Check1, user2Check2); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetUserChecksAsync(userId1); // Assert result.Should().HaveCount(1); result.Single().OriginalFileName.Should().Be("user1-cv.pdf"); result.Should().NotContain(c => c.OriginalFileName.StartsWith("user2")); } [Fact] public async Task GetUserChecksAsync_ReturnsChecksOrderedByCreatedAtDescending() { // Arrange var userId = Guid.NewGuid(); // Create checks with specific IDs so we can update them after save var oldestId = Guid.NewGuid(); var middleId = Guid.NewGuid(); var newestId = Guid.NewGuid(); var oldestCheck = new CVCheck { Id = oldestId, UserId = userId, OriginalFileName = "oldest.pdf", BlobUrl = "https://blob.url/oldest.pdf", Status = CheckStatus.Completed }; var middleCheck = new CVCheck { Id = middleId, UserId = userId, OriginalFileName = "middle.pdf", BlobUrl = "https://blob.url/middle.pdf", Status = CheckStatus.Completed }; var newestCheck = new CVCheck { Id = newestId, UserId = userId, OriginalFileName = "newest.pdf", BlobUrl = "https://blob.url/newest.pdf", Status = CheckStatus.Pending }; // Add in random order - SaveChangesAsync will set CreatedAt to DateTime.UtcNow _dbContext.CVChecks.AddRange(middleCheck, newestCheck, oldestCheck); await _dbContext.SaveChangesAsync(); // Detach all entities to allow direct update _dbContext.ChangeTracker.Clear(); // Now update CreatedAt timestamps using raw SQL to bypass SaveChangesAsync override // Since InMemory database doesn't support raw SQL, we modify directly var oldest = await _dbContext.CVChecks.FindAsync(oldestId); var middle = await _dbContext.CVChecks.FindAsync(middleId); var newest = await _dbContext.CVChecks.FindAsync(newestId); oldest!.CreatedAt = DateTime.UtcNow.AddDays(-7); middle!.CreatedAt = DateTime.UtcNow.AddDays(-3); newest!.CreatedAt = DateTime.UtcNow; // Mark as modified (not added) to bypass the CreatedAt override _dbContext.Entry(oldest).State = EntityState.Modified; _dbContext.Entry(middle).State = EntityState.Modified; _dbContext.Entry(newest).State = EntityState.Modified; await _dbContext.SaveChangesAsync(); _dbContext.ChangeTracker.Clear(); // Act var result = await _sut.GetUserChecksAsync(userId); // Assert result.Should().HaveCount(3); result[0].OriginalFileName.Should().Be("newest.pdf"); result[1].OriginalFileName.Should().Be("middle.pdf"); result[2].OriginalFileName.Should().Be("oldest.pdf"); } #endregion #region GetCheckForUserAsync Tests [Fact] public async Task GetCheckForUserAsync_ReturnsNull_WhenCheckDoesNotExist() { // Arrange var checkId = Guid.NewGuid(); var userId = Guid.NewGuid(); // Act var result = await _sut.GetCheckForUserAsync(checkId, userId); // Assert result.Should().BeNull(); } [Fact] public async Task GetCheckForUserAsync_ReturnsNull_WhenCheckBelongsToDifferentUser() { // Arrange var checkId = Guid.NewGuid(); var ownerId = Guid.NewGuid(); var requestingUserId = Guid.NewGuid(); var cvCheck = new CVCheck { Id = checkId, UserId = ownerId, OriginalFileName = "owner-cv.pdf", BlobUrl = "https://blob.url/owner-cv.pdf", Status = CheckStatus.Completed, CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetCheckForUserAsync(checkId, requestingUserId); // Assert result.Should().BeNull(); } [Fact] public async Task GetCheckForUserAsync_ReturnsCheck_WhenCheckBelongsToUser() { // Arrange var checkId = Guid.NewGuid(); var userId = Guid.NewGuid(); var cvCheck = new CVCheck { Id = checkId, UserId = userId, OriginalFileName = "my-cv.pdf", BlobUrl = "https://blob.url/my-cv.pdf", Status = CheckStatus.Completed, VeracityScore = 88, CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetCheckForUserAsync(checkId, userId); // Assert result.Should().NotBeNull(); result!.Id.Should().Be(checkId); result.OriginalFileName.Should().Be("my-cv.pdf"); result.VeracityScore.Should().Be(88); } #endregion #region GetReportAsync Tests [Fact] public async Task GetReportAsync_ReturnsNull_WhenCheckDoesNotExist() { // Arrange var checkId = Guid.NewGuid(); var userId = Guid.NewGuid(); // Act var result = await _sut.GetReportAsync(checkId, userId); // Assert result.Should().BeNull(); } [Fact] public async Task GetReportAsync_ReturnsNull_WhenCheckBelongsToDifferentUser() { // Arrange var checkId = Guid.NewGuid(); var ownerId = Guid.NewGuid(); var requestingUserId = Guid.NewGuid(); var report = CreateTestReport(); var cvCheck = new CVCheck { Id = checkId, UserId = ownerId, OriginalFileName = "cv.pdf", BlobUrl = "https://blob.url/cv.pdf", Status = CheckStatus.Completed, ReportJson = JsonSerializer.Serialize(report), CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetReportAsync(checkId, requestingUserId); // Assert result.Should().BeNull(); } [Fact] public async Task GetReportAsync_ReturnsNull_WhenCheckIsNotCompleted() { // Arrange var checkId = Guid.NewGuid(); var userId = Guid.NewGuid(); var cvCheck = new CVCheck { Id = checkId, UserId = userId, OriginalFileName = "cv.pdf", BlobUrl = "https://blob.url/cv.pdf", Status = CheckStatus.Processing, CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetReportAsync(checkId, userId); // Assert result.Should().BeNull(); } [Fact] public async Task GetReportAsync_ReturnsNull_WhenReportJsonIsEmpty() { // Arrange var checkId = Guid.NewGuid(); var userId = Guid.NewGuid(); var cvCheck = new CVCheck { Id = checkId, UserId = userId, OriginalFileName = "cv.pdf", BlobUrl = "https://blob.url/cv.pdf", Status = CheckStatus.Completed, ReportJson = string.Empty, CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetReportAsync(checkId, userId); // Assert result.Should().BeNull(); } [Fact] public async Task GetReportAsync_ReturnsReport_WhenCheckIsCompletedWithValidReport() { // Arrange var checkId = Guid.NewGuid(); var userId = Guid.NewGuid(); var report = CreateTestReport(); var cvCheck = new CVCheck { Id = checkId, UserId = userId, OriginalFileName = "cv.pdf", BlobUrl = "https://blob.url/cv.pdf", Status = CheckStatus.Completed, ReportJson = JsonSerializer.Serialize(report), CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetReportAsync(checkId, userId); // Assert result.Should().NotBeNull(); result!.OverallScore.Should().Be(85); result.ScoreLabel.Should().Be("Good"); } [Fact] public async Task GetReportAsync_ReturnsNull_WhenReportJsonIsInvalid() { // Arrange var checkId = Guid.NewGuid(); var userId = Guid.NewGuid(); var cvCheck = new CVCheck { Id = checkId, UserId = userId, OriginalFileName = "cv.pdf", BlobUrl = "https://blob.url/cv.pdf", Status = CheckStatus.Completed, ReportJson = "{ invalid json }", CreatedAt = DateTime.UtcNow }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.GetReportAsync(checkId, userId); // Assert result.Should().BeNull(); } #endregion #region Helper Methods private static VeracityReport CreateTestReport() { return new VeracityReport { OverallScore = 85, ScoreLabel = "Good", EmploymentVerifications = [], TimelineAnalysis = new TimelineAnalysisResult { Gaps = [], Overlaps = [], TotalGapMonths = 0, TotalOverlapMonths = 0 }, Flags = [], GeneratedAt = DateTime.UtcNow }; } #endregion }