Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user