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
12 changes: 10 additions & 2 deletions .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> + 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<T> + 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:
Expand All @@ -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
Expand Down
110 changes: 110 additions & 0 deletions tests/Orbit.Application.Tests/Chat/Tools/BulkCreateHabitsToolTests.cs
Original file line number Diff line number Diff line change
@@ -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<IMediator>();
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<BulkCreateHabitsCommand>(), Arg.Any<CancellationToken>());
}

[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<BulkCreateHabitsCommand>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
captured = callInfo.Arg<BulkCreateHabitsCommand>();
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<BulkCreateResult>();
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<BulkCreateHabitsCommand>(), Arg.Any<CancellationToken>())
.Returns(Result.Failure<BulkCreateResult>("Habit limit reached."));

var result = await Execute("""{"habits": [{"title": "Read"}]}""");

result.Success.Should().BeFalse();
result.Error.Should().Be("Habit limit reached.");
}

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
@@ -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<IMediator>();
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<BulkDeleteHabitsCommand>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task ValidIds_ReportsSuccessCount()
{
var first = Guid.NewGuid();
var second = Guid.NewGuid();
_mediator.Send(Arg.Any<BulkDeleteHabitsCommand>(), Arg.Any<CancellationToken>())
.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<BulkDeleteResult>();
}

[Fact]
public async Task CommandFails_PropagatesError()
{
_mediator.Send(Arg.Any<BulkDeleteHabitsCommand>(), Arg.Any<CancellationToken>())
.Returns(Result.Failure<BulkDeleteResult>("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<ToolResult> Execute(string json) =>
await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None);
}
107 changes: 107 additions & 0 deletions tests/Orbit.Application.Tests/Chat/Tools/LinkGoalsToHabitToolTests.cs
Original file line number Diff line number Diff line change
@@ -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<IMediator>();
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<LinkGoalsToHabitCommand>(), Arg.Any<CancellationToken>())
.Returns(callInfo => { captured = callInfo.Arg<LinkGoalsToHabitCommand>(); 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<LinkGoalsToHabitCommand>(), Arg.Any<CancellationToken>())
.Returns(callInfo => { captured = callInfo.Arg<LinkGoalsToHabitCommand>(); 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<LinkGoalsToHabitCommand>(), Arg.Any<CancellationToken>())
.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<ToolResult> Execute(string json) =>
await _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, UserId, CancellationToken.None);
}
Loading
Loading