diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index 919897471..0f62b1aab 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; +using Orbit.Api.ModelBinding; using Orbit.Api.RateLimiting; using Orbit.Application.Common; using Orbit.Application.Habits.Commands; @@ -387,13 +388,20 @@ public async Task RestoreHabit(Guid id, CancellationToken cancell }); } + /// Returns recent logs, or every log in an inclusive range of at most 366 calendar days. + /// First date in the user's timezone (yyyy-MM-dd). Supply together with to. + /// Last date in the user's timezone (yyyy-MM-dd). Supply together with from. [HttpGet("{id:guid}/logs")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task GetLogs(Guid id, CancellationToken cancellationToken) + public async Task GetLogs( + Guid id, + CancellationToken cancellationToken, + [FromQuery, ModelBinder(BinderType = typeof(IsoDateQueryModelBinder))] DateOnly? from = null, + [FromQuery, ModelBinder(BinderType = typeof(IsoDateQueryModelBinder))] DateOnly? to = null) { - var query = new GetHabitLogsQuery(HttpContext.GetUserId(), id); + var query = new GetHabitLogsQuery(HttpContext.GetUserId(), id, from, to); var result = await mediator.Send(query, cancellationToken); return result.ToPayGateAwareResult(v => Ok(v)); } diff --git a/src/Orbit.Api/ModelBinding/IsoDateQueryModelBinder.cs b/src/Orbit.Api/ModelBinding/IsoDateQueryModelBinder.cs new file mode 100644 index 000000000..d588386d0 --- /dev/null +++ b/src/Orbit.Api/ModelBinding/IsoDateQueryModelBinder.cs @@ -0,0 +1,34 @@ +using System.Globalization; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Orbit.Application.Common; + +namespace Orbit.Api.ModelBinding; + +public sealed class IsoDateQueryModelBinder : IModelBinder +{ + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + if (value == ValueProviderResult.None) + { + bindingContext.Result = ModelBindingResult.Success(null); + return Task.CompletedTask; + } + + if (value.Length != 1 || !DateOnly.TryParseExact( + value.FirstValue, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + throw new ValidationException([ + new ValidationFailure(bindingContext.ModelName, ErrorCopy.English(ValidationErrorCodes.CalendarDateValid)) + { + ErrorCode = ValidationErrorCodes.CalendarDateValid + } + ]); + } + + bindingContext.Result = ModelBindingResult.Success(date); + return Task.CompletedTask; + } +} diff --git a/src/Orbit.Api/openapi.json b/src/Orbit.Api/openapi.json index dfcbcdaad..ca1b77b09 100644 --- a/src/Orbit.Api/openapi.json +++ b/src/Orbit.Api/openapi.json @@ -7492,6 +7492,22 @@ "format": "uuid" } }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, { "name": "api-version", "in": "header", diff --git a/src/Orbit.Api/openapi_v2.json b/src/Orbit.Api/openapi_v2.json index 4adc2e2ca..93e5de059 100644 --- a/src/Orbit.Api/openapi_v2.json +++ b/src/Orbit.Api/openapi_v2.json @@ -7585,6 +7585,22 @@ "format": "uuid" } }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, { "name": "api-version", "in": "header", diff --git a/src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs index 85525158e..169ae8848 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs @@ -12,7 +12,11 @@ public record HabitLogResponse( decimal Value, DateTime CreatedAtUtc); -public record GetHabitLogsQuery(Guid UserId, Guid HabitId) : IRequest>>; +public record GetHabitLogsQuery( + Guid UserId, + Guid HabitId, + DateOnly? From = null, + DateOnly? To = null) : IRequest>>; public class GetHabitLogsQueryHandler( IGenericRepository habitRepository, @@ -28,14 +32,21 @@ public async Task>> Handle(GetHabitLogsQu if (habit is null) return Result.Failure>(ErrorMessages.HabitNotFound); - var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); - var cutoff = userToday.AddDays(-AppConstants.HabitLogsLookbackDays); - - var logs = await habitLogReader.ReadRecentLogsAsync( - request.HabitId, - cutoff, - AppConstants.MaxHabitLogsReturned, - cancellationToken); + IReadOnlyList logs; + if (request.From is { } from && request.To is { } to) + { + logs = await habitLogReader.ReadRangeLogsAsync(request.HabitId, from, to, cancellationToken); + } + else + { + var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + var cutoff = userToday.AddDays(-AppConstants.HabitLogsLookbackDays); + logs = await habitLogReader.ReadRecentLogsAsync( + request.HabitId, + cutoff, + AppConstants.MaxHabitLogsReturned, + cancellationToken); + } var result = logs .Select(l => new HabitLogResponse(l.Id, l.Date, l.Value, l.CreatedAtUtc)) diff --git a/src/Orbit.Application/Habits/Validators/GetHabitLogsQueryValidator.cs b/src/Orbit.Application/Habits/Validators/GetHabitLogsQueryValidator.cs index 9fc515986..75f8ad5bf 100644 --- a/src/Orbit.Application/Habits/Validators/GetHabitLogsQueryValidator.cs +++ b/src/Orbit.Application/Habits/Validators/GetHabitLogsQueryValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Orbit.Application.Common; using Orbit.Application.Habits.Queries; namespace Orbit.Application.Habits.Validators; @@ -9,5 +10,23 @@ public GetHabitLogsQueryValidator() { RuleFor(x => x.UserId).NotEmpty(); RuleFor(x => x.HabitId).NotEmpty(); + + RuleFor(x => x.From).NotNull() + .WithCopy(ValidationErrorCodes.DateFromRequired) + .When(x => x.To.HasValue); + RuleFor(x => x.To).NotNull() + .WithCopy(ValidationErrorCodes.DateToRequired) + .When(x => x.From.HasValue); + + When(x => x.From.HasValue && x.To.HasValue, () => + { + RuleFor(x => x.To).GreaterThanOrEqualTo(x => x.From) + .WithCopy(ValidationErrorCodes.DateRangeOrder); + RuleFor(x => x.To) + .Must((query, to) => query.From is { } from && to is { } end + && end.DayNumber - from.DayNumber < AppConstants.MaxRangeDays) + .WithCopy(ValidationErrorCodes.DateRangeLimit) + .When(x => x.To >= x.From); + }); } } diff --git a/src/Orbit.Domain/Interfaces/IHabitLogReader.cs b/src/Orbit.Domain/Interfaces/IHabitLogReader.cs index c37d498b5..438199990 100644 --- a/src/Orbit.Domain/Interfaces/IHabitLogReader.cs +++ b/src/Orbit.Domain/Interfaces/IHabitLogReader.cs @@ -3,9 +3,8 @@ namespace Orbit.Domain.Interfaces; /// -/// DB-side read for a habit's recent logs that bounds the result set server-side, so the handler never -/// materializes a habit's full log history to order and cap it in memory. The lookback window, newest-first -/// ordering, and row cap all run inside the query, backed by the (HabitId, Date) index. +/// DB-side reads for habit logs. Date bounds and newest-first ordering run server-side, +/// backed by the (HabitId, Date) index. Recent reads also apply a row cap. /// public interface IHabitLogReader { @@ -22,4 +21,11 @@ Task> ReadRecentLogsAsync( DateOnly since, int limit, CancellationToken cancellationToken = default); + + /// Loads every log in the inclusive date range, ordered newest-first. + Task> ReadRangeLogsAsync( + Guid habitId, + DateOnly from, + DateOnly to, + CancellationToken cancellationToken = default); } diff --git a/src/Orbit.Infrastructure/Persistence/HabitLogReader.cs b/src/Orbit.Infrastructure/Persistence/HabitLogReader.cs index 3193558cd..4ff465efc 100644 --- a/src/Orbit.Infrastructure/Persistence/HabitLogReader.cs +++ b/src/Orbit.Infrastructure/Persistence/HabitLogReader.cs @@ -29,6 +29,28 @@ public async Task> ReadRecentLogsAsync( .ToListAsync(cancellationToken); } + public async Task> ReadRangeLogsAsync( + Guid habitId, + DateOnly from, + DateOnly to, + CancellationToken cancellationToken = default) + { + return await BuildRangeLogs(context.HabitLogs.AsNoTracking(), habitId, from, to) + .ToListAsync(cancellationToken); + } + + internal static IOrderedQueryable BuildRangeLogs( + IQueryable logs, + Guid habitId, + DateOnly from, + DateOnly to) + { + return logs + .Where(l => l.HabitId == habitId && l.Date >= from && l.Date <= to) + .OrderByDescending(l => l.Date) + .ThenByDescending(l => l.CreatedAtUtc); + } + internal static IQueryable BuildRecentLogs( IQueryable logs, Guid habitId, diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitLogsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitLogsQueryHandlerTests.cs index 2cb0213f9..94e6a497b 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitLogsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitLogsQueryHandlerTests.cs @@ -139,4 +139,43 @@ public async Task Handle_CallsUserDateService() await _userDateService.Received(1).GetUserTodayAsync(UserId, Arg.Any()); } + + [Fact] + public async Task Handle_ExplicitRange_PassesDatesAndCancellationWithoutResolvingToday() + { + var from = new DateOnly(2025, 1, 1); + var to = new DateOnly(2025, 1, 31); + var habit = CreateTestHabit(); + var log = habit.Log(from).Value; + SetupHabitFound(habit); + using var cancellation = new CancellationTokenSource(); + _habitLogReader.ReadRangeLogsAsync(HabitId, from, to, cancellation.Token).Returns([log]); + + var result = await _handler.Handle(new GetHabitLogsQuery(UserId, HabitId, from, to), cancellation.Token); + + result.Value.Should().ContainSingle().Which.Should().BeEquivalentTo( + new HabitLogResponse(log.Id, log.Date, log.Value, log.CreatedAtUtc)); + await _habitLogReader.Received(1).ReadRangeLogsAsync(HabitId, from, to, cancellation.Token); + await _habitLogReader.DidNotReceive().ReadRecentLogsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await _userDateService.DidNotReceive().GetUserTodayAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_RangeForUnownedHabit_DoesNotReadLogs() + { + var owner = CreateTestHabit(); + _habitRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), Arg.Any()) + .Returns(call => call.Arg>>().Compile()(owner) ? owner : null); + + var result = await _handler.Handle(new GetHabitLogsQuery( + Guid.NewGuid(), owner.Id, new DateOnly(2025, 1, 1), new DateOnly(2025, 1, 31)), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("HABIT_NOT_FOUND"); + await _habitLogReader.DidNotReceive().ReadRangeLogsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } } diff --git a/tests/Orbit.Application.Tests/Validators/GetHabitLogsQueryValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/GetHabitLogsQueryValidatorTests.cs new file mode 100644 index 000000000..a894a8c1d --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/GetHabitLogsQueryValidatorTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using FluentValidation.TestHelper; +using Orbit.Application.Common; +using Orbit.Application.Habits.Queries; +using Orbit.Application.Habits.Validators; + +namespace Orbit.Application.Tests.Validators; + +public class GetHabitLogsQueryValidatorTests +{ + private readonly GetHabitLogsQueryValidator _validator = new(); + + [Theory] + [InlineData(null, null)] + [InlineData("2025-01-01", "2025-01-01")] + [InlineData("2024-01-01", "2024-12-31")] + [InlineData("0001-01-01", "0001-01-01")] + [InlineData("9999-12-31", "9999-12-31")] + public void ValidRange_HasNoErrors(string? from, string? to) + { + _validator.TestValidate(Query(from, to)).ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData("2025-01-31", "2025-01-01", nameof(GetHabitLogsQuery.To), ValidationErrorCodes.DateRangeOrder)] + [InlineData("2024-01-01", "2025-01-01", nameof(GetHabitLogsQuery.To), ValidationErrorCodes.DateRangeLimit)] + [InlineData("2025-01-01", null, nameof(GetHabitLogsQuery.To), ValidationErrorCodes.DateToRequired)] + [InlineData(null, "2025-01-01", nameof(GetHabitLogsQuery.From), ValidationErrorCodes.DateFromRequired)] + public void InvalidRange_HasFieldError(string? from, string? to, string field, string code) + { + var result = _validator.TestValidate(Query(from, to)); + + result.Errors.Should().ContainSingle().Which.Should().Match( + error => error.PropertyName == field && error.ErrorCode == code); + } + + [Fact] + public void EmptyIds_HaveErrors() + { + var result = _validator.TestValidate(new GetHabitLogsQuery(Guid.Empty, Guid.Empty)); + + result.ShouldHaveValidationErrorFor(query => query.UserId); + result.ShouldHaveValidationErrorFor(query => query.HabitId); + } + + private static GetHabitLogsQuery Query(string? from, string? to) => new( + Guid.NewGuid(), Guid.NewGuid(), + from is null ? null : DateOnly.ParseExact(from, "yyyy-MM-dd"), + to is null ? null : DateOnly.ParseExact(to, "yyyy-MM-dd")); +} diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/HabitLogRangeContractTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/HabitLogRangeContractTests.cs new file mode 100644 index 000000000..4183f2007 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Controllers/HabitLogRangeContractTests.cs @@ -0,0 +1,126 @@ +using System.Globalization; +using System.Text.Json; +using FluentAssertions; +using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Orbit.Api.Controllers; +using Orbit.Api.Middleware; +using Orbit.Application.Behaviors; +using Orbit.Application.Common; +using Orbit.Application.Habits.Queries; +using Orbit.Application.Habits.Validators; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Infrastructure.Tests.Controllers; + +public class HabitLogRangeContractTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("2025-01-01", "2025-01-31")] + [InlineData("2024-01-01", "2024-12-31")] + [InlineData("2025-01-01", "2025-01-01")] + public async Task QueryBindingAndValidation_AcceptValidDates(string? from, string? to) + { + var query = await BindRangeAsync(from, to); + + await ValidateThroughPipelineAsync(query); + + query.From.Should().Be(from is null ? null : DateOnly.ParseExact(from, "yyyy-MM-dd", CultureInfo.InvariantCulture)); + query.To.Should().Be(to is null ? null : DateOnly.ParseExact(to, "yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData("2025-01-31", "2025-01-01", "To", ValidationErrorCodes.DateRangeOrder)] + [InlineData("2024-01-01", "2025-01-01", "To", ValidationErrorCodes.DateRangeLimit)] + [InlineData("invalid", "2025-01-31", "from", ValidationErrorCodes.CalendarDateValid)] + [InlineData("2025-01-01", "2025-02-30", "to", ValidationErrorCodes.CalendarDateValid)] + [InlineData("01/01/2025", "2025-01-31", "from", ValidationErrorCodes.CalendarDateValid)] + [InlineData("2025-1-1", "2025-01-31", "from", ValidationErrorCodes.CalendarDateValid)] + [InlineData("2025-01-01T00:00:00Z", "2025-01-31", "from", ValidationErrorCodes.CalendarDateValid)] + [InlineData("", "2025-01-31", "from", ValidationErrorCodes.CalendarDateValid)] + [InlineData("2025-01-01", null, "To", ValidationErrorCodes.DateToRequired)] + [InlineData(null, "2025-01-31", "From", ValidationErrorCodes.DateFromRequired)] + public async Task InvalidRange_UsesExistingValidationEnvelope(string? from, string? to, string field, string code) + { + var act = async () => await ValidateThroughPipelineAsync(await BindRangeAsync(from, to)); + var exception = (await act.Should().ThrowAsync()).Which; + var context = new DefaultHttpContext { TraceIdentifier = "habit-log-range" }; + context.Response.Body = new MemoryStream(); + using var services = new ServiceCollection() + .AddSingleton( + new RequestLanguageResolver(Substitute.For>())) + .BuildServiceProvider(); + context.RequestServices = services; + var handler = new ValidationExceptionHandler(NullLogger.Instance); + + (await handler.TryHandleAsync(context, exception, CancellationToken.None)).Should().BeTrue(); + + context.Response.StatusCode.Should().Be(400); + context.Response.Body.Position = 0; + using var json = await JsonDocument.ParseAsync(context.Response.Body); + var root = json.RootElement; + root.GetProperty("type").GetString().Should().Be("ValidationFailure"); + root.GetProperty("status").GetInt32().Should().Be(400); + root.GetProperty("requestId").GetString().Should().Be("habit-log-range"); + root.GetProperty("errors").GetProperty(field).GetArrayLength().Should().Be(1); + root.GetProperty("errorDetails").GetProperty(field)[0].GetProperty("code").GetString().Should().Be(code); + } + + private static Task>> ValidateThroughPipelineAsync(GetHabitLogsQuery query) + { + var pipeline = new ValidationBehavior>>( + [new GetHabitLogsQueryValidator()]); + return pipeline.Handle(query, + _ => Task.FromResult(Result.Success>([])), CancellationToken.None); + } + + private static async Task BindRangeAsync(string? from, string? to) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddControllers().AddApplicationPart(typeof(HabitsController).Assembly); + using var provider = services.BuildServiceProvider(); + var action = provider.GetRequiredService() + .ActionDescriptors.Items.OfType() + .Single(action => action.ControllerTypeInfo.AsType() == typeof(HabitsController) + && action.ActionName == nameof(HabitsController.GetLogs)); + var httpContext = new DefaultHttpContext { RequestServices = provider }; + var query = new Dictionary(); + if (from is not null) query["from"] = from; + if (to is not null) query["to"] = to; + httpContext.Request.QueryString = QueryString.Create(query); + var context = new ActionContext(httpContext, new RouteData(), action); + var metadataProvider = (ModelMetadataProvider)provider.GetRequiredService(); + var binderFactory = provider.GetRequiredService(); + var parameterBinder = provider.GetRequiredService(); + var values = new QueryStringValueProvider(BindingSource.Query, httpContext.Request.Query, CultureInfo.InvariantCulture); + var dates = new Dictionary(); + foreach (var parameter in action.Parameters.OfType() + .Where(parameter => parameter.Name is "from" or "to")) + { + var metadata = metadataProvider.GetMetadataForParameter(parameter.ParameterInfo); + var binder = binderFactory.CreateBinder(new ModelBinderFactoryContext + { + Metadata = metadata, + BindingInfo = parameter.BindingInfo, + CacheToken = parameter + }); + var result = await parameterBinder.BindModelAsync(context, binder, values, parameter, metadata, null); + dates[parameter.Name] = (DateOnly?)result.Model; + } + + context.ModelState.IsValid.Should().BeTrue(); + return new GetHabitLogsQuery(Guid.NewGuid(), Guid.NewGuid(), dates["from"], dates["to"]); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/HabitsControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/HabitsControllerTests.cs index 874d0c685..651243098 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/HabitsControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/HabitsControllerTests.cs @@ -331,6 +331,21 @@ public async Task GetLogs_Failure_ReturnsBadRequest() result.Should().BeAssignableTo().Which.StatusCode.Should().Be(400); } + [Fact] + public async Task GetLogs_Range_ForwardsUserHabitAndDates() + { + var habitId = Guid.NewGuid(); + var from = new DateOnly(2025, 1, 1); + var to = new DateOnly(2025, 1, 31); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success>([])); + + var result = await _controller.GetLogs(habitId, CancellationToken.None, from, to); + + result.Should().BeOfType(); + await _mediator.Received(1).Send(new GetHabitLogsQuery(UserId, habitId, from, to), CancellationToken.None); + } + [Fact] public async Task GetMetrics_Success_ReturnsOk() { diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/HabitLogReaderTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/HabitLogReaderTests.cs index 125ab5275..cc97c1478 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/HabitLogReaderTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/HabitLogReaderTests.cs @@ -1,12 +1,18 @@ +using System.Data.Common; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Orbit.Application.Habits.Queries; using Orbit.Domain.Entities; using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; using Orbit.Infrastructure.Persistence; +using Xunit.Abstractions; namespace Orbit.Infrastructure.Tests.Persistence; -public class HabitLogReaderTests +public class HabitLogReaderTests(ITestOutputHelper output) { private static readonly Guid UserId = Guid.NewGuid(); private static readonly DateOnly Anchor = new(2026, 7, 1); @@ -25,6 +31,107 @@ private static HabitLog WithCreatedAt(HabitLog log, DateTime createdAtUtc) return log; } + [Fact] + public async Task Handle_ExplicitPastRange_ReturnsOnlyInclusiveRangeWithoutLegacyCap() + { + await using var context = CreateInMemoryDbContext(); + var habit = RecurringHabit(); + var other = RecurringHabit(); + var from = new DateOnly(2025, 1, 1); + var to = new DateOnly(2025, 1, 31); + var createdAtUtc = new DateTime(2025, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var expected = Enumerable.Range(0, 1001) + .Select(i => HabitLog.FromScheduleRead(Guid.NewGuid(), habit.Id, from, 1, i, createdAtUtc.AddSeconds(i))) + .ToList(); + expected.Add(HabitLog.FromScheduleRead(Guid.NewGuid(), habit.Id, to, 1, 0, createdAtUtc)); + var deleted = HabitLog.FromScheduleRead(Guid.NewGuid(), habit.Id, from, 1, 1002, createdAtUtc); + deleted.SoftDelete(); + context.Habits.AddRange(habit, other); + context.HabitLogs.AddRange(expected); + context.HabitLogs.AddRange( + deleted, + HabitLog.FromScheduleRead(Guid.NewGuid(), habit.Id, from.AddDays(-1), 1, 0, createdAtUtc), + HabitLog.FromScheduleRead(Guid.NewGuid(), habit.Id, to.AddDays(1), 1, 0, createdAtUtc), + HabitLog.FromScheduleRead(Guid.NewGuid(), other.Id, from, 1, 0, createdAtUtc)); + await context.SaveChangesAsync(); + var dates = Substitute.For(); + dates.GetUserTodayAsync(UserId, Arg.Any()).Returns(Anchor); + var handler = new GetHabitLogsQueryHandler( + new GenericRepository(context), new HabitLogReader(context), dates); + + var result = await handler.Handle(new GetHabitLogsQuery(UserId, habit.Id, from, to), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Select(log => log.Id).Should().Equal(expected + .OrderByDescending(log => log.Date).ThenByDescending(log => log.CreatedAtUtc).Select(log => log.Id)); + } + + [Fact] + public async Task ReadRangeLogsAsync_UsesExistingDateIndex() + { + var plan = new RangePlanInterceptor(); + using var factory = new SqliteOrbitDbContextFactory(plan); + var user = User.Create("Range User", "range@example.com").Value; + var from = new DateOnly(2025, 1, 1); + var to = new DateOnly(2025, 1, 31); + var habit = Habit.Create(new HabitCreateParams(user.Id, "Range", FrequencyUnit.Day, 1, from)).Value; + Log(habit, from.AddDays(-1)); + Log(habit, from); + Log(habit, to); + Log(habit, to.AddDays(1)); + factory.Context.Users.Add(user); + factory.Context.Habits.Add(habit); + await factory.Context.SaveChangesAsync(); + plan.Enabled = true; + + var result = await new HabitLogReader(factory.Context).ReadRangeLogsAsync(habit.Id, from, to); + + output.WriteLine(plan.Sql); + output.WriteLine(plan.Plan); + result.Select(log => log.Date).Should().Equal(to, from); + plan.Plan.Should().Contain("IX_HabitLogs_HabitId_Date"); + } + + private sealed class RangePlanInterceptor : DbCommandInterceptor + { + public bool Enabled { get; set; } + public string Sql { get; private set; } = ""; + public string Plan { get; private set; } = ""; + + public override async ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (!Enabled) + return result; + + Sql = command.CommandText; + await using var explain = command.Connection!.CreateCommand(); + explain.CommandText = "EXPLAIN QUERY PLAN " + command.CommandText; + foreach (DbParameter parameter in command.Parameters) + { + var copy = explain.CreateParameter(); + copy.ParameterName = parameter.ParameterName; + copy.Value = parameter.Value; + copy.DbType = parameter.DbType; + explain.Parameters.Add(copy); + } + + await using var reader = await explain.ExecuteReaderAsync(cancellationToken); + var rows = new List(); + while (await reader.ReadAsync(cancellationToken)) + { + var values = new object[reader.FieldCount]; + reader.GetValues(values); + rows.Add(string.Join(" | ", values)); + } + Plan = string.Join(Environment.NewLine, rows); + return result; + } + } + [Fact] public void RecentLogs_OnlyReturnsLogsForRequestedHabit() {