using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class GmailControllerTests { [Fact] public async Task Status_returns_sync_state_fields_for_connected_account() { await using var db = CreateDb(); var gmail = new Mock(); gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", ConnectedAt = DateTimeOffset.UtcNow.AddDays(-3), LastSyncedAt = DateTimeOffset.UtcNow.AddMinutes(-10), LastSyncAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-5), LastSyncSucceededAt = DateTimeOffset.UtcNow.AddMinutes(-10), LastSyncMode = "list-messages", LastSyncSource = "custom-query", LastSyncStatus = "error", LastSyncError = "Token refresh failed" }); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.Status(CancellationToken.None); var ok = Assert.IsType(result.Result); var payload = Assert.IsType(ok.Value); Assert.True(payload.Connected); Assert.Equal("user@example.test", payload.GmailAddress); Assert.Equal("list-messages", payload.LastSyncMode); Assert.Equal("custom-query", payload.LastSyncSource); Assert.Equal("error", payload.LastSyncStatus); Assert.Equal("Token refresh failed", payload.LastSyncError); } [Fact] public async Task Import_thread_rejects_missing_message_ids() { await using var db = CreateDb(); var controller = CreateController(db, Mock.Of(), "user-1"); var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty()), CancellationToken.None); var badRequest = Assert.IsType(result.Result); Assert.Equal("At least one messageId is required.", badRequest.Value); } [Fact] public async Task Job_candidates_rejects_invalid_job_id() { await using var db = CreateDb(); var gmail = new Mock(MockBehavior.Strict); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.JobCandidates(0, null, 6, CancellationToken.None); var badRequest = Assert.IsType(result.Result); Assert.Equal("Valid jobApplicationId is required.", badRequest.Value); gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Job_candidates_returns_ranked_threads_with_match_reasons_query_hits_and_import_flags() { await using var db = CreateDb(); var company = new Company { Name = "Acme", RecruiterName = "Maria Recruiter", RecruiterEmail = "maria@acme.test", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); db.Correspondences.Add(new Correspondence { JobApplicationId = job.Id, From = "Company", Subject = "Backend Developer interview", ExternalMessageId = "msg-imported", ExternalThreadId = "thread-top", Content = "Earlier imported thread" }); await db.SaveChangesAsync(); var overrideQuery = "label:important"; var gmail = new Mock(); gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny>(), 6, It.IsAny())) .ReturnsAsync(new[] { new GmailQueryMatchedMessage( new GmailMessageSummary( "msg-top", "thread-top", "Backend Developer interview", "Maria Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Acme wants to schedule a backend developer interview."), new[] { overrideQuery, "\"Acme\" \"Backend Developer\" newer_than:365d" }), new GmailQueryMatchedMessage( new GmailMessageSummary( "msg-imported", "thread-top", "Backend Developer interview", "Maria Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-4), "Already imported message."), new[] { "(from:maria@acme.test OR to:maria@acme.test) newer_than:365d" }), new GmailQueryMatchedMessage( new GmailMessageSummary( "msg-low", "thread-low", "Backend update", "alerts@example.test", "user@example.test", DateTimeOffset.UtcNow.AddDays(-90), "A generic backend role update without recruiter context."), new[] { "subject:\"Backend Developer\" newer_than:365d" }) }); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.JobCandidates(job.Id, overrideQuery, 6, CancellationToken.None); var ok = Assert.IsType(result.Result); var payload = Assert.IsType(ok.Value); Assert.Equal(job.Id, payload.JobApplicationId); Assert.Contains(overrideQuery, payload.Queries); Assert.Equal(3, payload.CandidateMessageCount); Assert.Equal(2, payload.CandidateThreadCount); Assert.Equal(2, payload.Threads.Count); var topThread = payload.Threads[0]; Assert.Equal("thread-top", topThread.ThreadId); Assert.Equal("high", topThread.Confidence); Assert.True(topThread.HasImportedMessages); Assert.Equal(1, topThread.ImportedMessageCount); Assert.Equal(2, topThread.MessageCount); Assert.Contains(overrideQuery, topThread.MatchedQueries); Assert.Contains(topThread.MatchReasons, reason => reason.Label == "company" && reason.Value == "Acme" && reason.Points == 18); Assert.Contains(topThread.MatchReasons, reason => reason.Label == "recruiterEmail" && reason.Value == "maria@acme.test" && reason.Points == 20); Assert.Contains(topThread.MatchReasons, reason => reason.Label == "queryHits" && reason.Value == "2" && reason.Points == 8); var importedMessage = Assert.Single(topThread.Messages, message => message.Id == "msg-imported"); Assert.True(importedMessage.AlreadyImported); Assert.Contains(importedMessage.MatchReasons, reason => reason.Label == "status" && reason.Value == "already-imported" && reason.Points == 0); var freshMessage = Assert.Single(topThread.Messages, message => message.Id == "msg-top"); Assert.False(freshMessage.AlreadyImported); Assert.Contains(overrideQuery, freshMessage.MatchedQueries); Assert.Contains(freshMessage.MatchReasons, reason => reason.Label == "status" && reason.Value == "thread-already-imported" && reason.Points == 0); var lowThread = payload.Threads[1]; Assert.Equal("thread-low", lowThread.ThreadId); Assert.Equal("low", lowThread.Confidence); } [Fact] public async Task Job_candidates_returns_empty_threads_when_gmail_has_no_matches() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var gmail = new Mock(); gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny>(), 6, It.IsAny())) .ReturnsAsync(Array.Empty()); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None); var ok = Assert.IsType(result.Result); var payload = Assert.IsType(ok.Value); Assert.NotEmpty(payload.Queries); Assert.Equal(0, payload.CandidateMessageCount); Assert.Equal(0, payload.CandidateThreadCount); Assert.Empty(payload.Threads); } [Fact] public async Task Import_returns_message_metadata_and_skips_repeat_message_imports() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var gmail = new Mock(); gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow }); gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny())) .ReturnsAsync(new GmailMessageDetail( "msg-1", "thread-1", "Interview update", "Maria Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Snippet", "Body text", null, new[] { "INBOX", "IMPORTANT" }, new[] { new GmailMessageAttachment("cv.pdf", "application/pdf", 2048, "att-1", false) })); var controller = CreateController(db, gmail.Object, "user-1"); var first = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); var firstOk = Assert.IsType(first.Result); var firstPayload = Assert.IsType(firstOk.Value); Assert.Equal(1, firstPayload.Imported); Assert.Equal(0, firstPayload.Skipped); Assert.Equal("thread-1", firstPayload.ThreadId); Assert.NotNull(firstPayload.Message); Assert.Equal("thread-1", firstPayload.Message!.ExternalThreadId); Assert.Equal("Maria Recruiter ", firstPayload.Message.ExternalFrom); Assert.Equal("user@example.test", firstPayload.Message.ExternalTo); Assert.Equal("inbound", firstPayload.Message.Direction); Assert.Contains("IMPORTANT", firstPayload.Message.ExternalLabels); Assert.Single(firstPayload.Message.AttachmentMetadata); Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName); var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); var secondOk = Assert.IsType(second.Result); var secondPayload = Assert.IsType(secondOk.Value); Assert.Equal(0, secondPayload.Imported); Assert.Equal(1, secondPayload.Skipped); Assert.Equal("thread-1", secondPayload.ThreadId); var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync(); Assert.Single(storedMessages); gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny()), Times.Once); } [Fact] public async Task Import_thread_reports_imported_and_skipped_counts_for_repeat_calls() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var gmail = new Mock(); gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow }); gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny())) .ReturnsAsync(new GmailMessageDetail( "msg-1", "thread-1", "Interview update", "Maria Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Snippet 1", "Body text 1", null, Array.Empty(), Array.Empty())); gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny())) .ReturnsAsync(new GmailMessageDetail( "msg-2", "thread-1", "Interview follow-up", "user@example.test", "Maria Recruiter ", DateTimeOffset.UtcNow, "Snippet 2", "Body text 2", null, Array.Empty(), Array.Empty())); var controller = CreateController(db, gmail.Object, "user-1"); var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" }); var first = await controller.ImportThread(request, CancellationToken.None); var firstOk = Assert.IsType(first.Result); var firstPayload = Assert.IsType(firstOk.Value); Assert.Equal(2, firstPayload.Imported); Assert.Equal(0, firstPayload.Skipped); Assert.Equal("thread-1", firstPayload.ThreadId); var second = await controller.ImportThread(request, CancellationToken.None); var secondOk = Assert.IsType(second.Result); var secondPayload = Assert.IsType(secondOk.Value); Assert.Equal(0, secondPayload.Imported); Assert.Equal(2, secondPayload.Skipped); var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).OrderBy(message => message.ExternalMessageId).ToListAsync(); Assert.Equal(2, storedMessages.Count); Assert.All(storedMessages, message => Assert.Equal("thread-1", message.ExternalThreadId)); } [Fact] public async Task Refresh_linked_threads_imports_new_messages_for_known_thread_ids() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); db.Correspondences.Add(new Correspondence { JobApplicationId = job.Id, From = "Company", Subject = "Initial recruiter note", ExternalMessageId = "msg-1", ExternalThreadId = "thread-1", ExternalFrom = "Maria Recruiter ", ExternalTo = "user@example.test", Content = "Existing import" }); await db.SaveChangesAsync(); var gmail = new Mock(); gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow }); gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny())) .ReturnsAsync(new[] { new GmailMessageSummary("msg-1", "thread-1", "Initial recruiter note", "Maria Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Old message"), new GmailMessageSummary("msg-2", "thread-1", "Follow-up reply", "user@example.test", "Maria Recruiter ", DateTimeOffset.UtcNow, "New reply") }); gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny())) .ReturnsAsync(new GmailMessageDetail( "msg-2", "thread-1", "Follow-up reply", "user@example.test", "Maria Recruiter ", DateTimeOffset.UtcNow, "New reply", "Reply body", null, Array.Empty(), Array.Empty())); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None); var ok = Assert.IsType(result.Result); var payload = Assert.IsType(ok.Value); Assert.Equal(job.Id, payload.JobApplicationId); Assert.Equal(1, payload.ThreadsChecked); Assert.Equal(1, payload.Imported); Assert.Equal(1, payload.Skipped); Assert.True(payload.HasLinkedThreads); var thread = Assert.Single(payload.Threads); Assert.Equal("thread-1", thread.ThreadId); Assert.Equal("imported-new-messages", thread.Status); Assert.Equal(2, thread.TotalMessages); var stored = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).OrderBy(message => message.ExternalMessageId).ToListAsync(); Assert.Equal(2, stored.Count); Assert.Equal("msg-2", stored[1].ExternalMessageId); Assert.Equal("thread-1", stored[1].ExternalThreadId); } [Fact] public async Task Refresh_linked_threads_reports_empty_and_disconnected_states() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var linkedJob = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; var emptyJob = new JobApplication { JobTitle = "Backend Developer II", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.AddRange(linkedJob, emptyJob); await db.SaveChangesAsync(); db.Correspondences.Add(new Correspondence { JobApplicationId = linkedJob.Id, From = "Company", Subject = "Initial recruiter note", ExternalMessageId = "msg-1", ExternalThreadId = "thread-1", Content = "Existing import" }); await db.SaveChangesAsync(); var disconnectedGmail = new Mock(MockBehavior.Strict); disconnectedGmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())).ReturnsAsync((GmailConnection?)null); var disconnectedController = CreateController(db, disconnectedGmail.Object, "user-1"); var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None); var conflict = Assert.IsType(disconnectedResult.Result); Assert.Equal("Connect Gmail before refreshing linked threads.", conflict.Value); var gmail = new Mock(); gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow }); var controller = CreateController(db, gmail.Object, "user-1"); var emptyResult = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None); var ok = Assert.IsType(emptyResult.Result); var payload = Assert.IsType(ok.Value); Assert.Equal(0, payload.ThreadsChecked); Assert.Equal(0, payload.Imported); Assert.Equal(0, payload.Skipped); Assert.False(payload.HasLinkedThreads); Assert.Empty(payload.Threads); gmail.Verify(service => service.ListThreadMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Review_candidates_returns_threads_grouped_with_routing_summary() { await using var db = CreateDb(); var company = new Company { Name = "Acme", RecruiterEmail = "maria@acme.test", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var gmail = new Mock(); gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny>(), 6, It.IsAny())) .ReturnsAsync(new[] { new GmailQueryMatchedMessage( new GmailMessageSummary( "msg-top", "thread-top", "Backend Developer interview", "Maria Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Acme wants to schedule a backend developer interview."), new[] { "\"Acme\" \"Backend Developer\" newer_than:365d" }) }); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.ReviewCandidates(null, 6, CancellationToken.None); var ok = Assert.IsType(result.Result); var payload = Assert.IsType(ok.Value); Assert.Equal(1, payload.CandidateThreadCount); Assert.Single(payload.Threads); Assert.Equal("thread-top", payload.Threads[0].ThreadId); Assert.True(payload.Threads[0].JobCandidates.Count > 0); Assert.Contains(payload.Threads[0].Routing, new[] { "auto-link", "review", "unmatched" }); } [Fact] public async Task Refresh_linked_threads_rejects_invalid_job_id() { await using var db = CreateDb(); var gmail = new Mock(MockBehavior.Strict); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(0), CancellationToken.None); var badRequest = Assert.IsType(result.Result); Assert.Equal("Valid jobApplicationId is required.", badRequest.Value); } [Fact] public async Task Refresh_linked_threads_respects_owned_job_scope() { await using var db = CreateDb(); var company = new Company { Name = "OtherCo", OwnerUserId = "user-2" }; db.Companies.Add(company); await db.SaveChangesAsync(); var foreignJob = new JobApplication { JobTitle = "Hidden job", CompanyId = company.Id, OwnerUserId = "user-2" }; db.JobApplications.Add(foreignJob); await db.SaveChangesAsync(); var gmail = new Mock(MockBehavior.Strict); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None); var notFound = Assert.IsType(result.Result); Assert.Equal("Job application not found.", notFound.Value); gmail.Verify(service => service.GetConnectionAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Job_candidates_respects_owned_job_scope() { await using var db = CreateDb(); var company = new Company { Name = "OtherCo", OwnerUserId = "user-2" }; db.Companies.Add(company); await db.SaveChangesAsync(); var foreignJob = new JobApplication { JobTitle = "Hidden job", CompanyId = company.Id, OwnerUserId = "user-2" }; db.JobApplications.Add(foreignJob); await db.SaveChangesAsync(); var gmail = new Mock(MockBehavior.Strict); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.JobCandidates(foreignJob.Id, null, 6, CancellationToken.None); var notFound = Assert.IsType(result.Result); Assert.Equal("Job application not found.", notFound.Value); gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); } private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId) { var controller = new GmailController(gmail, new GmailJobMatchingService(), db, BuildConfig()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")) } } }; return controller; } private static JobTrackerContext CreateDb() { return TestHostFactory.CreateInMemoryDb(); } private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig() { return new Microsoft.Extensions.Configuration.ConfigurationBuilder() .AddInMemoryCollection(new Dictionary()) .Build(); } }