diff --git a/src/Orbit.Application/Chat/Tools/Implementations/DuplicateHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/DuplicateHabitTool.cs index de2f0a7e..10f05b93 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/DuplicateHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/DuplicateHabitTool.cs @@ -1,10 +1,13 @@ using System.Text.Json; using MediatR; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; namespace Orbit.Application.Chat.Tools.Implementations; public class DuplicateHabitTool( - IMediator mediator) : IAiTool + IMediator mediator, + IGenericRepository? habitRepository = null) : IAiTool { public string Name => "duplicate_habit"; @@ -33,6 +36,10 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel if (result.IsFailure) return ToolResult.FromFailure(result); - return new ToolResult(true, EntityId: result.Value.ToString(), EntityName: "Duplicated habit"); + var duplicate = habitRepository is null + ? null + : await HabitToolHelpers.FindHabitAsync(habitRepository, result.Value, userId, ct); + + return new ToolResult(true, EntityId: result.Value.ToString(), EntityName: duplicate?.Title); } } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/LinkGoalsToHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/LinkGoalsToHabitTool.cs index 56e3d470..ecf3442f 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/LinkGoalsToHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/LinkGoalsToHabitTool.cs @@ -1,11 +1,14 @@ using System.Text.Json; using MediatR; using Orbit.Application.Habits.Commands; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; namespace Orbit.Application.Chat.Tools.Implementations; public class LinkGoalsToHabitTool( - IMediator mediator) : IAiTool + IMediator mediator, + IGenericRepository habitRepository) : IAiTool { public string Name => "link_goals_to_habit"; @@ -37,6 +40,10 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel if (!args.TryGetProperty("goal_ids", out var goalIdsEl) || goalIdsEl.ValueKind != JsonValueKind.Array) return new ToolResult(false, Error: "goal_ids is required and must be an array."); + var habit = await HabitToolHelpers.FindHabitAsync(habitRepository, habitId, userId, ct); + if (habit is null) + return HabitToolHelpers.HabitNotFoundResult(habitId); + var goalIds = JsonArgumentParser.ParseGuidArray(args, "goal_ids") ?? new List(); var result = await mediator.Send(new LinkGoalsToHabitCommand(userId, habitId, goalIds), ct); @@ -44,6 +51,6 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel if (result.IsFailure) return ToolResult.FromFailure(result); - return new ToolResult(true, EntityId: habitId.ToString()); + return new ToolResult(true, EntityId: habitId.ToString(), EntityName: habit.Title); } } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs index d4f9ab93..71610f85 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs @@ -1,11 +1,14 @@ using System.Text.Json; using MediatR; using Orbit.Application.Habits.Commands; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; namespace Orbit.Application.Chat.Tools.Implementations; public class MoveHabitParentTool( - IMediator mediator) : IAiTool + IMediator mediator, + IGenericRepository habitRepository) : IAiTool { public string Name => "move_habit_parent"; @@ -37,11 +40,15 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel parentId = parsedParentId; } + var habit = await HabitToolHelpers.FindHabitAsync(habitRepository, habitId, userId, ct); + if (habit is null) + return HabitToolHelpers.HabitNotFoundResult(habitId); + var result = await mediator.Send(new MoveHabitParentCommand(userId, habitId, parentId), ct); if (result.IsFailure) return ToolResult.FromFailure(result); - return new ToolResult(true, EntityId: habitId.ToString()); + return new ToolResult(true, EntityId: habitId.ToString(), EntityName: habit.Title); } } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/UpdateChecklistTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/UpdateChecklistTool.cs index d27ca83a..1675681d 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/UpdateChecklistTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/UpdateChecklistTool.cs @@ -1,12 +1,15 @@ using System.Text.Json; using MediatR; using Orbit.Application.Habits.Commands; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; using Orbit.Domain.ValueObjects; namespace Orbit.Application.Chat.Tools.Implementations; public class UpdateChecklistTool( - IMediator mediator) : IAiTool + IMediator mediator, + IGenericRepository habitRepository) : IAiTool { public string Name => "update_checklist"; @@ -47,6 +50,10 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel if (!args.TryGetProperty("checklist_items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array) return new ToolResult(false, Error: "checklist_items is required and must be an array."); + var habit = await HabitToolHelpers.FindHabitAsync(habitRepository, habitId, userId, ct); + if (habit is null) + return HabitToolHelpers.HabitNotFoundResult(habitId); + var items = JsonArgumentParser.ParseChecklistItems(args) ?? new List(); var result = await mediator.Send(new UpdateChecklistCommand(userId, habitId, items), ct); @@ -54,6 +61,6 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel if (result.IsFailure) return ToolResult.FromFailure(result); - return new ToolResult(true, EntityId: habitId.ToString()); + return new ToolResult(true, EntityId: habitId.ToString(), EntityName: habit.Title); } } diff --git a/tests/Orbit.Application.Tests/Chat/Tools/DuplicateHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/DuplicateHabitToolTests.cs index 8e25d66a..43b9c2e2 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/DuplicateHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/DuplicateHabitToolTests.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using System.Text.Json; using FluentAssertions; using MediatR; @@ -6,19 +7,23 @@ using Orbit.Application.Chat.Tools.Implementations; using Orbit.Application.Habits.Commands; using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; namespace Orbit.Application.Tests.Chat.Tools; public class DuplicateHabitToolTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly DuplicateHabitTool _tool; private static readonly Guid UserId = Guid.NewGuid(); public DuplicateHabitToolTests() { - _tool = new DuplicateHabitTool(_mediator); + _tool = new DuplicateHabitTool(_mediator, _habitRepo); } [Fact] @@ -26,14 +31,30 @@ public async Task SuccessfulDuplicate_ReturnsSuccessWithNewId() { var habitId = Guid.NewGuid(); var newId = Guid.NewGuid(); + var duplicate = CreateHabit("Read books"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(newId)); + SetupHabitFound(duplicate); var result = await Execute($$$"""{"habit_id": "{{{habitId}}}"}"""); result.Success.Should().BeTrue(); result.EntityId.Should().Be(newId.ToString()); - result.EntityName.Should().Be("Duplicated habit"); + result.EntityName.Should().Be("Read books"); + } + + [Fact] + public async Task SuccessfulDuplicate_WhenNewHabitCannotBeResolved_ReturnsNullEntityName() + { + var newId = Guid.NewGuid(); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(newId)); + + var result = await Execute($$$"""{"habit_id": "{{{Guid.NewGuid()}}}"}"""); + + result.Success.Should().BeTrue(); + result.EntityId.Should().Be(newId.ToString()); + result.EntityName.Should().BeNull(); } [Fact] @@ -47,6 +68,7 @@ public async Task HabitNotFound_ReturnsError() result.Success.Should().BeFalse(); result.Error.Should().Contain("not found"); + result.EntityName.Should().BeNull(); } [Fact] @@ -72,4 +94,14 @@ private async Task Execute(string json) var args = JsonDocument.Parse(json).RootElement; return await _tool.ExecuteAsync(args, UserId, CancellationToken.None); } + + private void SetupHabitFound(Habit habit) => + _habitRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habit); + + private static Habit CreateHabit(string title) => + Habit.Create(new HabitCreateParams(UserId, title, FrequencyUnit.Day, 1, new DateOnly(2026, 8, 6))).Value; } diff --git a/tests/Orbit.Application.Tests/Chat/Tools/LinkGoalsToHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/LinkGoalsToHabitToolTests.cs index 684d00ef..801bf3ff 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/LinkGoalsToHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/LinkGoalsToHabitToolTests.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using System.Text.Json; using FluentAssertions; using MediatR; @@ -6,17 +7,21 @@ using Orbit.Application.Chat.Tools.Implementations; using Orbit.Application.Habits.Commands; using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; namespace Orbit.Application.Tests.Chat.Tools; public class LinkGoalsToHabitToolTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly LinkGoalsToHabitTool _tool; private static readonly Guid UserId = Guid.NewGuid(); - public LinkGoalsToHabitToolTests() => _tool = new LinkGoalsToHabitTool(_mediator); + public LinkGoalsToHabitToolTests() => _tool = new LinkGoalsToHabitTool(_mediator, _habitRepo); [Fact] public void Metadata_IsExposed() @@ -67,13 +72,15 @@ 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 habit = CreateHabit("Run 5K"); + SetupHabitFound(habit); var goalId = Guid.NewGuid(); - var result = await Execute($$"""{"habit_id": "{{habitId}}", "goal_ids": ["{{goalId}}"]}"""); + var result = await Execute($$"""{"habit_id": "{{habit.Id}}", "goal_ids": ["{{goalId}}"]}"""); result.Success.Should().BeTrue(); - result.EntityId.Should().Be(habitId.ToString()); + result.EntityId.Should().Be(habit.Id.ToString()); + result.EntityName.Should().Be("Run 5K"); captured!.GoalIds.Should().ContainSingle().Which.Should().Be(goalId); } @@ -83,8 +90,10 @@ public async Task EmptyGoalIds_UnlinksAll_ReturnsSuccess() LinkGoalsToHabitCommand? captured = null; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + var habit = CreateHabit("Run 5K"); + SetupHabitFound(habit); - var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "goal_ids": []}"""); + var result = await Execute($$"""{"habit_id": "{{habit.Id}}", "goal_ids": []}"""); result.Success.Should().BeTrue(); captured!.GoalIds.Should().BeEmpty(); @@ -93,15 +102,28 @@ public async Task EmptyGoalIds_UnlinksAll_ReturnsSuccess() [Fact] public async Task CommandFails_PropagatesError() { + var habit = CreateHabit("Run 5K"); + SetupHabitFound(habit); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Failure("Habit not found.")); - var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "goal_ids": ["{{Guid.NewGuid()}}"]}"""); + var result = await Execute($$"""{"habit_id": "{{habit.Id}}", "goal_ids": ["{{Guid.NewGuid()}}"]}"""); result.Success.Should().BeFalse(); result.Error.Should().Be("Habit not found."); + result.EntityName.Should().BeNull(); } + private void SetupHabitFound(Habit habit) => + _habitRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habit); + + private static Habit CreateHabit(string title) => + Habit.Create(new HabitCreateParams(UserId, title, FrequencyUnit.Day, 1, new DateOnly(2026, 8, 6))).Value; + 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 index 64b6e63d..0c99293c 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/MoveHabitParentToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/MoveHabitParentToolTests.cs @@ -16,7 +16,8 @@ public class MoveHabitParentToolTests private static readonly Guid UserId = Guid.NewGuid(); - public MoveHabitParentToolTests() => _tool = new MoveHabitParentTool(_mediator); + public MoveHabitParentToolTests() => + _tool = HabitToolTestFactory.CreateMoveHabitParentTool(_mediator, UserId, "Floss"); [Fact] public void Metadata_IsExposed() @@ -65,6 +66,7 @@ public async Task PromoteToTopLevel_SendsNullParent_ReturnsSuccess() result.Success.Should().BeTrue(); result.EntityId.Should().Be(habitId.ToString()); + result.EntityName.Should().Be("Floss"); captured!.ParentId.Should().BeNull(); captured.UserId.Should().Be(UserId); } @@ -94,6 +96,7 @@ public async Task CommandFails_PropagatesError() result.Success.Should().BeFalse(); result.Error.Should().Be("Cannot create a cycle."); + result.EntityName.Should().BeNull(); } private async Task Execute(string json) => diff --git a/tests/Orbit.Application.Tests/Chat/Tools/UpdateChecklistToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/UpdateChecklistToolTests.cs index 96903f54..71e9d949 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/UpdateChecklistToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/UpdateChecklistToolTests.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using System.Text.Json; using FluentAssertions; using MediatR; @@ -6,17 +7,21 @@ using Orbit.Application.Chat.Tools.Implementations; using Orbit.Application.Habits.Commands; using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; namespace Orbit.Application.Tests.Chat.Tools; public class UpdateChecklistToolTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly UpdateChecklistTool _tool; private static readonly Guid UserId = Guid.NewGuid(); - public UpdateChecklistToolTests() => _tool = new UpdateChecklistTool(_mediator); + public UpdateChecklistToolTests() => _tool = new UpdateChecklistTool(_mediator, _habitRepo); [Fact] public void Metadata_IsExposed() @@ -67,13 +72,15 @@ 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 habit = CreateHabit("Morning mobility"); + SetupHabitFound(habit); var result = await Execute( - $$"""{"habit_id": "{{habitId}}", "checklist_items": [{"text": "Warm up", "is_checked": true}, {"text": "Stretch"}]}"""); + $$"""{"habit_id": "{{habit.Id}}", "checklist_items": [{"text": "Warm up", "is_checked": true}, {"text": "Stretch"}]}"""); result.Success.Should().BeTrue(); - result.EntityId.Should().Be(habitId.ToString()); + result.EntityId.Should().Be(habit.Id.ToString()); + result.EntityName.Should().Be("Morning mobility"); captured!.ChecklistItems.Should().HaveCount(2); captured.ChecklistItems[0].Text.Should().Be("Warm up"); captured.ChecklistItems[0].IsChecked.Should().BeTrue(); @@ -85,8 +92,10 @@ public async Task EmptyItems_ClearsChecklist_ReturnsSuccess() UpdateChecklistCommand? captured = null; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(callInfo => { captured = callInfo.Arg(); return Result.Success(); }); + var habit = CreateHabit("Morning mobility"); + SetupHabitFound(habit); - var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "checklist_items": []}"""); + var result = await Execute($$"""{"habit_id": "{{habit.Id}}", "checklist_items": []}"""); result.Success.Should().BeTrue(); captured!.ChecklistItems.Should().BeEmpty(); @@ -95,15 +104,63 @@ public async Task EmptyItems_ClearsChecklist_ReturnsSuccess() [Fact] public async Task CommandFails_PropagatesError() { + var habit = CreateHabit("Morning mobility"); + SetupHabitFound(habit); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Failure("Habit not found.")); - var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "checklist_items": [{"text": "x"}]}"""); + var result = await Execute($$"""{"habit_id": "{{habit.Id}}", "checklist_items": [{"text": "x"}]}"""); result.Success.Should().BeFalse(); result.Error.Should().Be("Habit not found."); + result.EntityName.Should().BeNull(); + } + + [Fact] + public async Task UnresolvableHabit_ReturnsNullEntityName() + { + var result = await Execute($$"""{"habit_id": "{{Guid.NewGuid()}}", "checklist_items": []}"""); + + result.Success.Should().BeFalse(); + result.EntityName.Should().BeNull(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } + private void SetupHabitFound(Habit habit) => + _habitRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habit); + + private static Habit CreateHabit(string title) => + Habit.Create(new HabitCreateParams(UserId, title, FrequencyUnit.Day, 1, new DateOnly(2026, 8, 6))).Value; + private async Task Execute(string json) => await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None); } + +internal static class HabitToolTestFactory +{ + public static MoveHabitParentTool CreateMoveHabitParentTool( + IMediator mediator, + Guid userId, + string title) + { + var habitRepository = Substitute.For>(); + var habit = Habit.Create(new HabitCreateParams( + userId, + title, + FrequencyUnit.Day, + 1, + new DateOnly(2026, 8, 6))).Value; + + habitRepository.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habit); + + return new MoveHabitParentTool(mediator, habitRepository); + } +}