chore(M001/S01): auto-commit after complete-slice

This commit is contained in:
2026-03-24 12:27:04 +01:00
parent 9f03d123d0
commit 13d4e29336
22 changed files with 970 additions and 118 deletions
+158
View File
@@ -315,6 +315,164 @@ public sealed class GmailControllerTests
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 <maria@acme.test>",
ExternalTo = "user@example.test",
Content = "Existing import"
});
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.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-1", "thread-1", "Initial recruiter note", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Old message"),
new GmailMessageSummary("msg-2", "thread-1", "Follow-up reply", "user@example.test", "Maria Recruiter <maria@acme.test>", DateTimeOffset.UtcNow, "New reply")
});
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-2",
"thread-1",
"Follow-up reply",
"user@example.test",
"Maria Recruiter <maria@acme.test>",
DateTimeOffset.UtcNow,
"New reply",
"Reply body",
null));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailThreadRefreshResultDto>(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<IGmailOAuthService>(MockBehavior.Strict);
disconnectedGmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>())).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<ConflictObjectResult>(disconnectedResult.Result);
Assert.Equal("Connect Gmail before refreshing linked threads.", conflict.Value);
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 });
var controller = CreateController(db, gmail.Object, "user-1");
var emptyResult = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(emptyResult.Result);
var payload = Assert.IsType<GmailController.GmailThreadRefreshResultDto>(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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Refresh_linked_threads_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.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(0), CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(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<IGmailOAuthService>(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<NotFoundObjectResult>(result.Result);
Assert.Equal("Job application not found.", notFound.Value);
gmail.Verify(service => service.GetConnectionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Job_candidates_respects_owned_job_scope()
{