diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index 1f56b1931..79bb6dfde 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -113,6 +113,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => diff --git a/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs b/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs index f1db14d1c..0033c39a1 100644 --- a/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs @@ -22,10 +22,13 @@ public record GetDailySummaryQuery( DateOnly DateTo, string Language) : IRequest>; +public record HabitSummarySlipDate(Guid HabitId, DateOnly Date); + public class GetDailySummaryQueryHandler( IGenericRepository habitRepository, IGenericRepository userRepository, IGenericRepository habitLogRepository, + IHabitSummaryLogReader summaryLogReader, IPayGateService payGate, ISummaryService summaryService, IMemoryCache cache) : IRequestHandler> @@ -72,25 +75,27 @@ public async Task> Handle( return Result.Success(new DailySummaryResponse(cached.Summary, string.Empty, FromCache: true)); } - var overdueLogFrom = userToday.AddDays(-AppConstants.MaxRangeDays); - var logFrom = request.DateFrom < overdueLogFrom ? request.DateFrom : overdueLogFrom; - var logTo = request.DateTo > userToday ? request.DateTo : userToday; var habits = await habitRepository.FindAsync( h => h.UserId == request.UserId && !h.IsGeneral, - q => q - .Include(h => h.Logs.Where(l => l.Date >= logFrom && l.Date <= logTo)) - .Include(h => h.Goals) - .AsSplitQuery(), + q => q.Include(h => h.Goals), cancellationToken); + var windows = BuildLogWindows(habits, request.DateFrom, request.DateTo, userToday, user.WeekStartDay); + var logFacts = await summaryLogReader.ReadAsync(windows, cancellationToken); + var logsByHabit = logFacts + .GroupBy(log => log.HabitId) + .ToDictionary(group => group.Key, group => group + .Select(log => HabitLog.FromSummaryRead(log.HabitId, log.Date, log.Value)).ToList()); + foreach (var habit in habits) + habit.LoadScheduleLogsForRead(logsByHabit.GetValueOrDefault(habit.Id) ?? []); + var summaryHabits = habits .Where(h => !HasSkipLogInRange(h, request.DateFrom, request.DateTo)) .ToList(); - var dueDateResolution = await HabitDueDateResolutionLoader.LoadAsync( - habitLogRepository, - summaryHabits, - logFrom, - cancellationToken); + var dueDateResolution = summaryHabits + .Where(habit => habit.Logs.Any(log => log.Date == habit.DueDate && log.Value >= 0)) + .Select(habit => habit.Id) + .ToHashSet(); var lastBadHabitSlipDates = await LoadLastBadHabitSlipDates( summaryHabits, userToday, cancellationToken); @@ -131,6 +136,55 @@ public async Task> Handle( private static bool HasSkipLogInRange(Habit habit, DateOnly dateFrom, DateOnly dateTo) => habit.Logs.Any(l => l.Date >= dateFrom && l.Date <= dateTo && l.Value == 0); + private static IReadOnlyList BuildLogWindows( + IEnumerable habits, DateOnly dateFrom, DateOnly dateTo, DateOnly userToday, int weekStartDay) + { + var result = new List(); + foreach (var habit in habits) + { + var windows = new List + { + new(habit.Id, dateFrom, dateTo) + }; + if (!habit.IsBadHabit && !habit.IsFlexible && !habit.IsCompleted + && habit.FrequencyUnit is not null && habit.DueDate < userToday + && !HabitScheduleService.IsHabitDueOnDate(habit, userToday, weekStartDay)) + { + var latest = userToday.AddDays(-1); + if (habit.EndDate.HasValue && habit.EndDate.Value < latest) + latest = habit.EndDate.Value; + + if (latest >= habit.DueDate) + { + var earliest = latest.AddDays(-Math.Min(AppConstants.MaxRangeDays - 1, latest.DayNumber)); + var from = habit.DueDate > earliest ? habit.DueDate : earliest; + windows.AddRange(HabitScheduleService.GetScheduledDates(habit, from, latest, weekStartDay) + .Select(date => new HabitSummaryLogWindow(habit.Id, date, date))); + } + + if ((!habit.EndDate.HasValue || habit.DueDate <= habit.EndDate.Value) + && HabitScheduleService.IsHabitDueOnDate(habit, habit.DueDate, weekStartDay)) + windows.Add(new HabitSummaryLogWindow(habit.Id, habit.DueDate, habit.DueDate)); + } + + foreach (var window in windows.OrderBy(window => window.From)) + { + if (result.Count > 0 && result[^1].HabitId == habit.Id + && window.From.DayNumber <= result[^1].To.DayNumber + 1) + { + var previous = result[^1]; + result[^1] = previous with { To = window.To > previous.To ? window.To : previous.To }; + } + else + { + result.Add(window); + } + } + } + + return result; + } + private async Task> LoadLastBadHabitSlipDates( IReadOnlyList habits, DateOnly userToday, CancellationToken cancellationToken) { @@ -138,13 +192,13 @@ private async Task> LoadLastBadHabitSlipDate if (badHabitIds.Count == 0) return new Dictionary(); - var slipLogs = await habitLogRepository.FindAsync( + var slipDates = await habitLogRepository.ProjectAsync( l => badHabitIds.Contains(l.HabitId) && l.Value > 0 && l.Date <= userToday, + query => query.GroupBy(log => log.HabitId) + .Select(group => new HabitSummarySlipDate(group.Key, group.Max(log => log.Date))), cancellationToken); - return slipLogs - .GroupBy(l => l.HabitId) - .ToDictionary(g => g.Key, g => g.Max(l => l.Date)); + return slipDates.ToDictionary(row => row.HabitId, row => row.Date); } private static string SummaryTimeBucket(TimeOnly? currentLocalTime) diff --git a/src/Orbit.Domain/Entities/HabitLog.cs b/src/Orbit.Domain/Entities/HabitLog.cs index 3fcf5e637..b2401d2c2 100644 --- a/src/Orbit.Domain/Entities/HabitLog.cs +++ b/src/Orbit.Domain/Entities/HabitLog.cs @@ -17,6 +17,14 @@ public class HabitLog : Entity, ITimestamped, ISoftDeletable private HabitLog() { } + public static HabitLog FromSummaryRead(Guid habitId, DateOnly date, decimal value) + { + if (habitId == Guid.Empty) + throw new ArgumentException("Summary log habit id must be set."); + + return new HabitLog { HabitId = habitId, Date = date, Value = value }; + } + public static HabitLog FromScheduleRead( Guid id, Guid habitId, diff --git a/src/Orbit.Domain/Interfaces/IHabitSummaryLogReader.cs b/src/Orbit.Domain/Interfaces/IHabitSummaryLogReader.cs new file mode 100644 index 000000000..43dfbfb7b --- /dev/null +++ b/src/Orbit.Domain/Interfaces/IHabitSummaryLogReader.cs @@ -0,0 +1,12 @@ +namespace Orbit.Domain.Interfaces; + +public sealed record HabitSummaryLogWindow(Guid HabitId, DateOnly From, DateOnly To); + +public sealed record HabitSummaryLogFact(Guid HabitId, DateOnly Date, decimal Value); + +public interface IHabitSummaryLogReader +{ + Task> ReadAsync( + IReadOnlyCollection windows, + CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Infrastructure/Persistence/HabitSummaryLogReader.cs b/src/Orbit.Infrastructure/Persistence/HabitSummaryLogReader.cs new file mode 100644 index 000000000..3c929e394 --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/HabitSummaryLogReader.cs @@ -0,0 +1,56 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Orbit.Domain.Interfaces; + +namespace Orbit.Infrastructure.Persistence; + +public sealed class HabitSummaryLogReader(OrbitDbContext context) : IHabitSummaryLogReader +{ + public async Task> ReadAsync( + IReadOnlyCollection windows, + CancellationToken cancellationToken = default) + { + if (windows.Count == 0) + return []; + + var windowsJson = JsonSerializer.Serialize(windows); + FormattableString sql = context.Database.ProviderName switch + { + "Microsoft.EntityFrameworkCore.Sqlite" => $""" + SELECT DISTINCT l."HabitId", l."Date", l."Value" + FROM "HabitLogs" AS l + JOIN json_each({windowsJson}) AS w + ON l."HabitId" = upper(json_extract(w.value, '$.HabitId')) + AND l."Date" >= json_extract(w.value, '$.From') + AND l."Date" <= json_extract(w.value, '$.To') + WHERE NOT l."IsDeleted" AND l."Value" >= 0 + """, + "Npgsql.EntityFrameworkCore.PostgreSQL" => $""" + SELECT DISTINCT l."HabitId", l."Date"::text AS "Date", l."Value" + FROM "HabitLogs" AS l + JOIN jsonb_to_recordset(CAST({windowsJson} AS jsonb)) + AS w("HabitId" uuid, "From" date, "To" date) + ON l."HabitId" = w."HabitId" + AND l."Date" >= w."From" + AND l."Date" <= w."To" + WHERE NOT l."IsDeleted" AND l."Value" >= 0 + """, + _ => throw new NotSupportedException("Summary log windows require a relational provider.") + }; + + var rows = await context.Database.SqlQuery(sql) + .ToListAsync(cancellationToken); + return rows.Select(row => new HabitSummaryLogFact( + row.HabitId, + DateOnly.Parse(row.Date, CultureInfo.InvariantCulture), + row.Value)).ToList(); + } + + public sealed class HabitSummaryLogRow + { + public Guid HabitId { get; set; } + public string Date { get; set; } = string.Empty; + public decimal Value { get; set; } + } +} diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetDailySummaryQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetDailySummaryQueryHandlerTests.cs index 42b52f298..c55ed8cc5 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetDailySummaryQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetDailySummaryQueryHandlerTests.cs @@ -18,6 +18,7 @@ public class GetDailySummaryQueryHandlerTests private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly IGenericRepository _userRepo = Substitute.For>(); private readonly IGenericRepository _habitLogRepo = Substitute.For>(); + private readonly IHabitSummaryLogReader _summaryLogReader = Substitute.For(); private readonly IPayGateService _payGate = Substitute.For(); private readonly ISummaryService _summaryService = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); @@ -29,8 +30,10 @@ public class GetDailySummaryQueryHandlerTests public GetDailySummaryQueryHandlerTests() { + _summaryLogReader.ReadAsync(Arg.Any>(), + Arg.Any()).Returns(Array.Empty()); _handler = new GetDailySummaryQueryHandler( - _habitRepo, _userRepo, _habitLogRepo, _payGate, _summaryService, _cache); + _habitRepo, _userRepo, _habitLogRepo, _summaryLogReader, _payGate, _summaryService, _cache); } private static User CreateTestUser() @@ -68,7 +71,7 @@ public async Task Handle_GeneratesNewSummary_WhenNotCached() } [Fact] - public async Task Handle_MultipleCollectionIncludes_UsesSplitQuery() + public async Task Handle_HabitLoad_IncludesGoalsWithoutFullLogs() { Func, IQueryable>? queryBuilder = null; var user = CreateTestUser(); @@ -95,7 +98,7 @@ await _handler.Handle( await using var context = new OrbitDbContext(options); var shapedQuery = queryBuilder!(context.Habits); - shapedQuery.Expression.ToString().Should().Contain(nameof(RelationalQueryableExtensions.AsSplitQuery)); + shapedQuery.Expression.ToString().Should().Contain("Goals").And.NotContain("Logs"); } [Fact] @@ -141,6 +144,9 @@ public async Task Handle_ExcludesHabitsSkippedInRequestedRange() var skipped = Habit.Create(new HabitCreateParams( UserId, "Flexible workout", FrequencyUnit.Week, 3, DueDate: Today, IsFlexible: true)).Value; skipped.SkipFlexible(Today); + _summaryLogReader.ReadAsync(Arg.Any>(), + Arg.Any()).Returns( + [new HabitSummaryLogFact(skipped.Id, Today, 0)]); _habitRepo.FindAsync( Arg.Any>>(), @@ -183,10 +189,11 @@ public async Task Handle_BadHabitSlippedBeforeWindow_PassesLastSlipDateFromTarge .Returns(new List { badHabit }.AsReadOnly()); var slipDate = Today.AddDays(-3); - _habitLogRepo.FindAsync( + _habitLogRepo.ProjectAsync( Arg.Any>>(), + Arg.Any, IQueryable>>(), Arg.Any()) - .Returns(new List { HabitLog.Create(badHabit.Id, slipDate, 1) }.AsReadOnly()); + .Returns(new List { new(badHabit.Id, slipDate) }.AsReadOnly()); _summaryService.GenerateSummaryAsync( Arg.Any>(), @@ -222,11 +229,14 @@ public async Task Handle_BadHabitSlipReadFilter_ExcludesLogsFromHabitsTheUserDoe .Returns(new List { badHabit }.AsReadOnly()); Expression>? readFilter = null; - _habitLogRepo.FindAsync(Arg.Any>>(), Arg.Any()) + _habitLogRepo.ProjectAsync( + Arg.Any>>(), + Arg.Any, IQueryable>>(), + Arg.Any()) .Returns(call => { readFilter = call.Arg>>(); - return (IReadOnlyList)new List(); + return (IReadOnlyList)[]; }); _summaryService.GenerateSummaryAsync( @@ -269,8 +279,9 @@ public async Task Handle_NoBadHabits_SkipsSlipQueryAndPassesEmptyMap() await _handler.Handle(query, CancellationToken.None); - await _habitLogRepo.DidNotReceive().FindAsync( + await _habitLogRepo.DidNotReceive().ProjectAsync( Arg.Any>>(), + Arg.Any, IQueryable>>(), Arg.Any()); await _summaryService.Received(1).GenerateSummaryAsync( Arg.Any>(), @@ -450,7 +461,7 @@ public async Task Handle_CacheExpiry_UsesUserTimezoneEndOfDay() { var capturingCache = new CapturingCache(); var handler = new GetDailySummaryQueryHandler( - _habitRepo, _userRepo, _habitLogRepo, _payGate, _summaryService, capturingCache); + _habitRepo, _userRepo, _habitLogRepo, _summaryLogReader, _payGate, _summaryService, capturingCache); var user = CreateTestUser(); user.SetTimeZone("America/Sao_Paulo"); diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/QueryRoundTripCountTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/QueryRoundTripCountTests.cs index 1e5539799..62fe9d604 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/QueryRoundTripCountTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/QueryRoundTripCountTests.cs @@ -1,11 +1,14 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.Habits.Queries; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; +using System.Reflection; namespace Orbit.Infrastructure.Tests.Persistence; @@ -14,6 +17,149 @@ public class QueryRoundTripCountTests private static readonly DateOnly DateFrom = new(2026, 6, 1); private static readonly DateOnly DateTo = new(2026, 6, 30); + [Fact] + public async Task DailySummaryRead_PreservesOverdueSkipsAndPriorBadHabitSlip() + { + var counter = new CountingDbCommandInterceptor(); + using var factory = new SqliteOrbitDbContextFactory(counter); + var context = factory.Context; + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var userId = Guid.NewGuid(); + var user = User.Create("Summary User", $"summary-{userId:N}@example.com").Value; + typeof(User).GetProperty("Id")!.SetValue(user, userId); + user.SetTimeZone("UTC"); + context.Users.Add(user); + + var overdue = Habit.Create(new HabitCreateParams(userId, "Overdue", FrequencyUnit.Week, 1, + DueDate: today.AddDays(-10))).Value; + var skippedToday = Habit.Create(new HabitCreateParams(userId, "Skipped today", FrequencyUnit.Day, 1, + DueDate: today.AddDays(-3))).Value; + var skippedEarlier = Habit.Create(new HabitCreateParams(userId, "Skipped earlier", FrequencyUnit.Day, 1, + DueDate: today.AddDays(-3))).Value; + var bad = Habit.Create(new HabitCreateParams(userId, "Bad habit", FrequencyUnit.Day, 1, + DueDate: today.AddDays(-10), IsBadHabit: true)).Value; + var oldResolved = Habit.Create(new HabitCreateParams(userId, "Old resolved", FrequencyUnit.Year, 2, + DueDate: today.AddDays(-400))).Value; + context.Habits.AddRange(overdue, skippedToday, skippedEarlier, bad, oldResolved); + context.HabitLogs.AddRange( + HabitLog.FromScheduleRead(Guid.NewGuid(), overdue.Id, today.AddDays(-10), 1, 0, DateTime.UtcNow), + HabitLog.FromScheduleRead(Guid.NewGuid(), overdue.Id, today.AddDays(-2), 0, 0, DateTime.UtcNow), + HabitLog.FromScheduleRead(Guid.NewGuid(), skippedToday.Id, today, 0, 0, DateTime.UtcNow), + HabitLog.FromScheduleRead(Guid.NewGuid(), skippedEarlier.Id, today.AddDays(-1), 0, 0, DateTime.UtcNow), + HabitLog.FromScheduleRead(Guid.NewGuid(), bad.Id, today.AddDays(-5), 1, 0, DateTime.UtcNow), + HabitLog.FromScheduleRead(Guid.NewGuid(), oldResolved.Id, oldResolved.DueDate, 1, 0, DateTime.UtcNow)); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + var directLogs = await new HabitSummaryLogReader(context).ReadAsync( + [new HabitSummaryLogWindow(overdue.Id, today.AddDays(-10), today)]); + directLogs.Should().HaveCount(2); + + var payGate = Substitute.For(); + payGate.CanUseDailySummary(userId, Arg.Any()).Returns(Orbit.Domain.Common.Result.Success()); + var summaryService = Substitute.For(); + IReadOnlyList? receivedHabits = null; + DailySummaryContext? receivedContext = null; + summaryService.GenerateSummaryAsync(Arg.Any>(), Arg.Any(), + Arg.Any()) + .Returns(call => + { + receivedHabits = call.ArgAt>(0).ToList(); + receivedContext = call.ArgAt(1); + return Orbit.Domain.Common.Result.Success(new DailySummaryContent("Pinned summary", string.Empty)); + }); + var handler = new GetDailySummaryQueryHandler( + new GenericRepository(context), + new GenericRepository(context), + new GenericRepository(context), + new HabitSummaryLogReader(context), + payGate, summaryService, new MemoryCache(new MemoryCacheOptions())); + + counter.Reset(); + var result = await handler.Handle(new GetDailySummaryQuery(userId, today, today, "en"), + CancellationToken.None); + + var logCommands = counter.Commands.Where(command => command.Sql.Contains("\"HabitLogs\"", StringComparison.Ordinal)).ToList(); + logCommands.Should().HaveCount(2); + logCommands.Should().OnlyContain(command => !command.Sql.Contains("\"Note\"", StringComparison.Ordinal) + && !command.Sql.Contains("\"CreatedAtUtc\"", StringComparison.Ordinal) + && !command.Sql.Contains("\"UpdatedAtUtc\"", StringComparison.Ordinal) + && !command.Sql.Contains("\"DeletedAtUtc\"", StringComparison.Ordinal) + && !command.Sql.Contains("\"IsSlip\"", StringComparison.Ordinal) + && !command.Sql.Contains("\"CompletionOrdinal\"", StringComparison.Ordinal)); + + result.Value.Summary.Should().Be("Pinned summary"); + receivedHabits!.Select(habit => habit.Title).Should().BeEquivalentTo( + ["Overdue", "Skipped earlier", "Bad habit", "Old resolved"]); + receivedHabits!.Single(habit => habit.Id == overdue.Id).Logs.Select(log => (log.Date, log.Value)) + .Should().Contain((today.AddDays(-10), 1)); + receivedContext!.ResolvedDueDateHabitIds.Should().Contain(overdue.Id); + receivedContext.ResolvedDueDateHabitIds.Should().Contain(oldResolved.Id); + receivedContext!.LastBadHabitSlipDates[bad.Id].Should().Be(today.AddDays(-5)); + + var legacyHabits = await new GenericRepository(context).FindAsync( + habit => habit.UserId == userId && !habit.IsGeneral, + query => query + .Include(habit => habit.Logs.Where(log => + log.Date >= today.AddDays(-366) && log.Date <= today)) + .Include(habit => habit.Goals) + .AsSplitQuery(), CancellationToken.None); + var legacyIncluded = legacyHabits + .Where(habit => !habit.Logs.Any(log => log.Date == today && log.Value == 0)) + .ToList(); + var legacyResolution = legacyIncluded + .Where(habit => habit.Logs.Any(log => log.Date == habit.DueDate && log.Value >= 0)) + .Select(habit => habit.Id).ToHashSet(); + if (await context.HabitLogs.AnyAsync(log => log.HabitId == oldResolved.Id + && log.Date == oldResolved.DueDate && log.Value >= 0)) + legacyResolution.Add(oldResolved.Id); + var legacySlipDates = legacyIncluded.Where(habit => habit.IsBadHabit) + .SelectMany(habit => habit.Logs.Where(log => log.Value > 0) + .Select(log => new { habit.Id, log.Date })) + .GroupBy(log => log.Id) + .ToDictionary(group => group.Key, group => group.Max(log => log.Date)); + var legacyContext = receivedContext with + { + LastBadHabitSlipDates = legacySlipDates, + ResolvedDueDateHabitIds = legacyResolution + }; + var prompt = BuildPrompt(receivedHabits!, receivedContext); + prompt.Should().Be(BuildPrompt(legacyIncluded, legacyContext)); + prompt.Should().Contain("Overdue (pending, overdue)"); + prompt.Should().Contain("Skipped earlier (pending)"); + prompt.Should().Contain("Bad habit (bad habit -- clean, 5 days since last slip)"); + prompt.Should().NotContain("Skipped today"); + prompt.Should().NotContain("Old resolved"); + } + + [Fact] + public async Task DailySummaryLogReader_SelectsThreeColumnsAndRequestedRowsAtAccountScale() + { + var counter = new CountingDbCommandInterceptor(); + using var factory = new SqliteOrbitDbContextFactory(counter); + var userId = await SeedHabits(factory.Context, 1000, logsPerHabit: 4, + withGoals: false, extraLogHabitCount: 650); + var habitIds = await factory.Context.Habits.AsNoTracking() + .Where(habit => habit.UserId == userId) + .Select(habit => habit.Id) + .ToListAsync(); + var windows = habitIds.Select(id => new HabitSummaryLogWindow( + id, DateFrom.AddDays(4), DateFrom.AddDays(4))).ToList(); + + counter.Reset(); + var logs = await new HabitSummaryLogReader(factory.Context).ReadAsync(windows); + + logs.Should().HaveCount(650); + counter.Commands.Should().ContainSingle(); + counter.Commands[0].Rows.Should().Be(650); + counter.Commands[0].Sql.Should().Contain("SELECT DISTINCT l.\"HabitId\", l.\"Date\", l.\"Value\""); + counter.Commands[0].Sql.Should().NotContain("l.\"Note\"") + .And.NotContain("l.\"CreatedAtUtc\"") + .And.NotContain("l.\"UpdatedAtUtc\"") + .And.NotContain("l.\"DeletedAtUtc\"") + .And.NotContain("l.\"IsSlip\"") + .And.NotContain("l.\"CompletionOrdinal\""); + } + [Fact] public async Task RetrospectiveHabitLoad_RoundTripCount_IsInvariantToHabitVolume() { @@ -31,7 +177,7 @@ public async Task DailySummaryHabitLoad_RoundTripCount_IsInvariantToHabitVolume( var large = await CountDailySummaryLoad(habitCount: 25); large.Should().Be(small); - large.Should().BeLessThanOrEqualTo(3); + large.Should().BeLessThanOrEqualTo(4); } [Fact] @@ -73,14 +219,23 @@ private static async Task CountDailySummaryLoad(int habitCount) var counter = new CountingDbCommandInterceptor(); using var factory = new SqliteOrbitDbContextFactory(counter); var userId = await SeedHabits(factory.Context, habitCount, logsPerHabit: 8, withGoals: true); + var payGate = Substitute.For(); + payGate.CanUseDailySummary(userId, Arg.Any()).Returns(Orbit.Domain.Common.Result.Success()); + var summaryService = Substitute.For(); + summaryService.GenerateSummaryAsync(Arg.Any>(), Arg.Any(), + Arg.Any()) + .Returns(Orbit.Domain.Common.Result.Success(new DailySummaryContent("Summary", string.Empty))); + var handler = new GetDailySummaryQueryHandler( + new GenericRepository(factory.Context), + new GenericRepository(factory.Context), + new GenericRepository(factory.Context), + new HabitSummaryLogReader(factory.Context), + payGate, summaryService, new MemoryCache(new MemoryCacheOptions())); counter.Reset(); - await new GenericRepository(factory.Context).FindAsync( - habit => habit.UserId == userId && !habit.IsGeneral, - query => query - .Include(habit => habit.Logs.Where(log => log.Date >= DateFrom && log.Date <= DateTo)) - .Include(habit => habit.Goals), + var result = await handler.Handle(new GetDailySummaryQuery(userId, DateFrom, DateTo, "en"), CancellationToken.None); + result.IsSuccess.Should().BeTrue(); return counter.CommandCount; } @@ -123,7 +278,8 @@ private static async Task CountHabitDetailLoad(int childCount) return counter.CommandCount; } - private static async Task SeedHabits(OrbitDbContext context, int habitCount, int logsPerHabit, bool withGoals) + private static async Task SeedHabits(OrbitDbContext context, int habitCount, int logsPerHabit, + bool withGoals, int extraLogHabitCount = 0) { var userId = Guid.NewGuid(); var user = User.Create("Bench User", $"bench-{userId:N}@example.com").Value; @@ -135,7 +291,8 @@ private static async Task SeedHabits(OrbitDbContext context, int habitCoun var habit = Habit.Create(new HabitCreateParams( userId, $"Habit {index}", FrequencyUnit.Day, 1, DueDate: DateFrom)).Value; - for (var offset = 0; offset < logsPerHabit; offset++) + var logCount = logsPerHabit + (index < extraLogHabitCount ? 1 : 0); + for (var offset = 0; offset < logCount; offset++) habit.Log(DateFrom.AddDays(offset), advanceDueDate: false); if (withGoals) @@ -153,6 +310,19 @@ private static async Task SeedHabits(OrbitDbContext context, int habitCoun return userId; } + private static string BuildPrompt(IEnumerable habits, DailySummaryContext summaryContext) + { + const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Static; + var selected = (List)typeof(AiSummaryService) + .GetMethod("SelectScheduledHabits", flags)! + .Invoke(null, [habits.ToList(), summaryContext.UserToday, summaryContext.DateFrom, + summaryContext.DateTo, summaryContext.WeekStartDay, + summaryContext.ResolvedDueDateHabitIds])!; + return (string)typeof(AiSummaryService) + .GetMethod("BuildSummaryPrompt", flags)! + .Invoke(null, [selected, summaryContext])!; + } + private static async Task<(Guid UserId, Guid HabitId)> SeedDetailHabitGraph( OrbitDbContext context, int childCount)