Complete Gmail correspondence workflow
This commit is contained in:
@@ -590,6 +590,316 @@ public sealed class GmailControllerTests
|
||||
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_review_decision_links_thread_and_imports_messages()
|
||||
{
|
||||
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.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Interview invite")
|
||||
});
|
||||
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",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Interview invite",
|
||||
"Body text",
|
||||
null,
|
||||
new[] { "INBOX" },
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.SaveReviewDecision(new GmailController.SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
Assert.Equal("linked", decision.Decision);
|
||||
Assert.Equal(job.Id, decision.JobApplicationId);
|
||||
Assert.Equal("Strong recruiter match", decision.Note);
|
||||
var imported = await db.Correspondences.SingleAsync();
|
||||
Assert.Equal("thread-1", imported.ExternalThreadId);
|
||||
Assert.Equal("msg-1", imported.ExternalMessageId);
|
||||
Assert.NotNull(ok.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Manual_sync_auto_links_high_confidence_thread()
|
||||
{
|
||||
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<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListJobCandidateMessagesAsync(
|
||||
"user-1",
|
||||
It.Is<IEnumerable<string>>(queries => queries.Any(query => query.Contains("-in:spam")) && queries.Any(query => query.Contains("-in:trash")) && queries.All(query => query.Contains("newer_than:365d"))),
|
||||
8,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailQueryMatchedMessage(
|
||||
new GmailMessageSummary(
|
||||
"msg-1",
|
||||
"thread-1",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-2),
|
||||
"Acme wants to schedule a backend developer interview."),
|
||||
new[]
|
||||
{
|
||||
"\"Acme\" \"Backend Developer\" newer_than:365d -in:spam -in:trash",
|
||||
"(from:maria@acme.test OR to:maria@acme.test) newer_than:365d -in:spam -in:trash"
|
||||
})
|
||||
});
|
||||
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Invite")
|
||||
});
|
||||
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",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-2),
|
||||
"Invite",
|
||||
"Interview details",
|
||||
null,
|
||||
new[] { "INBOX" },
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailManualSyncResultDto>(ok.Value);
|
||||
Assert.Equal(1, payload.AutoLinkedThreadCount);
|
||||
Assert.Equal(1, payload.ImportedThreads);
|
||||
Assert.Equal(1, payload.ImportedMessages);
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
Assert.Equal("linked", decision.Decision);
|
||||
Assert.Equal(job.Id, decision.JobApplicationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suggested_jobs_and_create_suggested_job_create_job_and_link_thread()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<GmailQueryMatchedMessage>());
|
||||
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-suggested", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary("msg-s1", "thread-suggested", "Platform Engineer interview", "Nina Recruiter <nina@beta.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Let's talk about the role")
|
||||
});
|
||||
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-s1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-s1",
|
||||
"thread-suggested",
|
||||
"Platform Engineer interview",
|
||||
"Nina Recruiter <nina@beta.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Let's talk about the role",
|
||||
"Interview details",
|
||||
null,
|
||||
new[] { "INBOX" },
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
db.GmailReviewDecisions.Add(new GmailReviewDecision
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
ThreadId = "thread-suggested",
|
||||
Decision = "suggested",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
|
||||
var reviewQueue = new GmailController.GmailReviewQueueResponseDto(
|
||||
Array.Empty<string>(),
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
new[]
|
||||
{
|
||||
new GmailController.GmailReviewThreadDto(
|
||||
"thread-suggested",
|
||||
"Platform Engineer interview",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
1,
|
||||
"suggested",
|
||||
false,
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailController.GmailReviewJobCandidateDto>(),
|
||||
new[]
|
||||
{
|
||||
new GmailController.GmailJobMatchedMessageDto(
|
||||
"msg-s1",
|
||||
"thread-suggested",
|
||||
"Platform Engineer interview",
|
||||
"Nina Recruiter <nina@beta.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Let's talk about the role",
|
||||
0,
|
||||
"low",
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailController.GmailJobMatchReasonDto>())
|
||||
})
|
||||
});
|
||||
|
||||
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
|
||||
Assert.IsType<GmailController.GmailSuggestedJobsResponseDto>(suggested.Value);
|
||||
|
||||
var create = await controller.CreateSuggestedJob(new GmailController.CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None);
|
||||
var createOk = Assert.IsType<OkObjectResult>(create.Result);
|
||||
var created = Assert.IsType<GmailController.CreatedSuggestedGmailJobDto>(createOk.Value);
|
||||
Assert.True(created.JobApplicationId > 0);
|
||||
Assert.Equal(1, created.Imported);
|
||||
Assert.Equal("thread-suggested", created.ThreadId);
|
||||
Assert.Equal(1, await db.JobApplications.CountAsync());
|
||||
Assert.Equal(1, await db.Correspondences.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlink_thread_removes_messages_and_sets_review_decision()
|
||||
{
|
||||
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.AddRange(
|
||||
new Correspondence { JobApplicationId = job.Id, From = "Company", Content = "First", ExternalMessageId = "msg-1", ExternalThreadId = "thread-1" },
|
||||
new Correspondence { JobApplicationId = job.Id, From = "Me", Content = "Second", ExternalMessageId = "msg-2", ExternalThreadId = "thread-1" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
||||
var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailUnlinkResultDto>(ok.Value);
|
||||
Assert.Equal(2, payload.RemovedMessages);
|
||||
Assert.Equal("review", payload.Decision);
|
||||
Assert.Empty(await db.Correspondences.ToListAsync());
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
Assert.Equal("review", decision.Decision);
|
||||
Assert.Equal("Need manual review", decision.Note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relink_thread_can_move_messages_from_other_jobs()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var sourceJob = new JobApplication { JobTitle = "Source", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||
var targetJob = new JobApplication { JobTitle = "Target", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||
db.JobApplications.AddRange(sourceJob, targetJob);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = sourceJob.Id,
|
||||
From = "Company",
|
||||
Content = "Existing import",
|
||||
ExternalMessageId = "msg-1",
|
||||
ExternalThreadId = "thread-1"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary("msg-1", "thread-1", "Interview", "Maria <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow, "Snippet")
|
||||
});
|
||||
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",
|
||||
"Maria <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow,
|
||||
"Snippet",
|
||||
"Body",
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.RelinkThread(new GmailController.RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailRelinkResultDto>(ok.Value);
|
||||
Assert.Equal(1, payload.UnlinkedMessages);
|
||||
Assert.Equal(1, payload.Imported);
|
||||
var stored = await db.Correspondences.SingleAsync();
|
||||
Assert.Equal(targetJob.Id, stored.JobApplicationId);
|
||||
Assert.Equal("thread-1", stored.ExternalThreadId);
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
Assert.Equal(targetJob.Id, decision.JobApplicationId);
|
||||
Assert.Equal("linked", decision.Decision);
|
||||
}
|
||||
|
||||
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
|
||||
{
|
||||
var controller = new GmailController(gmail, new GmailJobMatchingService(), db, BuildConfig())
|
||||
|
||||
Reference in New Issue
Block a user