Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Habit>? habitRepository = null) : IAiTool
{
public string Name => "duplicate_habit";

Expand Down Expand Up @@ -33,6 +36,10 @@ public async Task<ToolResult> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Habit> habitRepository) : IAiTool
{
public string Name => "link_goals_to_habit";

Expand Down Expand Up @@ -37,13 +40,17 @@ public async Task<ToolResult> 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<Guid>();

var result = await mediator.Send(new LinkGoalsToHabitCommand(userId, habitId, goalIds), 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Habit> habitRepository) : IAiTool
{
public string Name => "move_habit_parent";

Expand Down Expand Up @@ -37,11 +40,15 @@ public async Task<ToolResult> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Habit> habitRepository) : IAiTool
{
public string Name => "update_checklist";

Expand Down Expand Up @@ -47,13 +50,17 @@ public async Task<ToolResult> 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<ChecklistItem>();

var result = await mediator.Send(new UpdateChecklistCommand(userId, habitId, items), 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);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using System.Text.Json;
using FluentAssertions;
using MediatR;
Expand All @@ -6,34 +7,54 @@
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<IMediator>();
private readonly IGenericRepository<Habit> _habitRepo = Substitute.For<IGenericRepository<Habit>>();
private readonly DuplicateHabitTool _tool;

private static readonly Guid UserId = Guid.NewGuid();

public DuplicateHabitToolTests()
{
_tool = new DuplicateHabitTool(_mediator);
_tool = new DuplicateHabitTool(_mediator, _habitRepo);
}

[Fact]
public async Task SuccessfulDuplicate_ReturnsSuccessWithNewId()
{
var habitId = Guid.NewGuid();
var newId = Guid.NewGuid();
var duplicate = CreateHabit("Read books");
_mediator.Send(Arg.Any<DuplicateHabitCommand>(), Arg.Any<CancellationToken>())
.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<DuplicateHabitCommand>(), Arg.Any<CancellationToken>())
.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]
Expand All @@ -47,6 +68,7 @@ public async Task HabitNotFound_ReturnsError()

result.Success.Should().BeFalse();
result.Error.Should().Contain("not found");
result.EntityName.Should().BeNull();
}

[Fact]
Expand All @@ -72,4 +94,14 @@ private async Task<ToolResult> 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<Expression<Func<Habit, bool>>>(),
Arg.Any<Func<IQueryable<Habit>, IQueryable<Habit>>?>(),
Arg.Any<CancellationToken>())
.Returns(habit);

private static Habit CreateHabit(string title) =>
Habit.Create(new HabitCreateParams(UserId, title, FrequencyUnit.Day, 1, new DateOnly(2026, 8, 6))).Value;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using System.Text.Json;
using FluentAssertions;
using MediatR;
Expand All @@ -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<IMediator>();
private readonly IGenericRepository<Habit> _habitRepo = Substitute.For<IGenericRepository<Habit>>();
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()
Expand Down Expand Up @@ -67,13 +72,15 @@ public async Task LinkGoals_ForwardsCommand_ReturnsSuccess()
LinkGoalsToHabitCommand? captured = null;
_mediator.Send(Arg.Any<LinkGoalsToHabitCommand>(), Arg.Any<CancellationToken>())
.Returns(callInfo => { captured = callInfo.Arg<LinkGoalsToHabitCommand>(); 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);
}

Expand All @@ -83,8 +90,10 @@ public async Task EmptyGoalIds_UnlinksAll_ReturnsSuccess()
LinkGoalsToHabitCommand? captured = null;
_mediator.Send(Arg.Any<LinkGoalsToHabitCommand>(), Arg.Any<CancellationToken>())
.Returns(callInfo => { captured = callInfo.Arg<LinkGoalsToHabitCommand>(); 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();
Expand All @@ -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<LinkGoalsToHabitCommand>(), Arg.Any<CancellationToken>())
.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<Expression<Func<Habit, bool>>>(),
Arg.Any<Func<IQueryable<Habit>, IQueryable<Habit>>?>(),
Arg.Any<CancellationToken>())
.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<ToolResult> Execute(string json) =>
await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<ToolResult> Execute(string json) =>
Expand Down
Loading
Loading