Repository: butskristof/split-the-bill
Pull request #28: DeleteExpense implementation
Author: login: butskristof
Created: 2025-03-29T10:20:27Z | Merged: 2025-03-29T10:21:46Z



Diff:
diff --git a/api/src/1-core/Application/Modules/Groups/Expenses/DeleteExpense.cs b/api/src/1-core/Application/Modules/Groups/Expenses/DeleteExpense.cs
index e39189c..f45df9f 100644
--- a/api/src/1-core/Application/Modules/Groups/Expenses/DeleteExpense.cs
+++ b/api/src/1-core/Application/Modules/Groups/Expenses/DeleteExpense.cs
@@ -1,6 +1,8 @@
 using ErrorOr;
 using MediatR;
+using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Logging;
+using SplitTheBill.Application.Common.Persistence;
 using SplitTheBill.Application.Common.Validation;
 
 namespace SplitTheBill.Application.Modules.Groups.Expenses;
@@ -13,6 +15,10 @@ internal sealed class Validator : BaseValidator<Request>
     {
         public Validator()
         {
+            RuleFor(r => r.GroupId)
+                .NotEmptyWithErrorCode(ErrorCodes.Invalid);
+            RuleFor(r => r.ExpenseId)
+                .NotEmptyWithErrorCode(ErrorCodes.Invalid);
         }
     }
 
@@ -21,17 +27,49 @@ internal sealed class Handler : IRequestHandler<Request, ErrorOr<Deleted>>
         #region construction
 
         private readonly ILogger<Handler> _logger;
+        private readonly IAppDbContext _dbContext;
 
-        public Handler(ILogger<Handler> logger)
+        public Handler(ILogger<Handler> logger, IAppDbContext dbContext)
         {
             _logger = logger;
+            _dbContext = dbContext;
         }
 
         #endregion
 
-        public Task<ErrorOr<Deleted>> Handle(Request request, CancellationToken cancellationToken)
+        public async Task<ErrorOr<Deleted>> Handle(Request request, CancellationToken cancellationToken)
         {
-            throw new NotImplementedException();
+            _logger.LogDebug("Deleting Expense with id {ExpenseId} from group {GroupId}",
+                request.ExpenseId, request.GroupId);
+
+            var group = await _dbContext
+                .Groups
+                .Include(g => g.Expenses)
+                .SingleOrDefaultAsync(g => g.Id == request.GroupId, cancellationToken);
+            if (group is null)
+            {
+                _logger.LogDebug("No group with id {GroupId} found in database", request.GroupId);
+                return Error.NotFound(nameof(request.GroupId), $"Could not find group with id {request.GroupId}");
+            }
+
+            _logger.LogDebug("Fetched group to delete expense from from database");
+
+            var expense = group.Expenses
+                .SingleOrDefault(e => e.Id == request.ExpenseId);
+            if (expense is null)
+            {
+                _logger.LogDebug("No expense with id {ExpenseId} found in group {GroupId}",
+                    request.ExpenseId, request.GroupId);
+                return Error.NotFound(nameof(request.ExpenseId), $"Could not find expense with id {request.ExpenseId}");
+            }
+            
+            group.Expenses.Remove(expense);
+            _logger.LogDebug("Deleted expense with id {ExpenseId} from group {GroupId}",
+                request.ExpenseId, request.GroupId);
+            await _dbContext.SaveChangesAsync(CancellationToken.None);
+            _logger.LogDebug("Persisted changes to database");
+            
+            return Result.Deleted;
         }
     }
 }
\ No newline at end of file
diff --git a/api/tests/Application.IntegrationTests/Modules/Groups/Expenses/DeleteExpenseTests.cs b/api/tests/Application.IntegrationTests/Modules/Groups/Expenses/DeleteExpenseTests.cs
new file mode 100644
index 0000000..2d06159
--- /dev/null
+++ b/api/tests/Application.IntegrationTests/Modules/Groups/Expenses/DeleteExpenseTests.cs
@@ -0,0 +1,259 @@
+using ErrorOr;
+using Shouldly;
+using SplitTheBill.Application.Common.Validation;
+using SplitTheBill.Application.IntegrationTests.Common;
+using SplitTheBill.Application.Modules.Groups;
+using SplitTheBill.Application.Modules.Groups.Expenses;
+using SplitTheBill.Application.Tests.Shared.TestData;
+using SplitTheBill.Application.Tests.Shared.TestData.Builders;
+using SplitTheBill.Domain.Models.Groups;
+
+namespace SplitTheBill.Application.IntegrationTests.Modules.Groups.Expenses;
+
+internal sealed class DeleteExpenseTests() : ApplicationTestBase(true)
+{
+    [Test]
+    public async Task InvalidRequest_ReturnsValidationError()
+    {
+        var request = new DeleteExpense.Request(
+            Guid.Empty,
+            Guid.NewGuid()
+        );
+        var result = await Application.SendAsync(request);
+
+        result.IsError.ShouldBeTrue();
+        result.ErrorsOrEmptyList
+            .ShouldHaveSingleItem()
+            .ShouldSatisfyAllConditions(
+                e => e.Type.ShouldBe(ErrorType.Validation),
+                e => e.Code.ShouldBe(nameof(request.GroupId)),
+                e => e.Description.ShouldBe(ErrorCodes.Invalid)
+            );
+    }
+
+    [Test]
+    public async Task GroupDoesNotExist_ReturnsNotFoundError()
+    {
+        var expense = new ExpenseBuilder()
+            .WithId(Guid.NewGuid())
+            .WithPaidByMemberId(TestMembers.Alice.Id)
+            .WithAmount(100m)
+            .WithEvenSplit([TestMembers.Alice.Id])
+            .Build();
+        await Application.AddAsync(new GroupBuilder()
+            .WithId(Guid.NewGuid())
+            .WithMembers([TestMembers.Alice.Id])
+            .WithExpenses([expense])
+            .Build());
+
+        var request = new DeleteExpense.Request(
+            Guid.NewGuid(),
+            Guid.NewGuid()
+        );
+        var result = await Application.SendAsync(request);
+
+        result.IsError.ShouldBeTrue();
+        result.ErrorsOrEmptyList
+            .ShouldHaveSingleItem()
+            .ShouldSatisfyAllConditions(
+                e => e.Type.ShouldBe(ErrorType.NotFound),
+                e => e.Code.ShouldBe(nameof(request.GroupId))
+            );
+
+        // verify expenses are untouched
+        var expenseCount = await Application.CountAsync<Expense>();
+        expenseCount.ShouldBe(1);
+        var expenseParticipantCount = await Application.CountAsync<ExpenseParticipant>();
+        expenseParticipantCount.ShouldBe(1);
+    }
+
+    [Test]
+    public async Task ExpenseDoesNotExist_ReturnsNotFoundError()
+    {
+        var expense = new ExpenseBuilder()
+            .WithId(Guid.NewGuid())
+            .WithPaidByMemberId(TestMembers.Alice.Id)
+            .WithAmount(100m)
+            .WithEvenSplit([TestMembers.Alice.Id])
+            .Build();
+        var group = new GroupBuilder()
+            .WithId(Guid.NewGuid())
+            .WithMembers([TestMembers.Alice.Id])
+            .WithExpenses([expense])
+            .Build();
+        await Application.AddAsync(group);
+
+        var request = new DeleteExpense.Request(
+            group.Id,
+            Guid.NewGuid()
+        );
+        var result = await Application.SendAsync(request);
+
+        result.IsError.ShouldBeTrue();
+        result.ErrorsOrEmptyList
+            .ShouldHaveSingleItem()
+            .ShouldSatisfyAllConditions(
+                e => e.Type.ShouldBe(ErrorType.NotFound),
+                e => e.Code.ShouldBe(nameof(request.ExpenseId))
+            );
+
+        // verify expenses are untouched
+        var expenseCount = await Application.CountAsync<Expense>();
+        expenseCount.ShouldBe(1);
+        var expenseParticipantCount = await Application.CountAsync<ExpenseParticipant>();
+        expenseParticipantCount.ShouldBe(1);
+    }
+
+    [Test]
+    public async Task ExpenseExistsInOtherGroup_ReturnsNotFoundError()
+    {
+        var expense = new ExpenseBuilder()
+            .WithId(Guid.NewGuid())
+            .WithPaidByMemberId(TestMembers.Alice.Id)
+            .WithAmount(100m)
+            .WithEvenSplit([TestMembers.Alice.Id])
+            .Build();
+        var group1 = new GroupBuilder()
+            .WithId(Guid.NewGuid())
+            .WithMembers([TestMembers.Alice.Id])
+            .WithExpenses([expense])
+            .Build();
+        var group2Id = Guid.NewGuid();
+        await Application.AddAsync(
+            group1,
+            new GroupBuilder().WithId(group2Id)
+        );
+
+        var request = new DeleteExpense.Request(
+            group2Id,
+            expense.Id
+        );
+        var result = await Application.SendAsync(request);
+
+        result.IsError.ShouldBeTrue();
+        result.ErrorsOrEmptyList
+            .ShouldHaveSingleItem()
+            .ShouldSatisfyAllConditions(
+                e => e.Type.ShouldBe(ErrorType.NotFound),
+                e => e.Code.ShouldBe(nameof(request.ExpenseId))
+            );
+
+        // verify expenses are untouched
+        var expenseCount = await Application.CountAsync<Expense>();
+        expenseCount.ShouldBe(1);
+        var expenseParticipantCount = await Application.CountAsync<ExpenseParticipant>();
+        expenseParticipantCount.ShouldBe(1);
+    }
+
+    [Test]
+    public async Task DeletesExpense()
+    {
+        var groupId = Guid.NewGuid();
+        var expense = new ExpenseBuilder()
+            .WithId(Guid.NewGuid())
+            .WithPaidByMemberId(TestMembers.Alice.Id)
+            .WithAmount(100m)
+            .WithEvenSplit([TestMembers.Alice.Id])
+            .Build();
+        await Application.AddAsync(new GroupBuilder()
+            .WithId(groupId)
+            .WithMembers([TestMembers.Alice.Id])
+            .WithExpenses([expense])
+            .Build()
+        );
+
+        var request = new DeleteExpense.Request(
+            groupId,
+            expense.Id
+        );
+        var result = await Application.SendAsync(request);
+
+        result.IsError.ShouldBeFalse();
+        result.Value.ShouldBeOfType<Deleted>();
+
+        // verify expense is deleted
+        var expenseCount = await Application.CountAsync<Expense>();
+        expenseCount.ShouldBe(0);
+        var expenseParticipantCount = await Application.CountAsync<ExpenseParticipant>();
+        expenseParticipantCount.ShouldBe(0);
+    }
+
+    [Test]
+    public async Task DeletesCorrectExpense()
+    {
+        var groupId = Guid.NewGuid();
+        var expense1 = new ExpenseBuilder()
+            .WithId(Guid.NewGuid())
+            .WithPaidByMemberId(TestMembers.Alice.Id)
+            .WithAmount(100m)
+            .WithEvenSplit([TestMembers.Alice.Id])
+            .Build();
+        var expense2 = new ExpenseBuilder()
+            .WithId(Guid.NewGuid())
+            .WithPaidByMemberId(TestMembers.Alice.Id)
+            .WithAmount(100m)
+            .WithEvenSplit([TestMembers.Alice.Id])
+            .Build();
+        await Application.AddAsync(new GroupBuilder()
+            .WithId(groupId)
+            .WithMembers([TestMembers.Alice.Id])
+            .WithExpenses([expense1, expense2])
+            .Build()
+        );
+
+        var request = new DeleteExpense.Request(
+            groupId,
+            expense1.Id
+        );
+        var result = await Application.SendAsync(request);
+
+        result.IsError.ShouldBeFalse();
+        result.Value.ShouldBeOfType<Deleted>();
+
+        // verify expense is deleted
+        var expenseCount = await Application.CountAsync<Expense>();
+        expenseCount.ShouldBe(1);
+        var remainingExpense = await Application.FindAsync<Expense>(expense2.Id);
+        remainingExpense.ShouldNotBeNull();
+    }
+
+    [Test]
+    public async Task IsRemovedFromGroupDtoAndUpdatesTotal()
+    {
+        var groupId = Guid.NewGuid();
+        var expense1 = new ExpenseBuilder()
+            .WithId(Guid.NewGuid())
+            .WithPaidByMemberId(TestMembers.Alice.Id)
+            .WithAmount(100m)
+            .WithEvenSplit([TestMembers.Alice.Id])
+            .Build();
+        var expense2 = new ExpenseBuilder()
+            .WithId(Guid.NewGuid())
+            .WithPaidByMemberId(TestMembers.Alice.Id)
+            .WithAmount(100m)
+            .WithEvenSplit([TestMembers.Alice.Id])
+            .Build();
+        expense2.SetAmountAndParticipantsWithEvenSplit(100m, new HashSet<Guid> { TestMembers.Alice.Id });
+        await Application.AddAsync(new GroupBuilder()
+            .WithId(groupId)
+            .WithMembers([TestMembers.Alice.Id])
+            .WithExpenses([expense1, expense2])
+            .Build()
+        );
+
+        var request = new DeleteExpense.Request(
+            groupId,
+            expense1.Id
+        );
+        await Application.SendAsync(request);
+
+        var result = await Application.SendAsync(new GetGroup.Request(groupId));
+        var response = result.Value;
+        response.TotalExpenseAmount.ShouldBe(100m);
+        response.Expenses
+            .ShouldHaveSingleItem()
+            .ShouldSatisfyAllConditions(
+                e => e.Id.ShouldBe(expense2.Id)
+            );
+    }
+}
\ No newline at end of file
diff --git a/api/tests/Application.IntegrationTests/Modules/Groups/Payments/DeletePaymentTests.cs b/api/tests/Application.IntegrationTests/Modules/Groups/Payments/DeletePaymentTests.cs
index 6ba455c..eabc897 100644
--- a/api/tests/Application.IntegrationTests/Modules/Groups/Payments/DeletePaymentTests.cs
+++ b/api/tests/Application.IntegrationTests/Modules/Groups/Payments/DeletePaymentTests.cs
@@ -153,7 +153,6 @@ await Application.AddAsync(new GroupBuilder()
                     .WithSendingMemberId(TestMembers.Alice.Id)
                     .WithReceivingMemberId(TestMembers.Bob.Id)
             ])
-            .WithName("group name")
             .Build());
 
         var request = new DeletePayment.Request(
@@ -189,7 +188,6 @@ await Application.AddAsync(new GroupBuilder()
                     .WithSendingMemberId(TestMembers.Alice.Id)
                     .WithReceivingMemberId(TestMembers.Bob.Id)
             ])
-            .WithName("group name")
             .Build());
 
         var request = new DeletePayment.Request(
diff --git a/api/tests/Application.UnitTests/Modules/Groups/Expenses/DeleteExpenseValidatorTests.cs b/api/tests/Application.UnitTests/Modules/Groups/Expenses/DeleteExpenseValidatorTests.cs
new file mode 100644
index 0000000..4ecc54c
--- /dev/null
+++ b/api/tests/Application.UnitTests/Modules/Groups/Expenses/DeleteExpenseValidatorTests.cs
@@ -0,0 +1,66 @@
+using FluentValidation.TestHelper;
+using SplitTheBill.Application.Common.Validation;
+using SplitTheBill.Application.Modules.Groups.Expenses;
+
+namespace SplitTheBill.Application.UnitTests.Modules.Groups.Expenses;
+
+internal sealed class DeleteExpenseValidatorTests
+{
+    private readonly DeleteExpense.Validator _sut = new();
+
+    [Test]
+    public void EmptyGroupId_Fails()
+    {
+        var request = new DeleteExpense.Request(
+            Guid.Empty,
+            Guid.NewGuid()
+        );
+        var result = _sut.TestValidate(request);
+        result
+            .ShouldHaveValidationErrorFor(r => r.GroupId)
+            .WithErrorMessage(ErrorCodes.Invalid);
+    }
+
+    [Test]
+    public void ValidGroupId_Passes()
+    {
+        var request = new DeleteExpense.Request(
+            Guid.NewGuid(),
+            Guid.NewGuid()
+        );
+        var result = _sut.TestValidate(request);
+        result.ShouldNotHaveValidationErrorFor(r => r.GroupId);
+    }
+
+    [Test]
+    public void EmptyExpenseId_Fails()
+    {
+        var request = new DeleteExpense.Request(
+            Guid.NewGuid(),
+            Guid.Empty
+        );
+        var result = _sut.TestValidate(request);
+        result
+            .ShouldHaveValidationErrorFor(r => r.ExpenseId)
+            .WithErrorMessage(ErrorCodes.Invalid);
+    }
+
+    [Test]
+    public void ValidExpenseId_Passes()
+    {
+        var request = new DeleteExpense.Request(
+            Guid.NewGuid(),
+            Guid.NewGuid()
+        );
+        var result = _sut.TestValidate(request);
+        result.ShouldNotHaveValidationErrorFor(r => r.ExpenseId);
+    }
+
+    [Test]
+    public void ValidRequest_Passes()
+    {
+        var request = new DeleteExpense.Request(Guid.NewGuid(), Guid.NewGuid());
+        var result = _sut.TestValidate(request);
+        result.ShouldNotHaveAnyValidationErrors();
+    }
+}
\ No newline at end of file


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This pull request introduces the DeleteExpense functionality with appropriate validation rules, integration tests, and implementation of the expense deletion from a group.
- Added unit tests for validating DeleteExpense requests.
- Updated integration tests for DeleteExpense and DeletePayment.
- Implemented the DeleteExpense handler in the core application.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

| File | Description |
| ---- | ----------- |
| api/tests/Application.UnitTests/Modules/Groups/Expenses/DeleteExpenseValidatorTests.cs | Added tests for validating DeleteExpense requests. |
| api/tests/Application.IntegrationTests/Modules/Groups/Payments/DeletePaymentTests.cs | Removed an unnecessary WithName setter in group creation for payment deletion tests. |
| api/tests/Application.IntegrationTests/Modules/Groups/Expenses/DeleteExpenseTests.cs | Added integration tests to cover various delete scenarios for expenses. |
| api/src/1-core/Application/Modules/Groups/Expenses/DeleteExpense.cs | Implemented the DeleteExpense operation including validation and persistence. |




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-29T10:20:47Z
user: login: butskristof
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-29T10:21:30Z

Review comments:
diff_hunk: @@ -21,17 +27,49 @@ internal sealed class Handler : IRequestHandler<Request, ErrorOr<Deleted>>
         #region construction
 
         private readonly ILogger<Handler> _logger;
+        private readonly IAppDbContext _dbContext;
 
-        public Handler(ILogger<Handler> logger)
+        public Handler(ILogger<Handler> logger, IAppDbContext dbContext)
         {
             _logger = logger;
+            _dbContext = dbContext;
         }
 
         #endregion
 
-        public Task<ErrorOr<Deleted>> Handle(Request request, CancellationToken cancellationToken)
+        public async Task<ErrorOr<Deleted>> Handle(Request request, CancellationToken cancellationToken)
         {
-            throw new NotImplementedException();
+            _logger.LogDebug("Deleting Expense with id {ExpenseId} from group {GroupId}",
+                request.ExpenseId, request.GroupId);
+
+            var group = await _dbContext
+                .Groups
+                .Include(g => g.Expenses)
+                .SingleOrDefaultAsync(g => g.Id == request.GroupId, cancellationToken);
+            if (group is null)
+            {
+                _logger.LogDebug("No group with id {GroupId} found in database", request.GroupId);
+                return Error.NotFound(nameof(request.GroupId), $"Could not find group with id {request.GroupId}");
+            }
+
+            _logger.LogDebug("Fetched group to delete expense from from database");
+
+            var expense = group.Expenses
+                .SingleOrDefault(e => e.Id == request.ExpenseId);
+            if (expense is null)
+            {
+                _logger.LogDebug("No expense with id {ExpenseId} found in group {GroupId}",
+                    request.ExpenseId, request.GroupId);
+                return Error.NotFound(nameof(request.ExpenseId), $"Could not find expense with id {request.ExpenseId}");
+            }
+            
+            group.Expenses.Remove(expense);
+            _logger.LogDebug("Deleted expense with id {ExpenseId} from group {GroupId}",
+                request.ExpenseId, request.GroupId);
+            await _dbContext.SaveChangesAsync(CancellationToken.None);
path: api/src/1-core/Application/Modules/Groups/Expenses/DeleteExpense.cs
user: login: Copilot
body: Consider passing the provided cancellationToken instead of CancellationToken.None to ensure the save operation supports cancellation.
```suggestion
            await _dbContext.SaveChangesAsync(cancellationToken);
```
created_at: 2025-03-29T10:20:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,17 +27,49 @@ internal sealed class Handler : IRequestHandler<Request, ErrorOr<Deleted>>
         #region construction
 
         private readonly ILogger<Handler> _logger;
+        private readonly IAppDbContext _dbContext;
 
-        public Handler(ILogger<Handler> logger)
+        public Handler(ILogger<Handler> logger, IAppDbContext dbContext)
         {
             _logger = logger;
+            _dbContext = dbContext;
         }
 
         #endregion
 
-        public Task<ErrorOr<Deleted>> Handle(Request request, CancellationToken cancellationToken)
+        public async Task<ErrorOr<Deleted>> Handle(Request request, CancellationToken cancellationToken)
         {
-            throw new NotImplementedException();
+            _logger.LogDebug("Deleting Expense with id {ExpenseId} from group {GroupId}",
+                request.ExpenseId, request.GroupId);
+
+            var group = await _dbContext
+                .Groups
+                .Include(g => g.Expenses)
+                .SingleOrDefaultAsync(g => g.Id == request.GroupId, cancellationToken);
+            if (group is null)
+            {
+                _logger.LogDebug("No group with id {GroupId} found in database", request.GroupId);
+                return Error.NotFound(nameof(request.GroupId), $"Could not find group with id {request.GroupId}");
+            }
+
+            _logger.LogDebug("Fetched group to delete expense from from database");
+
+            var expense = group.Expenses
+                .SingleOrDefault(e => e.Id == request.ExpenseId);
+            if (expense is null)
+            {
+                _logger.LogDebug("No expense with id {ExpenseId} found in group {GroupId}",
+                    request.ExpenseId, request.GroupId);
+                return Error.NotFound(nameof(request.ExpenseId), $"Could not find expense with id {request.ExpenseId}");
+            }
+            
+            group.Expenses.Remove(expense);
+            _logger.LogDebug("Deleted expense with id {ExpenseId} from group {GroupId}",
+                request.ExpenseId, request.GroupId);
+            await _dbContext.SaveChangesAsync(CancellationToken.None);
path: api/src/1-core/Application/Modules/Groups/Expenses/DeleteExpense.cs
user: login: butskristof
body: this is intentional
created_at: 2025-03-29T10:21:30Z
author_association: OWNER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-29T10:21:34Z
body: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-96%25-success?style=flat)

Package | Line Rate | Branch Rate | Complexity | Health
-------- | --------- | ----------- | ---------- | ------
SplitTheBill.Application.dll | 96% | 100% | NaN | ✔
SplitTheBill.Application.Tests.Shared.dll | 89% | 100% | NaN | ✔
SplitTheBill.Domain.dll | 98% | 100% | NaN | ✔
SplitTheBill.Persistence.dll | 96% | 100% | NaN | ✔
**Summary** | **96%** (2313 / 2418) | **100%** (0 / 0) | **NaN** | ✔

<!-- Sticky Pull Request Comment -->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-29T10:21:42Z
body: ## Test Results
  3 files    3 suites   38s ⏱️
251 tests 250 ✅ 1 💤 0 ❌
274 runs  273 ✅ 1 💤 0 ❌

Results for commit 7b6cd97a.

[test-results]:data:application/gzip;base64,H4sIALPJ52cC/02Myw6DIBBFf8Ww7kJLZbA/0wwPk0lVGh4r038vEgsuz7k3Z2czLTawZ8dvHQuJYgWTPEZy24Eyc17isd3H4U+vkLQuqm/qTZ+s2mdGWrJoD+u986fxaStNeJxQk8CracXCl2Dha0+7daWYgYES2kyAEnsxKDRiAsE1WCPFyKUCRDAKBWffH3RdaqUEAQAA

author_association: NONE