Refactor code structure and remove redundant sections for improved readability and maintainability

This commit is contained in:
Stefan
2026-07-10 21:03:55 +02:00
parent 42997c4f49
commit 83d1c28b29
18 changed files with 696 additions and 86 deletions
+52
View File
@@ -0,0 +1,52 @@
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the block projection that feeds the PDF item table: each service-request
/// group exposes its heading (the section title the editor shows) and its line items,
/// so the PDF can print a heading row per block and the flat item list still works.
/// </summary>
public class FdsInvoiceDataBlocksTests
{
private static FdsInvoiceData FromReq(string reqJson) =>
new(JObject.Parse(@"{'admin':{'type':'r'},'new':{},'sms':{},'req':" + reqJson + "}"));
[Fact]
public void InvoiceBlocks_ExposesHeadingFromTextThenNme_AndItems()
{
var inv = FromReq(@"[
{'Id':'1','text':'Sektion A','items':[{'id':'a','type':'material','title':'X','price_net':10,'total_net':10}]},
{'Id':'2','nme':'Sektion B','items':[{'id':'b','type':'material','title':'Y','price_net':20,'total_net':20}]}
]");
var blocks = inv.InvoiceBlocks;
Assert.Equal(2, blocks.Count);
Assert.Equal("Sektion A", blocks[0].Heading);
Assert.Equal("Sektion B", blocks[1].Heading); // falls back to nme
Assert.Single(blocks[0].Items);
Assert.Equal("X", blocks[0].Items[0]["title"]);
}
[Fact]
public void InvoiceBlocks_MissingHeading_IsEmpty()
{
var inv = FromReq(@"[{'Id':'1','items':[{'id':'a','type':'material','total_net':5}]}]");
Assert.Equal("", Assert.Single(inv.InvoiceBlocks).Heading);
}
[Fact]
public void InvoiceItems_StillFlattensAcrossBlocks()
{
var inv = FromReq(@"[
{'Id':'1','text':'A','items':[{'id':'a','type':'material','total_net':10}]},
{'Id':'2','text':'B','items':[{'id':'b','type':'material','total_net':20},{'id':'c','type':'material','total_net':30}]}
]");
Assert.Equal(new[] { "a", "b", "c" }, inv.InvoiceItems.Select(i => i["id"]!.ToString()).ToArray());
}
}
@@ -136,4 +136,77 @@ public class InvoiceDraftCalculatorTests
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "total" && m.Severity == "warning");
}
// ── RecomputePositions ────────────────────────────────────────────────────
private static string Pos(InvoiceDraftSession s, int block, int line) =>
((JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line])["p"]!.ToString();
[Fact]
public void RecomputePositions_NumbersPricedLinesContinuouslyAcrossBlocks()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1}, {'id':'b','typ':'service','vt':2} ] },
{ 'Id':'11','itm':[ {'id':'c','typ':'material','vt':3} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0));
Assert.Equal("2", Pos(s, 0, 1));
Assert.Equal("3", Pos(s, 1, 0)); // continuous, not restarting per block
}
[Fact]
public void RecomputePositions_SkipsHeadingAndFreeTextLines()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[
{'id':'t','typ':'Title','vt':0},
{'id':'a','typ':'material','vt':1},
{'id':'x','typ':'Text','vt':0},
{'id':'b','typ':'material','vt':2} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("", Pos(s, 0, 0)); // title carries no number
Assert.Equal("1", Pos(s, 0, 1));
Assert.Equal("", Pos(s, 0, 2)); // free text carries no number
Assert.Equal("2", Pos(s, 0, 3));
}
[Fact]
public void RecomputePositions_NumbersSetHeaderLikeAnyItem()
{
// A set header is numbered just like the editor numbers it — only text/title lines are skipped.
var s = SessionWith(@"[
{ 'Id':'10','itm':[
{'id':'h','typ':'set','vt':1000},
{'id':'a','typ':'material','vt':600},
{'id':'b','typ':'material','vt':400} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0)); // set header keeps position 1 (matches the editor)
Assert.Equal("2", Pos(s, 0, 1));
Assert.Equal("3", Pos(s, 0, 2));
}
[Fact]
public void RecomputePositions_AfterBlockOrderChange_RenumbersToNewSequence()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1} ] },
{ 'Id':'11','itm':[ {'id':'b','typ':'material','vt':2} ] }
]");
// Simulate a section reorder: swap the two blocks.
var b0 = s.Req[0]; var b1 = s.Req[1];
s.Req = new JArray(b1.DeepClone(), b0.DeepClone());
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0)); // formerly block 11's item is now position 1
Assert.Equal("2", Pos(s, 1, 0));
}
}
+124
View File
@@ -352,4 +352,128 @@ public class InvoiceDraftServiceTests
Assert.Null(svc.Get(s.Token));
Assert.False(svc.Close(s.Token));
}
// ── HTML sanitisation (values must never reach the DB/PDF wrapped in tags) ─
[Theory]
[InlineData("provisionperiod", "provisionperiod")]
[InlineData("title", "invoicetitle")]
[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 InvoiceDraftDelta { Target = target, Value = JToken.FromObject("<p>18.06.2026</p>") });
Assert.Equal("18.06.2026", s2!.New[newKey]!.Value<string>()); // no <p> tags stored
Assert.Equal("18.06.2026", Assert.Single(s2.History).NewValue);
}
[Fact]
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
{
Target = "address",
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>5080 Laufenburg</p>")
});
Assert.Equal("Firma AG\nWeg 1\n5080 Laufenburg", s2!.New["invoiceaddress"]!.Value<string>());
}
[Fact]
public void ApplyPatch_ScalarField_DecodesEntities()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Tom &amp; Jerry") });
Assert.Equal("Tom & Jerry", s2!.New["invoicetitle"]!.Value<string>());
}
[Fact]
public void ApplyPatch_ProvisionLocation_SanitisesAndMirrorsLoc()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("<p>Baustelle 7</p>") });
Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value<string>());
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
}
// ── Change history records the changed field, not the whole block JSON ─────
[Fact]
public void ApplyPatch_BlockReplace_HistoryNewValueIsSectionText_NotJson()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var newBlock = JObject.Parse(@"{'Id':'1','text':'<p>Neue Überschrift</p>',
'itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
var h = Assert.Single(s2!.History);
Assert.Equal("Neue Überschrift", h.NewValue); // the heading, sanitised — never the block JSON
Assert.DoesNotContain("{", h.NewValue);
Assert.Equal("Auftrag", h.OldValue);
// and the cached block text is stored clean too
Assert.Equal("Neue Überschrift", ((JObject)s2.Req[0])["text"]!.Value<string>());
}
// ── Section reorder ───────────────────────────────────────────────────────
private static JObject TwoBlockPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1'},
'req':[
{'Id':'1','text':'A','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]},
{'Id':'2','text':'B','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}
]}");
[Fact]
public void ApplyPatch_BlockOrder_ReordersReqAndRenumbersPositions()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
Assert.Equal(new[] { "1", "2" }, s.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
Assert.Equal("1", ((JObject)((JArray)((JObject)s2.Req[0])["itm"]!)[0])["p"]!.ToString()); // block 2's item now position 1
Assert.Equal(130m, s2.Sums.TotalNet); // totals unaffected by reorder
var h = Assert.Single(s2.History);
Assert.Equal("1,2", h.OldValue);
Assert.Equal("2,1", h.NewValue);
}
[Fact]
public void ApplyPatch_BlockOrder_UnchangedSequence_IsNoOp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['1','2']") });
Assert.Equal(0, s2!.Version); // no-op: no version bump, no history
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_BlockOrder_UnknownIds_KeepMentionedFirstThenRest()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
// Only name block 2; block 1 is unmentioned and must be kept (appended after).
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','ghost']") });
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
}
}