Add unit tests for Fuchs_DataService and related components

- Introduced comprehensive unit tests for the Fuchs_DataService library, covering DATEV header formatting, CSV/XML generation, and FdsMfrClient construction.
- Implemented tests for FdsMfr.UpdateNeed parsing and FdsShared utility helpers, ensuring correct functionality and stability.
- Added tests for FdsConfig and FdsMfrClient to validate configuration resolution and client construction.

Document decisions on backend-authoritative invoice and reminder handling

- Created ADR 0008 to clarify that all invoice types and reminder stages are backend-authoritative during drafting and previewing.
- Established that all calculations and settings must be processed server-side, ensuring consistency between online editor and PDF outputs.

Define irreversible mutations for set-price modes in invoices

- Documented ADR 0009 to specify that the "Set mit Preis" and "Nur Set mit Preis" operations are irreversible mutations affecting service request blocks.
- Clarified that these operations are not display toggles but actual data changes, ensuring clear expectations for invoice handling.

Transition MFR ERP sync to in-process execution within the web app

- Created ADR 0010 to outline the migration of Fuchs_DataService from a standalone service to an in-process library within the Fuchs web application.
- Updated configuration and logging management to be handled by the host application, streamlining the sync process.

Add publish profile and periodic hosted service for job scheduling

- Introduced a publish profile for deployment to a specified folder.
- Implemented PeriodicHostedService to manage multiple independent jobs, including the MFR ERP sync, with configurable execution intervals.

Add dotnet-tools.json for EF Core CLI tools

- Included dotnet-tools.json to manage the version of dotnet-ef for Entity Framework Core migrations and commands.
This commit is contained in:
Stefan
2026-07-16 13:34:23 +02:00
parent f724b9b59d
commit 49e3ed2673
102 changed files with 2501 additions and 8438 deletions
+446
View File
@@ -0,0 +1,446 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml;
using fds;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Fuchs.Tests;
// ─────────────────────────────────────────────────────────────────────────────
// Tests for the Fuchs_DataService library (MFR ERP sync).
//
// Since Topshelf/own-config were removed and the library is hosted in-process by
// Fuchs, these tests exercise the parts that are pure/deterministic and do not
// require a live SQL Server or MFR endpoint: DATEV header/CSV/XML formatting,
// UpdateNeed parsing, the FdsShared utility helpers, config resolution, and the
// (network-free) FdsMfrClient construction.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>DATEV header line formatting — pure string assembly, no I/O.</summary>
public class DatevHeaderTests
{
[Fact]
public void ToHeaderString_Defaults_ProducesThirtySemicolonFields()
{
var header = new DatevHeader();
var parts = header.ToHeaderString().Split(';');
Assert.Equal(30, parts.Length);
Assert.Equal("\"EXTF\"", parts[0]); // Kennzeichen quoted
Assert.Equal("700", parts[1]); // Versionsnummer
Assert.Equal("1", parts[20]); // Festschreibung true → "1"
Assert.Equal("\"EUR\"", parts[21]); // WKZ quoted
}
[Fact]
public void ToHeaderString_MapsNumericAndDateFields()
{
var header = new DatevHeader
{
Beraternummer = 12345,
Mandantennummer = 678,
Sachkontenlänge = 4,
WJBeginn = new DateTime(2026, 1, 1),
DatumVon = new DateTime(2026, 7, 1),
DatumBis = new DateTime(2026, 7, 31),
Bezeichnung = "fds_m260731",
};
var parts = header.ToHeaderString().Split(';');
Assert.Equal("12345", parts[10]);
Assert.Equal("678", parts[11]);
Assert.Equal("20260101", parts[12]);
Assert.Equal("4", parts[13]);
Assert.Equal("20260701", parts[14]);
Assert.Equal("20260731", parts[15]);
Assert.Equal("\"fds_m260731\"", parts[16]);
}
[Theory]
[InlineData(true, "1")]
[InlineData(false, "0")]
public void ToHeaderString_Festschreibung_MapsToFlag(bool festschreibung, string expected)
{
var header = new DatevHeader { Festschreibung = festschreibung };
Assert.Equal(expected, header.ToHeaderString().Split(';')[20]);
}
[Fact]
public void ToHeaderString_DtvfKennzeichen_IsHonoured()
{
var header = new DatevHeader { Kennzeichen = DatevKennzeichen.DTVF };
Assert.Equal("\"DTVF\"", header.ToHeaderString().Split(';')[0]);
}
[Theory]
[InlineData(DatevFormatkategorie.Debitoren__Kreditoren, "Debitoren/Kreditoren")]
[InlineData(DatevFormatkategorie.Buchungsstapel, "Buchungsstapel")]
[InlineData(DatevFormatkategorie.Wiederkehrende_Buchungen, "Wiederkehrende Buchungen")]
public void Formatname_TranslatesEnumUnderscores(DatevFormatkategorie kat, string expected)
{
var header = new DatevHeader { Formatkategorie = kat };
Assert.Equal(expected, header.Formatname);
// Field 3 embeds the same, quoted.
Assert.Equal($"\"{expected}\"", header.ToHeaderString().Split(';')[3]);
}
}
/// <summary>DATEV CSV + document XML generation on FdsMfr (no DB / no MFR).</summary>
public class FdsMfrDatevTests
{
private static FdsMfr NewMfr() =>
new(NullLogger<FdsMfr>.Instance, NullLoggerFactory.Instance);
[Fact]
public void DATEV_PrependsHeaderLine_AndSemicolonCsvWithColumnHeaders()
{
var header = new DatevHeader { Bezeichnung = "test" };
var tbl = new DataTable();
tbl.Columns.Add("Konto", typeof(string));
tbl.Columns.Add("Betrag", typeof(decimal));
tbl.Rows.Add("1200", 119.50m);
var result = NewMfr().DATEV(header, tbl);
var lines = result.Split("\r\n");
Assert.Equal(header.ToHeaderString(), lines[0]); // header line first
Assert.Equal("Konto;Betrag", lines[1]); // column headers, semicolon-delimited, unquoted
Assert.Contains("1200", lines[2]);
// de-DE culture → decimal comma
Assert.Contains("119,5", lines[2]);
}
// Note: only the root <archive> is in the DATEV namespace; the generator emits child
// elements in the empty namespace (existing production behavior). Child lookups therefore
// use namespace-agnostic local-name() XPath rather than the datev namespace prefix.
[Fact]
public void CreateDatevDocumentXml_BuildsArchiveWithNamespaceAndOneDocumentPerInput()
{
var docs = new List<DatevDocument>
{
new("guid-1", "invoice1.pdf", "RgNr: 100", "ProcessWeb_Belege", "2026/07"),
new("guid-2", "invoice2.pdf", "", "ProcessWeb_Belege", "2026/07"),
};
var xml = NewMfr().CreateDatevDocumentXml(docs);
var doc = new XmlDocument();
doc.LoadXml(xml);
Assert.Equal("archive", doc.DocumentElement!.LocalName);
Assert.Equal("http://xml.datev.de/bedi/tps/document/v04.0", doc.DocumentElement.NamespaceURI);
Assert.Equal("ProcessWeb", doc.DocumentElement.GetAttribute("generatingSystem"));
Assert.Equal("4.0", doc.DocumentElement.GetAttribute("version"));
Assert.NotNull(doc.SelectSingleNode("//*[local-name()='header']/*[local-name()='date']"));
Assert.NotNull(doc.SelectSingleNode("//*[local-name()='header']/*[local-name()='description']"));
var documentNodes = doc.SelectNodes("//*[local-name()='content']/*[local-name()='document']");
Assert.Equal(2, documentNodes!.Count);
}
[Fact]
public void CreateDatevDocumentXml_OmitsKeywordsWhenEmpty_IncludesWhenPresent()
{
var docs = new List<DatevDocument>
{
new("g-withkw", "a.pdf", "keyword-here", "ProcessWeb_Belege", "2026/07"),
new("g-nokw", "b.pdf", "", "ProcessWeb_Belege", "2026/07"),
};
var doc = new XmlDocument();
doc.LoadXml(NewMfr().CreateDatevDocumentXml(docs));
var withKw = doc.SelectSingleNode("//*[local-name()='document'][@guid='g-withkw']")!;
var noKw = doc.SelectSingleNode("//*[local-name()='document'][@guid='g-nokw']")!;
var withKwNode = withKw.SelectSingleNode("*[local-name()='keywords']");
Assert.NotNull(withKwNode);
Assert.Equal("keyword-here", withKwNode.InnerText);
Assert.Null(noKw.SelectSingleNode("*[local-name()='keywords']"));
}
[Fact]
public void CreateDatevDocumentXml_EmitsThreeRepositoryLevels()
{
var docs = new List<DatevDocument>
{
new("g1", "a.pdf", "", "ProcessWeb_Belege", "2026/07_w03"),
};
var doc = new XmlDocument();
doc.LoadXml(NewMfr().CreateDatevDocumentXml(docs));
var levels = doc.SelectNodes(
"//*[local-name()='document']/*[local-name()='repository']/*[local-name()='level']")!;
Assert.Equal(3, levels.Count);
Assert.Equal("ProcessWeb", ((XmlElement)levels[0]!).GetAttribute("name"));
Assert.Equal("ProcessWeb_Belege", ((XmlElement)levels[1]!).GetAttribute("name"));
Assert.Equal("2026/07_w03", ((XmlElement)levels[2]!).GetAttribute("name"));
}
}
/// <summary>FdsMfr.UpdateNeed parsing + enum contract.</summary>
public class FdsMfrUpdateNeedTests
{
[Theory]
[InlineData("Reset", FdsMfr.UpdateNeed.Reset)]
[InlineData("Full", FdsMfr.UpdateNeed.Full)]
[InlineData("Short", FdsMfr.UpdateNeed.Short)]
[InlineData("None", FdsMfr.UpdateNeed.None)]
public void UpdateNeedValue_ValidName_Parses(string name, FdsMfr.UpdateNeed expected)
{
Assert.Equal(expected, FdsMfr.UpdateNeedValue(name));
}
[Fact]
public void UpdateNeedValue_UnknownName_Throws()
{
Assert.ThrowsAny<Exception>(() => FdsMfr.UpdateNeedValue("Bogus"));
}
[Fact]
public void UpdateNeed_NumericValues_AreStable()
{
// These map to SQL updateneed codes — must not drift.
Assert.Equal(5, (int)FdsMfr.UpdateNeed.Reset);
Assert.Equal(2, (int)FdsMfr.UpdateNeed.Full);
Assert.Equal(1, (int)FdsMfr.UpdateNeed.Short);
Assert.Equal(0, (int)FdsMfr.UpdateNeed.None);
}
}
/// <summary>FdsShared utility helpers — pure formatting + local file I/O.</summary>
public class FdsSharedTests
{
[Theory]
[InlineData((byte)0)]
[InlineData((byte)1)]
[InlineData((byte)16)]
[InlineData((byte)32)]
public void RandomString_ReturnsRequestedLength_AlphanumericOnly(byte length)
{
var s = FdsShared.RandomString(length);
Assert.Equal(length, s.Length);
Assert.All(s, c => Assert.True(char.IsLetterOrDigit(c), $"unexpected char '{c}'"));
}
[Fact]
public void ToCsv_DataRow_QuotesAndEscapesStringsWhenRequested()
{
var tbl = new DataTable();
tbl.Columns.Add("Name", typeof(string));
var row = tbl.NewRow();
row["Name"] = "Say \"hi\"";
tbl.Rows.Add(row);
Assert.Equal("\"Say \"\"hi\"\"\"", row.ToCsv(quoteStrings: true, CultureInfo.InvariantCulture));
Assert.Equal("Say \"hi\"", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture));
}
[Fact]
public void ToCsv_DataRow_NullAndDbNull_RenderAsEmpty()
{
var tbl = new DataTable();
tbl.Columns.Add("A", typeof(string));
tbl.Columns.Add("B", typeof(string));
var row = tbl.NewRow();
row["A"] = DBNull.Value;
row["B"] = "x";
Assert.Equal(";x", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture, delimiter: ";"));
}
[Fact]
public void ToCsv_DataRow_FormatsDecimalWithSuppliedCulture()
{
var tbl = new DataTable();
tbl.Columns.Add("V", typeof(decimal));
var row = tbl.NewRow();
row["V"] = 1234.5m;
tbl.Rows.Add(row);
Assert.Equal("1234.5", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture));
Assert.Equal("1234,5", row.ToCsv(quoteStrings: false, new CultureInfo("de-DE")));
}
[Fact]
public void ToCsv_DataTable_IncludesHeaderRow_WhenRequested()
{
var tbl = new DataTable();
tbl.Columns.Add("Konto", typeof(string));
tbl.Columns.Add("Betrag", typeof(decimal));
tbl.Rows.Add("1200", 5m);
tbl.Rows.Add("1400", 6m);
var csv = tbl.ToCsv(includeHeaders: true, quoteStrings: false, fieldDelimiter: ";");
var lines = csv.Split("\r\n");
Assert.Equal("Konto;Betrag", lines[0]);
Assert.Equal("1200;5", lines[1]);
Assert.Equal("1400;6", lines[2]);
}
[Fact]
public void ToCsv_DataTable_CanOmitHeaders()
{
var tbl = new DataTable();
tbl.Columns.Add("X", typeof(string));
tbl.Rows.Add("a");
var csv = tbl.ToCsv(includeHeaders: false, quoteStrings: false);
Assert.Equal("a", csv);
}
[Theory]
[InlineData("archive.zip", "archive")]
[InlineData("archive.tar.gz", "archive.tar")]
[InlineData("noext", "noext")]
public void NameBase_StripsFinalExtension(string fileName, string expected)
{
var fi = new FileInfo(Path.Combine(Path.GetTempPath(), fileName));
Assert.Equal(expected, fi.NameBase());
}
[Theory]
[InlineData("Hello, DATEV!")]
[InlineData("Ümläute & Straße")]
public void ToByteArray_Utf8_RoundTrips(string input)
{
// Default encoding is Encoding.UTF8, so the StreamWriter emits a leading BOM.
var bytes = input.ToByteArray();
var decoded = Encoding.UTF8.GetString(bytes).TrimStart('');
Assert.Equal(input, decoded);
}
[Fact]
public void ToByteArray_Iso8859_1_RoundTrips()
{
const string input = "Grüße";
var latin1 = Encoding.GetEncoding("ISO-8859-1");
var bytes = input.ToByteArray(latin1);
Assert.Equal(input, latin1.GetString(bytes));
}
[Fact]
public void ReadWriteStream_CopiesFullContent()
{
var payload = Encoding.UTF8.GetBytes("stream-copy-payload-0123456789");
using var src = new MemoryStream(payload);
using var dst = new MemoryStream();
Assert.True(FdsShared.ReadWriteStream(src, dst, closeWriteStream: false));
Assert.Equal(payload, dst.ToArray());
}
[Fact]
public void WriteStreamToDisk_PersistsStreamToFile()
{
var payload = Encoding.UTF8.GetBytes("disk-payload");
var path = Path.Combine(Path.GetTempPath(), $"fds_test_{Guid.NewGuid():N}.bin");
try
{
using var src = new MemoryStream(payload);
Assert.True(FdsShared.WriteStreamToDisk(src, path));
Assert.True(File.Exists(path));
Assert.Equal(payload, File.ReadAllBytes(path));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
/// <summary>
/// FdsConfig + FdsMfrClient share process-global config state (FdsConfig._config),
/// so their tests are grouped into one collection to run sequentially and never
/// clobber each other's <see cref="FdsConfig.Initialize"/> call.
/// </summary>
[CollectionDefinition("FdsConfig")]
public class FdsConfigCollection { }
[Collection("FdsConfig")]
public class FdsConfigTests
{
private static IConfiguration BuildConfig(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
[Fact]
public void FDSConnectionString_ReturnsConfiguredValue()
{
FdsConfig.Initialize(BuildConfig(new()
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = "Server=.;Database=fds;",
}));
Assert.Equal("Server=.;Database=fds;", FdsConfig.FDSConnectionString());
}
[Fact]
public void FDSConnectionString_Missing_Throws()
{
FdsConfig.Initialize(BuildConfig(new()));
Assert.Throws<InvalidOperationException>(() => FdsConfig.FDSConnectionString());
}
[Fact]
public void MfrSettings_ResolveFromFdsSection()
{
FdsConfig.Initialize(BuildConfig(new()
{
["Fds:MFR_UserName"] = "system@example.com",
["Fds:MFR_Password"] = "secret",
["Fds:MFR_host"] = "portal.mobilefieldreport.com",
}));
Assert.Equal("system@example.com", FdsConfig.MFR_UserName);
Assert.Equal("secret", FdsConfig.MFR_Password);
Assert.Equal("portal.mobilefieldreport.com", FdsConfig.MFR_host);
}
[Fact]
public void MfrSettings_Absent_FallBackToEmptyString()
{
FdsConfig.Initialize(BuildConfig(new()));
Assert.Equal("", FdsConfig.MFR_UserName);
Assert.Equal("", FdsConfig.MFR_Password);
Assert.Equal("", FdsConfig.MFR_host);
}
}
[Collection("FdsConfig")]
public class FdsMfrClientTests
{
private static void InitConfig(string host) =>
FdsConfig.Initialize(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Fds:MFR_host"] = host,
["Fds:MFR_UserName"] = "u",
["Fds:MFR_Password"] = "p",
}).Build());
[Fact]
public void Construction_BuildsClientConfig_FromHost()
{
InitConfig("portal.mobilefieldreport.com");
using var client = new FdsMfrClient();
Assert.Contains("portal.mobilefieldreport.com", client.ClientConfig.BaseUrl);
Assert.EndsWith("/", client.ClientConfig.BaseUrl);
}
[Fact]
public void IsReadonly_DefaultsTrue_AndIsSettable()
{
InitConfig("portal.mobilefieldreport.com");
using var client = new FdsMfrClient();
Assert.True(client.IsReadonly);
client.IsReadonly = false;
Assert.False(client.IsReadonly);
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+83 -1
View File
@@ -1,4 +1,4 @@
using Fuchs.intranet;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
@@ -22,6 +22,88 @@ public class InvoiceDraftCalculatorTests
return s;
}
// ── RecomputeLineValues ────────────────────────────────────────────────────
private static JObject Line(InvoiceDraftSession s, int block, int line) =>
(JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line];
[Fact]
public void RecomputeLineValues_ComputesNetAndVatFromRawQtyPriceAndRate()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'material','qn':2,'v':50,'vat':'19%'} ] }
]");
InvoiceDraftCalculator.RecomputeLineValues(s);
var l = Line(s, 0, 0);
Assert.Equal(100m, l["vt"]!.Value<decimal>());
Assert.Equal(19m, l["vv"]!.Value<decimal>());
Assert.Equal(0m, l["vs"]!.Value<decimal>());
Assert.Equal(0m, l["vsv"]!.Value<decimal>());
}
[Fact]
public void RecomputeLineValues_ServiceType_AlsoFillsServiceNetAndVat()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'Service','qn':3,'v':10,'vat':'19%'} ] }
]");
InvoiceDraftCalculator.RecomputeLineValues(s);
var l = Line(s, 0, 0);
Assert.Equal(30m, l["vt"]!.Value<decimal>());
Assert.Equal(5.7m, l["vv"]!.Value<decimal>());
Assert.Equal(30m, l["vs"]!.Value<decimal>());
Assert.Equal(5.7m, l["vsv"]!.Value<decimal>());
}
[Theory]
[InlineData(0, 50)] // no quantity posted -> leave value untouched
[InlineData(2, 0)] // no price posted -> leave value untouched
public void RecomputeLineValues_MissingQtyOrPrice_LeavesExistingValueUntouched(decimal qty, decimal price)
{
var s = SessionWith($@"[
{{ 'Id':'10','itm':[ {{'id':'a','typ':'material','qn':{qty},'v':{price},'vat':'19%','vt':777,'vv':111}} ] }}
]");
InvoiceDraftCalculator.RecomputeLineValues(s);
var l = Line(s, 0, 0);
Assert.Equal(777m, l["vt"]!.Value<decimal>()); // untouched — mirrors quantChange's own guard
Assert.Equal(111m, l["vv"]!.Value<decimal>());
}
[Fact]
public void RecomputeLineValues_ZeroVatRate_ComputesNetWithNoVat()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'material','qn':4,'v':25,'vat':'0%'} ] }
]");
InvoiceDraftCalculator.RecomputeLineValues(s);
var l = Line(s, 0, 0);
Assert.Equal(100m, l["vt"]!.Value<decimal>());
Assert.Equal(0m, l["vv"]!.Value<decimal>());
}
[Fact]
public void RecomputeLineValues_SetHeaderConvertedSum_IsNotClobberedByRecompute()
{
// A converted set header carries a synthesised sum (no raw qty/price of its own) —
// RecomputeLineValues must never overwrite it, mirroring quantChange's guard.
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'h','typ':'set','vt':1000,'vv':190,'vat':'19%'} ] }
]");
InvoiceDraftCalculator.RecomputeLineValues(s);
var l = Line(s, 0, 0);
Assert.Equal(1000m, l["vt"]!.Value<decimal>());
Assert.Equal(190m, l["vv"]!.Value<decimal>());
}
[Fact]
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
{
+368 -5
View File
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
@@ -205,9 +205,9 @@ public class InvoiceDraftServiceTests
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("itemprices") });
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("setonly") });
Assert.Equal("itemprices", s2!.Admin["setmode"]!.Value<string>());
Assert.Equal("setonly", s2!.Admin["setmode"]!.Value<string>());
}
[Fact]
@@ -240,6 +240,249 @@ public class InvoiceDraftServiceTests
Assert.Equal(30m, s2.Sums.NetByBlock["2"]);
}
// fds__prepInvoice's [SetItmID] window function anchors on the still-unconverted (price 0)
// Set header: the header row's own SetItmId self-references its own id ('1'), never null —
// ApplyItemSetPrice must still recognize id-equality first and never treat the header as its
// own member (see ApplyItemSetPrice's id == Ref check).
private static JObject SetPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'1','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
{'id':'2','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
{'id':'3','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'1'}],
'items':[{'id':'1','type':'set'},{'id':'2','type':'material','setId':'1','total_net':60,'vat':'19%'},
{'id':'3','type':'material','setId':'1','total_net':40,'vat':'19%'}]}]
}");
// A fourth, unrelated item ('4') follows the set's members in the same block but was never
// attributed a SetItmId by fds__prepInvoice (it isn't part of the set) — it must stay untouched
// by the conversion, proving membership is driven purely by SetItmId, not row order/position.
private static JObject SetPayloadWithTrailingUnrelatedItem() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'1','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
{'id':'2','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
{'id':'3','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'1'},
{'id':'4','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'1','type':'set'},{'id':'2','type':'material','setId':'1','total_net':60,'vat':'19%'},
{'id':'3','type':'material','setId':'1','total_net':40,'vat':'19%'},
{'id':'4','type':'material','total_net':25,'vat':'19%'}]}]
}");
[Fact]
public void ApplyPatch_ItemSetPrice_SumsMembersOntoHeaderAndNullsMembers()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(SetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
var block = (JObject)s2.Req[0];
var lines = (JArray)block["itm"]!;
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
var m2 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "2");
var m3 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "3");
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 60 + 40
Assert.Equal(19m, header["vv"]!.Value<decimal>()); // 11.4 + 7.6
Assert.Equal(10m, header["vs"]!.Value<decimal>());
Assert.Equal(1.9m, header["vsv"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, m2["vt"]!.Type); // ADR 0009: members nulled (empty cell), not 0
Assert.Equal(JTokenType.Null, m2["vv"]!.Type);
Assert.Equal(JTokenType.Null, m3["vt"]!.Type);
Assert.Equal(JTokenType.Null, m3["vv"]!.Type);
// Total invoice sum is unchanged by the conversion (set price == sum of members).
Assert.Equal(100m, s2.Sums.TotalNet);
Assert.Equal(19m, s2.Sums.TotalVat);
var h = Assert.Single(s2.History);
Assert.Equal("item.setprice", h.Target);
Assert.Equal("1", h.Ref);
Assert.Equal("0", h.OldValue);
Assert.Equal("100", h.NewValue);
}
[Fact]
public void ApplyPatch_ItemSetPrice_UnknownRef_IsNoOp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(SetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "999" });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_ItemSetPrice_RefNotASetHeader_IsNoOp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(SetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "2" });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_ItemSetPrice_HeaderSelfReferencingSetItmId_NeverCountsHeaderAsOwnMember()
{
// Regression test: fds__prepInvoice no longer nulls out the header's own SetItmId (it
// self-references its own id). ApplyItemSetPrice must still sum exactly the two real
// members (100 net), not 3x by also including the header as if it were a member of itself.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(SetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
Assert.NotNull(s2);
var block = (JObject)s2!.Req[0];
var lines = (JArray)block["itm"]!;
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 60 + 40, not tripled by self-inclusion
Assert.Equal(100m, s2.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_ItemSetPrice_UnrelatedItemAfterMembers_IsNeverSweptIntoSet()
{
// Regression test for the reported bug: an item after the set's real members in the same
// block, but with no SetItmId of its own, must stay fully priced and untouched — only
// items the server actually tagged with SetItmId == the header id are members.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(SetPayloadWithTrailingUnrelatedItem(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
Assert.NotNull(s2);
var block = (JObject)s2!.Req[0];
var lines = (JArray)block["itm"]!;
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
var m2 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "2");
var m3 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "3");
var other = lines.OfType<JObject>().Single(l => (string)l["id"]! == "4");
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // only the real members (60 + 40)
Assert.Equal(JTokenType.Null, m2["vt"]!.Type); // ADR 0009: nulled, not 0
Assert.Equal(JTokenType.Null, m3["vt"]!.Type);
Assert.Equal(25m, other["vt"]!.Value<decimal>()); // untouched — never part of the set
Assert.Equal(4.75m, other["vv"]!.Value<decimal>());
// Total invoice sum unaffected: 100 (set) + 25 (unrelated item) = 125.
Assert.Equal(125m, s2.Sums.TotalNet);
}
// ── Block set-price menu modes (ADR 0009): per-service-request-block, irreversible ──
private static JObject BlocksPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[
{'Id':'1','text':'Auftrag A','itm':[
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%'},
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%'}],
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'},
{'id':'12','type':'service','total_net':40,'vat':'19%'}]},
{'Id':'2','text':'Auftrag B','itm':[
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%'}],
'items':[{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
}");
[Fact]
public void ApplyPatch_BlockSetPrice_InsertsSetRowPerBlock_NullsMembers_TotalUnchanged()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
Assert.Equal(130m, s.Sums.TotalNet); // 100 (A) + 30 (B)
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
var linesA = (JArray)((JObject)s2.Req[0])["itm"]!;
Assert.Equal(3, linesA.Count); // set row + the two (nulled) members, kept
var setA = (JObject)linesA[0];
Assert.Equal("set", (string)setA["typ"]!);
Assert.Equal("bset_1", (string)setA["id"]!);
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // block sum
Assert.Equal(19m, setA["vv"]!.Value<decimal>());
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
var m11 = linesA.OfType<JObject>().Single(l => (string)l["id"]! == "11");
Assert.Equal(JTokenType.Null, m11["vt"]!.Type); // ADR 0009: null (empty cell), not 0
Assert.Equal(JTokenType.Null, m11["vv"]!.Type);
// items contract mirrors it (PDF reads total_net from here).
var itemsA = (JArray)((JObject)s2.Req[0])["items"]!;
Assert.Equal("set", (string)((JObject)itemsA[0])["type"]!);
Assert.Equal(100m, ((JObject)itemsA[0])["total_net"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "11")["total_net"]!.Type);
// second block converted too; total conserved.
Assert.Equal(30m, ((JObject)((JArray)((JObject)s2.Req[1])["itm"]!)[0])["vt"]!.Value<decimal>());
Assert.Equal(130m, s2.Sums.TotalNet);
Assert.Equal(24.7m, s2.Sums.TotalVat);
}
[Fact]
public void ApplyPatch_BlockSetOnly_InsertsSetRowPerBlock_RemovesMembers_TotalUnchanged()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
var linesA = (JArray)((JObject)s2!.Req[0])["itm"]!;
Assert.Single(linesA); // members removed, only the set row remains
Assert.Equal("set", (string)((JObject)linesA[0])["typ"]!);
Assert.Equal(100m, ((JObject)linesA[0])["vt"]!.Value<decimal>());
Assert.Single((JArray)((JObject)s2.Req[0])["items"]!);
Assert.Equal(130m, s2.Sums.TotalNet); // total unchanged
}
[Fact]
public void ApplyPatch_BlockSetPrice_SetRowNotSweptIntoBuildSetDisplay()
{
// The block set row has no setId members -> HasSetMembers is false -> it renders flat
// (emphasised on its own, members null to blank), so BuildSetDisplay must not emit flags for it.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
var setDisplay = (JObject)state["setDisplay"]!;
Assert.False(setDisplay.ContainsKey("bset_1"));
Assert.Empty(setDisplay);
}
[Fact]
public void ApplyPatch_BlockSetPrice_EmptyOrUnpricedBlocks_IsNoOp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Leer','itm':[],'items':[]}]
}"), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version); // nothing priced -> no-op
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_UnknownTarget_IsNoOp_NoVersionBumpNoHistory()
{
@@ -306,7 +549,7 @@ public class InvoiceDraftServiceTests
var (svc, inv, _) = NewService();
var payload = Payload();
payload["admin"]!["p13b"] = true;
payload["admin"]!["setmode"] = "itemprices";
payload["admin"]!["setmode"] = "setonly";
var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!);
@@ -314,7 +557,7 @@ public class InvoiceDraftServiceTests
var options = inv.Registered!.BuildInvoiceParams(change: false, invId: "")
.First(p => p.ParameterName == "@InvoiceOptions").Value?.ToString() ?? "";
Assert.Contains("§13b", options);
Assert.Contains("setmode:itemprices", options);
Assert.Contains("setmode:setonly", options);
}
[Fact]
@@ -476,4 +719,124 @@ public class InvoiceDraftServiceTests
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
}
// ── Multiple VAT rates aggregate independently (ADR 0008: server owns every tax sum) ──
private static JObject MultiRatePayload() => 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%'},
{'id':'901','typ':'material','vt':200,'vv':14,'vat':'7%'},
{'id':'902','typ':'material','vt':50,'vv':0,'vat':'0%'}],
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'},
{'id':'901','type':'material','total_net':200,'vat':'7%'},
{'id':'902','type':'material','total_net':50,'vat':'0%'}]}
]}");
[Fact]
public void OpenFromPayload_MultipleVatRates_GroupsSumsPerRate()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(MultiRatePayload(), "user1");
Assert.Equal(350m, s.Sums.TotalNet);
Assert.Equal(33m, s.Sums.TotalVat);
Assert.Equal(383m, s.Sums.TotalGross);
Assert.Equal(19m, s.Sums.VatByRate["19"]);
Assert.Equal(14m, s.Sums.VatByRate["7"]);
Assert.False(s.Sums.VatByRate.ContainsKey("0")); // zero-rate contributes no VAT key (mirrors calculator)
}
[Fact]
public void ApplyPatch_BlockReplace_MultiRate_RecomputesEachRateIndependently()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(MultiRatePayload(), "user1");
// Halve the 7%-rate line's net/VAT via a block replace; the 19% bucket must stay untouched.
var newBlock = JObject.Parse(@"{'Id':'1','text':'A','itm':[
{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'},
{'id':'901','typ':'material','vt':100,'vv':7,'vat':'7%'},
{'id':'902','typ':'material','vt':50,'vv':0,'vat':'0%'}],
'items':[]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
Assert.Equal(19m, s2!.Sums.VatByRate["19"]); // unchanged
Assert.Equal(7m, s2.Sums.VatByRate["7"]); // recomputed from new line
Assert.Equal(250m, s2.Sums.TotalNet);
Assert.Equal(26m, s2.Sums.TotalVat);
}
// ── BuildSetDisplay reflects both set-pricing modes (mirrors the PDF's InvoiceSetPricing) ──
// SetPayload()'s header item is still unconverted (own total_net == 0, as delivered by
// fds__prepInvoice) — until the set-item switch (ApplyItemSetPrice) actually gives it its own
// price, InvoiceSetPricing.Build must not apply the chosen setmode yet: header stays blank and
// each member keeps showing its own individual price.
[Theory]
[InlineData("setprice")]
[InlineData("setonly")]
public void BuildState_SetDisplay_UnconvertedSet_HeaderBlankMembersIndividuallyPriced(string setmode)
{
var (svc, _, _) = NewService();
var payload = SetPayload();
payload["admin"]!["setmode"] = setmode;
var s = svc.OpenFromPayload(payload, "user1");
var state = JObject.FromObject(svc.BuildState(s));
var setDisplay = (JObject)state["setDisplay"]!;
Assert.False(setDisplay["1"]!["p"]!.Value<bool>()); // header not priced yet
Assert.True(setDisplay["2"]!["p"]!.Value<bool>()); // member 2 keeps its own price
Assert.True(setDisplay["3"]!["p"]!.Value<bool>()); // member 3 keeps its own price
}
// Once converted (ApplyItemSetPrice has given the header its own price), the chosen setmode
// takes effect: SetPrice blanks the members (still present in the map); SetOnly drops them
// from the map entirely.
[Theory]
[InlineData("setprice", true, false)] // header priced, member 2 blank
[InlineData("setonly", true, false)] // header priced; member 2 dropped entirely (absent from map)
public void BuildState_SetDisplay_ConvertedSet_ReflectsSetmodeForHeaderAndMember(string setmode, bool headerShown, bool memberShown)
{
var (svc, _, _) = NewService();
var payload = SetPayload();
payload["admin"]!["setmode"] = setmode;
var s = svc.OpenFromPayload(payload, "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" }); // convert the set first
var state = JObject.FromObject(svc.BuildState(s));
var setDisplay = (JObject)state["setDisplay"]!;
Assert.Equal(headerShown, setDisplay["1"]!["p"]!.Value<bool>());
if (setmode == "setonly")
Assert.False(setDisplay.ContainsKey("2")); // member removed from the display entirely
else
Assert.Equal(memberShown, setDisplay["2"]!["p"]!.Value<bool>());
}
// ── Full lifecycle recalculation: several edits of different kinds land in one consistent recompute ──
[Fact]
public void ApplyPatch_FullEditSequence_TextReorderSetPriceAndTaxToggle_EndsConsistent()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
// 1) text change
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Endabrechnung") });
// 2) reorder sections
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
// 3) toggle reverse-charge on, then off again (settings roundtrip)
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(true) });
var final = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(false) });
Assert.NotNull(final);
Assert.Equal("Endabrechnung", final!.New["invoicetitle"]!.Value<string>());
Assert.Equal(new[] { "2", "1" }, final.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
Assert.Equal("1", ((JObject)((JArray)((JObject)final.Req[0])["itm"]!)[0])["p"]!.ToString()); // renumbered after reorder
Assert.Equal(130m, final.Sums.TotalNet); // totals stable across the whole sequence
Assert.Equal(24.7m, final.Sums.TotalVat); // VAT restored after the toggle roundtrip
Assert.Equal(4, final.History.Count);
Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error");
}
}
+12 -4
View File
@@ -38,12 +38,18 @@ public class InvoiceOptionsTests
=> Assert.Equal("setmode:setprice", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
[Theory]
[InlineData("itemprices", "setmode:itemprices")]
[InlineData("setonly", "setmode:setonly")]
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
[InlineData("SETONLY", "setmode:setonly")] // case-insensitive
public void SetMode_EmitsToken(string mode, string expected)
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
[Fact]
public void RemovedItemPricesMode_OmitsToken()
// "itemprices" was a valid mode before the button/mode was removed. Any invoice options
// still carrying the stale value (or a client re-posting it) must not be persisted as a
// recognized token — it is treated the same as an unknown/garbage mode.
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "itemprices" }));
[Fact]
public void UnknownSetMode_OmitsToken()
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
@@ -106,9 +112,11 @@ public class InvoiceOptionsTests
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
{
// The token this side emits must parse back to the same mode on the PDF side.
Assert.Equal(SetDisplayMode.ItemPrices,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
Assert.Equal(SetDisplayMode.SetOnly,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
// A removed mode never round-trips as itself — it is never persisted, so reading it back
// always yields the default SetPrice.
Assert.Equal(SetDisplayMode.SetPrice,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
}
}
+44 -31
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
using Xunit;
@@ -40,11 +40,11 @@ public class InvoiceSetPricingTests
// ── Mode parsing ────────────────────────────────────────────────────────
[Theory]
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
[InlineData("items", SetDisplayMode.ItemPrices)]
[InlineData("setonly", SetDisplayMode.SetOnly)]
[InlineData("set_only", SetDisplayMode.SetOnly)]
[InlineData("setprice", SetDisplayMode.SetPrice)]
[InlineData("", SetDisplayMode.SetPrice)]
[InlineData("itemprices", SetDisplayMode.SetPrice)] // removed mode — falls back to default, never throws
[InlineData("garbage", SetDisplayMode.SetPrice)]
public void ParseMode_Works(string raw, SetDisplayMode expected)
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
@@ -52,10 +52,12 @@ public class InvoiceSetPricingTests
[Fact]
public void ModeFromInvoiceOptions_ReadsToken()
{
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:setonly"));
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
// A stale/removed "itemprices" token (e.g. from an invoice created before the mode was
// dropped) must not throw — it degrades gracefully to the default SetPrice mode.
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
}
[Fact]
@@ -85,22 +87,6 @@ public class InvoiceSetPricingTests
Assert.Equal(50.00m, lines[3].TotalNet);
}
// ── ItemPrices: members priced, set header blank ──────────────────────────
[Fact]
public void Build_ItemPrices_MembersPricedHeaderBlank()
{
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
Assert.Equal(4, lines.Count);
Assert.True(lines[0].IsSetHeader);
Assert.False(lines[0].ShowPrice); // set header is just a title now
Assert.True(lines[1].ShowPrice);
Assert.Equal(600.00m, lines[1].TotalNet);
Assert.True(lines[2].ShowPrice);
Assert.Equal(400.00m, lines[2].TotalNet);
Assert.True(lines[3].ShowPrice); // standalone
}
// ── SetOnly: members removed ──────────────────────────────────────────────
[Fact]
public void Build_SetOnly_RemovesMembers()
@@ -115,18 +101,45 @@ public class InvoiceSetPricingTests
Assert.Equal("Anfahrt", lines[1].Title);
}
// ── Set price falls back to sum of members when header total is 0 ─────────
// ── Unconverted set (header total still 0): shown blank, members individually priced ──
[Fact]
public void Build_HeaderTotalZero_UsesSumOfMembers()
public void Build_HeaderTotalZero_UnconvertedSet_HeaderBlankMembersPriced()
{
var items = new List<Dictionary<string, object?>>
{
SetHeader("7", "Set ohne Preis"), // total 0
SetHeader("7", "Set ohne Preis"), // total 0 — not yet converted
Member("7", "A", "120.00", "120.00"),
Member("7", "B", "80.00", "80.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
Assert.Equal(3, lines.Count);
Assert.True(lines[0].IsSetHeader);
Assert.False(lines[0].ShowPrice); // header not priced yet
Assert.Equal(0m, lines[0].TotalNet);
Assert.True(lines[1].ShowPrice); // members keep their own price
Assert.Equal(120.00m, lines[1].TotalNet);
Assert.True(lines[2].ShowPrice);
Assert.Equal(80.00m, lines[2].TotalNet);
}
// ── SetOnly on an unconverted set also just passes items through unchanged ──
[Fact]
public void Build_HeaderTotalZero_UnconvertedSet_SetOnlyModeStillPassesThrough()
{
var items = new List<Dictionary<string, object?>>
{
SetHeader("7", "Set ohne Preis"),
Member("7", "A", "120.00", "120.00"),
Member("7", "B", "80.00", "80.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetOnly);
Assert.Equal(3, lines.Count); // members not dropped before conversion
Assert.False(lines[0].ShowPrice);
Assert.True(lines[1].ShowPrice);
Assert.True(lines[2].ShowPrice);
}
// ── No sets: pass-through unchanged ───────────────────────────────────────
@@ -164,7 +177,7 @@ public class InvoiceSetPricingTests
}
[Fact]
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
public void Build_TextLine_AsSetMember_NoPriceInSetPriceMode()
{
var items = new List<Dictionary<string, object?>>
{
@@ -172,10 +185,10 @@ public class InvoiceSetPricingTests
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
Member("10", "Waschbecken", "600.00", "600.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
var note = lines.First(l => l.Title == "Hinweis");
Assert.False(note.ShowPrice); // text member stays blank
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
Assert.False(note.ShowPrice); // text member stays blank (already blank in SetPrice mode)
Assert.False(lines.First(l => l.Title == "Waschbecken").ShowPrice); // members blank in SetPrice mode too
}
// ── Set price equals sum of member prices across modes (no double counting) ─