Complete S01 Gmail matching and import workflow
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -12,19 +17,8 @@ public sealed class GmailControllerTests
|
||||
[Fact]
|
||||
public async Task Import_thread_rejects_missing_message_ids()
|
||||
{
|
||||
var controller = new GmailController(Mock.Of<IGmailOAuthService>(), null!, BuildConfig())
|
||||
{
|
||||
ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext
|
||||
{
|
||||
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||
{
|
||||
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[]
|
||||
{
|
||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "user-1")
|
||||
}, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
||||
|
||||
var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
|
||||
|
||||
@@ -32,6 +26,271 @@ public sealed class GmailControllerTests
|
||||
Assert.Equal("At least one messageId is required.", badRequest.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Job_candidates_returns_ranked_threads_with_match_reasons_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",
|
||||
Content = "Earlier imported thread"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListMessagesForQueriesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary(
|
||||
"msg-top",
|
||||
"thread-top",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-2),
|
||||
"Acme wants to schedule a backend developer interview."),
|
||||
new GmailMessageSummary(
|
||||
"msg-imported",
|
||||
"thread-top",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-4),
|
||||
"Already imported message."),
|
||||
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.")
|
||||
});
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value);
|
||||
|
||||
Assert.Equal(job.Id, payload.JobApplicationId);
|
||||
Assert.NotEmpty(payload.Queries);
|
||||
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(2, topThread.MessageCount);
|
||||
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "company" && reason.Value == "Acme");
|
||||
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "recruiterEmail" && reason.Value == "maria@acme.test");
|
||||
Assert.Contains(topThread.Messages, message => message.Id == "msg-imported" && message.AlreadyImported);
|
||||
Assert.Contains(topThread.Messages, message => message.Id == "msg-top" && !message.AlreadyImported);
|
||||
|
||||
var lowThread = payload.Threads[1];
|
||||
Assert.Equal("thread-low", lowThread.ThreadId);
|
||||
Assert.Equal("low", lowThread.Confidence);
|
||||
}
|
||||
|
||||
[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<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.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<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-1",
|
||||
"thread-1",
|
||||
"Interview update",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Snippet",
|
||||
"Body text",
|
||||
null));
|
||||
|
||||
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<OkObjectResult>(first.Result);
|
||||
var firstPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(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 <maria@acme.test>", firstPayload.Message.ExternalFrom);
|
||||
Assert.Equal("user@example.test", firstPayload.Message.ExternalTo);
|
||||
|
||||
var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||
var secondPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(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<CancellationToken>()), 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<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.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<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-1",
|
||||
"thread-1",
|
||||
"Interview update",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Snippet 1",
|
||||
"Body text 1",
|
||||
null));
|
||||
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-2",
|
||||
"thread-1",
|
||||
"Interview follow-up",
|
||||
"user@example.test",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
DateTimeOffset.UtcNow,
|
||||
"Snippet 2",
|
||||
"Body text 2",
|
||||
null));
|
||||
|
||||
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<OkObjectResult>(first.Result);
|
||||
var firstPayload = Assert.IsType<GmailController.GmailImportResultDto>(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<OkObjectResult>(second.Result);
|
||||
var secondPayload = Assert.IsType<GmailController.GmailImportResultDto>(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 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<IGmailOAuthService>(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<NotFoundObjectResult>(result.Result);
|
||||
Assert.Equal("Job application not found.", notFound.Value);
|
||||
gmail.Verify(service => service.ListMessagesForQueriesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
|
||||
{
|
||||
var controller = new GmailController(gmail, 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()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
}
|
||||
|
||||
private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig()
|
||||
{
|
||||
return new Microsoft.Extensions.Configuration.ConfigurationBuilder()
|
||||
|
||||
Reference in New Issue
Block a user