From b0611589f2d53242af715e8b8704b6c92f6386ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 09:28:24 +0000 Subject: [PATCH 1/7] Initial plan From ef5b4a311e914571635c8409455868f93297a99a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 09:39:02 +0000 Subject: [PATCH 2/7] Fix newsletter trigger modes and section ordering --- .../Endpoints/NewsletterEndpoints.cs | 9 +- .../Services/NewsletterBackgroundService.cs | 42 +++++- .../Services/Newsletter/NewsletterService.cs | 37 ++++- .../Pages/Admin/AdminNewsletter.razor | 10 +- src/TechHub.Web/Services/ITechHubApiClient.cs | 2 +- src/TechHub.Web/Services/TechHubApiClient.cs | 8 +- .../Endpoints/NewsletterEndpointsTests.cs | 81 ++++++++++- .../Services/NewsletterServiceTests.cs | 128 ++++++++++++++++-- .../Services/TechHubApiClientTests.cs | 17 +++ 9 files changed, 308 insertions(+), 26 deletions(-) diff --git a/src/TechHub.Api/Endpoints/NewsletterEndpoints.cs b/src/TechHub.Api/Endpoints/NewsletterEndpoints.cs index fa5a09556..f7f4cf139 100644 --- a/src/TechHub.Api/Endpoints/NewsletterEndpoints.cs +++ b/src/TechHub.Api/Endpoints/NewsletterEndpoints.cs @@ -279,10 +279,13 @@ private static async Task GetSendLogAsync( return Results.Ok(log); } - private static IResult TriggerNewsletterAsync(NewsletterBackgroundService backgroundService) + private static IResult TriggerNewsletterAsync( + [FromQuery] string? kind, + NewsletterBackgroundService backgroundService) { - backgroundService.TriggerImmediateRun(); - return Results.Accepted("/api/admin/newsletter/send-log", new { message = "Newsletter send triggered" }); + var normalizedKind = string.Equals(kind?.Trim(), "daily", StringComparison.OrdinalIgnoreCase) ? "daily" : "roundup"; + backgroundService.TriggerImmediateRun(normalizedKind); + return Results.Accepted("/api/admin/newsletter/send-log", new { message = $"Newsletter {normalizedKind} send triggered" }); } private static IResult TestSendNewsletterAsync( diff --git a/src/TechHub.Api/Services/NewsletterBackgroundService.cs b/src/TechHub.Api/Services/NewsletterBackgroundService.cs index 1e145b61c..bd708a32e 100644 --- a/src/TechHub.Api/Services/NewsletterBackgroundService.cs +++ b/src/TechHub.Api/Services/NewsletterBackgroundService.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Diagnostics.CodeAnalysis; using Dapper; using Microsoft.Extensions.Options; using TechHub.Core.Configuration; @@ -17,6 +18,7 @@ public sealed class NewsletterBackgroundService : BackgroundService private string? _pendingTestSendEmail; private IReadOnlyList? _pendingTestSections; private string? _pendingTestSendKind; + private string? _pendingManualKind; public NewsletterBackgroundService( IServiceProvider serviceProvider, @@ -35,11 +37,12 @@ public NewsletterBackgroundService( _logger = logger; } - public void TriggerImmediateRun() + public void TriggerImmediateRun(string kind = "roundup") { _pendingTestSendEmail = null; _pendingTestSections = null; _pendingTestSendKind = null; + _pendingManualKind = kind; _manualTrigger.TrySetResult(true); } @@ -104,6 +107,15 @@ private async Task RunManualAsync(CancellationToken ct) return; } + var manualKind = string.Equals(_pendingManualKind?.Trim(), "daily", StringComparison.OrdinalIgnoreCase) ? "daily" : "roundup"; + _pendingManualKind = null; + + if (string.Equals(manualKind, "daily", StringComparison.OrdinalIgnoreCase)) + { + await SendDailyEmailsAsync(enforceHourGate: false, ct); + return; + } + await SendLatestRoundupsAsync(ct); } @@ -115,7 +127,8 @@ private async Task SendLatestRoundupsAsync(CancellationToken ct) const string Sql = """ SELECT DISTINCT ON (primary_section_name) - slug + slug AS Slug, + date_epoch AS DateEpoch FROM content_items WHERE collection_name = 'roundups' AND primary_section_name IS NOT NULL @@ -123,7 +136,20 @@ AND primary_section_name <> 'all' ORDER BY primary_section_name, date_epoch DESC """; - var slugs = (await connection.QueryAsync(new CommandDefinition(Sql, cancellationToken: ct))).AsList(); + var latestRoundups = (await connection.QueryAsync(new CommandDefinition(Sql, cancellationToken: ct))).AsList(); + if (latestRoundups.Count == 0) + { + return; + } + + if (latestRoundups.Select(r => r.DateEpoch).Distinct().Count() != 1) + { + _logger.LogInformation( + "Skipping roundup newsletter send because not all sections have the latest roundup available yet"); + return; + } + + var slugs = latestRoundups.Select(r => r.Slug).ToList(); if (slugs.Count > 0) { await newsletterService.SendCombinedWeeklyAsync(slugs, ct); @@ -131,10 +157,15 @@ AND primary_section_name <> 'all' } private async Task SendScheduledDailyEmailsAsync(CancellationToken ct) + { + await SendDailyEmailsAsync(enforceHourGate: true, ct); + } + + private async Task SendDailyEmailsAsync(bool enforceHourGate, CancellationToken ct) { var timeZone = ResolveTimeZone(_options.DailyDigestTimeZoneId); var localNow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone); - if (localNow.Hour != _options.DailyDigestHourLocal) + if (enforceHourGate && localNow.Hour != _options.DailyDigestHourLocal) { return; } @@ -146,6 +177,9 @@ private async Task SendScheduledDailyEmailsAsync(CancellationToken ct) await newsletterService.SendAdminStatusReportAsync(day, ct); } + [SuppressMessage("Performance", "CA1812", Justification = "Instantiated by Dapper materialization.")] + private sealed record LatestSectionRoundupRow(string Slug, long DateEpoch); + private async Task IsEnabledAsync(CancellationToken ct) { // If disabled via environment variable (Newsletter__ScheduledSendEnabled=false), skip the diff --git a/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs b/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs index cd6377aa6..c8370c966 100644 --- a/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs +++ b/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs @@ -227,6 +227,7 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu .Where(s => roundupsBySection.ContainsKey(s)) .Select(s => roundupsBySection[s]) .ToList(); + relevantRoundups = OrderRoundupsByWebsiteOrder(relevantRoundups); if (relevantRoundups.Count == 0) { @@ -340,6 +341,8 @@ LIMIT 1 return false; } + roundups = OrderRoundupsByWebsiteOrder(roundups); + var logKey = $"test-weekly@{DateTime.UtcNow:yyyyMMddHHmmss}"; var unsubscribeUrl = BuildUnsubscribeUrl(recipientEmail); var manageUrl = BuildManageUrl(recipientEmail, _options.UnsubscribeSecret); @@ -376,7 +379,7 @@ public async Task SendTestDailyEmailAsync(IReadOnlyList sections, var yesterday = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)); var (start, end) = GetDayUtcWindow(yesterday); - var sectionNames = sections.Select(s => s.Trim().ToLowerInvariant()).ToList(); + var sectionNames = OrderSectionsByWebsiteOrder(sections); var itemsBySection = await GetDailyItemsBySectionAsync(sectionNames, start, end, ct); var html = BuildDailyOverviewHtml(yesterday, sectionNames, itemsBySection, recipientEmail); var text = BuildDailyOverviewText(yesterday, sectionNames, itemsBySection, recipientEmail); @@ -517,6 +520,7 @@ public async Task SendDailyOverviewAsync(DateOnly day, CancellationToken c var selectedSections = subscriber.DailySections .Where(section => itemsBySection.ContainsKey(section)) .ToList(); + selectedSections = OrderSectionsByWebsiteOrder(selectedSections); var html = BuildDailyOverviewHtml(day, selectedSections, itemsBySection, subscriber.Email); var text = BuildDailyOverviewText(day, selectedSections, itemsBySection, subscriber.Email); @@ -930,6 +934,37 @@ private string BuildCombinedWeeklyPlainText(IReadOnlyList roundups, private string GetSectionTitle(string slug) => _appSettings.Content.Sections.TryGetValue(slug, out var config) ? config.Title : slug; + private List OrderSectionsByWebsiteOrder(IEnumerable sectionNames) + { + var sectionOrder = _appSettings.Content.Sections + .Where(kvp => !string.Equals(kvp.Key, "all", StringComparison.OrdinalIgnoreCase)) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.Order, + StringComparer.OrdinalIgnoreCase); + + return sectionNames + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim().ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(s => sectionOrder.TryGetValue(s, out var order) ? order : int.MaxValue) + .ThenBy(s => GetSectionTitle(s), StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private List OrderRoundupsByWebsiteOrder(IEnumerable roundups) + { + var orderedSections = OrderSectionsByWebsiteOrder(roundups.Select(r => r.SectionName)); + var sectionIndex = orderedSections + .Select((section, index) => new { section, index }) + .ToDictionary(x => x.section, x => x.index, StringComparer.OrdinalIgnoreCase); + + return roundups + .OrderBy(r => sectionIndex.TryGetValue(r.SectionName, out var index) ? index : int.MaxValue) + .ThenBy(r => GetSectionTitle(r.SectionName), StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private string BuildDailyOverviewHtml( DateOnly day, IReadOnlyList sections, diff --git a/src/TechHub.Web/Components/Pages/Admin/AdminNewsletter.razor b/src/TechHub.Web/Components/Pages/Admin/AdminNewsletter.razor index c12144cb5..822210d19 100644 --- a/src/TechHub.Web/Components/Pages/Admin/AdminNewsletter.razor +++ b/src/TechHub.Web/Components/Pages/Admin/AdminNewsletter.razor @@ -16,7 +16,8 @@

Manage subscribers, remove bot signups, and trigger sends

- + +
@@ -233,14 +234,15 @@ } } - private async Task TriggerSendAsync() + private async Task TriggerSendAsync(string kind) { + var normalizedKind = string.Equals(kind?.Trim(), "daily", StringComparison.OrdinalIgnoreCase) ? "daily" : "roundup"; try { _sending = true; _message = null; - await Api.TriggerNewsletterAsync(); - _message = "Newsletter send triggered."; + await Api.TriggerNewsletterAsync(normalizedKind); + _message = $"Newsletter {normalizedKind} send triggered."; _isError = false; } catch (HttpRequestException ex) diff --git a/src/TechHub.Web/Services/ITechHubApiClient.cs b/src/TechHub.Web/Services/ITechHubApiClient.cs index ba8a99767..297b3c726 100644 --- a/src/TechHub.Web/Services/ITechHubApiClient.cs +++ b/src/TechHub.Web/Services/ITechHubApiClient.cs @@ -320,7 +320,7 @@ Task GetCollectionRssFeedAsync( /// Trigger an immediate newsletter processing run. /// POST /api/admin/newsletter/trigger /// - Task TriggerNewsletterAsync(CancellationToken cancellationToken = default); + Task TriggerNewsletterAsync(string kind = "roundup", CancellationToken cancellationToken = default); /// /// Trigger a newsletter test send to a specific recipient for the given sections. diff --git a/src/TechHub.Web/Services/TechHubApiClient.cs b/src/TechHub.Web/Services/TechHubApiClient.cs index 407698ab3..63ee0deaa 100644 --- a/src/TechHub.Web/Services/TechHubApiClient.cs +++ b/src/TechHub.Web/Services/TechHubApiClient.cs @@ -994,12 +994,14 @@ public virtual async Task TriggerRoundupGenerationAsync(CancellationToken cancel } } - public virtual async Task TriggerNewsletterAsync(CancellationToken cancellationToken = default) + public virtual async Task TriggerNewsletterAsync(string kind = "roundup", CancellationToken cancellationToken = default) { + kind = string.Equals(kind?.Trim(), "daily", StringComparison.OrdinalIgnoreCase) ? "daily" : "roundup"; + try { - _logger.LogInformation("Triggering newsletter run"); - using var response = await _httpClient.PostAsync("/api/admin/newsletter/trigger", null, cancellationToken); + _logger.LogInformation("Triggering newsletter {Kind} run", kind); + using var response = await _httpClient.PostAsync($"/api/admin/newsletter/trigger?kind={kind}", null, cancellationToken); response.EnsureSuccessStatusCode(); } catch (HttpRequestException ex) diff --git a/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs b/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs index 4249506fb..5a0c60de4 100644 --- a/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs +++ b/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs @@ -4,6 +4,8 @@ using Dapper; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TechHub.Core.Configuration; using TechHub.Core.Models; using TechHub.Infrastructure.Services.Newsletter; @@ -114,7 +116,7 @@ LIMIT 1 "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", new { TargetKey = roundupSlug! }); - var response = await _client.PostAsync("/api/admin/newsletter/trigger", null, TestContext.Current.CancellationToken); + var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=roundup", null, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Accepted); var timedOut = true; @@ -137,6 +139,64 @@ LIMIT 1 timedOut.Should().BeFalse("manual admin trigger should execute even when scheduled sends are disabled"); } + [Fact] + public async Task AdminTriggerNewsletter_DailyTriggerProcessesDailyOverviewSend() + { + await using var arrangeScope = _factory.Services.CreateAsyncScope(); + var options = arrangeScope.ServiceProvider.GetRequiredService>().Value; + var timeZone = ResolveTimeZone(options.DailyDigestTimeZoneId); + var localNow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone); + var day = DateOnly.FromDateTime(localNow.DateTime.Date.AddDays(-1)); + var dayStartUtc = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + + var arrangeConnection = arrangeScope.ServiceProvider.GetRequiredService(); + await arrangeConnection.ExecuteAsync(""" + DELETE FROM newsletter_subscribers WHERE email = 'daily-trigger@example.com'; + DELETE FROM content_items WHERE collection_name = 'blogs' AND slug = 'daily-trigger-item'; + """); + await arrangeConnection.ExecuteAsync(""" + INSERT INTO newsletter_subscribers (email, is_confirmed, confirmed_at, preferences) + VALUES ('daily-trigger@example.com', TRUE, NOW(), '{"weeklySections":[],"dailySections":["ai"]}'::jsonb) + """); + await arrangeConnection.ExecuteAsync(""" + INSERT INTO content_items + (slug, collection_name, title, content, excerpt, date_epoch, + primary_section_name, external_url, author, feed_name, tags_csv, + sections_bitmask, content_hash, is_ai, created_at) + VALUES + ('daily-trigger-item', 'blogs', 'Daily Trigger Item', 'Body', 'Excerpt', 0, + 'ai', '/ai/all', 'TechHub', 'TechHub', ',AI,', + 1, 'hash-daily-trigger-item', TRUE, @CreatedAt) + ON CONFLICT (collection_name, slug) DO UPDATE SET created_at = EXCLUDED.created_at + """, new { CreatedAt = dayStartUtc.AddHours(12) }); + + var countBefore = await arrangeConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'daily-overview' AND target_key = @TargetKey", + new { TargetKey = day.ToString("yyyy-MM-dd") }); + + var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=daily", null, TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var timedOut = true; + for (var attempt = 0; attempt < 25; attempt++) + { + await Task.Delay(200, TestContext.Current.CancellationToken); + await using var pollScope = _factory.Services.CreateAsyncScope(); + var pollConnection = pollScope.ServiceProvider.GetRequiredService(); + var countAfter = await pollConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'daily-overview' AND target_key = @TargetKey", + new { TargetKey = day.ToString("yyyy-MM-dd") }); + + if (countAfter > countBefore) + { + timedOut = false; + break; + } + } + + timedOut.Should().BeFalse("manual admin daily trigger should execute daily overview send"); + } + [Fact] public async Task Subscribe_WithOnlyInvalidSections_ReturnsBadRequest() { @@ -267,6 +327,25 @@ private sealed class MessageResponse public string? Message { get; init; } } + private static TimeZoneInfo ResolveTimeZone(string configuredId) + { + if (!string.IsNullOrWhiteSpace(configuredId)) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(configuredId); + } + catch (TimeZoneNotFoundException) + { + } + catch (InvalidTimeZoneException) + { + } + } + + return TimeZoneInfo.Utc; + } + [Fact] public async Task ManageRequest_WithValidEmail_ReturnsOk() { diff --git a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs index 155192df3..5292a6482 100644 --- a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs +++ b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs @@ -300,6 +300,114 @@ ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO NOTHING dotnetStatus.Should().Be("sent"); } + [Fact] + public async Task SendCombinedWeeklyAsync_OrdersSectionsByWebsiteOrderInEmail() + { + const string AiSlug = "weekly-ai-roundup-2026-06-03-ordering"; + const string DotnetSlug = "weekly-dotnet-roundup-2026-06-03-ordering"; + + await _fixture.Connection.ExecuteAsync(""" + DELETE FROM newsletter_subscribers + WHERE preferences @> '{"weeklySections":["ai"]}' + OR preferences @> '{"weeklySections":["dotnet"]}' + OR email = 'weekly-ordering@example.com'; + DELETE FROM content_items WHERE collection_name = 'roundups' AND slug IN (@AiSlug, @DotnetSlug); + DELETE FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key IN (@AiSlug, @DotnetSlug); + """, new { AiSlug, DotnetSlug }); + + await SeedRoundupAsync(AiSlug); + await _fixture.Connection.ExecuteAsync(""" + INSERT INTO content_items + (slug, collection_name, title, content, excerpt, date_epoch, + primary_section_name, external_url, author, feed_name, tags_csv, + sections_bitmask, content_hash, is_ai) + VALUES + (@Slug, 'roundups', '.NET Weekly', '## .NET Highlights', 'Dotnet intro', 1748304000, + 'dotnet', '/dotnet/roundups/' || @Slug, 'TechHub', 'TechHub', ',Roundups,.NET,', + 2, 'hash-newsletter-dotnet-ordering', FALSE) + ON CONFLICT (collection_name, slug) DO NOTHING + """, new { Slug = DotnetSlug }); + + await _fixture.Connection.ExecuteAsync(""" + INSERT INTO newsletter_subscribers (email, is_confirmed, confirmed_at, preferences) + VALUES ('weekly-ordering@example.com', TRUE, NOW(), '{"weeklySections":["dotnet","ai"],"dailySections":[]}'::jsonb) + ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO UPDATE SET preferences = EXCLUDED.preferences + """); + + string? htmlBody = null; + var emailSender = new Mock(MockBehavior.Strict); + emailSender + .Setup(s => s.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((recipient, _, html, _, _) => + { + if (string.Equals(recipient, "weekly-ordering@example.com", StringComparison.OrdinalIgnoreCase)) + { + htmlBody = html; + } + }) + .ReturnsAsync(true); + + var sut = CreateService(emailSender: emailSender.Object); + var sent = await sut.SendCombinedWeeklyAsync([DotnetSlug, AiSlug], TestContext.Current.CancellationToken); + + sent.Should().BeTrue(); + htmlBody.Should().NotBeNull(); + htmlBody!.IndexOf("Artificial Intelligence", StringComparison.Ordinal).Should().BeLessThan( + htmlBody.IndexOf(".NET", StringComparison.Ordinal), + "weekly digest sections should follow configured website order"); + } + + [Fact] + public async Task SendDailyOverviewAsync_OrdersSectionsByWebsiteOrderInEmail() + { + await _fixture.Connection.ExecuteAsync(""" + DELETE FROM newsletter_send_log WHERE send_kind = 'daily-overview' AND target_key = '2026-05-22'; + DELETE FROM newsletter_subscribers WHERE email = 'daily-ordering@example.com'; + DELETE FROM content_items WHERE collection_name = 'blogs' AND slug IN ('daily-order-ai-2026-05-22', 'daily-order-dotnet-2026-05-22'); + """); + + await _fixture.Connection.ExecuteAsync(""" + INSERT INTO newsletter_subscribers (email, is_confirmed, confirmed_at, preferences) + VALUES ('daily-ordering@example.com', TRUE, NOW(), '{"weeklySections":[],"dailySections":["dotnet","ai"]}'::jsonb) + """); + + await _fixture.Connection.ExecuteAsync(""" + INSERT INTO content_items + (slug, collection_name, title, content, excerpt, date_epoch, + primary_section_name, external_url, author, feed_name, tags_csv, + sections_bitmask, content_hash, is_ai, created_at) + VALUES + ('daily-order-ai-2026-05-22', 'blogs', 'AI Daily Item', 'Body', 'Excerpt', 1747872000, + 'ai', '/ai/all-daily-order-2026-05-22', 'TechHub', 'TechHub', ',AI,', + 1, 'hash-daily-order-ai', TRUE, '2026-05-22T10:00:00Z'), + ('daily-order-dotnet-2026-05-22', 'blogs', '.NET Daily Item', 'Body', 'Excerpt', 1747872000, + 'dotnet', '/dotnet/all-daily-order-2026-05-22', 'TechHub', 'TechHub', ',.NET,', + 2, 'hash-daily-order-dotnet', FALSE, '2026-05-22T10:00:00Z') + ON CONFLICT (collection_name, slug) DO NOTHING + """); + + string? htmlBody = null; + var emailSender = new Mock(MockBehavior.Strict); + emailSender + .Setup(s => s.SendAsync("daily-ordering@example.com", It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, html, _, _) => htmlBody = html) + .ReturnsAsync(true); + + var contentRepository = new Mock(MockBehavior.Strict); + contentRepository + .Setup(x => x.GetAllSectionsAsync(It.IsAny())) + .ReturnsAsync([CreateSection("ai"), CreateSection("dotnet")]); + + var sut = CreateService(contentRepository.Object, emailSender.Object); + var sent = await sut.SendDailyOverviewAsync(new DateOnly(2026, 5, 22), TestContext.Current.CancellationToken); + + sent.Should().BeTrue(); + htmlBody.Should().NotBeNull(); + htmlBody!.IndexOf("Artificial Intelligence", StringComparison.Ordinal).Should().BeLessThan( + htmlBody.IndexOf(".NET", StringComparison.Ordinal), + "daily email sections should follow configured website order"); + } + private NewsletterService CreateService( IContentRepository? contentRepository = null, IEmailSender? emailSender = null, @@ -323,7 +431,9 @@ private NewsletterService CreateService( { Sections = new Dictionary { - ["ai"] = new SectionConfig { Title = "Artificial Intelligence", Description = "", Url = "/ai", Tag = "ai", Collections = [] } + ["ai"] = new SectionConfig { Title = "Artificial Intelligence", Description = "", Url = "/ai", Tag = "ai", Order = 1, Collections = [] }, + ["dotnet"] = new SectionConfig { Title = ".NET", Description = "", Url = "/dotnet", Tag = "dotnet", Order = 2, Collections = [] }, + ["azure"] = new SectionConfig { Title = "Azure", Description = "", Url = "/azure", Tag = "azure", Order = 3, Collections = [] } } } }); @@ -341,25 +451,25 @@ private NewsletterService CreateService( private static Section CreateSection(string name) => new( name, - "AI", - "AI section", - "/ai", - "AI", - [new Collection("blogs", "Blogs", "/ai/blogs", "Blogs", "Blogs")]); + name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? ".NET" : "AI", + $"{name} section", + $"/{name}", + name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? ".NET" : "AI", + [new Collection("blogs", "Blogs", $"/{name}/blogs", "Blogs", "Blogs")]); private async Task CleanupDailyOverviewTestDataAsync() { await _fixture.Connection.ExecuteAsync(""" DELETE FROM newsletter_send_log WHERE send_kind = 'daily-overview' - AND target_key IN ('2026-05-20', '2026-05-21'); + AND target_key IN ('2026-05-20', '2026-05-21', '2026-05-22'); DELETE FROM newsletter_subscribers - WHERE email IN ('confirmed@example.com', 'unconfirmed@example.com', 'confirmed-daily@example.com'); + WHERE email IN ('confirmed@example.com', 'unconfirmed@example.com', 'confirmed-daily@example.com', 'daily-ordering@example.com'); DELETE FROM content_items WHERE collection_name = 'blogs' - AND slug IN ('daily-item-newsletter-test-2026-05-20', 'daily-item-newsletter-test-2026-05-21'); + AND slug IN ('daily-item-newsletter-test-2026-05-20', 'daily-item-newsletter-test-2026-05-21', 'daily-order-ai-2026-05-22', 'daily-order-dotnet-2026-05-22'); """); } diff --git a/tests/TechHub.Web.Tests/Services/TechHubApiClientTests.cs b/tests/TechHub.Web.Tests/Services/TechHubApiClientTests.cs index f6291d883..4df739123 100644 --- a/tests/TechHub.Web.Tests/Services/TechHubApiClientTests.cs +++ b/tests/TechHub.Web.Tests/Services/TechHubApiClientTests.cs @@ -145,6 +145,23 @@ await sut.TriggerNewsletterTestSendAsync( handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/admin/newsletter/test-send?email=user%40example.com&kind=weekly§ions=azure§ions=dotnet"); } + [Fact] + public async Task TriggerNewsletterAsync_NormalizesRequestedKind() + { + using var handler = new CapturingHandler(); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://localhost:5003") + }; + + var sut = new TechHubApiClient(httpClient, NullLogger.Instance); + + await sut.TriggerNewsletterAsync("daily\r\n", TestContext.Current.CancellationToken); + + handler.LastRequest.Should().NotBeNull(); + handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/admin/newsletter/trigger?kind=daily"); + } + [Fact] public async Task RequestNewsletterManageLinkAsync_SanitizesQueryValues() { From 21dc899bbc6cb4114393b6b362d03ef66de30158 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 09:43:28 +0000 Subject: [PATCH 3/7] Finalize newsletter trigger and ordering fixes --- src/TechHub.Api/Services/NewsletterBackgroundService.cs | 3 ++- .../Services/NewsletterServiceTests.cs | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/TechHub.Api/Services/NewsletterBackgroundService.cs b/src/TechHub.Api/Services/NewsletterBackgroundService.cs index bd708a32e..fcf0d9207 100644 --- a/src/TechHub.Api/Services/NewsletterBackgroundService.cs +++ b/src/TechHub.Api/Services/NewsletterBackgroundService.cs @@ -142,7 +142,8 @@ AND primary_section_name <> 'all' return; } - if (latestRoundups.Select(r => r.DateEpoch).Distinct().Count() != 1) + var latestEpoch = latestRoundups[0].DateEpoch; + if (!latestRoundups.All(r => r.DateEpoch == latestEpoch)) { _logger.LogInformation( "Skipping roundup newsletter send because not all sections have the latest roundup available yet"); diff --git a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs index 5292a6482..a6bf15355 100644 --- a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs +++ b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs @@ -449,12 +449,15 @@ private NewsletterService CreateService( } private static Section CreateSection(string name) => + CreateSection(name, name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? ".NET" : "AI"); + + private static Section CreateSection(string name, string title) => new( name, - name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? ".NET" : "AI", + title, $"{name} section", $"/{name}", - name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? ".NET" : "AI", + title, [new Collection("blogs", "Blogs", $"/{name}/blogs", "Blogs", "Blogs")]); private async Task CleanupDailyOverviewTestDataAsync() From eaeff7ae7ab1f212634373b5449a1078959cfd34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 10:32:59 +0000 Subject: [PATCH 4/7] Gate roundup sends by expected Monday date --- .../Services/NewsletterBackgroundService.cs | 26 ++- .../Endpoints/NewsletterEndpointsTests.cs | 175 +++++++++++++++--- 2 files changed, 168 insertions(+), 33 deletions(-) diff --git a/src/TechHub.Api/Services/NewsletterBackgroundService.cs b/src/TechHub.Api/Services/NewsletterBackgroundService.cs index fcf0d9207..89dd6f413 100644 --- a/src/TechHub.Api/Services/NewsletterBackgroundService.cs +++ b/src/TechHub.Api/Services/NewsletterBackgroundService.cs @@ -142,11 +142,13 @@ AND primary_section_name <> 'all' return; } - var latestEpoch = latestRoundups[0].DateEpoch; - if (!latestRoundups.All(r => r.DateEpoch == latestEpoch)) + var roundupTimeZone = ResolveRoundupTimeZone(); + var expectedMonday = GetExpectedRoundupMonday(DateTimeOffset.UtcNow, roundupTimeZone); + if (!latestRoundups.All(r => GetRoundupDate(r.DateEpoch, roundupTimeZone) == expectedMonday)) { _logger.LogInformation( - "Skipping roundup newsletter send because not all sections have the latest roundup available yet"); + "Skipping roundup newsletter send because latest section roundups do not match expected Monday {ExpectedMonday}", + expectedMonday); return; } @@ -250,4 +252,22 @@ private TimeZoneInfo ResolveTimeZone(string configuredId) return TimeZoneInfo.Utc; } + + private static TimeZoneInfo ResolveRoundupTimeZone() + { + return TimeZoneInfo.FindSystemTimeZoneById( + OperatingSystem.IsWindows() ? "Romance Standard Time" : "Europe/Brussels"); + } + + private static DateOnly GetExpectedRoundupMonday(DateTimeOffset utcNow, TimeZoneInfo roundupTimeZone) + { + var localDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(utcNow, roundupTimeZone).Date); + var daysSinceMonday = ((int)localDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; + return localDate.AddDays(-daysSinceMonday); + } + + private static DateOnly GetRoundupDate(long dateEpoch, TimeZoneInfo roundupTimeZone) + { + return DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(dateEpoch), roundupTimeZone).Date); + } } diff --git a/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs b/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs index 5a0c60de4..d9d4cad02 100644 --- a/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs +++ b/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs @@ -99,44 +99,45 @@ public async Task AdminTriggerNewsletter_WhenScheduledSendingDisabled_StillProce { await using var arrangeScope = _factory.Services.CreateAsyncScope(); var arrangeConnection = arrangeScope.ServiceProvider.GetRequiredService(); - var roundupSlug = await arrangeConnection.ExecuteScalarAsync( - """ - SELECT slug - FROM content_items - WHERE collection_name = 'roundups' - AND primary_section_name IS NOT NULL - AND primary_section_name <> 'all' - ORDER BY date_epoch DESC - LIMIT 1 - """); - - roundupSlug.Should().NotBeNullOrWhiteSpace(); - - var countBefore = await arrangeConnection.ExecuteScalarAsync( - "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", - new { TargetKey = roundupSlug! }); + var latestRoundups = await LoadLatestSectionRoundupsAsync(arrangeConnection); + latestRoundups.Should().NotBeEmpty(); - var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=roundup", null, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Accepted); + var expectedMonday = GetExpectedRoundupMonday(DateTimeOffset.UtcNow, ResolveRoundupTimeZone()); + await SetLatestRoundupsEpochAsync(arrangeConnection, latestRoundups, ToRoundupEpoch(expectedMonday)); + var roundupSlug = latestRoundups[0].Slug; - var timedOut = true; - for (var attempt = 0; attempt < 25; attempt++) + try { - await Task.Delay(200, TestContext.Current.CancellationToken); - await using var pollScope = _factory.Services.CreateAsyncScope(); - var pollConnection = pollScope.ServiceProvider.GetRequiredService(); - var countAfter = await pollConnection.ExecuteScalarAsync( + var countBefore = await arrangeConnection.ExecuteScalarAsync( "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", - new { TargetKey = roundupSlug! }); + new { TargetKey = roundupSlug }); - if (countAfter > countBefore) + var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=roundup", null, TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var timedOut = true; + for (var attempt = 0; attempt < 25; attempt++) { - timedOut = false; - break; + await Task.Delay(200, TestContext.Current.CancellationToken); + await using var pollScope = _factory.Services.CreateAsyncScope(); + var pollConnection = pollScope.ServiceProvider.GetRequiredService(); + var countAfter = await pollConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey = roundupSlug }); + + if (countAfter > countBefore) + { + timedOut = false; + break; + } } - } - timedOut.Should().BeFalse("manual admin trigger should execute even when scheduled sends are disabled"); + timedOut.Should().BeFalse("manual admin trigger should execute even when scheduled sends are disabled"); + } + finally + { + await RestoreLatestRoundupsEpochAsync(arrangeConnection, latestRoundups); + } } [Fact] @@ -197,6 +198,53 @@ ON CONFLICT (collection_name, slug) DO UPDATE SET created_at = EXCLUDED.created_ timedOut.Should().BeFalse("manual admin daily trigger should execute daily overview send"); } + [Fact] + public async Task AdminTriggerNewsletter_RoundupsNotOnExpectedMonday_SkipsSend() + { + await using var arrangeScope = _factory.Services.CreateAsyncScope(); + var arrangeConnection = arrangeScope.ServiceProvider.GetRequiredService(); + var latestRoundups = await LoadLatestSectionRoundupsAsync(arrangeConnection); + latestRoundups.Should().NotBeEmpty(); + + var expectedMonday = GetExpectedRoundupMonday(DateTimeOffset.UtcNow, ResolveRoundupTimeZone()); + var nonMondayEpoch = ToRoundupEpoch(expectedMonday.AddYears(10).AddDays(1)); + var targetSlug = latestRoundups[0].Slug; + + await SetLatestRoundupsEpochAsync(arrangeConnection, latestRoundups, nonMondayEpoch); + + try + { + var countBefore = await arrangeConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey = targetSlug }); + + var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=roundup", null, TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var wasSent = false; + for (var attempt = 0; attempt < 25; attempt++) + { + await Task.Delay(200, TestContext.Current.CancellationToken); + await using var pollScope = _factory.Services.CreateAsyncScope(); + var pollConnection = pollScope.ServiceProvider.GetRequiredService(); + var countAfter = await pollConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey = targetSlug }); + if (countAfter > countBefore) + { + wasSent = true; + break; + } + } + + wasSent.Should().BeFalse("roundup send should be skipped until latest section roundups match expected Monday"); + } + finally + { + await RestoreLatestRoundupsEpochAsync(arrangeConnection, latestRoundups); + } + } + [Fact] public async Task Subscribe_WithOnlyInvalidSections_ReturnsBadRequest() { @@ -327,6 +375,8 @@ private sealed class MessageResponse public string? Message { get; init; } } + private sealed record LatestRoundupRow(string Slug, long DateEpoch); + private static TimeZoneInfo ResolveTimeZone(string configuredId) { if (!string.IsNullOrWhiteSpace(configuredId)) @@ -346,6 +396,71 @@ private static TimeZoneInfo ResolveTimeZone(string configuredId) return TimeZoneInfo.Utc; } + private static TimeZoneInfo ResolveRoundupTimeZone() + { + return TimeZoneInfo.FindSystemTimeZoneById( + OperatingSystem.IsWindows() ? "Romance Standard Time" : "Europe/Brussels"); + } + + private static DateOnly GetExpectedRoundupMonday(DateTimeOffset utcNow, TimeZoneInfo roundupTimeZone) + { + var localDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(utcNow, roundupTimeZone).Date); + var daysSinceMonday = ((int)localDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; + return localDate.AddDays(-daysSinceMonday); + } + + private static long ToRoundupEpoch(DateOnly publishDate) + { + var roundupTimeZone = ResolveRoundupTimeZone(); + var publishLocal = publishDate.ToDateTime(new TimeOnly(9, 0, 0)); + return (long)TimeZoneInfo.ConvertTimeToUtc(publishLocal, roundupTimeZone) + .Subtract(DateTime.UnixEpoch) + .TotalSeconds; + } + + private static async Task> LoadLatestSectionRoundupsAsync(IDbConnection connection) + { + return (await connection.QueryAsync( + """ + SELECT DISTINCT ON (primary_section_name) + slug AS Slug, + date_epoch AS DateEpoch + FROM content_items + WHERE collection_name = 'roundups' + AND primary_section_name IS NOT NULL + AND primary_section_name <> 'all' + ORDER BY primary_section_name, date_epoch DESC + """)).ToList(); + } + + private static Task SetLatestRoundupsEpochAsync(IDbConnection connection, IEnumerable latestRoundups, long dateEpoch) + { + return connection.ExecuteAsync( + "UPDATE content_items SET date_epoch = @DateEpoch WHERE collection_name = 'roundups' AND slug = ANY(@Slugs)", + new + { + DateEpoch = dateEpoch, + Slugs = latestRoundups.Select(r => r.Slug).ToArray() + }); + } + + private static Task RestoreLatestRoundupsEpochAsync(IDbConnection connection, IEnumerable latestRoundups) + { + return connection.ExecuteAsync( + """ + UPDATE content_items AS c + SET date_epoch = v.date_epoch + FROM (SELECT UNNEST(@Slugs) AS slug, UNNEST(@Epochs) AS date_epoch) AS v + WHERE c.collection_name = 'roundups' + AND c.slug = v.slug + """, + new + { + Slugs = latestRoundups.Select(r => r.Slug).ToArray(), + Epochs = latestRoundups.Select(r => r.DateEpoch).ToArray() + }); + } + [Fact] public async Task ManageRequest_WithValidEmail_ReturnsOk() { From 11a73c37675f50614b5226292e94645094063b3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 11:19:51 +0000 Subject: [PATCH 5/7] Consolidate roundup send log to one cycle key --- .../Services/NewsletterBackgroundService.cs | 5 +- .../Interfaces/INewsletterService.cs | 2 +- .../Services/Newsletter/NewsletterService.cs | 92 ++++++------------- .../Endpoints/NewsletterEndpointsTests.cs | 12 +-- .../Services/NewsletterServiceTests.cs | 31 ++++--- 5 files changed, 54 insertions(+), 88 deletions(-) diff --git a/src/TechHub.Api/Services/NewsletterBackgroundService.cs b/src/TechHub.Api/Services/NewsletterBackgroundService.cs index 89dd6f413..cb3d049d3 100644 --- a/src/TechHub.Api/Services/NewsletterBackgroundService.cs +++ b/src/TechHub.Api/Services/NewsletterBackgroundService.cs @@ -155,7 +155,10 @@ AND primary_section_name <> 'all' var slugs = latestRoundups.Select(r => r.Slug).ToList(); if (slugs.Count > 0) { - await newsletterService.SendCombinedWeeklyAsync(slugs, ct); + await newsletterService.SendCombinedWeeklyAsync( + slugs, + sendTargetKey: expectedMonday.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + ct); } } diff --git a/src/TechHub.Core/Interfaces/INewsletterService.cs b/src/TechHub.Core/Interfaces/INewsletterService.cs index 53b552836..b488ef458 100644 --- a/src/TechHub.Core/Interfaces/INewsletterService.cs +++ b/src/TechHub.Core/Interfaces/INewsletterService.cs @@ -5,7 +5,7 @@ namespace TechHub.Core.Interfaces; public interface INewsletterService { Task SendRoundupNewsletterAsync(string roundupSlug, CancellationToken ct = default); - Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlugs, CancellationToken ct = default); + Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlugs, string? sendTargetKey = null, CancellationToken ct = default); Task SendTestEmailAsync(string roundupSlug, string recipientEmail, CancellationToken ct = default); Task SendTestWeeklyAsync(IReadOnlyList sections, string recipientEmail, CancellationToken ct = default); Task SendTestDailyEmailAsync(IReadOnlyList sections, string recipientEmail, CancellationToken ct = default); diff --git a/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs b/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs index c8370c966..d6069b8d4 100644 --- a/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs +++ b/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs @@ -153,7 +153,7 @@ await _subscriberRepository.LogSendAsync( return sent; } - public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlugs, CancellationToken ct = default) + public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlugs, string? sendTargetKey = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(roundupSlugs); if (roundupSlugs.Count == 0) @@ -161,44 +161,34 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu return false; } - if (!IsUnsubscribeSecretConfigured("combined-weekly")) - { - foreach (var slug in roundupSlugs) - { - await _subscriberRepository.LogSendAsync("weekly-roundup", slug, 0, "failed", "Unsubscribe secret is not configured", ct); - } - - return false; - } - - // Skip slugs that have already been sent - var newSlugs = new List(roundupSlugs.Count); + // Load roundup content for each slug + var roundups = new List(roundupSlugs.Count); foreach (var slug in roundupSlugs) { - if (!await _subscriberRepository.HasBeenSentAsync("weekly-roundup", slug, ct)) + var roundup = await GetRoundupBySlugAsync(slug, ct); + if (roundup is not null) { - newSlugs.Add(slug); + roundups.Add(roundup); } } - if (newSlugs.Count == 0) + if (roundups.Count == 0) { return false; } - // Load roundup content for each new slug - var roundups = new List(newSlugs.Count); - foreach (var slug in newSlugs) + var targetKey = string.IsNullOrWhiteSpace(sendTargetKey) + ? $"batch:{string.Join(",", roundups.Select(r => r.Slug).OrderBy(s => s, StringComparer.OrdinalIgnoreCase))}" + : sendTargetKey.Trim(); + + if (await _subscriberRepository.HasBeenSentAsync("weekly-roundup", targetKey, ct)) { - var roundup = await GetRoundupBySlugAsync(slug, ct); - if (roundup is not null) - { - roundups.Add(roundup); - } + return false; } - if (roundups.Count == 0) + if (!IsUnsubscribeSecretConfigured("combined-weekly")) { + await _subscriberRepository.LogSendAsync("weekly-roundup", targetKey, 0, "failed", "Unsubscribe secret is not configured", ct); return false; } @@ -215,8 +205,8 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu var roundupsBySection = roundups.ToDictionary(r => r.SectionName, StringComparer.OrdinalIgnoreCase); - // Track per-slug delivery counts: (expected recipients, successful sends) - var slugStats = newSlugs.ToDictionary(s => s, _ => (Expected: 0, Actual: 0), StringComparer.OrdinalIgnoreCase); + var attempted = 0; + var successful = 0; try { @@ -234,10 +224,7 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu continue; } - foreach (var r in relevantRoundups) - { - slugStats[r.Slug] = (slugStats[r.Slug].Expected + 1, slugStats[r.Slug].Actual); - } + attempted++; var unsubscribeUrl = BuildUnsubscribeUrl(subscriber.Email); var manageUrl = BuildManageUrl(subscriber.Email, _options.UnsubscribeSecret); @@ -249,49 +236,24 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu if (await SendEmailAsync(subscriber.Email, subject, html, text, ct)) { - foreach (var r in relevantRoundups) - { - slugStats[r.Slug] = (slugStats[r.Slug].Expected, slugStats[r.Slug].Actual + 1); - } + successful++; } } } catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { - foreach (var slug in newSlugs) - { - var actual = slugStats[slug].Actual; - var status = actual > 0 ? "partial" : "failed"; - await _subscriberRepository.LogSendAsync("weekly-roundup", slug, actual, status, ex.Message, ct); - } - + var status = successful > 0 ? "partial" : "failed"; + await _subscriberRepository.LogSendAsync("weekly-roundup", targetKey, successful, status, ex.Message, ct); _logger.LogError(ex, "Failed sending combined weekly newsletter"); return false; } - // Log each slug with its individual delivery outcome - var anySent = false; - foreach (var slug in newSlugs) - { - var (expected, actual) = slugStats[slug]; - if (expected == 0) - { - await _subscriberRepository.LogSendAsync("weekly-roundup", slug, 0, "sent", null, ct); - anySent = true; - } - else - { - var status = actual == expected ? "sent" : actual > 0 ? "partial" : "failed"; - var error = actual < expected ? $"Delivered to {actual} of {expected} subscribers" : null; - await _subscriberRepository.LogSendAsync("weekly-roundup", slug, actual, status, error, ct); - if (actual > 0) - { - anySent = true; - } - } - } - - return anySent; + var sendStatus = attempted == 0 || successful == attempted ? "sent" : successful > 0 ? "partial" : "failed"; + var sendError = attempted == 0 || successful == attempted + ? null + : $"Delivered to {successful} of {attempted} subscribers"; + await _subscriberRepository.LogSendAsync("weekly-roundup", targetKey, successful, sendStatus, sendError, ct); + return attempted == 0 || successful > 0; } public async Task SendTestWeeklyAsync(IReadOnlyList sections, string recipientEmail, CancellationToken ct = default) diff --git a/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs b/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs index d9d4cad02..258f2708a 100644 --- a/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs +++ b/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs @@ -104,13 +104,13 @@ public async Task AdminTriggerNewsletter_WhenScheduledSendingDisabled_StillProce var expectedMonday = GetExpectedRoundupMonday(DateTimeOffset.UtcNow, ResolveRoundupTimeZone()); await SetLatestRoundupsEpochAsync(arrangeConnection, latestRoundups, ToRoundupEpoch(expectedMonday)); - var roundupSlug = latestRoundups[0].Slug; + var roundupTargetKey = expectedMonday.ToString("yyyy-MM-dd"); try { var countBefore = await arrangeConnection.ExecuteScalarAsync( "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", - new { TargetKey = roundupSlug }); + new { TargetKey = roundupTargetKey }); var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=roundup", null, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Accepted); @@ -123,7 +123,7 @@ public async Task AdminTriggerNewsletter_WhenScheduledSendingDisabled_StillProce var pollConnection = pollScope.ServiceProvider.GetRequiredService(); var countAfter = await pollConnection.ExecuteScalarAsync( "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", - new { TargetKey = roundupSlug }); + new { TargetKey = roundupTargetKey }); if (countAfter > countBefore) { @@ -208,7 +208,7 @@ public async Task AdminTriggerNewsletter_RoundupsNotOnExpectedMonday_SkipsSend() var expectedMonday = GetExpectedRoundupMonday(DateTimeOffset.UtcNow, ResolveRoundupTimeZone()); var nonMondayEpoch = ToRoundupEpoch(expectedMonday.AddYears(10).AddDays(1)); - var targetSlug = latestRoundups[0].Slug; + var roundupTargetKey = expectedMonday.ToString("yyyy-MM-dd"); await SetLatestRoundupsEpochAsync(arrangeConnection, latestRoundups, nonMondayEpoch); @@ -216,7 +216,7 @@ public async Task AdminTriggerNewsletter_RoundupsNotOnExpectedMonday_SkipsSend() { var countBefore = await arrangeConnection.ExecuteScalarAsync( "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", - new { TargetKey = targetSlug }); + new { TargetKey = roundupTargetKey }); var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=roundup", null, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Accepted); @@ -229,7 +229,7 @@ public async Task AdminTriggerNewsletter_RoundupsNotOnExpectedMonday_SkipsSend() var pollConnection = pollScope.ServiceProvider.GetRequiredService(); var countAfter = await pollConnection.ExecuteScalarAsync( "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", - new { TargetKey = targetSlug }); + new { TargetKey = roundupTargetKey }); if (countAfter > countBefore) { wasSent = true; diff --git a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs index a6bf15355..803e8bbb3 100644 --- a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs +++ b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs @@ -224,16 +224,17 @@ public void IsValidUnsubscribeToken_WithDifferentDecodedLength_ReturnsFalse() public async Task SendCombinedWeeklyAsync_WhenAllSlugsAlreadySent_ReturnsFalse() { const string Slug = "weekly-ai-roundup-2026-06-01-combined-skip"; + const string TargetKey = "2026-06-01"; await SeedRoundupAsync(Slug); await _fixture.Connection.ExecuteAsync(""" INSERT INTO newsletter_send_log (send_kind, target_key, recipient_count, status) - VALUES ('weekly-roundup', @Slug, 0, 'sent') + VALUES ('weekly-roundup', @TargetKey, 0, 'sent') ON CONFLICT (send_kind, target_key) DO UPDATE SET status = 'sent' - """, new { Slug }); + """, new { TargetKey }); var sut = CreateService(); - var sent = await sut.SendCombinedWeeklyAsync([Slug], TestContext.Current.CancellationToken); + var sent = await sut.SendCombinedWeeklyAsync([Slug], TargetKey, TestContext.Current.CancellationToken); sent.Should().BeFalse(); } @@ -243,6 +244,7 @@ public async Task SendCombinedWeeklyAsync_SendsOneEmailPerSubscriberAcrossMultip { const string AiSlug = "weekly-ai-roundup-2026-06-02-combined"; const string DotnetSlug = "weekly-dotnet-roundup-2026-06-02-combined"; + const string TargetKey = "2026-06-02"; // Remove any weekly AI subscribers left by other tests so VerifyNoOtherCalls is reliable await _fixture.Connection.ExecuteAsync(""" @@ -278,7 +280,7 @@ ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO NOTHING var sut = CreateService(emailSender: emailSender.Object); - var sent = await sut.SendCombinedWeeklyAsync([AiSlug, DotnetSlug], TestContext.Current.CancellationToken); + var sent = await sut.SendCombinedWeeklyAsync([AiSlug, DotnetSlug], TargetKey, TestContext.Current.CancellationToken); sent.Should().BeTrue(); @@ -288,16 +290,15 @@ ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO NOTHING Times.Once); emailSender.VerifyNoOtherCalls(); - // Both slugs should be logged as sent - var aiStatus = await _fixture.Connection.ExecuteScalarAsync( - "SELECT status FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @Slug", - new { Slug = AiSlug }); - var dotnetStatus = await _fixture.Connection.ExecuteScalarAsync( - "SELECT status FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @Slug", - new { Slug = DotnetSlug }); - - aiStatus.Should().Be("sent"); - dotnetStatus.Should().Be("sent"); + var status = await _fixture.Connection.ExecuteScalarAsync( + "SELECT status FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey }); + var rowCount = await _fixture.Connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey }); + + status.Should().Be("sent"); + rowCount.Should().Be(1); } [Fact] @@ -348,7 +349,7 @@ ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO UPDATE SET preferenc .ReturnsAsync(true); var sut = CreateService(emailSender: emailSender.Object); - var sent = await sut.SendCombinedWeeklyAsync([DotnetSlug, AiSlug], TestContext.Current.CancellationToken); + var sent = await sut.SendCombinedWeeklyAsync([DotnetSlug, AiSlug], ct: TestContext.Current.CancellationToken); sent.Should().BeTrue(); htmlBody.Should().NotBeNull(); From 1d34a2170cd5cb7bdd7617da151d11c2092420da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 12:15:27 +0000 Subject: [PATCH 6/7] Fix flaky daily ordering newsletter test setup --- .../Services/NewsletterServiceTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs index 803e8bbb3..2ff0f22ba 100644 --- a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs +++ b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs @@ -390,8 +390,14 @@ ON CONFLICT (collection_name, slug) DO NOTHING string? htmlBody = null; var emailSender = new Mock(MockBehavior.Strict); emailSender - .Setup(s => s.SendAsync("daily-ordering@example.com", It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, _, html, _, _) => htmlBody = html) + .Setup(s => s.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((recipient, _, html, _, _) => + { + if (string.Equals(recipient, "daily-ordering@example.com", StringComparison.OrdinalIgnoreCase)) + { + htmlBody = html; + } + }) .ReturnsAsync(true); var contentRepository = new Mock(MockBehavior.Strict); From 179a1cdbefcc6e425154f42347a5eb31cb47765e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 12:18:58 +0000 Subject: [PATCH 7/7] Stabilize daily newsletter ordering test against extra recipients --- .../Services/NewsletterServiceTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs index 2ff0f22ba..057218f3b 100644 --- a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs +++ b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs @@ -361,6 +361,8 @@ ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO UPDATE SET preferenc [Fact] public async Task SendDailyOverviewAsync_OrdersSectionsByWebsiteOrderInEmail() { + const string RecipientEmail = "daily-ordering@example.com"; + await _fixture.Connection.ExecuteAsync(""" DELETE FROM newsletter_send_log WHERE send_kind = 'daily-overview' AND target_key = '2026-05-22'; DELETE FROM newsletter_subscribers WHERE email = 'daily-ordering@example.com'; @@ -393,7 +395,7 @@ ON CONFLICT (collection_name, slug) DO NOTHING .Setup(s => s.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((recipient, _, html, _, _) => { - if (string.Equals(recipient, "daily-ordering@example.com", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(recipient, RecipientEmail, StringComparison.Ordinal)) { htmlBody = html; }