Refactor EntryService to allow shared DisplayId for transfers; update related tests and migration files
This commit is contained in:
169
src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs
generated
Normal file
169
src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs
generated
Normal file
@@ -0,0 +1,169 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Duempelkas.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(FinanceDbContext))]
|
||||
[Migration("20260403093901_AllowSharedDisplayIdForTransfers")]
|
||||
partial class AllowSharedDisplayIdForTransfers
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("CarryoverBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TransferLinkId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DisplayId");
|
||||
|
||||
b.HasIndex("TransferLinkId");
|
||||
|
||||
b.HasIndex("AccountId", "Date");
|
||||
|
||||
b.ToTable("Entries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.TransferLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SourceEntryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TargetEntryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceEntryId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TargetEntryId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TransferLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b =>
|
||||
{
|
||||
b.HasOne("Duempelkas.Domain.Entities.Account", "Account")
|
||||
.WithMany("Entries")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Duempelkas.Domain.Entities.TransferLink", "TransferLink")
|
||||
.WithMany()
|
||||
.HasForeignKey("TransferLinkId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("TransferLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.TransferLink", b =>
|
||||
{
|
||||
b.HasOne("Duempelkas.Domain.Entities.Entry", "SourceEntry")
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceEntryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Duempelkas.Domain.Entities.Entry", "TargetEntry")
|
||||
.WithMany()
|
||||
.HasForeignKey("TargetEntryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceEntry");
|
||||
|
||||
b.Navigation("TargetEntry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Account", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Duempelkas.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AllowSharedDisplayIdForTransfers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries",
|
||||
column: "DisplayId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries",
|
||||
column: "DisplayId",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,7 @@ namespace Duempelkas.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DisplayId")
|
||||
.IsUnique();
|
||||
b.HasIndex("DisplayId");
|
||||
|
||||
b.HasIndex("TransferLinkId");
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ public class EntryConfiguration : IEntityTypeConfiguration<Entry>
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.DisplayId).IsRequired().HasMaxLength(20);
|
||||
builder.HasIndex(e => e.DisplayId).IsUnique();
|
||||
builder.HasIndex(e => e.DisplayId);
|
||||
builder.Property(e => e.Title).IsRequired().HasMaxLength(500);
|
||||
builder.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||
builder.Property(e => e.Type).HasConversion<int>();
|
||||
|
||||
@@ -63,7 +63,7 @@ public class EntryService : IEntryService
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var displayId = await GenerateDisplayIdAsync(db, accountId, date.Year);
|
||||
var displayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
@@ -88,12 +88,12 @@ public class EntryService : IEntryService
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
var sourceDisplayId = await GenerateDisplayIdAsync(db, sourceAccountId, date.Year);
|
||||
var transferDisplayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||
|
||||
var sourceEntry = new Entry
|
||||
{
|
||||
AccountId = sourceAccountId,
|
||||
DisplayId = sourceDisplayId,
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Expense,
|
||||
Date = date,
|
||||
Title = title,
|
||||
@@ -103,12 +103,10 @@ public class EntryService : IEntryService
|
||||
db.Entries.Add(sourceEntry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var targetDisplayId = await GenerateDisplayIdAsync(db, targetAccountId, date.Year);
|
||||
|
||||
var targetEntry = new Entry
|
||||
{
|
||||
AccountId = targetAccountId,
|
||||
DisplayId = targetDisplayId,
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Income,
|
||||
Date = date,
|
||||
Title = title,
|
||||
@@ -234,13 +232,12 @@ public class EntryService : IEntryService
|
||||
if (otherEntry.AccountId != newLinkedAccountId)
|
||||
{
|
||||
otherEntry.AccountId = newLinkedAccountId;
|
||||
otherEntry.DisplayId = await GenerateDisplayIdAsync(db, newLinkedAccountId, date.Year);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> GenerateDisplayIdAsync(FinanceDbContext db, int accountId, int year)
|
||||
private static async Task<string> GenerateDisplayIdAsync(FinanceDbContext db, int year)
|
||||
{
|
||||
var prefix = $"{year}-";
|
||||
var maxDisplayId = await db.Entries
|
||||
|
||||
123
tests/Duempelkas.Tests/EntryServiceBookingTests.cs
Normal file
123
tests/Duempelkas.Tests/EntryServiceBookingTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Duempelkas.Domain.Entities;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class EntryServiceBookingTests : IDisposable
|
||||
{
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-entry-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public EntryServiceBookingTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
_db.Database.OpenConnection();
|
||||
_db.Database.EnsureCreated();
|
||||
|
||||
var dbFactory = new TestDbContextFactory(options);
|
||||
_entryService = new EntryService(dbFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEntry_AssignsSequentialDisplayIds_PerYear()
|
||||
{
|
||||
var accountA = new Account { Name = "Konto A" };
|
||||
var accountB = new Account { Name = "Konto B" };
|
||||
_db.Accounts.AddRange(accountA, accountB);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var first = await _entryService.CreateEntryAsync(accountA.Id, EntryType.Income, new DateTime(2026, 1, 10), "Einnahme A", 100m);
|
||||
var second = await _entryService.CreateEntryAsync(accountB.Id, EntryType.Expense, new DateTime(2026, 1, 11), "Ausgabe B", 20m);
|
||||
var third = await _entryService.CreateEntryAsync(accountA.Id, EntryType.Income, new DateTime(2027, 1, 1), "Neues Jahr", 5m);
|
||||
|
||||
first.DisplayId.Should().Be("2026-001");
|
||||
second.DisplayId.Should().Be("2026-002");
|
||||
third.DisplayId.Should().Be("2027-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEntry_AfterTransfer_UsesNextDisplayId()
|
||||
{
|
||||
var source = new Account { Name = "Barkasse" };
|
||||
var target = new Account { Name = "Girokonto" };
|
||||
_db.Accounts.AddRange(source, target);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _entryService.CreateTransferAsync(source.Id, target.Id, new DateTime(2026, 3, 15), "Umbuchung", 500m);
|
||||
var booking = await _entryService.CreateEntryAsync(source.Id, EntryType.Income, new DateTime(2026, 3, 16), "Einzahlung", 50m);
|
||||
|
||||
booking.DisplayId.Should().Be("2026-002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateEntry_UpdatesBookingFields()
|
||||
{
|
||||
var account = new Account { Name = "Konto" };
|
||||
_db.Accounts.Add(account);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var entry = await _entryService.CreateEntryAsync(account.Id, EntryType.Expense, new DateTime(2026, 2, 2), "Alt", 10m);
|
||||
|
||||
await _entryService.UpdateEntryAsync(entry.Id, new DateTime(2026, 2, 3), "Neu", 25m);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var updated = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
|
||||
updated.Date.Should().Be(new DateTime(2026, 2, 3));
|
||||
updated.Title.Should().Be("Neu");
|
||||
updated.Amount.Should().Be(25m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAndRestoreEntry_TogglesSoftDelete_ForBookingOnly()
|
||||
{
|
||||
var account = new Account { Name = "Konto" };
|
||||
_db.Accounts.Add(account);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var entry = await _entryService.CreateEntryAsync(account.Id, EntryType.Expense, new DateTime(2026, 2, 2), "Buchung", 10m);
|
||||
|
||||
await _entryService.DeleteEntryAsync(entry.Id);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var deleted = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
deleted.IsDeleted.Should().BeTrue();
|
||||
|
||||
await _entryService.RestoreEntryAsync(entry.Id);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var restored = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
restored.IsDeleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
|
||||
{
|
||||
private readonly DbContextOptions<FinanceDbContext> _options;
|
||||
|
||||
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public FinanceDbContext CreateDbContext() => new(_options);
|
||||
|
||||
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new FinanceDbContext(_options));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,12 @@ public class TransferServiceTests : IDisposable
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly BalanceQueryService _balanceQueryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-transfer-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public TransferServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite("Data Source=duempelkas-transfer-tests;Mode=Memory;Cache=Shared")
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
@@ -52,7 +53,8 @@ public class TransferServiceTests : IDisposable
|
||||
var targetEntry = entries.Single(e => e.AccountId == accountB.Id);
|
||||
targetEntry.Type.Should().Be(EntryType.Income);
|
||||
targetEntry.Amount.Should().Be(100.00m);
|
||||
targetEntry.DisplayId.Should().Be("2026-002");
|
||||
targetEntry.DisplayId.Should().Be("2026-001");
|
||||
targetEntry.DisplayId.Should().Be(sourceEntry.DisplayId);
|
||||
|
||||
var links = await _db.TransferLinks.ToListAsync();
|
||||
links.Should().HaveCount(1);
|
||||
@@ -105,6 +107,29 @@ public class TransferServiceTests : IDisposable
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTransfer_ChangeLinkedAccount_KeepsSharedDisplayId()
|
||||
{
|
||||
var source = new Account { Name = "Barkasse" };
|
||||
var initialTarget = new Account { Name = "Girokonto" };
|
||||
var newTarget = new Account { Name = "Sparkonto" };
|
||||
_db.Accounts.AddRange(source, initialTarget, newTarget);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _entryService.CreateTransferAsync(source.Id, initialTarget.Id, new DateTime(2026, 3, 15), "Umbuchung", 500.00m);
|
||||
var sourceEntry = await _db.Entries.SingleAsync(e => e.AccountId == source.Id);
|
||||
|
||||
await _entryService.UpdateTransferAsync(sourceEntry.Id, newTarget.Id, new DateTime(2026, 3, 16), "Umbuchung angepasst", 550.00m);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
|
||||
var updatedSourceEntry = await _db.Entries.SingleAsync(e => e.AccountId == source.Id);
|
||||
var updatedTargetEntry = await _db.Entries.SingleAsync(e => e.AccountId == newTarget.Id);
|
||||
|
||||
updatedSourceEntry.DisplayId.Should().Be("2026-001");
|
||||
updatedTargetEntry.DisplayId.Should().Be(updatedSourceEntry.DisplayId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
|
||||
@@ -13,11 +13,12 @@ public class BalanceCalculationTests : IDisposable
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly BalanceQueryService _balanceQueryService;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-balance-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public BalanceCalculationTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite("Data Source=duempelkas-balance-tests;Mode=Memory;Cache=Shared")
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
|
||||
Reference in New Issue
Block a user