From f4675f9cccc362b37471959715cb93faf5d5bb48 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 26 Jun 2026 01:16:46 -0300 Subject: [PATCH 1/3] feat(api): habit setup AI suggestion endpoint (#220) Add POST /api/habits/suggest-setup: an allowance-metered AI endpoint that, given a habit title, returns a suggested emoji, schedule (frequency unit/quantity/days) and sub-habit breakdown. Mirrors the existing AI-feature pattern (PayGate gate, IMemoryCache, cheap SubTask model tier, IncrementAiMessageCount) with a new habit-suggest rate-limit policy. No schema/DTO breaking changes, no migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Orbit.Api/Controllers/HabitsController.cs | 21 +++ .../Controllers/HabitsControllerRequests.cs | 2 + .../ServiceCollectionExtensions.AiServices.cs | 1 + .../Commands/SuggestHabitSetupCommand.cs | 82 ++++++++++ .../SuggestHabitSetupCommandValidator.cs | 20 +++ .../Interfaces/IHabitSuggestionService.cs | 14 ++ .../Models/HabitSetupSuggestion.cs | 16 ++ .../AgentCatalogService.Capabilities.cs | 1 + .../Services/AiHabitSuggestionService.cs | 132 ++++++++++++++++ .../Services/DistributedRateLimitService.cs | 1 + .../SuggestHabitSetupCommandHandlerTests.cs | 111 +++++++++++++ .../SuggestHabitSetupCommandValidatorTests.cs | 55 +++++++ .../Services/AiHabitSuggestionServiceTests.cs | 146 ++++++++++++++++++ 13 files changed, 602 insertions(+) create mode 100644 src/Orbit.Application/Habits/Commands/SuggestHabitSetupCommand.cs create mode 100644 src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs create mode 100644 src/Orbit.Domain/Interfaces/IHabitSuggestionService.cs create mode 100644 src/Orbit.Domain/Models/HabitSetupSuggestion.cs create mode 100644 src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs create mode 100644 tests/Orbit.Application.Tests/Commands/Habits/SuggestHabitSetupCommandHandlerTests.cs create mode 100644 tests/Orbit.Application.Tests/Validators/SuggestHabitSetupCommandValidatorTests.cs create mode 100644 tests/Orbit.Infrastructure.Tests/Services/AiHabitSuggestionServiceTests.cs diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index 27c17953..45e339ea 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; +using Orbit.Api.RateLimiting; using Orbit.Application.Habits.Commands; using Orbit.Application.Habits.Queries; using Orbit.Domain.Interfaces; @@ -186,6 +187,26 @@ public async Task CreateHabit( return result.ToPayGateAwareResult(v => CreatedAtAction(nameof(GetHabits), new { id = v }, new { id = v })); } + [HttpPost("suggest-setup")] + [DistributedRateLimit("habit-suggest")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] + public async Task SuggestSetup( + [FromBody] SuggestHabitSetupRequest request, + CancellationToken cancellationToken) + { + var command = new SuggestHabitSetupCommand( + HttpContext.GetUserId(), + request.Title, + request.Language); + + var result = await mediator.Send(command, cancellationToken); + return result.ToPayGateAwareResult(v => Ok(v)); + } + [HttpPost("{id:guid}/log")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/src/Orbit.Api/Controllers/HabitsControllerRequests.cs b/src/Orbit.Api/Controllers/HabitsControllerRequests.cs index ced81a06..1c22d636 100644 --- a/src/Orbit.Api/Controllers/HabitsControllerRequests.cs +++ b/src/Orbit.Api/Controllers/HabitsControllerRequests.cs @@ -131,4 +131,6 @@ public record CreateSubHabitRequest( string? Emoji = null); public record LinkGoalsRequest(List GoalIds); + + public record SuggestHabitSetupRequest(string Title, string Language = "en"); } diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs index 541b34e1..d8ecd78f 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs @@ -23,6 +23,7 @@ private static void AddAiPlatformServices(WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Orbit.Application/Habits/Commands/SuggestHabitSetupCommand.cs b/src/Orbit.Application/Habits/Commands/SuggestHabitSetupCommand.cs new file mode 100644 index 00000000..5b5dbcf7 --- /dev/null +++ b/src/Orbit.Application/Habits/Commands/SuggestHabitSetupCommand.cs @@ -0,0 +1,82 @@ +using System.Security.Cryptography; +using System.Text; +using MediatR; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; + +namespace Orbit.Application.Habits.Commands; + +public record SuggestHabitSetupCommand( + Guid UserId, + string Title, + string Language) : IRequest>; + +public partial class SuggestHabitSetupCommandHandler( + IPayGateService payGate, + IHabitSuggestionService suggestionService, + IGenericRepository userRepository, + IUnitOfWork unitOfWork, + IMemoryCache cache, + ILogger logger) + : IRequestHandler> +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1); + + public async Task> Handle( + SuggestHabitSetupCommand request, CancellationToken cancellationToken) + { + var gateCheck = await payGate.CanSendAiMessage(request.UserId, cancellationToken); + if (gateCheck.IsFailure) + return gateCheck.PropagateError(); + + var language = string.IsNullOrWhiteSpace(request.Language) ? "en" : request.Language; + var cacheKey = BuildCacheKey(request.UserId, request.Title, language); + + if (cache.TryGetValue(cacheKey, out HabitSetupSuggestion? cached) && cached is not null) + return Result.Success(cached); + + var suggestionResult = await suggestionService.SuggestSetupAsync( + request.Title, language, cancellationToken); + if (suggestionResult.IsFailure) + return suggestionResult; + + await IncrementUsageAsync(request.UserId, cancellationToken); + + cache.Set(cacheKey, suggestionResult.Value, CacheTtl); + + return suggestionResult; + } + + private async Task IncrementUsageAsync(Guid userId, CancellationToken cancellationToken) + { + var increment = await ConcurrencyRetry.ExecuteAsync( + userRepository, + unitOfWork, + ct => userRepository.FindOneTrackedAsync(user => user.Id == userId, cancellationToken: ct), + user => + { + user.IncrementAiMessageCount(); + return Task.FromResult(Result.Success()); + }, + ErrorMessages.UserNotFound, + cancellationToken); + + if (increment.IsFailure) + LogUsageIncrementFailed(logger, userId); + } + + private static string BuildCacheKey(Guid userId, string title, string language) + { + var normalizedTitle = title.Trim().ToLowerInvariant(); + var titleHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalizedTitle))); + return $"suggest-setup:{userId}:{titleHash}:{language.ToLowerInvariant()}"; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Failed to increment AI message usage after a habit suggestion for user {UserId}")] + private static partial void LogUsageIncrementFailed(ILogger logger, Guid userId); +} diff --git a/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs b/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs new file mode 100644 index 00000000..863db5ea --- /dev/null +++ b/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Orbit.Application.Common; +using Orbit.Application.Habits.Commands; + +namespace Orbit.Application.Habits.Validators; + +public class SuggestHabitSetupCommandValidator : AbstractValidator +{ + public SuggestHabitSetupCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty(); + + SharedHabitRules.AddTitleRules(RuleFor(x => x.Title)); + + RuleFor(x => x.Language) + .NotEmpty() + .MaximumLength(AppConstants.MaxLanguageLength); + } +} diff --git a/src/Orbit.Domain/Interfaces/IHabitSuggestionService.cs b/src/Orbit.Domain/Interfaces/IHabitSuggestionService.cs new file mode 100644 index 00000000..c8a7354f --- /dev/null +++ b/src/Orbit.Domain/Interfaces/IHabitSuggestionService.cs @@ -0,0 +1,14 @@ +using Orbit.Domain.Common; +using Orbit.Domain.Models; + +namespace Orbit.Domain.Interfaces; + +public interface IHabitSuggestionService +{ + /// + /// Asks the AI for a setup suggestion (emoji, schedule, sub-habit breakdown) for a habit with + /// the given title, written in the given language. Returns a sanitized suggestion on success, or + /// a failure when the AI produced no usable output or was unavailable. + /// + Task> SuggestSetupAsync(string title, string language, CancellationToken ct = default); +} diff --git a/src/Orbit.Domain/Models/HabitSetupSuggestion.cs b/src/Orbit.Domain/Models/HabitSetupSuggestion.cs new file mode 100644 index 00000000..75f5b4e9 --- /dev/null +++ b/src/Orbit.Domain/Models/HabitSetupSuggestion.cs @@ -0,0 +1,16 @@ +using Orbit.Domain.Enums; + +namespace Orbit.Domain.Models; + +/// +/// An AI-suggested starting point for a new habit: a representative emoji, a recurrence schedule +/// (or null fields for a one-time task), the weekdays it should run on (non-empty only for a daily +/// habit), and a breakdown into concrete sub-habit titles. Every field is optional so the user can +/// accept or edit any part; the shapes map 1:1 onto the create-habit request. +/// +public record HabitSetupSuggestion( + string? Emoji, + FrequencyUnit? FrequencyUnit, + int? FrequencyQuantity, + IReadOnlyList Days, + IReadOnlyList SubHabits); diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs index 0d77b9a7..d9ffc7d8 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs @@ -193,6 +193,7 @@ private static AgentCapability[] HabitCoreCapabilities() controllerActions: [ "HabitsController.CreateHabit", + "HabitsController.SuggestSetup", "HabitsController.LogHabit", "HabitsController.SkipHabit", "HabitsController.UpdateHabit", diff --git a/src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs b/src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs new file mode 100644 index 00000000..df1e8b76 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; +using Orbit.Infrastructure.AI; + +namespace Orbit.Infrastructure.Services; + +public sealed partial class AiHabitSuggestionService( + AiCompletionClient aiClient, + ILogger logger) : IHabitSuggestionService +{ + private const int MaxSuggestedSubHabits = 6; + + public async Task> SuggestSetupAsync( + string title, string language, CancellationToken ct = default) + { + var prompt = BuildPrompt(title, language); + + if (logger.IsEnabled(LogLevel.Information)) + LogGeneratingSuggestion(logger, language); + + try + { + var dto = await aiClient.CompleteJsonAsync( + prompt, + cancellationToken: ct, + purpose: "habit_suggest", + tier: AiModelTier.SubTask); + + return MapSuggestion(dto); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogSuggestionFailed(logger, ex); + return Result.Failure(ErrorMessages.AiUnavailable); + } + } + + internal static string BuildPrompt(string title, string language) + { + var languageName = LocaleHelper.GetAiLanguageName(language); + + return $""" + A user is creating a habit titled "{title}". + Reply with a JSON object suggesting a sensible setup, using exactly these fields: + - "emoji": a single emoji that best represents the habit, or null. + - "frequencyUnit": one of "Day", "Week", "Month", "Year" for a recurring habit, or null for a one-time task. + - "frequencyQuantity": a positive integer meaning "once every N units" (unit "Day" quantity 1 means daily; unit "Week" quantity 2 means every two weeks). Use null when "frequencyUnit" is null. + - "days": an array of English weekday names ("Monday" through "Sunday") ONLY when the habit should run on specific weekdays with "frequencyUnit" "Day" and "frequencyQuantity" 1; otherwise an empty array. + - "subHabits": an array of up to {MaxSuggestedSubHabits} short, concrete sub-task titles that break the habit into actionable steps, ONLY when the habit is broad enough to benefit; otherwise an empty array. + Write any sub-habit titles in {languageName}. Respond with JSON only, no prose. + """; + } + + internal static Result MapSuggestion(HabitSuggestionDto? dto) + { + if (dto is null) + return Result.Failure(ErrorMessages.AiEmptyResponse); + + var frequencyUnit = ParseFrequencyUnit(dto.FrequencyUnit); + var frequencyQuantity = SanitizeQuantity(dto.FrequencyQuantity, frequencyUnit); + var days = SanitizeDays(dto.Days, frequencyUnit, frequencyQuantity); + var subHabits = SanitizeSubHabits(dto.SubHabits); + + return Result.Success(new HabitSetupSuggestion( + SanitizeEmoji(dto.Emoji), + frequencyUnit, + frequencyQuantity, + days, + subHabits)); + } + + private static string? SanitizeEmoji(string? emoji) + { + var trimmed = emoji?.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.Length > AppConstants.MaxHabitEmojiLength) + return null; + return trimmed; + } + + private static FrequencyUnit? ParseFrequencyUnit(string? value) => + Enum.TryParse(value, ignoreCase: true, out var unit) ? unit : null; + + private static int? SanitizeQuantity(int? quantity, FrequencyUnit? frequencyUnit) + { + if (frequencyUnit is null) + return null; + return quantity is { } value && value >= 1 ? value : 1; + } + + private static IReadOnlyList SanitizeDays( + IReadOnlyList? days, FrequencyUnit? frequencyUnit, int? frequencyQuantity) + { + if (days is null || frequencyUnit != FrequencyUnit.Day || frequencyQuantity != 1) + return []; + + return days + .Select(day => Enum.TryParse(day, ignoreCase: true, out var parsed) ? parsed : (DayOfWeek?)null) + .Where(day => day is not null) + .Select(day => day!.Value) + .Distinct() + .ToList(); + } + + private static IReadOnlyList SanitizeSubHabits(IReadOnlyList? subHabits) + { + if (subHabits is null) + return []; + + return subHabits + .Select(title => title?.Trim() ?? string.Empty) + .Where(title => title.Length > 0 && title.Length <= AppConstants.MaxHabitTitleLength) + .Take(MaxSuggestedSubHabits) + .ToList(); + } + + internal sealed record HabitSuggestionDto( + string? Emoji, + string? FrequencyUnit, + int? FrequencyQuantity, + IReadOnlyList? Days, + IReadOnlyList? SubHabits); + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Generating habit setup suggestion (language: {Language})...")] + private static partial void LogGeneratingSuggestion(ILogger logger, string language); + + [LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "AI API call failed for habit setup suggestion")] + private static partial void LogSuggestionFailed(ILogger logger, Exception ex); +} diff --git a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs index cd9063c7..5e58a5ac 100644 --- a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs +++ b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs @@ -16,6 +16,7 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider ["auth"] = new(TimeSpan.FromMinutes(1), PermitLimit: 10, SegmentCount: 1), ["chat"] = new(TimeSpan.FromMinutes(1), PermitLimit: 20, SegmentCount: 4), ["ai-resolve"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4), + ["habit-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4), ["support"] = new(TimeSpan.FromHours(1), PermitLimit: 3, SegmentCount: 1), ["uploads"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4) }; diff --git a/tests/Orbit.Application.Tests/Commands/Habits/SuggestHabitSetupCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/SuggestHabitSetupCommandHandlerTests.cs new file mode 100644 index 00000000..11c6556e --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Habits/SuggestHabitSetupCommandHandlerTests.cs @@ -0,0 +1,111 @@ +using System.Linq.Expressions; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; + +namespace Orbit.Application.Tests.Commands.Habits; + +public class SuggestHabitSetupCommandHandlerTests +{ + private readonly IPayGateService _payGate = Substitute.For(); + private readonly IHabitSuggestionService _suggestionService = Substitute.For(); + private readonly IGenericRepository _userRepo = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly ILogger _logger = + Substitute.For>(); + private readonly SuggestHabitSetupCommandHandler _handler; + + private static readonly Guid UserId = Guid.NewGuid(); + + public SuggestHabitSetupCommandHandlerTests() + { + _handler = new SuggestHabitSetupCommandHandler( + _payGate, _suggestionService, _userRepo, _unitOfWork, _cache, _logger); + } + + private static HabitSetupSuggestion SampleSuggestion() => + new("R", FrequencyUnit.Day, 1, new[] { DayOfWeek.Monday }, new[] { "Warm up" }); + + private void SetupTrackedUser() + { + var user = User.Create("Test", "test@example.com").Value; + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(user); + } + + [Fact] + public async Task Handle_PayGateFails_ReturnsFailure_WithoutCallingServiceOrIncrementing() + { + _payGate.CanSendAiMessage(UserId, Arg.Any()) + .Returns(Result.PayGateFailure("Monthly AI message limit reached")); + + var result = await _handler.Handle( + new SuggestHabitSetupCommand(UserId, "Run", "en"), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(Result.PayGateErrorCode); + await _suggestionService.DidNotReceive() + .SuggestSetupAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_Success_CallsService_IncrementsCounter_ReturnsSuggestion() + { + _payGate.CanSendAiMessage(UserId, Arg.Any()).Returns(Result.Success()); + _suggestionService.SuggestSetupAsync("Run", "en", Arg.Any()) + .Returns(Result.Success(SampleSuggestion())); + SetupTrackedUser(); + + var result = await _handler.Handle( + new SuggestHabitSetupCommand(UserId, "Run", "en"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Emoji.Should().Be("R"); + await _suggestionService.Received(1).SuggestSetupAsync("Run", "en", Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_SuggestionServiceFails_PropagatesFailure_WithoutIncrementing() + { + _payGate.CanSendAiMessage(UserId, Arg.Any()).Returns(Result.Success()); + _suggestionService.SuggestSetupAsync("Run", "en", Arg.Any()) + .Returns(Result.Failure("AI service temporarily unavailable")); + + var result = await _handler.Handle( + new SuggestHabitSetupCommand(UserId, "Run", "en"), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_SecondCallSameTitle_ServedFromCache_WithoutSecondServiceCallOrIncrement() + { + _payGate.CanSendAiMessage(UserId, Arg.Any()).Returns(Result.Success()); + _suggestionService.SuggestSetupAsync("Run", "en", Arg.Any()) + .Returns(Result.Success(SampleSuggestion())); + SetupTrackedUser(); + + var command = new SuggestHabitSetupCommand(UserId, "Run", "en"); + await _handler.Handle(command, CancellationToken.None); + var second = await _handler.Handle(command, CancellationToken.None); + + second.IsSuccess.Should().BeTrue(); + second.Value.Emoji.Should().Be("R"); + await _suggestionService.Received(1).SuggestSetupAsync("Run", "en", Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } +} diff --git a/tests/Orbit.Application.Tests/Validators/SuggestHabitSetupCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/SuggestHabitSetupCommandValidatorTests.cs new file mode 100644 index 00000000..9191c932 --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/SuggestHabitSetupCommandValidatorTests.cs @@ -0,0 +1,55 @@ +using FluentValidation.TestHelper; +using Orbit.Application.Common; +using Orbit.Application.Habits.Commands; +using Orbit.Application.Habits.Validators; + +namespace Orbit.Application.Tests.Validators; + +public class SuggestHabitSetupCommandValidatorTests +{ + private readonly SuggestHabitSetupCommandValidator _validator = new(); + + private static SuggestHabitSetupCommand ValidCommand() => + new(Guid.NewGuid(), "Read a book", "en"); + + [Fact] + public void Validate_Valid_NoErrors() + { + var result = _validator.TestValidate(ValidCommand()); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyTitle_HasError() + { + var result = _validator.TestValidate(ValidCommand() with { Title = "" }); + + result.ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void Validate_TitleOverMaxLength_HasError() + { + var result = _validator.TestValidate( + ValidCommand() with { Title = new string('a', AppConstants.MaxHabitTitleLength + 1) }); + + result.ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void Validate_EmptyLanguage_HasError() + { + var result = _validator.TestValidate(ValidCommand() with { Language = "" }); + + result.ShouldHaveValidationErrorFor(x => x.Language); + } + + [Fact] + public void Validate_EmptyUserId_HasError() + { + var result = _validator.TestValidate(ValidCommand() with { UserId = Guid.Empty }); + + result.ShouldHaveValidationErrorFor(x => x.UserId); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiHabitSuggestionServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiHabitSuggestionServiceTests.cs new file mode 100644 index 00000000..6e05e7c4 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AiHabitSuggestionServiceTests.cs @@ -0,0 +1,146 @@ +using FluentAssertions; +using Orbit.Domain.Enums; +using Orbit.Infrastructure.Services; +using Dto = Orbit.Infrastructure.Services.AiHabitSuggestionService.HabitSuggestionDto; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AiHabitSuggestionServiceTests +{ + [Fact] + public void BuildPrompt_English_ContainsTitleAndVocabulary() + { + var prompt = AiHabitSuggestionService.BuildPrompt("Run daily", "en"); + + prompt.Should().Contain("Run daily"); + prompt.Should().Contain("English"); + prompt.Should().Contain("Day"); + prompt.Should().Contain("Week"); + prompt.Should().Contain("subHabits"); + } + + [Fact] + public void BuildPrompt_Portuguese_RequestsBrazilianPortuguese() + { + AiHabitSuggestionService.BuildPrompt("Ler", "pt-BR") + .Should().Contain("Brazilian Portuguese"); + } + + [Fact] + public void MapSuggestion_NullDto_ReturnsFailure() + { + var result = AiHabitSuggestionService.MapSuggestion(null); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void MapSuggestion_ValidDailyJson_MapsAllFields() + { + var dto = new Dto("R", "Day", 1, new[] { "Monday", "Wednesday" }, new[] { "Warm up", "Cool down" }); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.IsSuccess.Should().BeTrue(); + result.Value.Emoji.Should().Be("R"); + result.Value.FrequencyUnit.Should().Be(FrequencyUnit.Day); + result.Value.FrequencyQuantity.Should().Be(1); + result.Value.Days.Should().BeEquivalentTo(new[] { DayOfWeek.Monday, DayOfWeek.Wednesday }); + result.Value.SubHabits.Should().BeEquivalentTo(new[] { "Warm up", "Cool down" }); + } + + [Fact] + public void MapSuggestion_WeeklyWithDays_StripsDays() + { + var dto = new Dto(null, "Week", 1, new[] { "Monday" }, null); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.Days.Should().BeEmpty(); + result.Value.FrequencyUnit.Should().Be(FrequencyUnit.Week); + } + + [Fact] + public void MapSuggestion_DailyQuantityNotOne_StripsDays() + { + var dto = new Dto(null, "Day", 2, new[] { "Monday" }, null); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.Days.Should().BeEmpty(); + result.Value.FrequencyQuantity.Should().Be(2); + } + + [Fact] + public void MapSuggestion_OneTimeTask_NullsQuantityRegardlessOfModelValue() + { + var dto = new Dto("P", null, 5, null, null); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.FrequencyUnit.Should().BeNull(); + result.Value.FrequencyQuantity.Should().BeNull(); + } + + [Fact] + public void MapSuggestion_RecurringWithoutQuantity_DefaultsToOne() + { + var dto = new Dto(null, "Week", null, null, null); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.FrequencyQuantity.Should().Be(1); + } + + [Fact] + public void MapSuggestion_TooManySubHabits_ClampsToCap() + { + var many = Enumerable.Range(1, 20).Select(i => $"Step {i}").ToArray(); + var dto = new Dto(null, null, null, null, many); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.SubHabits.Should().HaveCount(6); + } + + [Fact] + public void MapSuggestion_InvalidFrequencyUnit_BecomesNullAndDropsQuantity() + { + var dto = new Dto(null, "Fortnight", 3, null, new[] { "x" }); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.FrequencyUnit.Should().BeNull(); + result.Value.FrequencyQuantity.Should().BeNull(); + } + + [Fact] + public void MapSuggestion_OverLongEmoji_Dropped() + { + var dto = new Dto(new string('x', 50), null, null, null, null); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.Emoji.Should().BeNull(); + } + + [Fact] + public void MapSuggestion_DropsBlankAndOverLongSubHabitTitles() + { + var dto = new Dto(null, null, null, null, new[] { " ", "ok", new string('a', 201) }); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.SubHabits.Should().BeEquivalentTo(new[] { "ok" }); + } + + [Fact] + public void MapSuggestion_InvalidWeekdayNames_DroppedKeepingValidOnes() + { + var dto = new Dto(null, "Day", 1, new[] { "Monday", "Notaday", "monday" }, null); + + var result = AiHabitSuggestionService.MapSuggestion(dto); + + result.Value.Days.Should().BeEquivalentTo(new[] { DayOfWeek.Monday }); + } +} From a38c3f422ea62416f183b6dcae437cffb243657d Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 26 Jun 2026 11:20:18 -0300 Subject: [PATCH 2/3] fix(api): enforce supported-language allowlist on habit setup suggestion (#220) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Validators/SuggestHabitSetupCommandValidator.cs | 4 +++- .../Validators/SuggestHabitSetupCommandValidatorTests.cs | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs b/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs index 863db5ea..e520216d 100644 --- a/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs @@ -15,6 +15,8 @@ public SuggestHabitSetupCommandValidator() RuleFor(x => x.Language) .NotEmpty() - .MaximumLength(AppConstants.MaxLanguageLength); + .MaximumLength(AppConstants.MaxLanguageLength) + .Must(lang => AppConstants.SupportedLanguages.Contains(lang)) + .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); } } diff --git a/tests/Orbit.Application.Tests/Validators/SuggestHabitSetupCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/SuggestHabitSetupCommandValidatorTests.cs index 9191c932..1dbcd707 100644 --- a/tests/Orbit.Application.Tests/Validators/SuggestHabitSetupCommandValidatorTests.cs +++ b/tests/Orbit.Application.Tests/Validators/SuggestHabitSetupCommandValidatorTests.cs @@ -45,6 +45,14 @@ public void Validate_EmptyLanguage_HasError() result.ShouldHaveValidationErrorFor(x => x.Language); } + [Fact] + public void Validate_UnsupportedLanguage_HasError() + { + var result = _validator.TestValidate(ValidCommand() with { Language = "xx" }); + + result.ShouldHaveValidationErrorFor(x => x.Language); + } + [Fact] public void Validate_EmptyUserId_HasError() { From c31271c404b8ffdbb0682189931df56b592065fe Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 26 Jun 2026 12:11:58 -0300 Subject: [PATCH 3/3] fix(api): adapt habit-suggest call to generalized CompleteJsonAsync (#220) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs b/src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs index df1e8b76..fc00268e 100644 --- a/src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs +++ b/src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs @@ -25,6 +25,7 @@ public async Task> SuggestSetupAsync( try { var dto = await aiClient.CompleteJsonAsync( + "You help set up a habit and reply with a single JSON object, nothing else.", prompt, cancellationToken: ct, purpose: "habit_suggest",