Refactor code structure for improved readability and maintainability

This commit is contained in:
Stefan
2026-07-10 14:29:51 +02:00
parent af445c015e
commit 42997c4f49
18 changed files with 1237 additions and 615 deletions
+34 -53
View File
@@ -1,4 +1,3 @@
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
@@ -6,9 +5,11 @@ using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the server-side port of the former client-side invoice math
/// (<c>quantChange</c> + <c>invSumUpdate</c>). Because the truth now lives in the
/// backend (ADR 0006), this logic is finally unit-testable directly.
/// Verifies the server-side aggregation of invoice draft totals (the port of the
/// former client-side <c>invSumUpdate</c> footer math). The truth now lives in the
/// backend (ADR 0006), so this is unit-testable directly. Line values are read from
/// each block's <c>itm</c> array (the editor's <c>co</c> objects: <c>vt</c>=net,
/// <c>vv</c>=VAT, <c>vs</c>=service-net, <c>vsv</c>=service-VAT, <c>vat</c>=rate).
/// </summary>
public class InvoiceDraftCalculatorTests
{
@@ -21,16 +22,15 @@ public class InvoiceDraftCalculatorTests
return s;
}
// ── RecomputeTotals ──────────────────────────────────────────────────────
[Fact]
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
{
var s = SessionWith(@"[
{ 'Id':'10','items':[
{'net_val':100,'vat_val':19,'svcnet_val':0,'svcvat_val':0,'vat':'19%','Type':'material'},
{'net_val':50,'vat_val':9.5,'svcnet_val':50,'svcvat_val':9.5,'vat':'19%','Type':'Service'} ] },
{ 'Id':'11','items':[
{'net_val':200,'vat_val':14,'svcnet_val':0,'svcvat_val':0,'vat':'7%','Type':'material'} ] }
{ 'Id':'10','itm':[
{'vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'},
{'vt':50,'vv':9.5,'vs':50,'vsv':9.5,'vat':'19%'} ] },
{ 'Id':'11','itm':[
{'vt':200,'vv':14,'vs':0,'vsv':0,'vat':'7%'} ] }
]");
InvoiceDraftCalculator.RecomputeTotals(s);
@@ -49,8 +49,7 @@ public class InvoiceDraftCalculatorTests
[Fact]
public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
{
var s = SessionWith(@"[{ 'Id':'1','items':[
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]", p13b: true);
var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]", p13b: true);
InvoiceDraftCalculator.RecomputeTotals(s);
@@ -70,41 +69,6 @@ public class InvoiceDraftCalculatorTests
Assert.Empty(s.Sums.VatByRate);
}
// ── RecomputeItem (quantChange port) ─────────────────────────────────────
[Theory]
[InlineData("Service", true)]
[InlineData("material", false)]
public void RecomputeItem_DerivesLineValuesFromQtyPriceVat(string type, bool isService)
{
var item = new JObject
{
["quantityhours"] = 5, ["net"] = "10", ["vat"] = "19", ["Type"] = type
};
InvoiceDraftCalculator.RecomputeItem(item);
Assert.Equal(50m, item["net_val"]!.Value<decimal>());
Assert.Equal(9.5m, item["vat_val"]!.Value<decimal>());
if (isService)
{
Assert.Equal(50m, item["svcnet_val"]!.Value<decimal>());
Assert.Equal(9.5m, item["svcvat_val"]!.Value<decimal>());
}
else
{
Assert.Null(item["svcnet_val"]);
}
}
[Fact]
public void RecomputeItem_ZeroQuantity_LeavesValuesUntouched()
{
var item = new JObject { ["quantityhours"] = 0, ["net"] = "10", ["vat"] = "19", ["Type"] = "material" };
InvoiceDraftCalculator.RecomputeItem(item);
Assert.Null(item["net_val"]); // guard qty>0 && price>0 not met → no derivation
}
// ── NormalizeRate ────────────────────────────────────────────────────────
[Theory]
[InlineData("19,0%", "19")]
[InlineData("7%", "7")]
@@ -115,23 +79,21 @@ public class InvoiceDraftCalculatorTests
public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
=> Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
// ── Validate ─────────────────────────────────────────────────────────────
[Fact]
public void Validate_ValidDraft_NoErrors()
{
var s = SessionWith(@"[{ 'Id':'1','items':[
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]");
var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
}
[Theory]
[InlineData("", "warning")] // missing email → advisory
[InlineData("", "warning")]
[InlineData("not-an-email", "error")]
public void Validate_EmailProblems_AreFlagged(string email, string severity)
{
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat':'19%','Type':'material'}] }]");
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
s.New["invoiceemail"] = email;
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
@@ -150,9 +112,28 @@ public class InvoiceDraftCalculatorTests
[Fact]
public void Validate_UnknownVatRate_IsWarning()
{
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat_val':0.5,'vat':'5%','Type':'material'}] }]");
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vv':0.5,'vat':'5%'}] }]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "vat" && m.Severity == "warning");
}
[Fact]
public void Validate_MissingAddress_IsWarning()
{
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
s.New["invoiceaddress"] = "";
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
}
[Fact]
public void Validate_NegativeTotal_IsWarning()
{
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':-50,'vv':0,'vat':''}] }]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "total" && m.Severity == "warning");
}
}
+126
View File
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Notifications;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies <see cref="DraftNotifier"/> targets the draft's SignalR group (keyed by
/// session token, ADR 0007) with the right method/payload, and — like the other
/// notification path — swallows hub failures so a missed coordination ping never
/// fails the underlying operation.
/// </summary>
public class InvoiceDraftNotifierTests
{
private sealed class CapturingClientProxy : IClientProxy
{
private readonly bool _throw;
public string? Method { get; private set; }
public object?[]? Args { get; private set; }
public CapturingClientProxy(bool doThrow = false) => _throw = doThrow;
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
if (_throw) throw new InvalidOperationException("hub down");
Method = method;
Args = args;
return Task.CompletedTask;
}
}
private sealed class StubHubClients : IHubClients
{
private readonly IClientProxy _proxy;
public string? RequestedGroup { get; private set; }
public StubHubClients(IClientProxy proxy) => _proxy = proxy;
public IClientProxy Group(string groupName) { RequestedGroup = groupName; return _proxy; }
public IClientProxy All => throw new NotImplementedException();
public IClientProxy AllExcept(IReadOnlyList<string> e) => throw new NotImplementedException();
public IClientProxy Client(string c) => throw new NotImplementedException();
public IClientProxy Clients(IReadOnlyList<string> c) => throw new NotImplementedException();
public IClientProxy Groups(IReadOnlyList<string> g) => throw new NotImplementedException();
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => throw new NotImplementedException();
public IClientProxy User(string u) => throw new NotImplementedException();
public IClientProxy Users(IReadOnlyList<string> u) => throw new NotImplementedException();
}
private sealed class StubHubContext : IHubContext<DraftPreviewHub>
{
public StubHubContext(IHubClients clients) => Clients = clients;
public IHubClients Clients { get; }
public IGroupManager Groups => throw new NotImplementedException();
}
private static (DraftNotifier notifier, StubHubClients clients, CapturingClientProxy proxy) Create(bool doThrow = false)
{
var proxy = new CapturingClientProxy(doThrow);
var clients = new StubHubClients(proxy);
var notifier = new DraftNotifier(new StubHubContext(clients), NullLogger<DraftNotifier>.Instance);
return (notifier, clients, proxy);
}
[Fact]
public async Task SignalDraftReadyAsync_SendsToTokenGroupWithVersion()
{
var (notifier, clients, proxy) = Create();
await notifier.SignalDraftReadyAsync("tok-1", 7);
Assert.Equal("tok-1", clients.RequestedGroup);
Assert.Equal("draftReady", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal("tok-1", payload["token"]!.Value<string>());
Assert.Equal(7, payload["version"]!.Value<int>());
}
[Fact]
public async Task SignalExpiringAsync_SendsSecondsLeft()
{
var (notifier, _, proxy) = Create();
await notifier.SignalExpiringAsync("tok-2", 120);
Assert.Equal("draftExpiring", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal(120, payload["secondsLeft"]!.Value<int>());
}
[Fact]
public async Task SignalClosedAsync_SendsReason()
{
var (notifier, _, proxy) = Create();
await notifier.SignalClosedAsync("tok-3", "expired");
Assert.Equal("draftClosed", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal("expired", payload["reason"]!.Value<string>());
}
[Fact]
public async Task EmptyToken_DoesNotSend()
{
var (notifier, clients, proxy) = Create();
await notifier.SignalDraftReadyAsync("", 1);
Assert.Null(clients.RequestedGroup);
Assert.Null(proxy.Method);
}
[Fact]
public async Task HubFailure_IsSwallowed()
{
var (notifier, _, _) = Create(doThrow: true);
// Must not throw — a failed coordination ping cannot fail the caller's operation.
await notifier.SignalDraftReadyAsync("tok", 1);
await notifier.SignalExpiringAsync("tok", 60);
await notifier.SignalClosedAsync("tok", "expired");
}
}
+222 -22
View File
@@ -2,7 +2,6 @@ using System.Linq;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using MigraDoc.DocumentObjectModel;
@@ -15,8 +14,8 @@ namespace Fuchs.Tests;
/// <summary>
/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
/// without any database, proving the backend-authoritative model behaves correctly
/// end-to-end at the service seam (ADR 0006).
/// without a database, proving the backend-authoritative model behaves correctly at
/// the service seam (ADR 0006). Blocks use the editor's <c>itm</c>/<c>items</c> shape.
/// </summary>
public class InvoiceDraftServiceTests
{
@@ -32,8 +31,10 @@ public class InvoiceDraftServiceTests
invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "INV42" });
return Task.FromResult(invoice);
}
public FdsInvoiceData? PreviewInvoice;
public bool? PreviewDraft;
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return new Document(); }
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
@@ -41,18 +42,17 @@ public class InvoiceDraftServiceTests
private static (InvoiceDraftEditService svc, FakeInvoiceService inv, InvoiceDraftCache cache) NewService()
{
var cfg = new ConfigurationBuilder().Build();
var cache = new InvoiceDraftCache(cfg);
var cache = new InvoiceDraftCache(new ConfigurationBuilder().Build());
var inv = new FakeInvoiceService();
var svc = new InvoiceDraftEditService(cache, inv, intranet: null!, NullLogger<InvoiceDraftEditService>.Instance);
var svc = new InvoiceDraftEditService(cache, inv, NullLogger<InvoiceDraftEditService>.Instance);
return (svc, inv, cache);
}
private static JObject Payload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','items':[
{'Id':'900','net_val':100,'vat_val':19,'vat':'19%','Type':'material','net':'10','quantityhours':10} ]}]
'req':[{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'}],
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}]
}");
[Fact]
@@ -85,18 +85,31 @@ public class InvoiceDraftServiceTests
}
[Fact]
public void ApplyPatch_ItemQty_RecomputesLineAndTotals()
public void ApplyPatch_BlockReplace_RecomputesTotals()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.qty", Ref = "900", Value = JToken.FromObject(5) });
var newBlock = JObject.Parse(@"{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':50,'vv':9.5,'vat':'19%'}],
'items':[{'id':'900','type':'material','total_net':50,'vat':'19%'}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
// qty 5 × price 10 = 50 net, 19% → 9.5 VAT.
Assert.Equal(50m, s2!.Sums.TotalNet);
Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
}
[Fact]
public void ApplyPatch_BlockRemove_EmptiesDraftAndFlagsNoItems()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.remove", Ref = "1" });
Assert.Equal(0m, s2!.Sums.TotalNet);
Assert.Contains(s2.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
}
[Fact]
public void ApplyPatch_P13bToggle_FlipsAndSuppressesVat()
{
@@ -129,7 +142,6 @@ public class InvoiceDraftServiceTests
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
// The FdsInvoiceData handed to registration carries the session's server-computed totals.
var prms = inv.Registered!.BuildInvoiceParams(change: false, invId: "");
var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
@@ -137,19 +149,207 @@ public class InvoiceDraftServiceTests
Assert.Equal("19", vatRate.Value);
}
[Fact]
public async Task DiscardAsync_NeverSaved_ReturnsSessionUnchanged()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var back = await svc.DiscardAsync(s.Token, "user1", null!);
Assert.Same(s, back); // no InvId → nothing to reload from the DB
}
[Fact]
public void GetHistory_UnknownToken_IsEmpty()
{
var (svc, _, _) = NewService();
Assert.Empty(svc.GetHistory("ghost"));
}
[Theory]
[InlineData("address", "invoiceaddress")]
[InlineData("title", "invoicetitle")]
[InlineData("provisionperiod", "provisionperiod")]
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 InvoiceDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
Assert.Equal("X-VALUE", s2!.New[newKey]!.Value<string>());
}
[Fact]
public void ApplyPatch_ProvisionLocation_MirrorsLocAndProvisionlocation()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("Baustelle 7") });
Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value<string>());
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
}
[Fact]
public void ApplyPatch_Contact_BuildsCustomValuesJson()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
{
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_SetmodeDelta_UpdatesAdmin()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("itemprices") });
Assert.Equal("itemprices", s2!.Admin["setmode"]!.Value<string>());
}
[Fact]
public void ApplyPatch_P13bExplicitFalse_TurnsOffAndRestoresVat()
{
var (svc, _, _) = NewService();
var payload = Payload();
payload["admin"]!["p13b"] = true; // start reverse-charge
var s = svc.OpenFromPayload(payload, "user1");
Assert.Empty(s.Sums.VatByRate);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(false) });
Assert.Equal(119m, s2!.Sums.TotalGross); // VAT restored
Assert.Equal(19m, s2.Sums.VatByRate["19"]);
}
[Fact]
public void ApplyPatch_BlockReplace_InsertsWhenBlockIsNew()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var newBlock = JObject.Parse(@"{'Id':'2','text':'Zusatz','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],
'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "2", Value = newBlock });
Assert.Equal(2, s2!.Req.Count);
Assert.Equal(130m, s2.Sums.TotalNet); // 100 (block 1) + 30 (new block 2)
Assert.Equal(30m, s2.Sums.NetByBlock["2"]);
}
[Fact]
public void ApplyPatch_UnknownTarget_IsNoOp_NoVersionBumpNoHistory()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Titel 2") });
var s3 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Adr 3") });
Assert.Equal(3, s3!.Version);
Assert.Equal(3, s3.History.Count);
Assert.Equal(new[] { "email", "title", "address" }, 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 InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") });
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
Assert.Equal(1, state["version"]!.Value<int>());
Assert.Equal(100m, state["sums"]!["total_net"]!.Value<decimal>());
Assert.Equal(119m, state["sums"]!["total_gross"]!.Value<decimal>());
Assert.Equal(19m, state["sums"]!["vat"]!["19"]!.Value<decimal>());
Assert.Single((JArray)state["req"]!);
Assert.Equal(1, state["historyCount"]!.Value<int>());
Assert.NotNull(state["validation"]);
}
[Fact]
public async Task FlushToDbAsync_ExistingInvId_UpdatesInsteadOfCreates()
{
var (svc, inv, _) = NewService();
var payload = Payload();
payload["invid"] = "INV7";
var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.True(inv.LastChange); // prior InvId → update path
}
[Fact]
public async Task FlushToDbAsync_MapsInvoiceOptionsFrom13bAndSetmode()
{
var (svc, inv, _) = NewService();
var payload = Payload();
payload["admin"]!["p13b"] = true;
payload["admin"]!["setmode"] = "itemprices";
var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!);
var options = inv.Registered!.BuildInvoiceParams(change: false, invId: "")
.First(p => p.ParameterName == "@InvoiceOptions").Value?.ToString() ?? "";
Assert.Contains("§13b", options);
Assert.Contains("setmode:itemprices", options);
}
[Fact]
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
{
var (svc, inv, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var doc = svc.RenderPreview(s.Token);
Assert.NotNull(doc);
Assert.True(inv.PreviewDraft); // always rendered as a draft
var reg = inv.PreviewInvoice!.InvoiceRegistration!;
Assert.Equal("Rechnung", reg.getString("InvoiceTitle"));
Assert.Equal("Weg 1", reg.getString("SendToAddress"));
Assert.Equal("a@b.de", reg.getString("SendToEmail"));
Assert.Equal("19", reg.getString("InvoiceVAT_1")); // rate synthesised from server sums
Assert.True(inv.PreviewInvoice.IsDraft);
}
[Fact]
public void RenderPreview_UnknownToken_ReturnsNull()
{
var (svc, _, _) = NewService();
Assert.Null(svc.RenderPreview("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));
}
}