Changed Reminder Systematics analogue to invoices

This commit is contained in:
Stefan
2026-07-10 22:50:26 +02:00
parent 83d1c28b29
commit 5c0fdc6c1d
24 changed files with 1810 additions and 145 deletions
@@ -0,0 +1,92 @@
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exhaustively exercises the pure reminder-draft aggregation/validation (ADR 0006,
/// the reminder mirror of <see cref="InvoiceDraftCalculatorTests"/>). Being static/pure,
/// the open-amount math and the plausibility checks are unit-testable without a DB.
/// </summary>
public class ReminderDraftCalculatorTests
{
private static ReminderDraftSession Session(string newJson) =>
new() { New = JObject.Parse(newJson) };
[Theory]
[InlineData("{'amount':119,'amount_payed':0}", 119, 0, 119)]
[InlineData("{'amount':119,'amount_payed':20}", 119, 20, 99)]
[InlineData("{'amount':'119,50','amount_payed':'19,50'}", 119.50, 19.50, 100)] // German decimals
[InlineData("{'amount':'100.00','amount_payed':'40.00'}", 100, 40, 60)] // invariant decimals
[InlineData("{}", 0, 0, 0)] // missing → 0
[InlineData("{'amount':50,'amount_payed':80}", 50, 80, -30)] // overpaid → negative
public void RecomputeTotals_ComputesOpenAmount(string newJson, double total, double payed, double open)
{
var s = Session(newJson);
ReminderDraftCalculator.RecomputeTotals(s);
Assert.Equal((decimal)total, s.Sums.AmountTotal);
Assert.Equal((decimal)payed, s.Sums.AmountPayed);
Assert.Equal((decimal)open, s.Sums.AmountOpen);
}
[Fact]
public void Validate_EmptyEmail_Warns()
{
var s = Session("{'amount':119,'invoiceaddress':'Weg 1','subject':'X'}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "warning");
}
[Theory]
[InlineData("bad")]
[InlineData("no-at-sign.de")]
[InlineData("trailing@dot.")]
public void Validate_InvalidEmail_Errors(string email)
{
var s = Session($"{{'amount':119,'invoiceemail':'{email}','invoiceaddress':'Weg 1','subject':'X'}}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "error");
}
[Fact]
public void Validate_ValidEmail_NoEmailMessage()
{
var s = Session("{'amount':119,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'X'}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.DoesNotContain(s.ValidationMessages, m => m.Field == "email");
}
[Fact]
public void Validate_EmptyAddressAndSubject_Warn()
{
var s = Session("{'amount':119,'invoiceemail':'a@b.de'}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
Assert.Contains(s.ValidationMessages, m => m.Field == "subject" && m.Severity == "warning");
}
[Theory]
[InlineData("{'amount':0,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
[InlineData("{'amount':50,'amount_payed':80,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
public void Validate_NonPositiveOpenAmount_Warns(string newJson)
{
var s = Session(newJson);
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "amount" && m.Severity == "warning");
}
[Fact]
public void Validate_HealthyDraft_HasNoMessages()
{
var s = Session("{'amount':119,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung'}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Empty(s.ValidationMessages);
}
}
+307
View File
@@ -0,0 +1,307 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the reminder draft edit orchestrator's pure paths (open/patch/history/flush)
/// without a database — the reminder mirror of <see cref="InvoiceDraftServiceTests"/>,
/// proving the backend-authoritative model behaves correctly at the service seam (ADR 0006).
/// </summary>
public class ReminderDraftServiceTests
{
/// <summary>Captures the reminder handed to registration and returns it with a fake DB id — no SQL.</summary>
private sealed class FakeReminderService : IReminderService
{
public FdsReminderData? Registered;
public bool? LastChange;
public FdsReminderData? PreviewReminder;
public bool? PreviewDraft;
public Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
{
Registered = reminder;
LastChange = change;
reminder.ReminderRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "REM42" });
return Task.FromResult(reminder);
}
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) { PreviewReminder = reminder; PreviewDraft = draft; return new Document(); }
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
}
private static (ReminderDraftEditService svc, FakeReminderService rem) NewService()
{
var cache = new ReminderDraftCache(new ConfigurationBuilder().Build());
var rem = new FakeReminderService();
var svc = new ReminderDraftEditService(cache, rem, NullLogger<ReminderDraftEditService>.Instance);
return (svc, rem);
}
private static JObject Payload() => JObject.Parse(@"{
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
}");
[Fact]
public void OpenFromPayload_SeedsSessionAndComputesOpenAmount()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
Assert.False(string.IsNullOrEmpty(s.Token));
Assert.Equal(0, s.Version);
Assert.Equal(119m, s.Sums.AmountTotal);
Assert.Equal(119m, s.Sums.AmountOpen);
}
[Fact]
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("neu@x.de") });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
Assert.Equal("neu@x.de", s2.New["invoiceemail"]!.Value<string>());
var h = Assert.Single(s2.History);
Assert.Equal("email", h.Target);
Assert.Equal("a@b.de", h.OldValue);
Assert.Equal("neu@x.de", h.NewValue);
}
[Fact]
public void ApplyPatch_Amount_RecomputesOpenAmount()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
Assert.Equal(200m, s2!.Sums.AmountTotal);
Assert.Equal(200m, s2.Sums.AmountOpen);
}
[Fact]
public void ApplyPatch_AmountPayed_RecomputesOpenAmount()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
Assert.Equal(100m, s2!.Sums.AmountOpen); // 119 - 19
}
[Fact]
public void ApplyPatch_AmountFromGermanString_NormalisesToInvariant()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject("249,90") });
Assert.Equal("249.90", s2!.New["amount"]!.Value<string>()); // stored invariant
Assert.Equal(249.90m, s2.Sums.AmountTotal);
}
[Fact]
public void ApplyPatch_UnknownToken_ReturnsNull()
{
var (svc, _) = NewService();
Assert.Null(svc.ApplyPatch("ghost", new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
}
[Fact]
public void ApplyPatch_UnknownTarget_IsNoOp()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
}
[Theory]
[InlineData("subject", "subject")]
[InlineData("address", "invoiceaddress")]
[InlineData("text", "text")]
public void ApplyPatch_ScalarFieldDeltas_UpdateNew(string target, string newKey)
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
Assert.Equal("X-VALUE", s2!.New[newKey]!.Value<string>());
}
[Theory]
[InlineData("subject", "subject")]
[InlineData("email", "invoiceemail")]
public void ApplyPatch_ScalarField_StripsHtmlWrapper(string target, string newKey)
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = target, Value = JToken.FromObject("<p>clean me</p>") });
Assert.Equal("clean me", s2!.New[newKey]!.Value<string>());
Assert.DoesNotContain("<", s2.New[newKey]!.Value<string>());
}
[Fact]
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
{
Target = "address",
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>40000 Düsseldorf</p>")
});
Assert.Equal("Firma AG\nWeg 1\n40000 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
}
[Fact]
public void ApplyPatch_Contact_BuildsCustomValuesJson()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
{
Target = "contact",
Value = JObject.Parse(@"{'name':'Max Mustermann','email':'max@kunde.de'}")
});
var cv = JObject.Parse(s2!.New["CustomValues"]!.Value<string>()!);
Assert.Equal("Max Mustermann", cv["contactName"]!.Value<string>());
Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value<string>());
}
[Fact]
public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "subject", Value = JToken.FromObject("Mahnung 2") });
var s3 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
Assert.Equal(3, s3!.Version);
Assert.Equal(new[] { "email", "subject", "amount" }, s3.History.Select(h => h.Target).ToArray());
Assert.Equal(new[] { 1, 2, 3 }, s3.History.Select(h => h.Version).ToArray());
}
[Fact]
public void BuildState_ExposesPayloadSumsValidationAndVersion()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
Assert.Equal(1, state["version"]!.Value<int>());
Assert.Equal(119m, state["sums"]!["amount_total"]!.Value<decimal>());
Assert.Equal(100m, state["sums"]!["amount_open"]!.Value<decimal>());
Assert.Equal(1, state["historyCount"]!.Value<int>());
Assert.NotNull(state["validation"]);
}
[Fact]
public async Task FlushToDbAsync_RegistersAndSetsRemId_CreatePath()
{
var (svc, rem) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.NotNull(result);
Assert.Equal("REM42", result!.Id);
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
// the email/subject the editor set must reach registration
Assert.Equal("a@b.de", rem.Registered!.RawInvoiceEmail);
Assert.Equal("Zahlungserinnerung", rem.Registered!.NewValues!.getString("subject"));
Assert.Equal("INV5", rem.Registered!.RawInvId);
}
[Fact]
public async Task FlushToDbAsync_ExistingRemId_UpdatePath()
{
var (svc, rem) = NewService();
var payload = Payload();
payload["remid"] = "REM7";
var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.True(rem.LastChange); // prior RemId → update path
}
[Fact]
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
{
var (svc, rem) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var doc = svc.RenderPreview(s.Token);
Assert.NotNull(doc);
Assert.True(rem.PreviewDraft);
Assert.True(rem.PreviewReminder!.IsDraft);
var reg = rem.PreviewReminder!.ReminderRegistration!;
Assert.Equal("Zahlungserinnerung", reg.getString("subject"));
Assert.Equal("Weg 1", reg.getString("SendToAddress"));
Assert.Equal("a@b.de", reg.getString("SendToEmail"));
Assert.Equal("R2026-1", reg.getString("InvoiceId"));
// the synthesised single-invoice row the reminder table renders
Assert.Single(rem.PreviewReminder!.ReminderItems);
}
[Fact]
public void RenderPreview_UnknownToken_ReturnsNull()
{
var (svc, _) = NewService();
Assert.Null(svc.RenderPreview("ghost"));
}
[Fact]
public void GetHistory_UnknownToken_IsEmpty()
{
var (svc, _) = NewService();
Assert.Empty(svc.GetHistory("ghost"));
}
[Fact]
public void Close_RemovesSession_ThenReportsFalse()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
Assert.True(svc.Close(s.Token));
Assert.Null(svc.Get(s.Token));
Assert.False(svc.Close(s.Token));
}
}