Changed Reminder Systematics analogue to invoices
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user