From d62224c56be18c9e89341a66b52bbaeb3b7d8dc1 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 19 Jun 2026 22:35:34 -0300 Subject: [PATCH 1/2] fix(reminders,streaks): correct UTC-vs-user-local date skew in background jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reminder scheduler's relative-reminder path deduped against `Date == utcToday`, but HabitLog.Date and SentReminder.Date store the user's LOCAL date. For users whose local date differs from UTC (e.g. Brazil after UTC midnight), the bulk pre-check missed already-sent reminders and already-logged habits, so every tick re-attempted the insert and hit the SentReminders unique index — a recurring production 500 surfaced via Sentry. - ReminderSchedulerService.ProcessRelativeReminders: query the +/-1-day window and key the dedup and logged sets on the user's local date (mirrors ProcessScheduledReminders). - GoalDeadlineNotificationService / StreakGoalSyncService: widen the streak log lookback by one day so a far-west user's oldest in-window logs aren't clipped by the UTC bound. - HabitDueDateAdvancementService inspected and intentionally left: its UTC cutoff is a deliberate conservative buffer; the per-user check (DueDate < userToday) is authoritative. Adds regression tests that reproduce the cross-timezone scenario deterministically. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GoalDeadlineNotificationService.cs | 2 +- .../Services/ReminderSchedulerService.cs | 34 ++++--- .../Services/StreakGoalSyncService.cs | 2 +- .../Services/ReminderSchedulerServiceTests.cs | 97 +++++++++++++++++++ 4 files changed, 120 insertions(+), 15 deletions(-) diff --git a/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs b/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs index f0d8fc57..af16f045 100644 --- a/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs +++ b/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs @@ -93,7 +93,7 @@ await ProcessGoalDeadlineAsync( private async Task> ComputeFreshStreakValuesAsync(OrbitDbContext dbContext, CancellationToken ct) { - var streakWindowStart = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-AppConstants.MaxStreakLookbackDays); + var streakWindowStart = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-AppConstants.MaxStreakLookbackDays - 1); var streakGoals = await dbContext.Goals .AsNoTracking() .Where(g => g.Type == GoalType.Streak && g.Status == GoalStatus.Active && g.Deadline != null && !g.IsDeleted) diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index 9823c5b2..f90cf835 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -79,31 +79,39 @@ private async Task ProcessRelativeReminders(OrbitDbContext dbContext, IPushNotif var habitIds = habits.Select(h => h.Id).ToList(); var utcToday = DateOnly.FromDateTime(DateTime.UtcNow); - var loggedHabitIds = (await dbContext.HabitLogs + var minWindowDate = utcToday.AddDays(-1); + var maxWindowDate = utcToday.AddDays(1); + + var loggedHabitDates = (await dbContext.HabitLogs .AsNoTracking() - .Where(l => habitIds.Contains(l.HabitId) && l.Date == utcToday) - .Select(l => l.HabitId) - .ToListAsync(ct)).ToHashSet(); + .Where(l => habitIds.Contains(l.HabitId) + && l.Date >= minWindowDate && l.Date <= maxWindowDate) + .Select(l => new { l.HabitId, l.Date }) + .ToListAsync(ct)) + .Select(l => (l.HabitId, l.Date)) + .ToHashSet(); var sentReminderKeys = await dbContext.SentReminders .AsNoTracking() - .Where(r => habitIds.Contains(r.HabitId) && r.Date == utcToday && r.ReminderTimeUtc == null) - .Select(r => new { r.HabitId, r.MinutesBefore }) + .Where(r => habitIds.Contains(r.HabitId) && r.ReminderTimeUtc == null + && r.Date >= minWindowDate && r.Date <= maxWindowDate) + .Select(r => new { r.HabitId, r.Date, r.MinutesBefore }) .ToListAsync(ct); var sentReminderSet = sentReminderKeys - .Select(r => (r.HabitId, r.MinutesBefore)) + .Select(r => (r.HabitId, r.Date, r.MinutesBefore)) .ToHashSet(); foreach (var habit in habits) { await ProcessSingleRelativeReminderAsync( - habit, users, loggedHabitIds, sentReminderSet, pushService, dbContext, ct); + habit, users, loggedHabitDates, sentReminderSet, pushService, dbContext, ct); } } private async Task ProcessSingleRelativeReminderAsync( - Habit habit, Dictionary users, HashSet loggedHabitIds, - HashSet<(Guid HabitId, int MinutesBefore)> sentReminderSet, + Habit habit, Dictionary users, + HashSet<(Guid HabitId, DateOnly Date)> loggedHabitDates, + HashSet<(Guid HabitId, DateOnly Date, int MinutesBefore)> sentReminderSet, IPushNotificationService pushService, OrbitDbContext dbContext, CancellationToken ct) { if (!users.TryGetValue(habit.UserId, out var user)) return; @@ -114,13 +122,13 @@ private async Task ProcessSingleRelativeReminderAsync( var userTimeNow = TimeOnly.FromDateTime(userNow); if (!HabitScheduleService.IsHabitDueOnDate(habit, userToday)) return; - if (loggedHabitIds.Contains(habit.Id)) return; + if (loggedHabitDates.Contains((habit.Id, userToday))) return; foreach (var minutesBefore in habit.ReminderTimes) { var reminderTime = habit.DueTime!.Value.AddMinutes(-minutesBefore); if (userTimeNow < reminderTime) continue; - if (sentReminderSet.Contains((habit.Id, minutesBefore))) continue; + if (sentReminderSet.Contains((habit.Id, userToday, minutesBefore))) continue; var lang = user.Language ?? "en"; var minutesText = FormatReminderText(minutesBefore, lang); @@ -128,7 +136,7 @@ private async Task ProcessSingleRelativeReminderAsync( var sentReminder = SentReminder.Create(habit.Id, userToday, minutesBefore); var notification = Notification.Create(habit.UserId, habit.Title, minutesText, "/", habit.Id); - sentReminderSet.Add((habit.Id, minutesBefore)); + sentReminderSet.Add((habit.Id, userToday, minutesBefore)); if (!await TryRecordAndSendAsync(habit, sentReminder, notification, minutesText, pushService, dbContext, ct)) continue; diff --git a/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs b/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs index cfd81a57..27e28737 100644 --- a/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs +++ b/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs @@ -62,7 +62,7 @@ internal async Task SyncActiveStreakGoals(CancellationToken ct) using var scope = scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var streakWindowStart = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-AppConstants.MaxStreakLookbackDays); + var streakWindowStart = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-AppConstants.MaxStreakLookbackDays - 1); var goals = await dbContext.Goals .Where(g => g.Type == GoalType.Streak && g.Status == GoalStatus.Active && !g.IsDeleted) .Include(g => g.Habits).ThenInclude(h => h.Logs.Where(l => l.Date >= streakWindowStart)) diff --git a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs index 70e55fc8..09b10873 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs @@ -471,6 +471,103 @@ await pushService.DidNotReceive().SendToUserAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task CheckAndSendReminders_RelativeReminderAlreadySentOnUserLocalDate_DoesNotResendWhenLocalDiffersFromUtc() + { + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + + var nowUtc = DateTime.UtcNow; + var timeZoneId = nowUtc.TimeOfDay < TimeSpan.FromHours(12) ? "Etc/GMT+12" : "Etc/GMT-12"; + var userToday = DateOnly.FromDateTime( + TimeZoneInfo.ConvertTimeFromUtc(nowUtc, TimeZoneInfo.FindSystemTimeZoneById(timeZoneId))); + userToday.Should().NotBe(UtcToday); + + var user = User.Create("Thomas", "thomas@test.com").Value; + user.SetTimeZone(timeZoneId); + var habit = Habit.Create(new HabitCreateParams( + user.Id, "Workout", FrequencyUnit.Day, 1, + ReminderEnabled: true, + DueDate: UtcToday.AddDays(-1), + DueTime: new TimeOnly(0, 0), + ReminderTimes: new[] { 0 })).Value; + + dbContext.Users.Add(user); + dbContext.Habits.Add(habit); + dbContext.SentReminders.Add(SentReminder.Create(habit.Id, userToday, 0)); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService); + await service.CheckAndSendReminders(CancellationToken.None); + + (await dbContext.SentReminders.CountAsync(r => r.HabitId == habit.Id)).Should().Be(1); + await pushService.DidNotReceive().SendToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CheckAndSendReminders_RelativeReminderHabitLoggedOnUserLocalDate_DoesNotSendWhenLocalDiffersFromUtc() + { + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + + var nowUtc = DateTime.UtcNow; + var timeZoneId = nowUtc.TimeOfDay < TimeSpan.FromHours(12) ? "Etc/GMT+12" : "Etc/GMT-12"; + var userToday = DateOnly.FromDateTime( + TimeZoneInfo.ConvertTimeFromUtc(nowUtc, TimeZoneInfo.FindSystemTimeZoneById(timeZoneId))); + userToday.Should().NotBe(UtcToday); + + var user = User.Create("Thomas", "thomas@test.com").Value; + user.SetTimeZone(timeZoneId); + var habit = Habit.Create(new HabitCreateParams( + user.Id, "Workout", FrequencyUnit.Day, 1, + ReminderEnabled: true, + DueDate: UtcToday.AddDays(-1), + DueTime: new TimeOnly(0, 0), + ReminderTimes: new[] { 0 })).Value; + + habit.Log(userToday, advanceDueDate: false); + dbContext.Users.Add(user); + dbContext.Habits.Add(habit); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService); + await service.CheckAndSendReminders(CancellationToken.None); + + (await dbContext.SentReminders.CountAsync(r => r.HabitId == habit.Id)).Should().Be(0); + await pushService.DidNotReceive().SendToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CheckAndSendReminders_RelativeReminderSentYesterday_DoesNotBlockToday() + { + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + + var user = User.Create("Thomas", "thomas@test.com").Value; + var habit = Habit.Create(new HabitCreateParams( + user.Id, "Workout", FrequencyUnit.Day, 1, + ReminderEnabled: true, + DueDate: UtcToday.AddDays(-1), + DueTime: new TimeOnly(0, 0), + ReminderTimes: new[] { 0 })).Value; + + dbContext.Users.Add(user); + dbContext.Habits.Add(habit); + dbContext.SentReminders.Add(SentReminder.Create(habit.Id, UtcToday.AddDays(-1), 0)); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService); + await service.CheckAndSendReminders(CancellationToken.None); + + var sentToday = await dbContext.SentReminders + .Where(r => r.HabitId == habit.Id && r.Date == UtcToday).ToListAsync(); + sentToday.Should().ContainSingle(); + await pushService.Received(1).SendToUserAsync( + user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); + } + private static OrbitDbContext CreateInMemoryDbContext() => new(new DbContextOptionsBuilder() .UseInMemoryDatabase($"ReminderSchedulerServiceTests_{Guid.NewGuid()}") From 0132cb3a8ec7f490d4a738e95e85f8a8d604bd2a Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 19 Jun 2026 22:56:39 -0300 Subject: [PATCH 2/2] refactor(observability): retire code-level Discord notifier; Sentry is the single alert source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UnhandledExceptionHandler already calls SentrySdk.CaptureException for every HTTP 500, so with orbit-api added to the Sentry->Discord alert rule the bespoke DiscordAlertNotifier is redundant — and it double-pinged Discord on 500s. Removing it unifies alerting across web, mobile, and API on one mechanism: consistent formatting, one place to tune rules, and Sentry messages link to full issue context. Removes DiscordAlertNotifier + IAlertNotifier + DiscordAlertSettings + the "Discord" HttpClient registration + the DiscordAlerts config section, and the now-unused IAlertNotifier arg in UnhandledExceptionHandler. The DiscordAlerts__WebhookUrl env var on Render is now unused. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Extensions/ServiceCollectionExtensions.cs | 6 - .../Middleware/UnhandledExceptionHandler.cs | 17 +-- src/Orbit.Api/appsettings.json | 3 - src/Orbit.Domain/Interfaces/IAlertNotifier.cs | 16 --- .../Configuration/DiscordAlertSettings.cs | 7 -- .../Services/DiscordAlertNotifier.cs | 107 ------------------ .../RequestObservabilityMiddlewareTests.cs | 5 +- .../Services/DiscordAlertNotifierTests.cs | 86 -------------- 8 files changed, 2 insertions(+), 245 deletions(-) delete mode 100644 src/Orbit.Domain/Interfaces/IAlertNotifier.cs delete mode 100644 src/Orbit.Infrastructure/Configuration/DiscordAlertSettings.cs delete mode 100644 src/Orbit.Infrastructure/Services/DiscordAlertNotifier.cs delete mode 100644 tests/Orbit.Infrastructure.Tests/Services/DiscordAlertNotifierTests.cs diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index 38eb26bb..6dc5010e 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -561,12 +561,6 @@ public static WebApplicationBuilder AddOrbitObservability(this WebApplicationBui { builder.Services.Configure( builder.Configuration.GetSection(SentrySettings.SectionName)); - builder.Services.Configure( - builder.Configuration.GetSection(DiscordAlertSettings.SectionName)); - - var httpTimeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("HttpClients:DefaultTimeoutSeconds", 30)); - builder.Services.AddHttpClient("Discord", client => client.Timeout = httpTimeout); - builder.Services.AddSingleton(); var sentrySettings = builder.Configuration.GetSection(SentrySettings.SectionName).Get() ?? new SentrySettings(); diff --git a/src/Orbit.Api/Middleware/UnhandledExceptionHandler.cs b/src/Orbit.Api/Middleware/UnhandledExceptionHandler.cs index 483ecadc..24e62079 100644 --- a/src/Orbit.Api/Middleware/UnhandledExceptionHandler.cs +++ b/src/Orbit.Api/Middleware/UnhandledExceptionHandler.cs @@ -1,13 +1,11 @@ using Microsoft.AspNetCore.Diagnostics; using Orbit.Api.Extensions; -using Orbit.Domain.Interfaces; using Sentry; namespace Orbit.Api.Middleware; internal sealed partial class UnhandledExceptionHandler( - ILogger logger, - IAlertNotifier alertNotifier) : IExceptionHandler + ILogger logger) : IExceptionHandler { public async ValueTask TryHandleAsync( HttpContext httpContext, @@ -24,19 +22,6 @@ public async ValueTask TryHandleAsync( SentrySdk.CaptureException(exception); - _ = alertNotifier.SendCriticalAsync( - $"{exception.GetType().Name}: {exception.Message}", - $"{method} {path}", - new Dictionary - { - ["Method"] = method, - ["Path"] = path, - ["RequestId"] = requestId, - ["ClientIp"] = clientIp, - ["UserId"] = userId, - }, - CancellationToken.None); - httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; httpContext.Response.ContentType = "application/json"; httpContext.Response.Headers[HttpContextExtensions.RequestIdHeaderName] = httpContext.GetRequestId(); diff --git a/src/Orbit.Api/appsettings.json b/src/Orbit.Api/appsettings.json index fbe77b2e..461d64bc 100644 --- a/src/Orbit.Api/appsettings.json +++ b/src/Orbit.Api/appsettings.json @@ -84,8 +84,5 @@ "Dsn": "", "Environment": "production", "TracesSampleRate": 0 - }, - "DiscordAlerts": { - "WebhookUrl": "" } } diff --git a/src/Orbit.Domain/Interfaces/IAlertNotifier.cs b/src/Orbit.Domain/Interfaces/IAlertNotifier.cs deleted file mode 100644 index 5326dfec..00000000 --- a/src/Orbit.Domain/Interfaces/IAlertNotifier.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Orbit.Domain.Interfaces; - -/// -/// Posts a critical operational alert to an out-of-band channel (Discord webhook) so the operator -/// learns about a server fault from a push instead of an angry user. The context map carries only -/// non-PII diagnostics (method, path, request id, client ip, user id); implementations must scrub -/// anything sensitive before transmitting. A disabled or failing notifier never throws. -/// -public interface IAlertNotifier -{ - Task SendCriticalAsync( - string title, - string detail, - IReadOnlyDictionary context, - CancellationToken cancellationToken); -} diff --git a/src/Orbit.Infrastructure/Configuration/DiscordAlertSettings.cs b/src/Orbit.Infrastructure/Configuration/DiscordAlertSettings.cs deleted file mode 100644 index bbd8b637..00000000 --- a/src/Orbit.Infrastructure/Configuration/DiscordAlertSettings.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Orbit.Infrastructure.Configuration; - -public sealed class DiscordAlertSettings -{ - public const string SectionName = "DiscordAlerts"; - public string WebhookUrl { get; init; } = ""; -} diff --git a/src/Orbit.Infrastructure/Services/DiscordAlertNotifier.cs b/src/Orbit.Infrastructure/Services/DiscordAlertNotifier.cs deleted file mode 100644 index 932f40bd..00000000 --- a/src/Orbit.Infrastructure/Services/DiscordAlertNotifier.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Orbit.Domain.Interfaces; -using Orbit.Infrastructure.Configuration; - -namespace Orbit.Infrastructure.Services; - -/// -/// Posts critical alerts to a Discord channel webhook. No-ops when the webhook URL is unconfigured, -/// so a missing Render env var disables alerting instead of crashing. Scrubs email addresses and -/// bearer/JWT tokens out of every field before serialization so an exception message cannot leak -/// PII into the channel, and never lets a webhook failure mask the original error it was reporting. -/// -public sealed partial class DiscordAlertNotifier( - IHttpClientFactory httpClientFactory, - IOptions options, - ILogger logger) : IAlertNotifier -{ - private const int MaxFieldLength = 1000; - private static readonly string[] AllowedContextKeys = - ["Method", "Path", "RequestId", "ClientIp", "UserId"]; - - private readonly DiscordAlertSettings _settings = options.Value; - - public async Task SendCriticalAsync( - string title, - string detail, - IReadOnlyDictionary context, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_settings.WebhookUrl)) - return; - - var payload = BuildPayload(title, detail, context); - var content = new StringContent( - JsonSerializer.Serialize(payload), - Encoding.UTF8, - "application/json"); - - try - { - var client = httpClientFactory.CreateClient("Discord"); - var response = await client.PostAsync(_settings.WebhookUrl, content, cancellationToken); - if (!response.IsSuccessStatusCode) - LogAlertRejected(logger, response.StatusCode); - } - catch (Exception exception) - { - LogAlertFailed(logger, exception); - } - } - - private static object BuildPayload( - string title, - string detail, - IReadOnlyDictionary context) - { - var fields = AllowedContextKeys - .Where(key => context.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)) - .Select(key => new - { - name = key, - value = Scrub(Truncate(context[key]!)), - inline = true, - }) - .ToArray(); - - return new - { - content = Scrub(Truncate(title)), - embeds = new[] - { - new - { - title = "Critical error", - description = Scrub(Truncate(detail)), - color = 15548997, - fields, - }, - }, - }; - } - - private static string Truncate(string value) => - value.Length <= MaxFieldLength ? value : value[..MaxFieldLength]; - - private static string Scrub(string value) - { - var withoutEmails = EmailPattern().Replace(value, "[redacted-email]"); - return BearerTokenPattern().Replace(withoutEmails, "[redacted-token]"); - } - - [GeneratedRegex(@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", RegexOptions.Compiled)] - private static partial Regex EmailPattern(); - - [GeneratedRegex(@"(?i)bearer\s+[A-Za-z0-9._\-]+|eyJ[A-Za-z0-9._\-]+", RegexOptions.Compiled)] - private static partial Regex BearerTokenPattern(); - - [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Discord alert rejected with status {Status}")] - private static partial void LogAlertRejected(ILogger logger, System.Net.HttpStatusCode status); - - [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Discord alert failed to send")] - private static partial void LogAlertFailed(ILogger logger, Exception exception); -} diff --git a/tests/Orbit.Infrastructure.Tests/Middleware/RequestObservabilityMiddlewareTests.cs b/tests/Orbit.Infrastructure.Tests/Middleware/RequestObservabilityMiddlewareTests.cs index 96dd074f..8d2a4a21 100644 --- a/tests/Orbit.Infrastructure.Tests/Middleware/RequestObservabilityMiddlewareTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Middleware/RequestObservabilityMiddlewareTests.cs @@ -2,10 +2,8 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; using Orbit.Api.Extensions; using Orbit.Api.Middleware; -using Orbit.Domain.Interfaces; namespace Orbit.Infrastructure.Tests.Middleware; @@ -58,8 +56,7 @@ public async Task ValidationExceptionHandler_ReturnsRequestIdInResponse() public async Task UnhandledExceptionHandler_ReturnsStructured500WithRequestId() { var handler = new UnhandledExceptionHandler( - NullLogger.Instance, - Substitute.For()); + NullLogger.Instance); var httpContext = new DefaultHttpContext(); httpContext.TraceIdentifier = "req_server_123"; httpContext.Request.Method = HttpMethods.Post; diff --git a/tests/Orbit.Infrastructure.Tests/Services/DiscordAlertNotifierTests.cs b/tests/Orbit.Infrastructure.Tests/Services/DiscordAlertNotifierTests.cs deleted file mode 100644 index 101752a0..00000000 --- a/tests/Orbit.Infrastructure.Tests/Services/DiscordAlertNotifierTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Net; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using NSubstitute; -using Orbit.Infrastructure.Configuration; -using Orbit.Infrastructure.Services; - -namespace Orbit.Infrastructure.Tests.Services; - -public class DiscordAlertNotifierTests -{ - private const string WebhookUrl = "https://discord.com/api/webhooks/123/abc"; - - [Fact] - public async Task SendCriticalAsync_ScrubsEmailTokenAndBodyFromPayload() - { - var handler = new FakeHttpMessageHandler { ResponseToReturn = new HttpResponseMessage(HttpStatusCode.NoContent) }; - var notifier = CreateNotifier(handler, WebhookUrl); - - await notifier.SendCriticalAsync( - "InvalidOperationException: send-code failed for alice@example.com using Bearer eyJabc123.def456.ghi789", - "POST /api/auth/verify-code", - new Dictionary - { - ["Method"] = "POST", - ["Path"] = "/api/auth/verify-code", - ["RequestId"] = "req_abc", - ["ClientIp"] = "203.0.113.7", - ["UserId"] = "11111111-1111-1111-1111-111111111111", - ["Email"] = "bob@example.com", - }, - CancellationToken.None); - - var body = handler.LastRequestBody; - body.Should().NotContain("alice@example.com"); - body.Should().NotContain("bob@example.com"); - body.Should().NotContain("eyJabc123.def456.ghi789"); - body.Should().NotContain("Bearer eyJ"); - body.Should().Contain("req_abc"); - body.Should().Contain("/api/auth/verify-code"); - body.Should().Contain("11111111-1111-1111-1111-111111111111"); - } - - [Fact] - public async Task SendCriticalAsync_DoesNotPost_WhenWebhookUrlEmpty() - { - var handler = new FakeHttpMessageHandler(); - var notifier = CreateNotifier(handler, ""); - - await notifier.SendCriticalAsync( - "boom", - "GET /health", - new Dictionary { ["RequestId"] = "req_x" }, - CancellationToken.None); - - handler.LastRequest.Should().BeNull(); - } - - private static DiscordAlertNotifier CreateNotifier(FakeHttpMessageHandler handler, string webhookUrl) - { - var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://discord.com") }; - var factory = Substitute.For(); - factory.CreateClient("Discord").Returns(httpClient); - - var settings = Options.Create(new DiscordAlertSettings { WebhookUrl = webhookUrl }); - return new DiscordAlertNotifier(factory, settings, NullLogger.Instance); - } - - private sealed class FakeHttpMessageHandler : HttpMessageHandler - { - public HttpResponseMessage ResponseToReturn { get; set; } = new(HttpStatusCode.NoContent); - public HttpRequestMessage? LastRequest { get; private set; } - public string LastRequestBody { get; private set; } = ""; - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - LastRequest = request; - if (request.Content is not null) - LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); - - return ResponseToReturn; - } - } -}