From 7d9c22b37a2034b0f2c42ae924acdd5bf6742c70 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 29 Jul 2026 14:14:14 +0200 Subject: [PATCH] fix(tests): make notification-email waits deterministic instead of wall-clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create_NotifiesTheOwner (and the other notification tests) polled the fake email sender against a fixed 20s deadline. Under full-suite load that broke 2 ways: the daemon can legitimately take longer than the deadline, and a test event committed before the SubscribeFromPresent subscriptions seed their starting position is skipped outright, so no deadline would ever see the email. The factory now blocks fixture init until every subscription agent is registered (position seeded while the database is still empty), and tests wait on Marten's WaitForNonStaleProjectionDataAsync — daemon progress, not a guessed timeout. Since delivery is final once the daemon catches up, the 1.5s "no extra email" grace sleeps are gone too. Co-Authored-By: Claude Fable 5 --- .../Features/Blog/BlogApiTests.cs | 26 ++---------- .../Features/JobOffers/JobOfferApiTests.cs | 28 ++----------- .../Helpers/TestWebApplicationFactory.cs | 42 +++++++++++++++++++ 3 files changed, 50 insertions(+), 46 deletions(-) diff --git a/backend/tests/Kalandra.Api.IntegrationTests/Features/Blog/BlogApiTests.cs b/backend/tests/Kalandra.Api.IntegrationTests/Features/Blog/BlogApiTests.cs index d42a1594..982e9e8e 100644 --- a/backend/tests/Kalandra.Api.IntegrationTests/Features/Blog/BlogApiTests.cs +++ b/backend/tests/Kalandra.Api.IntegrationTests/Features/Blog/BlogApiTests.cs @@ -785,7 +785,7 @@ public async Task PostComment_NotifiesTheBlogAuthor() var response = await client.PostAsJsonAsync($"/api/blog/{slug}/comments", new { content }, Ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var email = Assert.Single(await WaitForEmailsAsync(m => m.TextBody.Value.Contains(content), expectedCount: 1)); + var email = Assert.Single(await factory.WaitForDeliveredEmailsAsync(m => m.TextBody.Value.Contains(content))); Assert.Equal("author@kalandra.local", email.To.Address); Assert.Contains(slug, email.Subject.Value); Assert.Contains($"https://www.kalandra.tech/blog/{slug}", email.TextBody.Value); @@ -804,7 +804,7 @@ public async Task Reply_NotifiesBlogAuthorAndParentCommentAuthor() Authenticate(userId: Guid.NewGuid(), email: "replier@test.com"); await client.PostAsJsonAsync($"/api/blog/{slug}/comments", new { content = replyContent, parentCommentId = parentId }, Ct); - var emails = await WaitForEmailsAsync(m => m.TextBody.Value.Contains(replyContent), expectedCount: 2); + var emails = await factory.WaitForDeliveredEmailsAsync(m => m.TextBody.Value.Contains(replyContent)); Assert.Equal(2, emails.Length); Assert.Contains(emails, m => m.To.Address == "author@kalandra.local"); var parentNotification = Assert.Single(emails, m => m.To.Address == "parent-author@test.com"); @@ -825,10 +825,7 @@ await client.PostAsJsonAsync( new { content = replyContent, parentCommentId = parent.GetProperty("id").GetString() }, Ct); - var emails = await WaitForEmailsAsync(m => m.TextBody.Value.Contains(replyContent), expectedCount: 1); - // Grace period: a wrong extra notification would arrive moments later. - await Task.Delay(1500, Ct); - emails = [.. factory.EmailSender.Sent.Where(m => m.TextBody.Value.Contains(replyContent))]; + var emails = await factory.WaitForDeliveredEmailsAsync(m => m.TextBody.Value.Contains(replyContent)); var email = Assert.Single(emails); Assert.Equal("author@kalandra.local", email.To.Address); @@ -849,26 +846,11 @@ public async Task AuthorsOwnComment_SendsNoEmailAtAll() var comments = (await ParseJsonAsync(await client.GetAsync($"/api/blog/{slug}/comments", Ct))).GetProperty("comments"); Assert.Equal(1, comments.GetArrayLength()); - await Task.Delay(1500, Ct); - Assert.DoesNotContain(factory.EmailSender.Sent, m => m.TextBody.Value.Contains(content)); + Assert.Empty(await factory.WaitForDeliveredEmailsAsync(m => m.TextBody.Value.Contains(content))); } // ───── Helpers ───── - private async Task WaitForEmailsAsync( - Func predicate, int expectedCount) - { - var deadline = DateTime.UtcNow.AddSeconds(20); - while (DateTime.UtcNow < deadline) - { - var matches = factory.EmailSender.Sent.Where(predicate).ToArray(); - if (matches.Length >= expectedCount) - return matches; - await Task.Delay(200, Ct); - } - return [.. factory.EmailSender.Sent.Where(predicate)]; - } - /// Unique per test — the factory shares one database across the class. private static string NewSlug() => $"post-{Guid.NewGuid():N}"; diff --git a/backend/tests/Kalandra.Api.IntegrationTests/Features/JobOffers/JobOfferApiTests.cs b/backend/tests/Kalandra.Api.IntegrationTests/Features/JobOffers/JobOfferApiTests.cs index b59babb2..4405c3a2 100644 --- a/backend/tests/Kalandra.Api.IntegrationTests/Features/JobOffers/JobOfferApiTests.cs +++ b/backend/tests/Kalandra.Api.IntegrationTests/Features/JobOffers/JobOfferApiTests.cs @@ -697,7 +697,7 @@ public async Task Create_NotifiesTheOwner() Assert.Equal(HttpStatusCode.Created, response.StatusCode); var offerId = AssertValidGuid(await ParseJsonAsync(response), "id"); - var email = Assert.Single(await WaitForEmailsAsync(m => m.TextBody.Value.Contains(marker), expectedCount: 1)); + var email = Assert.Single(await factory.WaitForDeliveredEmailsAsync(m => m.TextBody.Value.Contains(marker))); Assert.Equal("owner@kalandra.local", email.To.Address); Assert.Equal("New job offer: Senior Developer at Acme Corp", email.Subject.Value); Assert.Contains("john@acme.com", email.TextBody.Value); @@ -713,10 +713,7 @@ public async Task OfferAuthorsComment_NotifiesOnlyTheOwner() var response = await client.PostAsJsonAsync($"/api/job-offers/{id}/comments", new { content = marker }, Ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var emails = await WaitForEmailsAsync(m => m.TextBody.Value.Contains(marker), expectedCount: 1); - // Grace period: a wrong extra notification would arrive moments later. - await Task.Delay(1500, Ct); - emails = [.. factory.EmailSender.Sent.Where(m => m.TextBody.Value.Contains(marker))]; + var emails = await factory.WaitForDeliveredEmailsAsync(m => m.TextBody.Value.Contains(marker)); var email = Assert.Single(emails); Assert.Equal("owner@kalandra.local", email.To.Address); @@ -734,10 +731,7 @@ public async Task OwnersComment_NotifiesOnlyTheOfferAuthor() var response = await client.PostAsJsonAsync($"/api/job-offers/{id}/comments", new { content = marker }, Ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var emails = await WaitForEmailsAsync(m => m.TextBody.Value.Contains(marker), expectedCount: 1); - // Grace period: a wrong extra notification would arrive moments later. - await Task.Delay(1500, Ct); - emails = [.. factory.EmailSender.Sent.Where(m => m.TextBody.Value.Contains(marker))]; + var emails = await factory.WaitForDeliveredEmailsAsync(m => m.TextBody.Value.Contains(marker)); var email = Assert.Single(emails); Assert.Equal("notified-author@test.com", email.To.Address); @@ -755,7 +749,7 @@ public async Task NonOwnerAdminsComment_NotifiesOwnerAndOfferAuthor() var response = await client.PostAsJsonAsync($"/api/job-offers/{id}/comments", new { content = marker }, Ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var emails = await WaitForEmailsAsync(m => m.TextBody.Value.Contains(marker), expectedCount: 2); + var emails = await factory.WaitForDeliveredEmailsAsync(m => m.TextBody.Value.Contains(marker)); Assert.Equal(2, emails.Length); Assert.Contains(emails, m => m.To.Address == "owner@kalandra.local"); Assert.Contains(emails, m => m.To.Address == "watched-author@test.com"); @@ -810,20 +804,6 @@ public async Task Health_ReturnsOk() private static readonly Guid AdminUserId = new("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - private async Task WaitForEmailsAsync( - Func predicate, int expectedCount) - { - var deadline = DateTime.UtcNow.AddSeconds(20); - while (DateTime.UtcNow < deadline) - { - var matches = factory.EmailSender.Sent.Where(predicate).ToArray(); - if (matches.Length >= expectedCount) - return matches; - await Task.Delay(200, Ct); - } - return [.. factory.EmailSender.Sent.Where(predicate)]; - } - private Guid Authenticate( string email = "test@example.com", bool isAdmin = false) diff --git a/backend/tests/Kalandra.Api.IntegrationTests/Helpers/TestWebApplicationFactory.cs b/backend/tests/Kalandra.Api.IntegrationTests/Helpers/TestWebApplicationFactory.cs index 201408c8..be33fa40 100644 --- a/backend/tests/Kalandra.Api.IntegrationTests/Helpers/TestWebApplicationFactory.cs +++ b/backend/tests/Kalandra.Api.IntegrationTests/Helpers/TestWebApplicationFactory.cs @@ -5,6 +5,9 @@ using Kalandra.Infrastructure.Storage; using Kalandra.Infrastructure.Turnstile; using Kalandra.Infrastructure.Users; +using Marten; +using Marten.Events; +using Marten.Events.Daemon.Coordination; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -38,10 +41,26 @@ public class TestWebApplicationFactory : WebApplicationFactory, IAsyncL private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine") .Build(); + // Generous because CI runs Docker and several test hosts in parallel; progress-based waits return + // the moment the daemon catches up, so the cap only bites when something is genuinely broken. + private static readonly TimeSpan AsyncDaemonTimeout = TimeSpan.FromSeconds(60); + public FakeSupabaseAdminService FakeAdminService { get; } = new(); public TestEmailSender EmailSender { get; } = new(); public FakeUserInfoService UserInfoService { get; } = new(); + /// + /// Waits until the async daemon has processed every event committed so far, then returns the sent + /// emails matching the predicate. The result is final for those events — a missing or extra + /// notification is a real bug, not a race — so callers assert exact counts without sleeping. + /// + public async Task WaitForDeliveredEmailsAsync(Func predicate) + { + var store = Services.GetRequiredService(); + await store.WaitForNonStaleProjectionDataAsync(AsyncDaemonTimeout); + return [.. EmailSender.Sent.Where(predicate)]; + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { // Mirror the dev/e2e environment so the configs' prod-only localhost checks stay out of the test host. @@ -106,6 +125,29 @@ protected override void ConfigureClient(HttpClient client) public async ValueTask InitializeAsync() { await _postgres.StartAsync(); + await WaitForNotificationSubscriptionsToStartAsync(); + } + + // SubscribeFromPresent seeds each subscription at the current high-water mark when its shard starts — + // an event committed before that would be skipped, never emailed. Agents register only after seeding, + // so waiting for registration while the database is still empty guarantees no test event is missed. + private async Task WaitForNotificationSubscriptionsToStartAsync() + { + var store = (DocumentStore)Services.GetRequiredService(); + var expectedShards = store.Options.Projections.AllShards().Select(shard => shard.Name.Identity).ToHashSet(); + var daemon = Services.GetRequiredService().DaemonForMainDatabase(); + + using var cancellation = new CancellationTokenSource(AsyncDaemonTimeout); + try + { + while (!expectedShards.IsSubsetOf(daemon.CurrentAgents().Select(agent => agent.Name.Identity))) + await Task.Delay(100, cancellation.Token); + } + catch (OperationCanceledException) + { + throw new TimeoutException( + $"The async daemon did not start subscriptions [{string.Join(", ", expectedShards)}] within {AsyncDaemonTimeout}."); + } } public new async ValueTask DisposeAsync()