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:
2026-03-24 12:07:25 +01:00
parent 8890906231
commit 955cae6d4b
15 changed files with 557 additions and 65 deletions
+23 -6
View File
@@ -19,11 +19,13 @@ public interface IGmailOAuthService
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record GmailOAuthExchangeResult(string GmailAddress);
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList<string> MatchedQueries);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
internal sealed class GmailTokenResponse
@@ -182,6 +184,12 @@ public sealed class GmailOAuthService : IGmailOAuthService
}
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
{
var matchedMessages = await ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
return matchedMessages.Select(static item => item.Message).ToList();
}
public async Task<IReadOnlyList<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
{
maxResultsPerQuery = Math.Clamp(maxResultsPerQuery, 1, 25);
var normalizedQueries = queries
@@ -192,19 +200,28 @@ public sealed class GmailOAuthService : IGmailOAuthService
if (normalizedQueries.Count == 0)
{
return Array.Empty<GmailMessageSummary>();
return Array.Empty<GmailQueryMatchedMessage>();
}
var combined = new List<GmailMessageSummary>();
var combined = new Dictionary<string, (GmailMessageSummary Message, HashSet<string> Queries)>(StringComparer.Ordinal);
foreach (var query in normalizedQueries)
{
var items = await ListMessagesAsync(ownerUserId, query, maxResultsPerQuery, cancellationToken);
combined.AddRange(items);
foreach (var item in items)
{
if (!combined.TryGetValue(item.Id, out var existing))
{
combined[item.Id] = (item, new HashSet<string>(StringComparer.OrdinalIgnoreCase) { query });
continue;
}
existing.Queries.Add(query);
combined[item.Id] = existing;
}
}
return combined
.GroupBy(message => message.Id, StringComparer.Ordinal)
.Select(group => group.First())
return combined.Values
.Select(static entry => new GmailQueryMatchedMessage(entry.Message, entry.Queries.OrderBy(static query => query, StringComparer.OrdinalIgnoreCase).ToList()))
.ToList();
}