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,6 +1,7 @@
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Orbit.Application.Habits.Commands;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
Expand Down Expand Up @@ -125,19 +126,71 @@ public async Task Handle_InvalidatesSummaryCache()
}

[Fact]
public async Task Handle_UsesTransactionWrapper()
public async Task Handle_SaveFailsMidBatch_RollsBackWholeBatchAndSkipsRecalcAndCacheInvalidation()
{
var habit1 = Habit.Create(new HabitCreateParams(UserId, "Habit 1", FrequencyUnit.Day, 1, DueDate: Today)).Value;
var habit2 = Habit.Create(new HabitCreateParams(UserId, "Habit 2", FrequencyUnit.Day, 1, DueDate: Today)).Value;
_habitRepo.FindTrackedAsync(
Arg.Any<Expression<Func<Habit, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<Habit>());
Arg.Any<Expression<Func<Habit, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<Habit> { habit1, habit2 });
_unitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("db failure mid-batch"));

var command = new BulkDeleteHabitsCommand(UserId, new List<Guid> { Guid.NewGuid() });
var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en";
_cache.Set(cacheKey, "cached-summary");

await _handler.Handle(command, CancellationToken.None);
var command = new BulkDeleteHabitsCommand(UserId, new List<Guid> { habit1.Id, habit2.Id });

var act = async () => await _handler.Handle(command, CancellationToken.None);

await act.Should().ThrowAsync<InvalidOperationException>();
await _userStreakService.DidNotReceive().RecalculateAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
_cache.TryGetValue(cacheKey, out _).Should().BeTrue();
await _unitOfWork.Received(1).ExecuteInTransactionAsync(
Arg.Any<Func<CancellationToken, Task>>(),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_PersistenceAndRecalcRunInsideTransaction_NotOutsideIt()
{
var habit = Habit.Create(new HabitCreateParams(UserId, "Habit", FrequencyUnit.Day, 1, DueDate: Today)).Value;
_habitRepo.FindTrackedAsync(
Arg.Any<Expression<Func<Habit, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<Habit> { habit });

var insideTransaction = false;
var saveObservedInsideTransaction = new List<bool>();
var recalcObservedInsideTransaction = new List<bool>();

_unitOfWork.ExecuteInTransactionAsync(
Arg.Any<Func<CancellationToken, Task>>(),
Arg.Any<CancellationToken>())
.Returns(async call =>
{
insideTransaction = true;
try
{
await call.ArgAt<Func<CancellationToken, Task>>(0)(call.ArgAt<CancellationToken>(1));
}
finally
{
insideTransaction = false;
}
});
_unitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
.Returns(_ => { saveObservedInsideTransaction.Add(insideTransaction); return 1; });
_userStreakService.RecalculateAsync(UserId, Arg.Any<CancellationToken>())
.Returns(_ => { recalcObservedInsideTransaction.Add(insideTransaction); return new UserStreakState(0, 0, null); });

var command = new BulkDeleteHabitsCommand(UserId, new List<Guid> { habit.Id });

await _handler.Handle(command, CancellationToken.None);

saveObservedInsideTransaction.Should().NotBeEmpty().And.OnlyContain(observed => observed);
recalcObservedInsideTransaction.Should().NotBeEmpty().And.OnlyContain(observed => observed);
habit.IsDeleted.Should().BeTrue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,110 @@ await _unitOfWork.Received(1).ExecuteInTransactionAsync(
Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_InvalidItemsInterleavedWithValid_AppliesValidReportsInvalidPerItem()
{
var habitA = Habit.Create(new HabitCreateParams(UserId, "Habit A", FrequencyUnit.Day, 1, DueDate: Today)).Value;
var habitB = Habit.Create(new HabitCreateParams(UserId, "Habit B", FrequencyUnit.Day, 1, DueDate: Today)).Value;
SetupHabitsForUser(new List<Habit> { habitA, habitB });

var missingId = Guid.NewGuid();
var items = new List<BulkLogItem>
{
new(Guid.NewGuid(), Today.AddDays(3)),
new(habitA.Id),
new(missingId),
new(habitB.Id)
};
var command = new BulkLogHabitsCommand(UserId, items);

var result = await _handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Results.Should().HaveCount(4);

result.Value.Results[0].Index.Should().Be(0);
result.Value.Results[0].Status.Should().Be(BulkItemStatus.Failed);
result.Value.Results[0].ErrorCode.Should().Be(ErrorMessages.CannotLogFutureDate.Code);

result.Value.Results[1].Index.Should().Be(1);
result.Value.Results[1].Status.Should().Be(BulkItemStatus.Success);
result.Value.Results[1].HabitId.Should().Be(habitA.Id);
result.Value.Results[1].LogId.Should().NotBeNull();

result.Value.Results[2].Index.Should().Be(2);
result.Value.Results[2].Status.Should().Be(BulkItemStatus.Failed);
result.Value.Results[2].ErrorCode.Should().Be(ErrorMessages.HabitNotFound.Code);

result.Value.Results[3].Index.Should().Be(3);
result.Value.Results[3].Status.Should().Be(BulkItemStatus.Success);
result.Value.Results[3].HabitId.Should().Be(habitB.Id);
result.Value.Results[3].LogId.Should().NotBeNull();

await _habitLogRepo.Received(2).AddAsync(Arg.Any<HabitLog>(), Arg.Any<CancellationToken>());
await _unitOfWork.Received(2).SaveChangesAsync(Arg.Any<CancellationToken>());
habitA.Logs.Should().ContainSingle(l => l.Date == Today);
habitB.Logs.Should().ContainSingle(l => l.Date == Today);
}

[Fact]
public async Task Handle_ItemPersistenceThrowsMidBatch_IsolatesFailureAndAppliesOtherItems()
{
var failingHabit = Habit.Create(new HabitCreateParams(UserId, "Failing", FrequencyUnit.Day, 1, DueDate: Today)).Value;
var okHabit = Habit.Create(new HabitCreateParams(UserId, "Ok", FrequencyUnit.Day, 1, DueDate: Today)).Value;
SetupHabitsForUser(new List<Habit> { failingHabit, okHabit });

_habitLogRepo.AddAsync(
Arg.Is<HabitLog>(l => l.HabitId == failingHabit.Id),
Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("insert failed"));

var items = new List<BulkLogItem> { new(failingHabit.Id), new(okHabit.Id) };
var command = new BulkLogHabitsCommand(UserId, items);

var result = await _handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Results.Should().HaveCount(2);

var failing = result.Value.Results.Single(r => r.HabitId == failingHabit.Id);
failing.Status.Should().Be(BulkItemStatus.Failed);
failing.ErrorCode.Should().Be(ErrorMessages.BulkLogItemFailed.Code);
failing.Error.Should().Be(ErrorMessages.BulkLogItemFailed.Message);

var ok = result.Value.Results.Single(r => r.HabitId == okHabit.Id);
ok.Status.Should().Be(BulkItemStatus.Success);
ok.LogId.Should().NotBeNull();

await _gamificationService.Received(1).ProcessHabitsLogged(
UserId,
Arg.Is<IReadOnlyList<Guid>>(ids => ids.Count == 1 && ids[0] == okHabit.Id),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_CompletedOneTimeTask_ReportsDomainFailurePerItemAndContinuesBatch()
{
var task = Habit.Create(new HabitCreateParams(UserId, "Task", null, null, DueDate: Today)).Value;
task.Log(Today.AddDays(-1));
task.IsCompleted.Should().BeTrue();

var okHabit = Habit.Create(new HabitCreateParams(UserId, "Habit", FrequencyUnit.Day, 1, DueDate: Today)).Value;
SetupHabitsForUser(new List<Habit> { task, okHabit });

var items = new List<BulkLogItem> { new(task.Id), new(okHabit.Id) };
var command = new BulkLogHabitsCommand(UserId, items);

var result = await _handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
var taskResult = result.Value.Results.Single(r => r.HabitId == task.Id);
taskResult.Status.Should().Be(BulkItemStatus.Failed);
taskResult.ErrorCode.Should().Be(DomainErrors.CannotLogCompletedHabit.Code);

result.Value.Results.Single(r => r.HabitId == okHabit.Id).Status.Should().Be(BulkItemStatus.Success);
}

private void SetupHabitsForUser(List<Habit> habits)
{
_habitRepo.FindTrackedAsync(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using FluentAssertions;
using MediatR;
using Microsoft.EntityFrameworkCore;
Expand All @@ -6,6 +7,7 @@
using NSubstitute.ExceptionExtensions;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Interfaces;

namespace Orbit.Infrastructure.Tests.Behaviors;
Expand All @@ -15,6 +17,9 @@ public class IdempotencyBehaviorRaceTests
private static readonly Guid UserId = Guid.NewGuid();
private const string Key = "mutation-key-1";

private static readonly JsonSerializerOptions LedgerSerializerOptions =
new(JsonSerializerDefaults.Web) { Converters = { new ResultJsonConverterFactory() } };

[Fact]
public async Task Handle_ConcurrentDuplicateLosesUniqueRace_ReplaysWinnerResponse()
{
Expand Down Expand Up @@ -56,6 +61,45 @@ public async Task Handle_UniqueViolationWithNoStoredResponse_Rethrows()
await act.Should().ThrowAsync<DbUpdateException>();
}

[Fact]
public async Task Handle_ConcurrentDuplicateBatchCommand_ReplaysWinnersFullBatchResponse()
{
var winnerBatch = Result.Success(new FakeBatchResponse(new List<FakeBatchItem>
{
new(0, "Success", Guid.NewGuid()),
new(1, "Failed", Guid.NewGuid()),
new(2, "Success", Guid.NewGuid())
}));
var winnerJson = JsonSerializer.Serialize(winnerBatch, LedgerSerializerOptions);

var store = Substitute.For<IIdempotencyStore>();
store.FindResponseBodyAsync(UserId, Key, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<string?>(null), Task.FromResult<string?>(winnerJson));
store.Reserve(UserId, Key, Arg.Any<string>()).Returns(Substitute.For<IIdempotencyReservation>());

var unitOfWork = BuildUnitOfWorkThatThrowsUniqueViolationOnSave();
var behavior = new IdempotencyBehavior<FakeBatchRequest, Result<FakeBatchResponse>>(
BuildContextWithKey(), store, unitOfWork);

var handlerCalls = 0;
RequestHandlerDelegate<Result<FakeBatchResponse>> next = _ =>
{
handlerCalls++;
return Task.FromResult(Result.Success(new FakeBatchResponse(new List<FakeBatchItem>
{
new(0, "Failed", Guid.NewGuid())
})));
};

var result = await behavior.Handle(new FakeBatchRequest(), next, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Results.Should().HaveCount(3);
result.Value.Results.Should().BeEquivalentTo(
winnerBatch.Value.Results, options => options.WithStrictOrdering());
handlerCalls.Should().Be(0);
}

private static IIdempotencyContext BuildContextWithKey()
{
var context = Substitute.For<IIdempotencyContext>();
Expand All @@ -82,4 +126,10 @@ private static IUnitOfWork BuildUnitOfWorkThatThrowsUniqueViolationOnSave()
}

private sealed record FakeRequest : IRequest<string>, IIdempotentCommand;

private sealed record FakeBatchRequest : IRequest<Result<FakeBatchResponse>>, IIdempotentCommand;

private sealed record FakeBatchResponse(IReadOnlyList<FakeBatchItem> Results);

private sealed record FakeBatchItem(int Index, string Status, Guid HabitId);
}
Loading