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