refactor(api): extract Gmail DTOs/parsers, batch N+1 loops
- Move inline DTOs to GmailDtos.cs, pure parse helpers to GmailParsing.cs - Batch per-message existence checks in CreateSuggestedJob/RefreshLinkedThreads - Remove redundant second pass in RelinkThread, reuse existing HashSet - Replace ToListAsync+scan with FirstOrDefaultAsync for GmailReviewDecisions lookups Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ public sealed class GmailControllerTests
|
|||||||
var result = await controller.Status(CancellationToken.None);
|
var result = await controller.Status(CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailConnectionStatusDto>(ok.Value);
|
var payload = Assert.IsType<GmailConnectionStatusDto>(ok.Value);
|
||||||
Assert.True(payload.Connected);
|
Assert.True(payload.Connected);
|
||||||
Assert.Equal("user@example.test", payload.GmailAddress);
|
Assert.Equal("user@example.test", payload.GmailAddress);
|
||||||
Assert.Equal("list-messages", payload.LastSyncMode);
|
Assert.Equal("list-messages", payload.LastSyncMode);
|
||||||
@@ -54,7 +54,7 @@ public sealed class GmailControllerTests
|
|||||||
await using var db = CreateDb();
|
await using var db = CreateDb();
|
||||||
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
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);
|
var result = await controller.ImportThread(new ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
|
||||||
|
|
||||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
|
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||||
Assert.Equal("At least one messageId is required.", badRequest.Value);
|
Assert.Equal("At least one messageId is required.", badRequest.Value);
|
||||||
@@ -159,7 +159,7 @@ public sealed class GmailControllerTests
|
|||||||
var result = await controller.JobCandidates(job.Id, overrideQuery, 6, CancellationToken.None);
|
var result = await controller.JobCandidates(job.Id, overrideQuery, 6, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value);
|
var payload = Assert.IsType<GmailJobMatchesResponseDto>(ok.Value);
|
||||||
|
|
||||||
Assert.Equal(job.Id, payload.JobApplicationId);
|
Assert.Equal(job.Id, payload.JobApplicationId);
|
||||||
Assert.Contains(overrideQuery, payload.Queries);
|
Assert.Contains(overrideQuery, payload.Queries);
|
||||||
@@ -221,7 +221,7 @@ public sealed class GmailControllerTests
|
|||||||
var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None);
|
var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value);
|
var payload = Assert.IsType<GmailJobMatchesResponseDto>(ok.Value);
|
||||||
Assert.NotEmpty(payload.Queries);
|
Assert.NotEmpty(payload.Queries);
|
||||||
Assert.Equal(0, payload.CandidateMessageCount);
|
Assert.Equal(0, payload.CandidateMessageCount);
|
||||||
Assert.Equal(0, payload.CandidateThreadCount);
|
Assert.Equal(0, payload.CandidateThreadCount);
|
||||||
@@ -264,9 +264,9 @@ public sealed class GmailControllerTests
|
|||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
|
|
||||||
var first = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
var first = await controller.Import(new ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||||
var firstOk = Assert.IsType<OkObjectResult>(first.Result);
|
var firstOk = Assert.IsType<OkObjectResult>(first.Result);
|
||||||
var firstPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(firstOk.Value);
|
var firstPayload = Assert.IsType<GmailImportMessageResultDto>(firstOk.Value);
|
||||||
Assert.Equal(1, firstPayload.Imported);
|
Assert.Equal(1, firstPayload.Imported);
|
||||||
Assert.Equal(0, firstPayload.Skipped);
|
Assert.Equal(0, firstPayload.Skipped);
|
||||||
Assert.Equal("thread-1", firstPayload.ThreadId);
|
Assert.Equal("thread-1", firstPayload.ThreadId);
|
||||||
@@ -279,9 +279,9 @@ public sealed class GmailControllerTests
|
|||||||
Assert.Single(firstPayload.Message.AttachmentMetadata);
|
Assert.Single(firstPayload.Message.AttachmentMetadata);
|
||||||
Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName);
|
Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName);
|
||||||
|
|
||||||
var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
var second = await controller.Import(new ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||||
var secondPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(secondOk.Value);
|
var secondPayload = Assert.IsType<GmailImportMessageResultDto>(secondOk.Value);
|
||||||
Assert.Equal(0, secondPayload.Imported);
|
Assert.Equal(0, secondPayload.Imported);
|
||||||
Assert.Equal(1, secondPayload.Skipped);
|
Assert.Equal(1, secondPayload.Skipped);
|
||||||
Assert.Equal("thread-1", secondPayload.ThreadId);
|
Assert.Equal("thread-1", secondPayload.ThreadId);
|
||||||
@@ -340,18 +340,18 @@ public sealed class GmailControllerTests
|
|||||||
Array.Empty<GmailMessageAttachment>()));
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
|
var request = new ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
|
||||||
|
|
||||||
var first = await controller.ImportThread(request, CancellationToken.None);
|
var first = await controller.ImportThread(request, CancellationToken.None);
|
||||||
var firstOk = Assert.IsType<OkObjectResult>(first.Result);
|
var firstOk = Assert.IsType<OkObjectResult>(first.Result);
|
||||||
var firstPayload = Assert.IsType<GmailController.GmailImportResultDto>(firstOk.Value);
|
var firstPayload = Assert.IsType<GmailImportResultDto>(firstOk.Value);
|
||||||
Assert.Equal(2, firstPayload.Imported);
|
Assert.Equal(2, firstPayload.Imported);
|
||||||
Assert.Equal(0, firstPayload.Skipped);
|
Assert.Equal(0, firstPayload.Skipped);
|
||||||
Assert.Equal("thread-1", firstPayload.ThreadId);
|
Assert.Equal("thread-1", firstPayload.ThreadId);
|
||||||
|
|
||||||
var second = await controller.ImportThread(request, CancellationToken.None);
|
var second = await controller.ImportThread(request, CancellationToken.None);
|
||||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||||
var secondPayload = Assert.IsType<GmailController.GmailImportResultDto>(secondOk.Value);
|
var secondPayload = Assert.IsType<GmailImportResultDto>(secondOk.Value);
|
||||||
Assert.Equal(0, secondPayload.Imported);
|
Assert.Equal(0, secondPayload.Imported);
|
||||||
Assert.Equal(2, secondPayload.Skipped);
|
Assert.Equal(2, secondPayload.Skipped);
|
||||||
|
|
||||||
@@ -414,10 +414,10 @@ public sealed class GmailControllerTests
|
|||||||
Array.Empty<GmailMessageAttachment>()));
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
|
var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailThreadRefreshResultDto>(ok.Value);
|
var payload = Assert.IsType<GmailThreadRefreshResultDto>(ok.Value);
|
||||||
Assert.Equal(job.Id, payload.JobApplicationId);
|
Assert.Equal(job.Id, payload.JobApplicationId);
|
||||||
Assert.Equal(1, payload.ThreadsChecked);
|
Assert.Equal(1, payload.ThreadsChecked);
|
||||||
Assert.Equal(1, payload.Imported);
|
Assert.Equal(1, payload.Imported);
|
||||||
@@ -461,7 +461,7 @@ public sealed class GmailControllerTests
|
|||||||
var disconnectedGmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
var disconnectedGmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||||
disconnectedGmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>())).ReturnsAsync((GmailConnection?)null);
|
disconnectedGmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>())).ReturnsAsync((GmailConnection?)null);
|
||||||
var disconnectedController = CreateController(db, disconnectedGmail.Object, "user-1");
|
var disconnectedController = CreateController(db, disconnectedGmail.Object, "user-1");
|
||||||
var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None);
|
var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None);
|
||||||
var conflict = Assert.IsType<ConflictObjectResult>(disconnectedResult.Result);
|
var conflict = Assert.IsType<ConflictObjectResult>(disconnectedResult.Result);
|
||||||
Assert.Equal("Connect Gmail before refreshing linked threads.", conflict.Value);
|
Assert.Equal("Connect Gmail before refreshing linked threads.", conflict.Value);
|
||||||
|
|
||||||
@@ -469,10 +469,10 @@ public sealed class GmailControllerTests
|
|||||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
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 });
|
.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 controller = CreateController(db, gmail.Object, "user-1");
|
||||||
var emptyResult = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None);
|
var emptyResult = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(emptyResult.Result);
|
var ok = Assert.IsType<OkObjectResult>(emptyResult.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailThreadRefreshResultDto>(ok.Value);
|
var payload = Assert.IsType<GmailThreadRefreshResultDto>(ok.Value);
|
||||||
Assert.Equal(0, payload.ThreadsChecked);
|
Assert.Equal(0, payload.ThreadsChecked);
|
||||||
Assert.Equal(0, payload.Imported);
|
Assert.Equal(0, payload.Imported);
|
||||||
Assert.Equal(0, payload.Skipped);
|
Assert.Equal(0, payload.Skipped);
|
||||||
@@ -526,7 +526,7 @@ public sealed class GmailControllerTests
|
|||||||
var result = await controller.ReviewCandidates(null, 6, CancellationToken.None);
|
var result = await controller.ReviewCandidates(null, 6, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailReviewQueueResponseDto>(ok.Value);
|
var payload = Assert.IsType<GmailReviewQueueResponseDto>(ok.Value);
|
||||||
Assert.Equal(1, payload.CandidateThreadCount);
|
Assert.Equal(1, payload.CandidateThreadCount);
|
||||||
Assert.Single(payload.Threads);
|
Assert.Single(payload.Threads);
|
||||||
Assert.Equal("thread-top", payload.Threads[0].ThreadId);
|
Assert.Equal("thread-top", payload.Threads[0].ThreadId);
|
||||||
@@ -541,7 +541,7 @@ public sealed class GmailControllerTests
|
|||||||
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
|
|
||||||
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(0), CancellationToken.None);
|
var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(0), CancellationToken.None);
|
||||||
|
|
||||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
|
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||||
Assert.Equal("Valid jobApplicationId is required.", badRequest.Value);
|
Assert.Equal("Valid jobApplicationId is required.", badRequest.Value);
|
||||||
@@ -566,7 +566,7 @@ public sealed class GmailControllerTests
|
|||||||
|
|
||||||
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None);
|
var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None);
|
||||||
|
|
||||||
var notFound = Assert.IsType<NotFoundObjectResult>(result.Result);
|
var notFound = Assert.IsType<NotFoundObjectResult>(result.Result);
|
||||||
Assert.Equal("Job application not found.", notFound.Value);
|
Assert.Equal("Job application not found.", notFound.Value);
|
||||||
@@ -639,7 +639,7 @@ public sealed class GmailControllerTests
|
|||||||
Array.Empty<GmailMessageAttachment>()));
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
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 result = await controller.SaveReviewDecision(new SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result);
|
var ok = Assert.IsType<OkObjectResult>(result);
|
||||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||||
@@ -719,10 +719,10 @@ public sealed class GmailControllerTests
|
|||||||
Array.Empty<GmailMessageAttachment>()));
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
|
var result = await controller.ManualSync(new GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailManualSyncResultDto>(ok.Value);
|
var payload = Assert.IsType<GmailManualSyncResultDto>(ok.Value);
|
||||||
Assert.Equal(1, payload.AutoLinkedThreadCount);
|
Assert.Equal(1, payload.AutoLinkedThreadCount);
|
||||||
Assert.Equal(1, payload.ImportedThreads);
|
Assert.Equal(1, payload.ImportedThreads);
|
||||||
Assert.Equal(1, payload.ImportedMessages);
|
Assert.Equal(1, payload.ImportedMessages);
|
||||||
@@ -770,7 +770,7 @@ public sealed class GmailControllerTests
|
|||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
|
|
||||||
var reviewQueue = new GmailController.GmailReviewQueueResponseDto(
|
var reviewQueue = new GmailReviewQueueResponseDto(
|
||||||
Array.Empty<string>(),
|
Array.Empty<string>(),
|
||||||
1,
|
1,
|
||||||
0,
|
0,
|
||||||
@@ -778,7 +778,7 @@ public sealed class GmailControllerTests
|
|||||||
1,
|
1,
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new GmailController.GmailReviewThreadDto(
|
new GmailReviewThreadDto(
|
||||||
"thread-suggested",
|
"thread-suggested",
|
||||||
"Platform Engineer interview",
|
"Platform Engineer interview",
|
||||||
DateTimeOffset.UtcNow.AddDays(-1),
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
@@ -787,10 +787,10 @@ public sealed class GmailControllerTests
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
Array.Empty<string>(),
|
Array.Empty<string>(),
|
||||||
Array.Empty<GmailController.GmailReviewJobCandidateDto>(),
|
Array.Empty<GmailReviewJobCandidateDto>(),
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new GmailController.GmailJobMatchedMessageDto(
|
new GmailJobMatchedMessageDto(
|
||||||
"msg-s1",
|
"msg-s1",
|
||||||
"thread-suggested",
|
"thread-suggested",
|
||||||
"Platform Engineer interview",
|
"Platform Engineer interview",
|
||||||
@@ -802,16 +802,16 @@ public sealed class GmailControllerTests
|
|||||||
"low",
|
"low",
|
||||||
false,
|
false,
|
||||||
Array.Empty<string>(),
|
Array.Empty<string>(),
|
||||||
Array.Empty<GmailController.GmailJobMatchReasonDto>())
|
Array.Empty<GmailJobMatchReasonDto>())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
|
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
|
||||||
Assert.IsType<GmailController.GmailSuggestedJobsResponseDto>(suggested.Value);
|
Assert.IsType<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 create = await controller.CreateSuggestedJob(new 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 createOk = Assert.IsType<OkObjectResult>(create.Result);
|
||||||
var created = Assert.IsType<GmailController.CreatedSuggestedGmailJobDto>(createOk.Value);
|
var created = Assert.IsType<CreatedSuggestedGmailJobDto>(createOk.Value);
|
||||||
Assert.True(created.JobApplicationId > 0);
|
Assert.True(created.JobApplicationId > 0);
|
||||||
Assert.Equal(1, created.Imported);
|
Assert.Equal(1, created.Imported);
|
||||||
Assert.Equal("thread-suggested", created.ThreadId);
|
Assert.Equal("thread-suggested", created.ThreadId);
|
||||||
@@ -837,10 +837,10 @@ public sealed class GmailControllerTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
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 result = await controller.UnlinkThread(new UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailUnlinkResultDto>(ok.Value);
|
var payload = Assert.IsType<GmailUnlinkResultDto>(ok.Value);
|
||||||
Assert.Equal(2, payload.RemovedMessages);
|
Assert.Equal(2, payload.RemovedMessages);
|
||||||
Assert.Equal("review", payload.Decision);
|
Assert.Equal("review", payload.Decision);
|
||||||
Assert.Empty(await db.Correspondences.ToListAsync());
|
Assert.Empty(await db.Correspondences.ToListAsync());
|
||||||
@@ -895,10 +895,10 @@ public sealed class GmailControllerTests
|
|||||||
Array.Empty<GmailMessageAttachment>()));
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
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 result = await controller.RelinkThread(new RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var payload = Assert.IsType<GmailController.GmailRelinkResultDto>(ok.Value);
|
var payload = Assert.IsType<GmailRelinkResultDto>(ok.Value);
|
||||||
Assert.Equal(1, payload.UnlinkedMessages);
|
Assert.Equal(1, payload.UnlinkedMessages);
|
||||||
Assert.Equal(1, payload.Imported);
|
Assert.Equal(1, payload.Imported);
|
||||||
var stored = await db.Correspondences.SingleAsync();
|
var stored = await db.Correspondences.SingleAsync();
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ using JobTrackerApi.Services.EmailProviders;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static JobTrackerApi.Services.GmailParsing;
|
||||||
|
|
||||||
namespace JobTrackerApi.Controllers;
|
namespace JobTrackerApi.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/gmail")]
|
[Route("api/gmail")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public sealed partial class GmailController : ControllerBase
|
public sealed class GmailController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGmailOAuthService _gmail;
|
private readonly IGmailOAuthService _gmail;
|
||||||
private readonly IGmailJobMatchingService _matching;
|
private readonly IGmailJobMatchingService _matching;
|
||||||
|
|||||||
@@ -2,78 +2,75 @@ using JobTrackerApi.Models;
|
|||||||
|
|
||||||
namespace JobTrackerApi.Controllers;
|
namespace JobTrackerApi.Controllers;
|
||||||
|
|
||||||
// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour
|
// DTOs for GmailController, split out for readability (no behaviour change; these were
|
||||||
// change; these were previously nested inline in the controller file).
|
// previously nested inside the controller class).
|
||||||
public partial class GmailController
|
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
|
||||||
{
|
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
|
||||||
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
|
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
||||||
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
|
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
||||||
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
|
||||||
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
|
||||||
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
|
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
|
||||||
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
|
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
|
||||||
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
|
public sealed record GmailJobMatchedMessageDto(
|
||||||
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
|
string Id,
|
||||||
public sealed record GmailJobMatchedMessageDto(
|
string ThreadId,
|
||||||
string Id,
|
string Subject,
|
||||||
string ThreadId,
|
string From,
|
||||||
string Subject,
|
string To,
|
||||||
string From,
|
DateTimeOffset? Date,
|
||||||
string To,
|
string Snippet,
|
||||||
DateTimeOffset? Date,
|
int Score,
|
||||||
string Snippet,
|
string Confidence,
|
||||||
int Score,
|
bool AlreadyImported,
|
||||||
string Confidence,
|
IReadOnlyList<string> MatchedQueries,
|
||||||
bool AlreadyImported,
|
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
||||||
IReadOnlyList<string> MatchedQueries,
|
public sealed record GmailJobMatchedThreadDto(
|
||||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
string ThreadId,
|
||||||
public sealed record GmailJobMatchedThreadDto(
|
string Subject,
|
||||||
string ThreadId,
|
int Score,
|
||||||
string Subject,
|
string Confidence,
|
||||||
int Score,
|
bool HasImportedMessages,
|
||||||
string Confidence,
|
int ImportedMessageCount,
|
||||||
bool HasImportedMessages,
|
int MessageCount,
|
||||||
int ImportedMessageCount,
|
DateTimeOffset? LatestDate,
|
||||||
int MessageCount,
|
IReadOnlyList<string> MatchedQueries,
|
||||||
DateTimeOffset? LatestDate,
|
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
||||||
IReadOnlyList<string> MatchedQueries,
|
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
public sealed record GmailJobMatchesResponseDto(
|
||||||
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
int JobApplicationId,
|
||||||
public sealed record GmailJobMatchesResponseDto(
|
string JobTitle,
|
||||||
int JobApplicationId,
|
string CompanyName,
|
||||||
string JobTitle,
|
string? RecruiterName,
|
||||||
string CompanyName,
|
string? RecruiterEmail,
|
||||||
string? RecruiterName,
|
IReadOnlyList<string> Queries,
|
||||||
string? RecruiterEmail,
|
int CandidateMessageCount,
|
||||||
IReadOnlyList<string> Queries,
|
int CandidateThreadCount,
|
||||||
int CandidateMessageCount,
|
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||||
int CandidateThreadCount,
|
|
||||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
|
||||||
|
|
||||||
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||||
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||||
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
||||||
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
|
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
|
||||||
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
|
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
|
||||||
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
|
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
|
||||||
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
|
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
|
||||||
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
|
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
|
||||||
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
|
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
|
||||||
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
|
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
|
||||||
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
|
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
|
||||||
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
|
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
|
||||||
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
|
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
|
||||||
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
|
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
|
||||||
|
|
||||||
public sealed record GmailConnectionStatusDto(
|
public sealed record GmailConnectionStatusDto(
|
||||||
bool Connected,
|
bool Connected,
|
||||||
string? GmailAddress,
|
string? GmailAddress,
|
||||||
DateTimeOffset? ConnectedAt,
|
DateTimeOffset? ConnectedAt,
|
||||||
DateTimeOffset? LastSyncedAt,
|
DateTimeOffset? LastSyncedAt,
|
||||||
DateTimeOffset? LastSyncAttemptedAt,
|
DateTimeOffset? LastSyncAttemptedAt,
|
||||||
DateTimeOffset? LastSyncSucceededAt,
|
DateTimeOffset? LastSyncSucceededAt,
|
||||||
string? LastSyncMode,
|
string? LastSyncMode,
|
||||||
string? LastSyncSource,
|
string? LastSyncSource,
|
||||||
string? LastSyncStatus,
|
string? LastSyncStatus,
|
||||||
string? LastSyncError);
|
string? LastSyncError);
|
||||||
}
|
|
||||||
|
|||||||
+12
-14
@@ -1,12 +1,10 @@
|
|||||||
using JobTrackerApi.Services;
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
namespace JobTrackerApi.Controllers;
|
// Pure parsing/formatting helpers used by GmailController, split out for readability (no
|
||||||
|
// behaviour change). All are static and side-effect free.
|
||||||
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
|
public static class GmailParsing
|
||||||
// refactor -- no behaviour change). All are static and side-effect free.
|
|
||||||
public sealed partial class GmailController
|
|
||||||
{
|
{
|
||||||
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
public static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
||||||
{
|
{
|
||||||
var bounded = (query ?? string.Empty).Trim();
|
var bounded = (query ?? string.Empty).Trim();
|
||||||
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
|
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -25,7 +23,7 @@ public sealed partial class GmailController
|
|||||||
return bounded.Trim();
|
return bounded.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
public static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
||||||
{
|
{
|
||||||
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
||||||
if (string.IsNullOrWhiteSpace(sample)) return false;
|
if (string.IsNullOrWhiteSpace(sample)) return false;
|
||||||
@@ -40,7 +38,7 @@ public sealed partial class GmailController
|
|||||||
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
|
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ToConfidence(int score)
|
public static string ToConfidence(int score)
|
||||||
{
|
{
|
||||||
return score switch
|
return score switch
|
||||||
{
|
{
|
||||||
@@ -50,21 +48,21 @@ public sealed partial class GmailController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ExtractFirstEmail(string? value)
|
public static string? ExtractFirstEmail(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
return match.Success ? match.Value : null;
|
return match.Success ? match.Value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ExtractRecruiterName(string? value)
|
public static string? ExtractRecruiterName(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
var trimmed = value.Split('<')[0].Trim().Trim('"');
|
var trimmed = value.Split('<')[0].Trim().Trim('"');
|
||||||
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
|
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ExtractCompanyName(string? from, string? subject)
|
public static string? ExtractCompanyName(string? from, string? subject)
|
||||||
{
|
{
|
||||||
var subjectText = (subject ?? string.Empty).Trim();
|
var subjectText = (subject ?? string.Empty).Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(subjectText))
|
if (!string.IsNullOrWhiteSpace(subjectText))
|
||||||
@@ -77,7 +75,7 @@ public sealed partial class GmailController
|
|||||||
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
|
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ExtractRoleFromSubject(string? subject)
|
public static string? ExtractRoleFromSubject(string? subject)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(subject)) return null;
|
if (string.IsNullOrWhiteSpace(subject)) return null;
|
||||||
var trimmed = subject.Trim();
|
var trimmed = subject.Trim();
|
||||||
@@ -88,7 +86,7 @@ public sealed partial class GmailController
|
|||||||
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
|
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildPopupHtml(bool success, string message)
|
public static string BuildPopupHtml(bool success, string message)
|
||||||
{
|
{
|
||||||
var escaped = System.Net.WebUtility.HtmlEncode(message);
|
var escaped = System.Net.WebUtility.HtmlEncode(message);
|
||||||
var status = success ? "connected" : "error";
|
var status = success ? "connected" : "error";
|
||||||
Reference in New Issue
Block a user