diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 061a912c..a05bb9ba 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -64,8 +64,16 @@ jobs: # Services/StripeCouponRewardService.cs Stripe SDK coupon/subscription wrapper. # Services/GeoLocationService.cs ipapi.co HTTP lookup (IsPrivateIpAddress classifier is a Phase-2 extract target). # AI/AiCompletionClient.cs OpenAI SDK wrapper. + # AI/AiBatchClient.cs OpenAI Files/Batch SDK wrapper (sibling of AiCompletionClient; pre-release OPENAI001 surface, no mockable seam). + # AI/AudioTranscriptionService.cs OpenAI Audio SDK wrapper (constructs AudioClient in-ctor from settings, no seam). + # Services/StripeBillingService.cs Stripe SDK wrapper (checkout/portal/subscription/invoice/coupon), sibling of StripeCouponRewardService. + # Services/GooglePlayBillingService.cs Google Android Publisher SDK glue (Play subscription verify/acknowledge). + # Services/Calendar/** Google Calendar v3 SDK glue + its DI extension; the testable filtering/dedup/mapping lives in GoogleCalendarEventFetcher (in coverage). + # BackgroundJobs/HangfireRecurringJobRegistrar.cs Hangfire recurring-job registration IHostedService (pure AddOrUpdate wiring). # Orbit.Analyzers/** build-time Roslyn analyzers (netstandard2.0; Orbit.Analyzers.Tests runs outside the runtime coverage set). - # DROPPED (now in coverage): Services/AppConfigService.cs (pure ConvertValue + injectable cache/repo) and + # Orbit.Analyzers.CodeFixes/** build-time Roslyn code-fix providers (netstandard2.0; sibling of Orbit.Analyzers, outside the runtime coverage set). + # .github/** CI tooling scripts (e.g. scripts/check_coverage.py) — not shipped product code (parity with load-tests/**). + # DROPPED (now in coverage): Services/AppConfigService.cs (pure ConvertValue + injectable cache/repo — now covered by AppConfigServiceTests) and # Services/UserDateService.cs (user-timezone "today" + week-start, injectable deps) — both live in # Orbit.Infrastructure, which is in the coverage run, so they are unit-testable and should count. # sonar.exclusions additions — non-product / false-positive sources: @@ -81,7 +89,7 @@ jobs: /d:sonar.token="${SONAR_TOKEN}" \ /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" \ /d:sonar.exclusions="**/Migrations/**,**/bin/**,**/obj/**,**/OAuth/OAuthLoginPage.cs,**/.claude/**,**/Email/Templates/**,**/load-tests/**" \ - /d:sonar.coverage.exclusions="**/Program.cs,**/Extensions/ServiceCollectionExtensions*.cs,**/Extensions/WebApplicationExtensions.cs,**/Middleware/**,**/OpenApi/**,**/OAuth/**,**/Persistence/OrbitDbContext.cs,**/Persistence/OrbitDbContextFactory.cs,**/Persistence/GenericRepository.cs,**/Persistence/UnitOfWork.cs,**/Persistence/AccountResetRepository.cs,**/Configuration/**,**/Services/ReminderSchedulerService.cs,**/Services/SlipAlertSchedulerService.cs,**/Services/GoalDeadlineNotificationService.cs,**/Services/HabitDueDateAdvancementService.cs,**/Services/DataEncryptionMigrationService.cs,**/Services/PushNotificationService.cs,**/Services/AccountDeletionService.cs,**/Services/GoogleTokenService.cs,**/Services/BackgroundServiceHealthCheck.cs,**/Services/StripeCouponRewardService.cs,**/AI/AiCompletionClient.cs,**/Services/GeoLocationService.cs,**/Orbit.Analyzers/**" + /d:sonar.coverage.exclusions="**/Program.cs,**/Extensions/ServiceCollectionExtensions*.cs,**/Extensions/WebApplicationExtensions.cs,**/Middleware/**,**/OpenApi/**,**/OAuth/**,**/Persistence/OrbitDbContext.cs,**/Persistence/OrbitDbContextFactory.cs,**/Persistence/GenericRepository.cs,**/Persistence/UnitOfWork.cs,**/Persistence/AccountResetRepository.cs,**/Configuration/**,**/Services/ReminderSchedulerService.cs,**/Services/SlipAlertSchedulerService.cs,**/Services/GoalDeadlineNotificationService.cs,**/Services/HabitDueDateAdvancementService.cs,**/Services/DataEncryptionMigrationService.cs,**/Services/PushNotificationService.cs,**/Services/AccountDeletionService.cs,**/Services/GoogleTokenService.cs,**/Services/BackgroundServiceHealthCheck.cs,**/Services/StripeCouponRewardService.cs,**/Services/StripeBillingService.cs,**/Services/GooglePlayBillingService.cs,**/Services/Calendar/**,**/AI/AiCompletionClient.cs,**/AI/AiBatchClient.cs,**/AI/AudioTranscriptionService.cs,**/Services/GeoLocationService.cs,**/BackgroundJobs/HangfireRecurringJobRegistrar.cs,**/Orbit.Analyzers/**,**/Orbit.Analyzers.CodeFixes/**,**/.github/**" - name: Build run: dotnet build --no-restore diff --git a/tests/Orbit.Application.Tests/Chat/Tools/BulkCreateHabitsToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/BulkCreateHabitsToolTests.cs new file mode 100644 index 00000000..abeac5a3 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/BulkCreateHabitsToolTests.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using FluentAssertions; +using MediatR; +using NSubstitute; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; + +namespace Orbit.Application.Tests.Chat.Tools; + +public class BulkCreateHabitsToolTests +{ + private readonly IMediator _mediator = Substitute.For(); + private readonly BulkCreateHabitsTool _tool; + + private static readonly Guid UserId = Guid.NewGuid(); + + public BulkCreateHabitsToolTests() => _tool = new BulkCreateHabitsTool(_mediator); + + [Fact] + public void Metadata_And_Schema_AreExposed() + { + _tool.Name.Should().Be("bulk_create_habits"); + _tool.Description.Should().NotBeNullOrWhiteSpace(); + _tool.GetParameterSchema().Should().NotBeNull(); + } + + [Fact] + public async Task MissingHabits_ReturnsError() + { + var result = await Execute("{}"); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habits is required"); + } + + [Fact] + public async Task HabitsNotArray_ReturnsError() + { + var result = await Execute("""{"habits": "nope"}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habits is required"); + } + + [Fact] + public async Task HabitWithoutTitle_ReturnsError() + { + var result = await Execute("""{"habits": [{"description": "no title here"}]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("non-empty title"); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EmptyArray_ReturnsNoHabitsError() + { + var result = await Execute("""{"habits": []}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("No habits provided"); + } + + [Fact] + public async Task ValidHabitsWithSubHabits_ReportsSuccessCount() + { + BulkCreateHabitsCommand? captured = null; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + captured = callInfo.Arg(); + return Result.Success(new BulkCreateResult(new[] + { + new BulkCreateItemResult(0, BulkItemStatus.Success, Guid.NewGuid(), "Morning routine"), + new BulkCreateItemResult(1, BulkItemStatus.Failed, null, "Gym", "duplicate"), + })); + }); + + var result = await Execute(""" + {"habits": [ + {"title": "Morning routine", "frequency_unit": "day", "frequency_quantity": 1, + "sub_habits": [{"title": "Make bed"}, {"description": "child missing title"}]}, + {"title": "Gym", "is_bad_habit": false} + ]} + """); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Be("1/2 habits created"); + result.Payload.Should().BeOfType(); + captured!.Habits.Should().HaveCount(2); + captured.Habits[0].SubHabits.Should().ContainSingle().Which.Title.Should().Be("Make bed"); + } + + [Fact] + public async Task CommandFails_PropagatesError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Habit limit reached.")); + + var result = await Execute("""{"habits": [{"title": "Read"}]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Habit limit reached."); + } + + private async Task Execute(string json) => + await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None); +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/BulkDeleteHabitsToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/BulkDeleteHabitsToolTests.cs new file mode 100644 index 00000000..7dd79960 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/BulkDeleteHabitsToolTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using FluentAssertions; +using MediatR; +using NSubstitute; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; + +namespace Orbit.Application.Tests.Chat.Tools; + +public class BulkDeleteHabitsToolTests +{ + private readonly IMediator _mediator = Substitute.For(); + private readonly BulkDeleteHabitsTool _tool; + + private static readonly Guid UserId = Guid.NewGuid(); + + public BulkDeleteHabitsToolTests() => _tool = new BulkDeleteHabitsTool(_mediator); + + [Fact] + public void Metadata_IsExposed() + { + _tool.Name.Should().Be("bulk_delete_habits"); + _tool.GetParameterSchema().Should().NotBeNull(); + } + + [Fact] + public async Task MissingHabitIds_ReturnsError() + { + var result = await Execute("{}"); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habit_ids is required"); + } + + [Fact] + public async Task HabitIdsNotArray_ReturnsError() + { + var result = await Execute("""{"habit_ids": "x"}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habit_ids is required"); + } + + [Fact] + public async Task EmptyArray_ReturnsNoValidIdsError() + { + var result = await Execute("""{"habit_ids": []}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("No valid habit IDs"); + } + + [Fact] + public async Task AllInvalidIds_ReturnsNoValidIdsError() + { + var result = await Execute("""{"habit_ids": ["nope", "still-not"]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("No valid habit IDs"); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ValidIds_ReportsSuccessCount() + { + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(new BulkDeleteResult(new[] + { + new BulkDeleteItemResult(0, BulkItemStatus.Success, first), + new BulkDeleteItemResult(1, BulkItemStatus.Failed, second, "in use"), + }))); + + var result = await Execute($$"""{"habit_ids": ["{{first}}", "{{second}}"]}"""); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Be("1/2 habits deleted"); + result.Payload.Should().BeOfType(); + } + + [Fact] + public async Task CommandFails_PropagatesError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Too many habits.")); + + var result = await Execute($$"""{"habit_ids": ["{{Guid.NewGuid()}}"]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Too many habits."); + } + + private async Task Execute(string json) => + await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None); +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/LinkGoalsToHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/LinkGoalsToHabitToolTests.cs new file mode 100644 index 00000000..684d00ef --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/LinkGoalsToHabitToolTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using FluentAssertions; +using MediatR; +using NSubstitute; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; + +namespace Orbit.Application.Tests.Chat.Tools; + +public class LinkGoalsToHabitToolTests +{ + private readonly IMediator _mediator = Substitute.For(); + private readonly LinkGoalsToHabitTool _tool; + + private static readonly Guid UserId = Guid.NewGuid(); + + public LinkGoalsToHabitToolTests() => _tool = new LinkGoalsToHabitTool(_mediator); + + [Fact] + public void Metadata_IsExposed() + { + _tool.Name.Should().Be("link_goals_to_habit"); + _tool.GetParameterSchema().Should().NotBeNull(); + } + + [Fact] + public async Task MissingHabitId_ReturnsError() + { + var result = await Execute("""{"goal_ids": []}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habit_id is required"); + } + + [Fact] + public async Task InvalidHabitId_ReturnsError() + { + var result = await Execute("""{"habit_id": "x", "goal_ids": []}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habit_id is required"); + } + + [Fact] + public async Task MissingGoalIds_ReturnsError() + { + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}"}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("goal_ids is required"); + } + + [Fact] + public async Task GoalIdsNotArray_ReturnsError() + { + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "goal_ids": "nope"}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("goal_ids is required"); + } + + [Fact] + public async Task LinkGoals_ForwardsCommand_ReturnsSuccess() + { + LinkGoalsToHabitCommand? captured = null; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + var habitId = Guid.NewGuid(); + var goalId = Guid.NewGuid(); + + var result = await Execute($$"""{"habit_id": "{{habitId}}", "goal_ids": ["{{goalId}}"]}"""); + + result.Success.Should().BeTrue(); + result.EntityId.Should().Be(habitId.ToString()); + captured!.GoalIds.Should().ContainSingle().Which.Should().Be(goalId); + } + + [Fact] + public async Task EmptyGoalIds_UnlinksAll_ReturnsSuccess() + { + LinkGoalsToHabitCommand? captured = null; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "goal_ids": []}"""); + + result.Success.Should().BeTrue(); + captured!.GoalIds.Should().BeEmpty(); + } + + [Fact] + public async Task CommandFails_PropagatesError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Habit not found.")); + + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "goal_ids": ["{{Guid.NewGuid()}}"]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Habit not found."); + } + + private async Task Execute(string json) => + await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None); +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/MoveHabitParentToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/MoveHabitParentToolTests.cs new file mode 100644 index 00000000..64b6e63d --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/MoveHabitParentToolTests.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using FluentAssertions; +using MediatR; +using NSubstitute; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; + +namespace Orbit.Application.Tests.Chat.Tools; + +public class MoveHabitParentToolTests +{ + private readonly IMediator _mediator = Substitute.For(); + private readonly MoveHabitParentTool _tool; + + private static readonly Guid UserId = Guid.NewGuid(); + + public MoveHabitParentToolTests() => _tool = new MoveHabitParentTool(_mediator); + + [Fact] + public void Metadata_IsExposed() + { + _tool.Name.Should().Be("move_habit_parent"); + _tool.GetParameterSchema().Should().NotBeNull(); + } + + [Fact] + public async Task MissingHabitId_ReturnsError() + { + var result = await Execute("{}"); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habit_id is required"); + } + + [Fact] + public async Task InvalidHabitId_ReturnsError() + { + var result = await Execute("""{"habit_id": "not-a-guid"}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habit_id is required"); + } + + [Fact] + public async Task InvalidParentId_ReturnsError() + { + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "parent_id": "nope"}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("parent_id must be a valid GUID"); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PromoteToTopLevel_SendsNullParent_ReturnsSuccess() + { + MoveHabitParentCommand? captured = null; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + var habitId = Guid.NewGuid(); + + var result = await Execute($$"""{"habit_id": "{{habitId}}"}"""); + + result.Success.Should().BeTrue(); + result.EntityId.Should().Be(habitId.ToString()); + captured!.ParentId.Should().BeNull(); + captured.UserId.Should().Be(UserId); + } + + [Fact] + public async Task Reparent_ForwardsParentId() + { + MoveHabitParentCommand? captured = null; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + var habitId = Guid.NewGuid(); + var parentId = Guid.NewGuid(); + + var result = await Execute($$"""{"habit_id": "{{habitId}}", "parent_id": "{{parentId}}"}"""); + + result.Success.Should().BeTrue(); + captured!.ParentId.Should().Be(parentId); + } + + [Fact] + public async Task CommandFails_PropagatesError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Cannot create a cycle.")); + + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}"}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Cannot create a cycle."); + } + + private async Task Execute(string json) => + await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None); +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/ReorderHabitsToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/ReorderHabitsToolTests.cs new file mode 100644 index 00000000..4ee0433a --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/ReorderHabitsToolTests.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using FluentAssertions; +using MediatR; +using NSubstitute; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; + +namespace Orbit.Application.Tests.Chat.Tools; + +public class ReorderHabitsToolTests +{ + private readonly IMediator _mediator = Substitute.For(); + private readonly ReorderHabitsTool _tool; + + private static readonly Guid UserId = Guid.NewGuid(); + + public ReorderHabitsToolTests() => _tool = new ReorderHabitsTool(_mediator); + + [Fact] + public void Metadata_IsExposed() + { + _tool.Name.Should().Be("reorder_habits"); + _tool.GetParameterSchema().Should().NotBeNull(); + } + + [Fact] + public async Task MissingPositions_ReturnsError() + { + var result = await Execute("{}"); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("positions is required"); + } + + [Fact] + public async Task PositionsNotArray_ReturnsError() + { + var result = await Execute("""{"positions": 3}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("positions is required"); + } + + [Fact] + public async Task ItemMissingPosition_ReturnsError() + { + var result = await Execute($$"""{"positions": [{"habit_id": "{{Guid.NewGuid()}}"}]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("valid habit_id GUID and an integer position"); + } + + [Fact] + public async Task ItemWithInvalidGuid_ReturnsError() + { + var result = await Execute("""{"positions": [{"habit_id": "bad", "position": 0}]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("valid habit_id GUID"); + } + + [Fact] + public async Task ValidPositions_ForwardsCommand_ReturnsSuccess() + { + ReorderHabitsCommand? captured = null; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + + var result = await Execute( + $$"""{"positions": [{"habit_id": "{{first}}", "position": 0}, {"habit_id": "{{second}}", "position": 1}]}"""); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Be("2 habits"); + captured!.Positions.Should().HaveCount(2); + captured.Positions[1].HabitId.Should().Be(second); + captured.Positions[1].Position.Should().Be(1); + } + + [Fact] + public async Task CommandFails_PropagatesError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Some habits do not belong to you.")); + + var result = await Execute($$"""{"positions": [{"habit_id": "{{Guid.NewGuid()}}", "position": 0}]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Some habits do not belong to you."); + } + + private async Task Execute(string json) => + await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None); +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/UpdateChecklistToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/UpdateChecklistToolTests.cs new file mode 100644 index 00000000..96903f54 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/UpdateChecklistToolTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using FluentAssertions; +using MediatR; +using NSubstitute; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; + +namespace Orbit.Application.Tests.Chat.Tools; + +public class UpdateChecklistToolTests +{ + private readonly IMediator _mediator = Substitute.For(); + private readonly UpdateChecklistTool _tool; + + private static readonly Guid UserId = Guid.NewGuid(); + + public UpdateChecklistToolTests() => _tool = new UpdateChecklistTool(_mediator); + + [Fact] + public void Metadata_IsExposed() + { + _tool.Name.Should().Be("update_checklist"); + _tool.GetParameterSchema().Should().NotBeNull(); + } + + [Fact] + public async Task MissingHabitId_ReturnsError() + { + var result = await Execute("""{"checklist_items": []}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habit_id is required"); + } + + [Fact] + public async Task InvalidHabitId_ReturnsError() + { + var result = await Execute("""{"habit_id": "x", "checklist_items": []}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("habit_id is required"); + } + + [Fact] + public async Task MissingChecklistItems_ReturnsError() + { + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}"}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("checklist_items is required"); + } + + [Fact] + public async Task ChecklistItemsNotArray_ReturnsError() + { + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "checklist_items": 5}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("checklist_items is required"); + } + + [Fact] + public async Task ValidItems_ForwardsCommand_ReturnsSuccess() + { + UpdateChecklistCommand? captured = null; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + var habitId = Guid.NewGuid(); + + var result = await Execute( + $$"""{"habit_id": "{{habitId}}", "checklist_items": [{"text": "Warm up", "is_checked": true}, {"text": "Stretch"}]}"""); + + result.Success.Should().BeTrue(); + result.EntityId.Should().Be(habitId.ToString()); + captured!.ChecklistItems.Should().HaveCount(2); + captured.ChecklistItems[0].Text.Should().Be("Warm up"); + captured.ChecklistItems[0].IsChecked.Should().BeTrue(); + } + + [Fact] + public async Task EmptyItems_ClearsChecklist_ReturnsSuccess() + { + UpdateChecklistCommand? captured = null; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "checklist_items": []}"""); + + result.Success.Should().BeTrue(); + captured!.ChecklistItems.Should().BeEmpty(); + } + + [Fact] + public async Task CommandFails_PropagatesError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Habit not found.")); + + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "checklist_items": [{"text": "x"}]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Habit not found."); + } + + private async Task Execute(string json) => + await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None); +} diff --git a/tests/Orbit.Application.Tests/Commands/Profile/DismissImportPromptCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/DismissImportPromptCommandHandlerTests.cs new file mode 100644 index 00000000..5dec315b --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Profile/DismissImportPromptCommandHandlerTests.cs @@ -0,0 +1,57 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Profile.Commands; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Commands.Profile; + +public class DismissImportPromptCommandHandlerTests +{ + private readonly IGenericRepository _userRepo = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly DismissImportPromptCommandHandler _handler; + + private static readonly Guid UserId = Guid.NewGuid(); + + public DismissImportPromptCommandHandlerTests() => + _handler = new DismissImportPromptCommandHandler(_userRepo, _unitOfWork); + + private void SetupUserFound(User user) => + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(user); + + [Fact] + public async Task Handle_MarksPromptSeenAndSaves() + { + var user = User.Create("Test User", "test@example.com").Value; + user.HasSeenImportPrompt.Should().BeFalse(); + SetupUserFound(user); + + var result = await _handler.Handle(new DismissImportPromptCommand(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.HasSeenImportPrompt.Should().BeTrue(); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_UserNotFound_ReturnsFailureAndDoesNotSave() + { + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns((User?)null); + + var result = await _handler.Handle(new DismissImportPromptCommand(UserId), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("User not found."); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } +} diff --git a/tests/Orbit.Application.Tests/Commands/Profile/SetSocialOptInCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/SetSocialOptInCommandHandlerTests.cs new file mode 100644 index 00000000..eff96085 --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Profile/SetSocialOptInCommandHandlerTests.cs @@ -0,0 +1,69 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Profile.Commands; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Commands.Profile; + +public class SetSocialOptInCommandHandlerTests +{ + private readonly IGenericRepository _userRepo = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly SetSocialOptInCommandHandler _handler; + + private static readonly Guid UserId = Guid.NewGuid(); + + public SetSocialOptInCommandHandlerTests() => _handler = new SetSocialOptInCommandHandler(_userRepo, _unitOfWork); + + private void SetupUserFound(User user) => + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(user); + + [Fact] + public async Task Handle_Enable_SetsOptInAndSaves() + { + var user = User.Create("Test User", "test@example.com").Value; + SetupUserFound(user); + + var result = await _handler.Handle(new SetSocialOptInCommand(UserId, true), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.SocialOptIn.Should().BeTrue(); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_Disable_ClearsOptInAndSaves() + { + var user = User.Create("Test User", "test@example.com").Value; + user.SetSocialOptIn(true); + SetupUserFound(user); + + var result = await _handler.Handle(new SetSocialOptInCommand(UserId, false), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.SocialOptIn.Should().BeFalse(); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_UserNotFound_ReturnsFailureAndDoesNotSave() + { + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns((User?)null); + + var result = await _handler.Handle(new SetSocialOptInCommand(UserId, true), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("User not found."); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } +} diff --git a/tests/Orbit.Application.Tests/Validators/AcceptFriendRequestCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/AcceptFriendRequestCommandValidatorTests.cs new file mode 100644 index 00000000..ad01ac49 --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/AcceptFriendRequestCommandValidatorTests.cs @@ -0,0 +1,25 @@ +using FluentValidation.TestHelper; +using Orbit.Application.Social.Commands; +using Orbit.Application.Social.Validators; + +namespace Orbit.Application.Tests.Validators; + +public class AcceptFriendRequestCommandValidatorTests +{ + private readonly AcceptFriendRequestCommandValidator _validator = new(); + + [Fact] + public void Valid_NoErrors() => + _validator.TestValidate(new AcceptFriendRequestCommand(Guid.NewGuid(), Guid.NewGuid())) + .ShouldNotHaveAnyValidationErrors(); + + [Fact] + public void EmptyUserId_HasError() => + _validator.TestValidate(new AcceptFriendRequestCommand(Guid.Empty, Guid.NewGuid())) + .ShouldHaveValidationErrorFor(x => x.UserId); + + [Fact] + public void EmptyFriendshipId_HasError() => + _validator.TestValidate(new AcceptFriendRequestCommand(Guid.NewGuid(), Guid.Empty)) + .ShouldHaveValidationErrorFor(x => x.FriendshipId); +} diff --git a/tests/Orbit.Application.Tests/Validators/ApplyOnboardingCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/ApplyOnboardingCommandValidatorTests.cs new file mode 100644 index 00000000..a0db2c94 --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/ApplyOnboardingCommandValidatorTests.cs @@ -0,0 +1,150 @@ +using FluentValidation.TestHelper; +using Orbit.Application.Common; +using Orbit.Application.Profile.Commands; +using Orbit.Application.Profile.Validators; +using Orbit.Domain.Enums; + +namespace Orbit.Application.Tests.Validators; + +public class ApplyOnboardingCommandValidatorTests +{ + private readonly ApplyOnboardingCommandValidator _validator = new(); + + private static ApplyHabitInput ValidHabit() => + new("Drink water", null, null, FrequencyUnit.Day, 1); + + private static ApplyGoalInput ValidGoal() => + new("Run 100km", null, 100, "km"); + + private static ApplyOnboardingCommand Valid() => new( + UserId: Guid.NewGuid(), + Habits: new[] { ValidHabit() }, + FirstLog: null, + Goal: ValidGoal(), + WeekStartDay: 1, + ColorScheme: "purple"); + + [Fact] + public void Valid_NoErrors() => + _validator.TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + + [Fact] + public void NullHabits_HasError() + { + var command = Valid() with { Habits = null! }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.Habits); + } + + [Fact] + public void TooManyHabits_HasError() + { + var habits = Enumerable.Range(0, AppConstants.MaxBulkOperationSize + 1).Select(_ => ValidHabit()).ToArray(); + var command = Valid() with { Habits = habits }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor("Habits.Count"); + } + + [Fact] + public void HabitWithEmptyTitle_HasError() + { + var command = Valid() with { Habits = new[] { ValidHabit() with { Title = "" } } }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor("Habits[0].Title"); + } + + [Fact] + public void InvalidGoal_HasError() + { + var command = Valid() with { Goal = ValidGoal() with { Title = "" } }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor("Goal.Title"); + } + + [Fact] + public void NullGoal_NoGoalError() + { + var command = Valid() with { Goal = null }; + + _validator.TestValidate(command).ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void SupportedWeekStartDay_NoError(int day) + { + var command = Valid() with { WeekStartDay = day }; + + _validator.TestValidate(command).ShouldNotHaveValidationErrorFor(x => x.WeekStartDay!.Value); + } + + [Fact] + public void UnsupportedWeekStartDay_HasError() + { + var command = Valid() with { WeekStartDay = 3 }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.WeekStartDay!.Value); + } +} + +public class ApplyHabitInputValidatorTests +{ + private readonly ApplyHabitInputValidator _validator = new(); + + [Fact] + public void Valid_NoErrors() => + _validator.TestValidate(new ApplyHabitInput("Read", null, null, FrequencyUnit.Day, 1)) + .ShouldNotHaveAnyValidationErrors(); + + [Fact] + public void EmptyTitle_HasError() => + _validator.TestValidate(new ApplyHabitInput("", null, null, FrequencyUnit.Day, 1)) + .ShouldHaveValidationErrorFor(x => x.Title); + + [Fact] + public void ZeroFrequencyQuantity_HasError() => + _validator.TestValidate(new ApplyHabitInput("Read", null, null, FrequencyUnit.Day, 0)) + .ShouldHaveValidationErrorFor(x => x.FrequencyQuantity); + + [Fact] + public void FrequencyUnitWithoutQuantity_HasError() => + _validator.TestValidate(new ApplyHabitInput("Read", null, null, FrequencyUnit.Day, null)) + .ShouldHaveValidationErrorFor(x => x.FrequencyQuantity); +} + +public class ApplyGoalInputValidatorTests +{ + private readonly ApplyGoalInputValidator _validator = new(); + + private static ApplyGoalInput Valid() => new("Run 100km", null, 100, "km"); + + [Fact] + public void Valid_NoErrors() => + _validator.TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + + [Fact] + public void EmptyTitle_HasError() => + _validator.TestValidate(Valid() with { Title = "" }).ShouldHaveValidationErrorFor(x => x.Title); + + [Fact] + public void DescriptionTooLong_HasError() => + _validator.TestValidate(Valid() with { Description = new string('d', AppConstants.MaxGoalDescriptionLength + 1) }) + .ShouldHaveValidationErrorFor(x => x.Description); + + [Fact] + public void NonPositiveTargetValue_HasError() => + _validator.TestValidate(Valid() with { TargetValue = 0 }).ShouldHaveValidationErrorFor(x => x.TargetValue); + + [Fact] + public void EmptyUnit_HasError() => + _validator.TestValidate(Valid() with { Unit = "" }).ShouldHaveValidationErrorFor(x => x.Unit); + + [Fact] + public void UnitTooLong_HasError() => + _validator.TestValidate(Valid() with { Unit = new string('u', 51) }).ShouldHaveValidationErrorFor(x => x.Unit); + + [Fact] + public void InvalidType_HasError() => + _validator.TestValidate(Valid() with { Type = (GoalType)999 }).ShouldHaveValidationErrorFor(x => x.Type); +} diff --git a/tests/Orbit.Application.Tests/Validators/CreateChallengeCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/CreateChallengeCommandValidatorTests.cs new file mode 100644 index 00000000..677c570b --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/CreateChallengeCommandValidatorTests.cs @@ -0,0 +1,143 @@ +using FluentValidation.TestHelper; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Challenges.Validators; +using Orbit.Application.Common; +using Orbit.Domain.Enums; + +namespace Orbit.Application.Tests.Validators; + +public class CreateChallengeCommandValidatorTests +{ + private readonly CreateChallengeCommandValidator _validator = new(); + private static readonly DateOnly Start = new(2026, 3, 1); + + private static CreateChallengeCommand ValidCoopGoal() => new( + UserId: Guid.NewGuid(), + Type: ChallengeType.CoopGoal, + Title: "March miles", + Description: null, + TargetCount: 30, + PeriodStartUtc: Start, + PeriodEndUtc: Start.AddDays(30), + LinkedHabitIds: new[] { Guid.NewGuid() }, + InvitedFriendUserIds: Array.Empty()); + + private static CreateChallengeCommand ValidStreakTogether() => new( + UserId: Guid.NewGuid(), + Type: ChallengeType.StreakTogether, + Title: "Streak buddies", + Description: "Keep each other honest", + TargetCount: null, + PeriodStartUtc: Start, + PeriodEndUtc: null, + LinkedHabitIds: new[] { Guid.NewGuid() }, + InvitedFriendUserIds: new[] { Guid.NewGuid() }); + + [Fact] + public void ValidCoopGoal_NoErrors() => + _validator.TestValidate(ValidCoopGoal()).ShouldNotHaveAnyValidationErrors(); + + [Fact] + public void ValidStreakTogether_NoErrors() => + _validator.TestValidate(ValidStreakTogether()).ShouldNotHaveAnyValidationErrors(); + + [Fact] + public void EmptyUserId_HasError() + { + var command = ValidCoopGoal() with { UserId = Guid.Empty }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.UserId); + } + + [Fact] + public void EmptyTitle_HasError() + { + var command = ValidCoopGoal() with { Title = "" }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void TitleTooLong_HasError() + { + var command = ValidCoopGoal() with { Title = new string('a', AppConstants.MaxChallengeTitleLength + 1) }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void CoopGoal_WithoutTargetCount_HasError() + { + var command = ValidCoopGoal() with { TargetCount = null }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.TargetCount); + } + + [Fact] + public void CoopGoal_WithZeroTargetCount_HasError() + { + var command = ValidCoopGoal() with { TargetCount = 0 }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.TargetCount); + } + + [Fact] + public void CoopGoal_WithoutEndDate_HasError() + { + var command = ValidCoopGoal() with { PeriodEndUtc = null }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.PeriodEndUtc); + } + + [Fact] + public void StreakTogether_WithTargetCount_HasError() + { + var command = ValidStreakTogether() with { TargetCount = 5 }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.TargetCount); + } + + [Fact] + public void EndDateBeforeStartDate_HasError() + { + var command = ValidCoopGoal() with { PeriodEndUtc = Start.AddDays(-1) }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.PeriodEndUtc); + } + + [Fact] + public void NoLinkedHabits_HasError() + { + var command = ValidCoopGoal() with { LinkedHabitIds = Array.Empty() }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.LinkedHabitIds); + } + + [Fact] + public void EmptyLinkedHabitId_HasError() + { + var command = ValidCoopGoal() with { LinkedHabitIds = new[] { Guid.Empty } }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor("LinkedHabitIds[0]"); + } + + [Fact] + public void TooManyLinkedHabits_HasError() + { + var habits = Enumerable.Range(0, AppConstants.MaxHabitsPerChallengeParticipant + 1) + .Select(_ => Guid.NewGuid()).ToArray(); + var command = ValidCoopGoal() with { LinkedHabitIds = habits }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.LinkedHabitIds); + } + + [Fact] + public void TooManyInvitedFriends_HasError() + { + var friends = Enumerable.Range(0, AppConstants.MaxChallengeParticipants) + .Select(_ => Guid.NewGuid()).ToArray(); + var command = ValidCoopGoal() with { InvitedFriendUserIds = friends }; + + _validator.TestValidate(command).ShouldHaveValidationErrorFor(x => x.InvitedFriendUserIds); + } +} diff --git a/tests/Orbit.Application.Tests/Validators/DateRangeQueryValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/DateRangeQueryValidatorTests.cs new file mode 100644 index 00000000..6044daf1 --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/DateRangeQueryValidatorTests.cs @@ -0,0 +1,152 @@ +using FluentAssertions; +using FluentValidation.TestHelper; +using Orbit.Application.Common; +using Orbit.Application.Gamification.Queries; +using Orbit.Application.Goals.Queries; +using Orbit.Application.Habits.Queries; + +namespace Orbit.Application.Tests.Validators; + +public class DateRangeQueryValidatorTests +{ + private static readonly DateOnly Start = new(2026, 1, 1); + + [Fact] + public void GetStreakHistory_EqualDates_IsValid() + { + var validator = new GetStreakHistoryQueryValidator(); + + var result = validator.TestValidate(new GetStreakHistoryQuery(Guid.NewGuid(), Start, Start)); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void GetStreakHistory_InvertedRange_HasDateFromError() + { + var validator = new GetStreakHistoryQueryValidator(); + + var result = validator.TestValidate(new GetStreakHistoryQuery(Guid.NewGuid(), Start.AddDays(5), Start)); + + result.ShouldHaveValidationErrorFor(x => x.DateFrom); + } + + [Fact] + public void GetStreakHistory_RangeExceedingCap_IsInvalid() + { + var validator = new GetStreakHistoryQueryValidator(); + + var result = validator.TestValidate( + new GetStreakHistoryQuery(Guid.NewGuid(), Start, Start.AddDays(AppConstants.MaxRangeDays + 1))); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void GetStreakHistory_RangeAtCap_IsValid() + { + var validator = new GetStreakHistoryQueryValidator(); + + var result = validator.TestValidate( + new GetStreakHistoryQuery(Guid.NewGuid(), Start, Start.AddDays(AppConstants.MaxRangeDays))); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void GetXpHistory_EqualDates_IsValid() + { + var validator = new GetXpHistoryQueryValidator(); + + var result = validator.TestValidate(new GetXpHistoryQuery(Guid.NewGuid(), Start, Start)); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void GetXpHistory_InvertedRange_HasDateFromError() + { + var validator = new GetXpHistoryQueryValidator(); + + var result = validator.TestValidate(new GetXpHistoryQuery(Guid.NewGuid(), Start.AddDays(1), Start)); + + result.ShouldHaveValidationErrorFor(x => x.DateFrom); + } + + [Fact] + public void GetXpHistory_RangeExceedingCap_IsInvalid() + { + var validator = new GetXpHistoryQueryValidator(); + + var result = validator.TestValidate( + new GetXpHistoryQuery(Guid.NewGuid(), Start, Start.AddDays(AppConstants.MaxRangeDays + 1))); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void GetGoalProgressHistory_EqualDates_IsValid() + { + var validator = new GetGoalProgressHistoryQueryValidator(); + + var result = validator.TestValidate( + new GetGoalProgressHistoryQuery(Guid.NewGuid(), Guid.NewGuid(), Start, Start)); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void GetGoalProgressHistory_InvertedRange_HasDateFromError() + { + var validator = new GetGoalProgressHistoryQueryValidator(); + + var result = validator.TestValidate( + new GetGoalProgressHistoryQuery(Guid.NewGuid(), Guid.NewGuid(), Start.AddDays(10), Start)); + + result.ShouldHaveValidationErrorFor(x => x.DateFrom); + } + + [Fact] + public void GetGoalProgressHistory_RangeExceedingCap_IsInvalid() + { + var validator = new GetGoalProgressHistoryQueryValidator(); + + var result = validator.TestValidate( + new GetGoalProgressHistoryQuery( + Guid.NewGuid(), Guid.NewGuid(), Start, Start.AddDays(AppConstants.MaxRangeDays + 1))); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void GetHabitsCompletionTrends_EqualDates_IsValid() + { + var validator = new GetHabitsCompletionTrendsQueryValidator(); + + var result = validator.TestValidate(new GetHabitsCompletionTrendsQuery(Guid.NewGuid(), Start, Start)); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void GetHabitsCompletionTrends_InvertedRange_HasDateFromError() + { + var validator = new GetHabitsCompletionTrendsQueryValidator(); + + var result = validator.TestValidate( + new GetHabitsCompletionTrendsQuery(Guid.NewGuid(), Start.AddDays(2), Start)); + + result.ShouldHaveValidationErrorFor(x => x.DateFrom); + } + + [Fact] + public void GetHabitsCompletionTrends_RangeExceedingCap_IsInvalid() + { + var validator = new GetHabitsCompletionTrendsQueryValidator(); + + var result = validator.TestValidate( + new GetHabitsCompletionTrendsQuery(Guid.NewGuid(), Start, Start.AddDays(AppConstants.MaxRangeDays + 1))); + + result.IsValid.Should().BeFalse(); + } +} diff --git a/tests/Orbit.Application.Tests/Validators/JoinChallengeCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/JoinChallengeCommandValidatorTests.cs new file mode 100644 index 00000000..9c77f726 --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/JoinChallengeCommandValidatorTests.cs @@ -0,0 +1,52 @@ +using FluentValidation.TestHelper; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Challenges.Validators; +using Orbit.Application.Common; + +namespace Orbit.Application.Tests.Validators; + +public class JoinChallengeCommandValidatorTests +{ + private readonly JoinChallengeCommandValidator _validator = new(); + + private static JoinChallengeCommand Valid() => new( + UserId: Guid.NewGuid(), + Code: "ABCD2345", + LinkedHabitIds: new[] { Guid.NewGuid() }); + + [Fact] + public void Valid_NoErrors() => + _validator.TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + + [Fact] + public void EmptyUserId_HasError() => + _validator.TestValidate(Valid() with { UserId = Guid.Empty }).ShouldHaveValidationErrorFor(x => x.UserId); + + [Fact] + public void EmptyCode_HasError() => + _validator.TestValidate(Valid() with { Code = "" }).ShouldHaveValidationErrorFor(x => x.Code); + + [Fact] + public void CodeTooLong_HasError() => + _validator.TestValidate(Valid() with { Code = new string('A', 17) }).ShouldHaveValidationErrorFor(x => x.Code); + + [Fact] + public void NoLinkedHabits_HasError() => + _validator.TestValidate(Valid() with { LinkedHabitIds = Array.Empty() }) + .ShouldHaveValidationErrorFor(x => x.LinkedHabitIds); + + [Fact] + public void EmptyLinkedHabitId_HasError() => + _validator.TestValidate(Valid() with { LinkedHabitIds = new[] { Guid.Empty } }) + .ShouldHaveValidationErrorFor("LinkedHabitIds[0]"); + + [Fact] + public void TooManyLinkedHabits_HasError() + { + var habits = Enumerable.Range(0, AppConstants.MaxHabitsPerChallengeParticipant + 1) + .Select(_ => Guid.NewGuid()).ToArray(); + + _validator.TestValidate(Valid() with { LinkedHabitIds = habits }) + .ShouldHaveValidationErrorFor(x => x.LinkedHabitIds); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiGoalReviewServiceGenerationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiGoalReviewServiceGenerationTests.cs new file mode 100644 index 00000000..b8f4f3da --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AiGoalReviewServiceGenerationTests.cs @@ -0,0 +1,104 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenAI; +using OpenAI.Chat; +using Orbit.Application.Common; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.AI; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AiGoalReviewServiceGenerationTests +{ + [Fact] + public async Task GenerateReviewAsync_EmptyContext_ReturnsNoGoalsData() + { + var service = BuildService("ignored"); + + var result = await service.GenerateReviewAsync(" ", "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.NoGoalsData.Message); + } + + [Fact] + public async Task GenerateReviewAsync_ModelReturnsText_ReturnsTrimmedReview() + { + var service = BuildService("```\nYou are on track with running. Keep the momentum.\n```"); + + var result = await service.GenerateReviewAsync("Goal: run 100km, 60km done", "pt-BR"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("You are on track with running. Keep the momentum."); + } + + [Fact] + public async Task GenerateReviewAsync_ModelReturnsBlank_ReturnsEmptyResponseFailure() + { + var service = BuildService(" "); + + var result = await service.GenerateReviewAsync("Goal: read more", "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiEmptyResponse.Message); + } + + [Fact] + public async Task GenerateReviewAsync_AiCallFails_ReturnsUnavailable() + { + var service = BuildService("boom", HttpStatusCode.BadRequest); + + var result = await service.GenerateReviewAsync("Goal: meditate", "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiGoalReviewUnavailable.Message); + } + + private static AiGoalReviewService BuildService(string content, HttpStatusCode status = HttpStatusCode.OK) + { + var chatClient = new ChatClient( + model: "gpt-test", + credential: new ApiKeyCredential("test-key"), + options: new OpenAIClientOptions + { + Endpoint = new Uri("https://orbit.test/v1"), + Transport = new HttpClientPipelineTransport( + new HttpClient(new CannedChatHandler(content, status))), + }); + var aiClient = new AiCompletionClient( + chatClient, NullLogger.Instance, Substitute.For()); + return new AiGoalReviewService(aiClient, NullLogger.Instance); + } + + private sealed class CannedChatHandler(string content, HttpStatusCode status) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (status != HttpStatusCode.OK) + return Task.FromResult(new HttpResponseMessage(status) + { + RequestMessage = request, + Content = new StringContent("{\"error\":{\"message\":\"bad\"}}", Encoding.UTF8, "application/json"), + }); + + var escaped = JsonSerializer.Serialize(content); + var body = + "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-test\"," + + "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":" + escaped + "},\"finish_reason\":\"stop\"}]," + + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiProactiveCheckinMessageServiceGenerationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiProactiveCheckinMessageServiceGenerationTests.cs new file mode 100644 index 00000000..a42833ae --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AiProactiveCheckinMessageServiceGenerationTests.cs @@ -0,0 +1,157 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenAI; +using OpenAI.Chat; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.AI; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AiProactiveCheckinMessageServiceGenerationTests +{ + private static readonly string[] OffTrackHabits = ["Meditate", "Read"]; + + [Fact] + public async Task GenerateMessageAsync_TwoLines_ReturnsTitleAndBody() + { + var service = BuildService("Still time today, Thomas\nYou fell behind on Meditate. Astra's got your back."); + + var result = await service.GenerateMessageAsync("Thomas", OffTrackHabits, 5, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Still time today, Thomas"); + result.Value.Body.Should().Be("You fell behind on Meditate. Astra's got your back."); + } + + [Fact] + public async Task GenerateMessageAsync_NoActiveStreak_StillReturnsModelText() + { + var service = BuildService("Let's finish strong, Thomas\nA couple of habits are still open today."); + + var result = await service.GenerateMessageAsync("Thomas", OffTrackHabits, 0, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Let's finish strong, Thomas"); + result.Value.Body.Should().Be("A couple of habits are still open today."); + } + + [Fact] + public async Task GenerateMessageAsync_SingleLineEnglish_UsesEnglishFallbackTitle() + { + var service = BuildService("A couple of habits are still open today."); + + var result = await service.GenerateMessageAsync("Thomas", OffTrackHabits, 5, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Still time today, Thomas"); + result.Value.Body.Should().Be("A couple of habits are still open today."); + } + + [Fact] + public async Task GenerateMessageAsync_SingleLinePortuguese_UsesPortugueseFallbackTitle() + { + var service = BuildService("Alguns habitos ainda estao abertos hoje."); + + var result = await service.GenerateMessageAsync("Thomas", OffTrackHabits, 5, "pt-BR"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Ainda dá tempo hoje, Thomas"); + result.Value.Body.Should().Be("Alguns habitos ainda estao abertos hoje."); + } + + [Fact] + public async Task GenerateMessageAsync_BlankResponseEnglish_ReturnsEnglishFallback() + { + var service = BuildService(" "); + + var result = await service.GenerateMessageAsync("Thomas", OffTrackHabits, 5, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Still time today, Thomas"); + result.Value.Body.Should().Be("You've fallen behind on a few habits today. Astra's got your back -- let's get back on track."); + } + + [Fact] + public async Task GenerateMessageAsync_BlankResponsePortuguese_ReturnsPortugueseFallback() + { + var service = BuildService(" "); + + var result = await service.GenerateMessageAsync("Thomas", OffTrackHabits, 0, "pt-BR"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Ainda dá tempo hoje, Thomas"); + result.Value.Body.Should().Be("Você ficou para trás em alguns hábitos hoje. A Astra está aqui -- bora retomar?"); + } + + [Fact] + public async Task GenerateMessageAsync_AiCallFailsEnglish_ReturnsEnglishFallback() + { + var service = BuildService("boom", HttpStatusCode.BadRequest); + + var result = await service.GenerateMessageAsync("Thomas", OffTrackHabits, 5, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Still time today, Thomas"); + result.Value.Body.Should().Be("You've fallen behind on a few habits today. Astra's got your back -- let's get back on track."); + } + + [Fact] + public async Task GenerateMessageAsync_AiCallFailsPortuguese_ReturnsPortugueseFallback() + { + var service = BuildService("boom", HttpStatusCode.BadRequest); + + var result = await service.GenerateMessageAsync("Thomas", OffTrackHabits, 0, "pt-BR"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Ainda dá tempo hoje, Thomas"); + result.Value.Body.Should().Be("Você ficou para trás em alguns hábitos hoje. A Astra está aqui -- bora retomar?"); + } + + private static AiProactiveCheckinMessageService BuildService(string content, HttpStatusCode status = HttpStatusCode.OK) + { + var chatClient = new ChatClient( + model: "gpt-test", + credential: new ApiKeyCredential("test-key"), + options: new OpenAIClientOptions + { + Endpoint = new Uri("https://orbit.test/v1"), + Transport = new HttpClientPipelineTransport( + new HttpClient(new CannedChatHandler(content, status))), + }); + var aiClient = new AiCompletionClient( + chatClient, NullLogger.Instance, Substitute.For()); + return new AiProactiveCheckinMessageService(aiClient, NullLogger.Instance); + } + + private sealed class CannedChatHandler(string content, HttpStatusCode status) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (status != HttpStatusCode.OK) + return Task.FromResult(new HttpResponseMessage(status) + { + RequestMessage = request, + Content = new StringContent("{\"error\":{\"message\":\"bad\"}}", Encoding.UTF8, "application/json"), + }); + + var escaped = JsonSerializer.Serialize(content); + var body = + "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-test\"," + + "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":" + escaped + "},\"finish_reason\":\"stop\"}]," + + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiRescheduleSuggestionServiceGenerationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiRescheduleSuggestionServiceGenerationTests.cs new file mode 100644 index 00000000..2b014b51 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AiRescheduleSuggestionServiceGenerationTests.cs @@ -0,0 +1,154 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenAI; +using OpenAI.Chat; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.AI; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AiRescheduleSuggestionServiceGenerationTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 7, 13); + + [Fact] + public async Task GenerateAsync_ValidPayload_ReturnsClampedSuggestion() + { + var service = BuildService(JsonSerializer.Serialize(new + { + frequencyUnit = "Day", + frequencyQuantity = 1, + dueDate = "2026-07-20", + dueTime = "08:00", + days = new[] { "Monday", "Tuesday" }, + rationale = " Let's ease back in gently. " + })); + + var result = await service.GenerateAsync(OverdueHabit(), Today, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.FrequencyUnit.Should().Be(FrequencyUnit.Day); + result.Value.FrequencyQuantity.Should().Be(1); + result.Value.DueDate.Should().Be(new DateOnly(2026, 7, 20)); + result.Value.DueTime.Should().Be(new TimeOnly(8, 0)); + result.Value.Days.Should().Equal(DayOfWeek.Monday, DayOfWeek.Tuesday); + result.Value.Rationale.Should().Be("Let's ease back in gently."); + } + + [Fact] + public async Task GenerateAsync_EmptyCompletion_ReturnsEmptyResponseFailure() + { + var service = BuildService(" "); + + var result = await service.GenerateAsync(OverdueHabit(), Today, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiEmptyResponse.Message); + } + + [Fact] + public async Task GenerateAsync_BlankRationale_ReturnsEmptyResponseFailure() + { + var service = BuildService(JsonSerializer.Serialize(new { dueDate = "2026-07-20", rationale = " " })); + + var result = await service.GenerateAsync(OverdueHabit(), Today, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiEmptyResponse.Message); + } + + [Fact] + public async Task GenerateAsync_UnparseableDueDate_ReturnsRescheduleUnavailable() + { + var service = BuildService(JsonSerializer.Serialize(new + { + dueDate = "not-a-date", + rationale = "Restart tomorrow, you've got this." + })); + + var result = await service.GenerateAsync(OverdueHabit(), Today, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiRescheduleUnavailable.Message); + } + + [Fact] + public async Task GenerateAsync_AiCallFails_ReturnsRescheduleUnavailable() + { + var service = BuildService("boom", HttpStatusCode.BadRequest); + + var result = await service.GenerateAsync(OverdueHabit(), Today, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiRescheduleUnavailable.Message); + } + + [Fact] + public async Task GenerateAsync_OverlongRationale_CapsToSentenceWithinLimit() + { + var longRationale = new string('a', 120) + ". " + new string('b', 150); + var service = BuildService(JsonSerializer.Serialize(new { dueDate = "2026-07-20", rationale = longRationale })); + + var result = await service.GenerateAsync(OverdueHabit(), Today, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Rationale.Length.Should().BeLessThanOrEqualTo(240); + result.Value.Rationale.Should().EndWith("."); + } + + private static Habit OverdueHabit() => + Habit.Create(new HabitCreateParams( + UserId, "Evening run", FrequencyUnit.Day, 1, DueDate: Today.AddDays(-5))).Value; + + private static AiRescheduleSuggestionService BuildService(string content, HttpStatusCode status = HttpStatusCode.OK) + { + var chatClient = new ChatClient( + model: "gpt-test", + credential: new ApiKeyCredential("test-key"), + options: new OpenAIClientOptions + { + Endpoint = new Uri("https://orbit.test/v1"), + Transport = new HttpClientPipelineTransport( + new HttpClient(new CannedChatHandler(content, status))), + }); + var aiClient = new AiCompletionClient( + chatClient, NullLogger.Instance, Substitute.For()); + return new AiRescheduleSuggestionService(aiClient, NullLogger.Instance); + } + + private sealed class CannedChatHandler(string content, HttpStatusCode status) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (status != HttpStatusCode.OK) + return Task.FromResult(new HttpResponseMessage(status) + { + RequestMessage = request, + Content = new StringContent("{\"error\":{\"message\":\"bad\"}}", Encoding.UTF8, "application/json"), + }); + + var escaped = JsonSerializer.Serialize(content); + var body = + "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-test\"," + + "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":" + escaped + "},\"finish_reason\":\"stop\"}]," + + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiSlipAlertMessageServiceGenerationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiSlipAlertMessageServiceGenerationTests.cs new file mode 100644 index 00000000..a171b81b --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AiSlipAlertMessageServiceGenerationTests.cs @@ -0,0 +1,155 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenAI; +using OpenAI.Chat; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.AI; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AiSlipAlertMessageServiceGenerationTests +{ + [Fact] + public async Task GenerateMessageAsync_TwoLines_ReturnsTitleAndBody() + { + var service = BuildService("Stay strong today!\nYou tend to reach for it now -- ride it out."); + + var result = await service.GenerateMessageAsync("Smoking", DayOfWeek.Friday, 14, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Stay strong today!"); + result.Value.Body.Should().Be("You tend to reach for it now -- ride it out."); + } + + [Fact] + public async Task GenerateMessageAsync_NullPeakHour_StillReturnsModelText() + { + var service = BuildService("You've got this!\nOne clean day at a time."); + + var result = await service.GenerateMessageAsync("Smoking", DayOfWeek.Monday, null, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("You've got this!"); + result.Value.Body.Should().Be("One clean day at a time."); + } + + [Fact] + public async Task GenerateMessageAsync_SingleLineEnglish_UsesEnglishFallbackTitle() + { + var service = BuildService("Keep your streak clean today."); + + var result = await service.GenerateMessageAsync("Smoking", DayOfWeek.Friday, 14, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Heads up: Smoking"); + result.Value.Body.Should().Be("Keep your streak clean today."); + } + + [Fact] + public async Task GenerateMessageAsync_SingleLinePortuguese_UsesPortugueseFallbackTitle() + { + var service = BuildService("Mantenha a sequencia limpa hoje."); + + var result = await service.GenerateMessageAsync("Smoking", DayOfWeek.Friday, 14, "pt-BR"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Fique atento: Smoking"); + result.Value.Body.Should().Be("Mantenha a sequencia limpa hoje."); + } + + [Fact] + public async Task GenerateMessageAsync_BlankResponseEnglish_ReturnsEnglishFallback() + { + var service = BuildService(" "); + + var result = await service.GenerateMessageAsync("Smoking", DayOfWeek.Friday, 14, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Heads up: Smoking"); + result.Value.Body.Should().Be("You tend to slip around this time. Stay strong -- you've got this!"); + } + + [Fact] + public async Task GenerateMessageAsync_BlankResponsePortuguese_ReturnsPortugueseFallback() + { + var service = BuildService(" "); + + var result = await service.GenerateMessageAsync("Smoking", DayOfWeek.Friday, null, "pt-BR"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Fique atento: Smoking"); + result.Value.Body.Should().Be("Você costuma deslizar por volta desse horário. Força -- você consegue!"); + } + + [Fact] + public async Task GenerateMessageAsync_AiCallFailsEnglish_ReturnsEnglishFallback() + { + var service = BuildService("boom", HttpStatusCode.BadRequest); + + var result = await service.GenerateMessageAsync("Smoking", DayOfWeek.Friday, 14, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Heads up: Smoking"); + result.Value.Body.Should().Be("You tend to slip around this time. Stay strong -- you've got this!"); + } + + [Fact] + public async Task GenerateMessageAsync_AiCallFailsPortuguese_ReturnsPortugueseFallback() + { + var service = BuildService("boom", HttpStatusCode.BadRequest); + + var result = await service.GenerateMessageAsync("Smoking", DayOfWeek.Friday, null, "pt-BR"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Fique atento: Smoking"); + result.Value.Body.Should().Be("Você costuma deslizar por volta desse horário. Força -- você consegue!"); + } + + private static AiSlipAlertMessageService BuildService(string content, HttpStatusCode status = HttpStatusCode.OK) + { + var chatClient = new ChatClient( + model: "gpt-test", + credential: new ApiKeyCredential("test-key"), + options: new OpenAIClientOptions + { + Endpoint = new Uri("https://orbit.test/v1"), + Transport = new HttpClientPipelineTransport( + new HttpClient(new CannedChatHandler(content, status))), + }); + var aiClient = new AiCompletionClient( + chatClient, NullLogger.Instance, Substitute.For()); + return new AiSlipAlertMessageService(aiClient, NullLogger.Instance); + } + + private sealed class CannedChatHandler(string content, HttpStatusCode status) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (status != HttpStatusCode.OK) + return Task.FromResult(new HttpResponseMessage(status) + { + RequestMessage = request, + Content = new StringContent("{\"error\":{\"message\":\"bad\"}}", Encoding.UTF8, "application/json"), + }); + + var escaped = JsonSerializer.Serialize(content); + var body = + "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-test\"," + + "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":" + escaped + "},\"finish_reason\":\"stop\"}]," + + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiTagSuggestionServiceGenerationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiTagSuggestionServiceGenerationTests.cs new file mode 100644 index 00000000..8273c5ef --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AiTagSuggestionServiceGenerationTests.cs @@ -0,0 +1,130 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenAI; +using OpenAI.Chat; +using Orbit.Application.Common; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.AI; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AiTagSuggestionServiceGenerationTests +{ + private static readonly string[] NoExistingTags = []; + + [Fact] + public async Task SuggestTagsAsync_ValidTags_ReturnsTrimmedTagsWithBlanksFiltered() + { + var service = BuildService(TagsJson(" health ", "fitness", "", " ")); + + var result = await service.SuggestTagsAsync("Morning run", "Jog the park", NoExistingTags, "en"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Equal("health", "fitness"); + } + + [Fact] + public async Task SuggestTagsAsync_EmptyTagsArray_ReturnsEmptyResponseFailure() + { + var service = BuildService(TagsJson()); + + var result = await service.SuggestTagsAsync("Morning run", null, NoExistingTags, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiEmptyResponse.Message); + } + + [Fact] + public async Task SuggestTagsAsync_WhitespaceOnlyTags_ReturnsEmptyResponseFailure() + { + var service = BuildService(TagsJson(" ", "")); + + var result = await service.SuggestTagsAsync("Morning run", null, NoExistingTags, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiEmptyResponse.Message); + } + + [Fact] + public async Task SuggestTagsAsync_MissingTagsProperty_ReturnsEmptyResponseFailure() + { + var service = BuildService("{}"); + + var result = await service.SuggestTagsAsync("Morning run", null, NoExistingTags, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiEmptyResponse.Message); + } + + [Fact] + public async Task SuggestTagsAsync_EmptyCompletion_ReturnsEmptyResponseFailure() + { + var service = BuildService(" "); + + var result = await service.SuggestTagsAsync("Morning run", null, NoExistingTags, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiEmptyResponse.Message); + } + + [Fact] + public async Task SuggestTagsAsync_AiCallFails_ReturnsTagSuggestionUnavailable() + { + var service = BuildService("boom", HttpStatusCode.BadRequest); + + var result = await service.SuggestTagsAsync("Morning run", null, NoExistingTags, "en"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ErrorMessages.AiTagSuggestionUnavailable.Message); + } + + private static string TagsJson(params string[] tags) => JsonSerializer.Serialize(new { tags }); + + private static AiTagSuggestionService BuildService(string content, HttpStatusCode status = HttpStatusCode.OK) + { + var chatClient = new ChatClient( + model: "gpt-test", + credential: new ApiKeyCredential("test-key"), + options: new OpenAIClientOptions + { + Endpoint = new Uri("https://orbit.test/v1"), + Transport = new HttpClientPipelineTransport( + new HttpClient(new CannedChatHandler(content, status))), + }); + var aiClient = new AiCompletionClient( + chatClient, NullLogger.Instance, Substitute.For()); + return new AiTagSuggestionService(aiClient, NullLogger.Instance); + } + + private sealed class CannedChatHandler(string content, HttpStatusCode status) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (status != HttpStatusCode.OK) + return Task.FromResult(new HttpResponseMessage(status) + { + RequestMessage = request, + Content = new StringContent("{\"error\":{\"message\":\"bad\"}}", Encoding.UTF8, "application/json"), + }); + + var escaped = JsonSerializer.Serialize(content); + var body = + "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-test\"," + + "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":" + escaped + "},\"finish_reason\":\"stop\"}]," + + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs new file mode 100644 index 00000000..c0e48e7a --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs @@ -0,0 +1,126 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orbit.Domain.Entities; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AiUsageSummaryServiceGenerationTests +{ + private static readonly DateOnly Yesterday = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1); + + [Fact] + public async Task SummarizeYesterdayAsync_WithRows_EmitsAggregatedSummary() + { + var harness = new Harness(); + await harness.SeedAsync( + Row("chat", calls: 3, costUsd: 0.1000m), + Row("daily_summary", calls: 2, costUsd: 0.0500m)); + + await harness.Service.SummarizeYesterdayAsync(CancellationToken.None); + + var summary = harness.SingleSummaryLine(); + summary.Should().Contain($"AI cost {Yesterday:yyyy-MM-dd}: total=$0.1500 over 5 calls"); + } + + [Fact] + public async Task SummarizeYesterdayAsync_NoRows_EmitsNoUsageRecorded() + { + var harness = new Harness(); + + await harness.Service.SummarizeYesterdayAsync(CancellationToken.None); + + harness.SingleSummaryLine().Should().Be($"AI cost {Yesterday:yyyy-MM-dd}: no usage recorded"); + } + + [Fact] + public async Task SummarizeYesterdayAsync_CalledTwice_EmitsSummaryOnlyOnce() + { + var harness = new Harness(); + await harness.SeedAsync(Row("chat", calls: 1, costUsd: 0.2000m)); + + await harness.Service.SummarizeYesterdayAsync(CancellationToken.None); + await harness.Service.SummarizeYesterdayAsync(CancellationToken.None); + + harness.Logger.Entries.Count(entry => entry.Message.StartsWith("AI cost")).Should().Be(1); + } + + private static AiUsageDaily Row(string purpose, long calls, decimal costUsd) => + AiUsageDaily.Create( + Yesterday, "gpt-4.1-mini", purpose, + new AiUsageTotals(calls, CachedTokens: 0, PromptTokens: 0, CompletionTokens: 0, TotalTokens: 0, CostUsd: costUsd)); + + private sealed class Harness + { + private readonly string _databaseName = $"AiUsageSummary_{Guid.NewGuid()}"; + private readonly ServiceProvider _provider; + + public CapturingLogger Logger { get; } = new(); + + public AiUsageSummaryService Service { get; } + + public Harness() + { + _provider = new ServiceCollection() + .AddDbContext(options => options.UseInMemoryDatabase(_databaseName)) + .BuildServiceProvider(); + + var settings = new AiSettings + { + Pricing = + { + ["gpt-4.1-mini"] = new AiModelPrice + { + InputPerMillionUsd = 0.40m, + CachedInputPerMillionUsd = 0.10m, + OutputPerMillionUsd = 1.60m + } + } + }; + Service = new AiUsageSummaryService( + _provider.GetRequiredService(), + Logger, + new ConfigurationBuilder().Build(), + Options.Create(settings)); + } + + public async Task SeedAsync(params AiUsageDaily[] rows) + { + using var scope = _provider.GetRequiredService().CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.AiUsageDaily.AddRange(rows); + await dbContext.SaveChangesAsync(); + } + + public string SingleSummaryLine() => + Logger.Entries.Single(entry => entry.Message.StartsWith("AI cost")).Message; + } + + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => Entries.Add((logLevel, formatter(state, exception))); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AppConfigServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AppConfigServiceTests.cs new file mode 100644 index 00000000..d4ada952 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AppConfigServiceTests.cs @@ -0,0 +1,184 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Orbit.Domain.Entities; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AppConfigServiceTests +{ + private static OrbitDbContext NewDbContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"AppConfigServiceTests_{Guid.NewGuid()}") + .Options); + + private static async Task SeedAsync(OrbitDbContext dbContext, string key, string value) + { + dbContext.Set().Add(AppConfig.Create(key, value)); + await dbContext.SaveChangesAsync(); + } + + private static AppConfigService Create(OrbitDbContext dbContext) => + new(dbContext, new MemoryCache(new MemoryCacheOptions())); + + [Fact] + public async Task GetAsync_MissingKey_ReturnsDefault() + { + await using var dbContext = NewDbContext(); + var service = Create(dbContext); + + var value = await service.GetAsync("missing", 42); + + value.Should().Be(42); + } + + [Fact] + public async Task GetAsync_IntValue_ParsesFromStore() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "max_habits", "7"); + var service = Create(dbContext); + + var value = await service.GetAsync("max_habits", 0); + + value.Should().Be(7); + } + + [Fact] + public async Task GetAsync_BoolValue_ParsesFromStore() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "feature_on", "true"); + var service = Create(dbContext); + + var value = await service.GetAsync("feature_on", false); + + value.Should().BeTrue(); + } + + [Fact] + public async Task GetAsync_LongValue_ParsesFromStore() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "big", "9000000000"); + var service = Create(dbContext); + + var value = await service.GetAsync("big", 0L); + + value.Should().Be(9_000_000_000L); + } + + [Fact] + public async Task GetAsync_DoubleValue_ParsesFromStore() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "ratio", "1.5"); + var service = Create(dbContext); + + var value = await service.GetAsync("ratio", 0d); + + value.Should().Be(1.5d); + } + + [Fact] + public async Task GetAsync_StringValue_ReturnsRaw() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "greeting", "hello"); + var service = Create(dbContext); + + var value = await service.GetAsync("greeting", "default"); + + value.Should().Be("hello"); + } + + [Fact] + public async Task GetAsync_UnparseableValue_ReturnsDefault() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "max_habits", "not-a-number"); + var service = Create(dbContext); + + var value = await service.GetAsync("max_habits", 5); + + value.Should().Be(5); + } + + [Fact] + public async Task GetAsync_UnsupportedType_ReturnsDefault() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "some_guid", Guid.NewGuid().ToString()); + var service = Create(dbContext); + var fallback = Guid.NewGuid(); + + var value = await service.GetAsync("some_guid", fallback); + + value.Should().Be(fallback); + } + + [Fact] + public async Task GetAsync_SecondCall_ServesFromCacheAfterRowRemoved() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "cached_key", "10"); + var service = Create(dbContext); + + var first = await service.GetAsync("cached_key", 0); + + var row = await dbContext.Set().FirstAsync(c => c.Key == "cached_key"); + dbContext.Set().Remove(row); + await dbContext.SaveChangesAsync(); + + var second = await service.GetAsync("cached_key", 0); + + first.Should().Be(10); + second.Should().Be(10); + } + + [Fact] + public async Task GetAsync_MissingKey_CachesDefault() + { + await using var dbContext = NewDbContext(); + var service = Create(dbContext); + + var first = await service.GetAsync("later_key", 1); + await SeedAsync(dbContext, "later_key", "99"); + var second = await service.GetAsync("later_key", 1); + + first.Should().Be(1); + second.Should().Be(1); + } + + [Fact] + public async Task GetAllAsync_ReturnsAllConfigs() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "a", "1"); + await SeedAsync(dbContext, "b", "2"); + var service = Create(dbContext); + + var all = await service.GetAllAsync(); + + all.Should().HaveCount(2); + all["a"].Should().Be("1"); + all["b"].Should().Be("2"); + } + + [Fact] + public async Task GetAllAsync_SecondCall_ServesFromCache() + { + await using var dbContext = NewDbContext(); + await SeedAsync(dbContext, "a", "1"); + var service = Create(dbContext); + + var first = await service.GetAllAsync(); + await SeedAsync(dbContext, "b", "2"); + var second = await service.GetAllAsync(); + + first.Should().HaveCount(1); + second.Should().HaveCount(1); + } +}