test(S01/T01): Added a job-scoped Gmail matching contract with backend-…
- JobTrackerApi/Controllers/GmailController.cs - JobTrackerApi/Services/GmailOAuthService.cs - JobTrackerApi.Tests/GmailControllerTests.cs - .gsd/milestones/M001/slices/S01/S01-PLAN.md - .gsd/KNOWLEDGE.md
This commit is contained in:
@@ -27,7 +27,21 @@ public sealed class GmailControllerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Job_candidates_returns_ranked_threads_with_match_reasons_and_import_flags()
|
||||
public async Task Job_candidates_rejects_invalid_job_id()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
|
||||
var result = await controller.JobCandidates(0, null, 6, CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
Assert.Equal("Valid jobApplicationId is required.", badRequest.Value);
|
||||
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), 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
|
||||
@@ -55,65 +69,131 @@ public sealed class GmailControllerTests
|
||||
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<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListMessagesForQueriesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||
gmail.Setup(service => service.ListJobCandidateMessagesAsync("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.")
|
||||
new GmailQueryMatchedMessage(
|
||||
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[]
|
||||
{
|
||||
overrideQuery,
|
||||
"\"Acme\" \"Backend Developer\" newer_than:365d"
|
||||
}),
|
||||
new GmailQueryMatchedMessage(
|
||||
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[]
|
||||
{
|
||||
"(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, null, 6, CancellationToken.None);
|
||||
var result = await controller.JobCandidates(job.Id, overrideQuery, 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.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(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);
|
||||
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<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<GmailQueryMatchedMessage>());
|
||||
|
||||
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.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()
|
||||
{
|
||||
@@ -235,7 +315,6 @@ public sealed class GmailControllerTests
|
||||
Assert.All(storedMessages, message => Assert.Equal("thread-1", message.ExternalThreadId));
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Job_candidates_respects_owned_job_scope()
|
||||
{
|
||||
@@ -259,7 +338,7 @@ public sealed class GmailControllerTests
|
||||
|
||||
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);
|
||||
gmail.Verify(service => service.ListJobCandidateMessagesAsync(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)
|
||||
|
||||
Reference in New Issue
Block a user