feat: Implement mfr truncation fix and enhance invoice handling
Playwright Tests / test (push) Canceled after 0s

- Added a new SQL script to address the "String or binary data would be truncated" error in mfr sync by widening the Note fields in service objects and companies to NVARCHAR(MAX).
- Updated the table types and associated stored procedures to accommodate the changes, ensuring compatibility with existing data.
- Queued a re-sync for previously rejected rows due to truncation issues.
- Created application logins for fuchs_fds and fuchs_web with placeholder passwords for initial setup.
- Introduced functions for invoice backdating and invoice date retrieval to streamline invoice processing.
- Developed a password generation function with customizable parameters for length and character types.
- Added a random number generation function to support password creation.
This commit is contained in:
Stefan
2026-09-30 16:56:08 +02:00
parent 7357f60120
commit c741535aec
79 changed files with 2739 additions and 143 deletions
@@ -173,6 +173,62 @@ public class AzureBlobStorageServiceTests
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
}
// ── Cancellation: a caller-cancelled token (app shutdown during the startup backfill) must
// surface as OperationCanceledException, not be logged/reported as a storage failure ────
[Fact]
public async Task UploadInvoicePdfAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 }, null, cts.Token));
}
[Fact]
public async Task UploadInvoicePdfAsync_TimeoutWithoutCallerCancel_ReturnsNull()
{
var (service, _, blob) = CreateMockedClientChain();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("network timeout"));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 });
Assert.Null(result);
}
[Fact]
public async Task ExistsAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf", cts.Token));
}
[Theory]
[InlineData(false)] // storage error
[InlineData(true)] // timeout-style cancellation while the caller's token is still live
public async Task ExistsAsync_CheckFailsWithoutCallerCancel_ReturnsFalse(bool timeout)
{
var (service, _, blob) = CreateMockedClientChain();
Exception ex = timeout ? new TaskCanceledException("timeout") : new RequestFailedException("boom");
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>())).ThrowsAsync(ex);
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Assert.False(await svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf"));
}
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
[Fact]
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/datev</c> (HandleDatev) called <c>fds__getDatevExports @tgtdate, @mode, @authuser</c> positionally
/// against the signature (@tgtdate, @mode, @files, @authuser): the user id landed in <c>@files bit</c> and
/// <c>@authuser</c> was never supplied, so every call failed (8114 / 201). The command now binds by name.
/// These tests check the command against the real SSDT procedure so a future signature change is caught.
/// </summary>
public class DatevOverviewCommandTests
{
private const string ProcFile = "Stored Procedures/fds__getDatevExports.sql";
private static readonly Regex ParamDecl = new(
@"@(?<name>\w+)\s+\w+(\s*\([^)]*\))?\s*(?<def>=)?",
RegexOptions.Compiled);
private static readonly Regex ExecArg = new(
@"(?<name>@\w+)\s*(?<named>=)?",
RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>(name, hasDefault) of each parameter, in declaration order.</summary>
private static List<(string Name, bool HasDefault)> ProcParameters()
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, ProcFile));
int start = sql.IndexOf("[fds__getDatevExports]", StringComparison.OrdinalIgnoreCase);
int end = Regex.Match(sql[start..], @"^\s*AS\s*$", RegexOptions.Multiline).Index + start;
return ParamDecl.Matches(sql[start..end])
.Select(m => ("@" + m.Groups["name"].Value, m.Groups["def"].Success))
.ToList();
}
/// <summary>
/// Binding problems of an EXECUTE text against the procedure: positional arguments, names the proc
/// does not declare, and required (no default) parameters that are not supplied.
/// </summary>
internal static List<string> BindingProblems(string execSql, List<(string Name, bool HasDefault)> procParams)
{
string args = execSql[(execSql.IndexOf(']', execSql.IndexOf("fds__getDatevExports", StringComparison.Ordinal)) + 1)..];
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var part in args.TrimEnd(';', ' ').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var m = ExecArg.Match(part);
if (!m.Success || !m.Groups["named"].Success) { problems.Add($"positional argument '{part}'"); continue; }
string name = m.Groups["name"].Value;
if (!procParams.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
problems.Add($"unknown parameter {name}");
supplied.Add(name);
}
problems.AddRange(procParams.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
[Fact]
public void ProcParameters_RealSsdtFile_MatchesKnownSignature()
{
var ps = ProcParameters();
Assert.Equal(new[] { "@tgtdate", "@mode", "@files", "@authuser" }, ps.Select(p => p.Name));
Assert.Equal(new[] { false, true, false, false }, ps.Select(p => p.HasDefault));
}
[Fact]
public void DatevOverviewSql_AgainstRealProc_BindsEveryRequiredParameterByName()
=> Assert.Empty(BindingProblems(IntranetController.DatevOverviewSql, ProcParameters()));
[Theory]
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;")] // the former call
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0;")] // @authuser missing
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @files = 0, @user = @authuser;")] // unknown name
public void BindingProblems_MisboundCommand_IsReported(string execSql)
=> Assert.NotEmpty(BindingProblems(execSql, ProcParameters()));
[Fact]
public void DatevOverviewTables_NamesAllFourResultSetsInProcOrder()
// fds__getDatevExports returns header, invoice documents, bookings, debtors — the same four sets
// FdsMfr.GetDatevZip names admin/inv/buchungen/debitoren. Three names shifted every set by one.
=> Assert.Equal(new[] { "admin", "files", "invoices", "debits" }, IntranetController.DatevOverviewTables);
}
+304
View File
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using fds;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// mfr merge truncation (2026-09-30): mfr's free-text Note (ServiceObject up to 753 chars,
/// Company up to 723) overflowed NVARCHAR(255) in the mfr__ tables / mfr__tt__ types and every
/// batch holding such a row was rejected with the column-less message 8152. Covers the
/// failure-path diagnosis in <see cref="FdsColumnWidths"/> and guards the widened SSDT schema.
/// </summary>
public class FdsColumnWidthsTests
{
private const long LongNoteServiceObjectId = 38722404355;
// Staged batch shaped like "SELECT TOP(0) [setid] = …, * FROM mfr__serviceobjects".
private static DataTable ServiceObjectBatch(params (long Id, string? Note)[] rows)
{
var t = new DataTable("mfr__serviceobjects");
t.Columns.Add("setid", typeof(string));
t.Columns.Add("Id", typeof(long));
t.Columns.Add("Name", typeof(string));
t.Columns.Add("Note", typeof(string));
t.Columns.Add("CustomValues", typeof(string));
t.Columns.Add("Version", typeof(int));
foreach (var (id, note) in rows)
t.Rows.Add("abcde", id, "Heizung Keller", (object?)note ?? DBNull.Value, "[]", 1);
return t;
}
private static Dictionary<string, int> Widths(params (string Column, int Max)[] w) =>
w.ToDictionary(x => x.Column, x => x.Max, StringComparer.OrdinalIgnoreCase);
// ── IsTruncationError ────────────────────────────────────────────────────
[Theory]
[InlineData(8152, true)] // classic "String or binary data would be truncated."
[InlineData(2628, true)] // verbose variant (compat level 150+) that names the column
[InlineData(2627, false)] // PK violation
[InlineData(515, false)] // NULL into NOT NULL
[InlineData(8672, false)] // MERGE duplicate source row
[InlineData(0, false)]
[InlineData(-2, false)] // client timeout
public void IsTruncationError_ErrorNumber_OnlyTruncationNumbersMatch(int number, bool expected)
{
Assert.Equal(expected, FdsColumnWidths.IsTruncationError(number));
}
[Fact]
public void IsTruncationError_NonSqlOrNullException_ReturnsFalse()
{
Assert.False(FdsColumnWidths.IsTruncationError((Exception?)null));
Assert.False(FdsColumnWidths.IsTruncationError(new InvalidOperationException("String or binary data would be truncated.")));
}
// ── WidthsSql ────────────────────────────────────────────────────────────
[Fact]
public void WidthsSql_IsParameterised_AndCoversTargetTableAndItsTableType()
{
Assert.Contains("@tablename", FdsColumnWidths.WidthsSql);
Assert.Contains("REPLACE(@tablename, N'__', N'__tt__')", FdsColumnWidths.WidthsSql); // same naming as mfr__updt__ merge proc
Assert.Contains("sys.table_types", FdsColumnWidths.WidthsSql);
Assert.Contains("MIN(", FdsColumnWidths.WidthsSql); // narrower of table vs. type wins
}
// ── ToWidthMap ───────────────────────────────────────────────────────────
private static DataTable WidthRows(params (object Name, object Max)[] rows)
{
var t = new DataTable();
t.Columns.Add("columnname", typeof(object));
t.Columns.Add("maxchars", typeof(object));
foreach (var (n, m) in rows) t.Rows.Add(n, m);
return t;
}
[Fact]
public void ToWidthMap_ValidRows_MapsCaseInsensitively()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 255), ("setid", (short)5)));
Assert.Equal(255, map["note"]);
Assert.Equal(5, map["SETID"]);
}
[Fact]
public void ToWidthMap_SameColumnTwice_KeepsNarrower()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 500), ("note", 255)));
Assert.Equal(255, Assert.Single(map).Value);
}
[Fact]
public void ToWidthMap_InvalidRows_AreSkipped()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(
("", 10), (DBNull.Value, 10), ("A", DBNull.Value), ("B", 0), ("C", -1), ("Ok", 7)));
Assert.Equal(new[] { "Ok" }, map.Keys);
}
[Fact]
public void ToWidthMap_NullOrWrongShape_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.ToWidthMap(null));
var wrong = new DataTable();
wrong.Columns.Add("name");
wrong.Rows.Add("Note");
Assert.Empty(FdsColumnWidths.ToWidthMap(wrong));
}
// ── FindOverflows ────────────────────────────────────────────────────────
[Theory]
[InlineData(0, false)]
[InlineData(254, false)]
[InlineData(255, false)] // exactly the limit fits
[InlineData(256, true)] // one over is rejected by SQL Server
[InlineData(753, true)] // the production ServiceObject note
public void FindOverflows_NoteLengthAroundLimit_ReportsOnlyWhenOver(int length, bool overflows)
{
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', length)));
var result = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("Name", 255), ("setid", 5)));
if (!overflows) { Assert.Empty(result); return; }
var o = Assert.Single(result);
Assert.Equal("Note", o.Column);
Assert.Equal(255, o.MaxAllowed);
Assert.Equal(length, o.LongestLength);
Assert.Equal(1, o.RowsOver);
Assert.Equal(LongNoteServiceObjectId, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralRowsOver_CountsThemAndPointsAtTheLongest()
{
var batch = ServiceObjectBatch((1, new string('a', 300)), (2, "short"), (3, new string('b', 539)), (4, null));
var o = Assert.Single(FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255))));
Assert.Equal(2, o.RowsOver);
Assert.Equal(539, o.LongestLength);
Assert.Equal(3L, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralColumnsOver_ReportsEachColumn()
{
var batch = ServiceObjectBatch((7, new string('n', 300)));
batch.Rows[0]["setid"] = "toolong";
var cols = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("setid", 5))).Select(o => o.Column);
Assert.Equal(new[] { "setid", "Note" }, cols); // DataTable column order
}
[Fact]
public void FindOverflows_MaxOrUnknownColumns_AreNeverReported()
{
// CustomValues is NVARCHAR(MAX) → WidthsSql omits it → no entry in the map.
var batch = ServiceObjectBatch((1, new string('x', 10_000)));
batch.Rows[0]["CustomValues"] = new string('c', 100_000);
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Name", 255))));
}
[Fact]
public void FindOverflows_NonStringColumnWithWidth_IsIgnored()
{
var batch = ServiceObjectBatch((123456789012, "x"));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Id", 2), ("Version", 0))));
}
[Fact]
public void FindOverflows_EmptyBatchOrNoWidths_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch(), Widths(("Note", 1))));
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch((1, new string('x', 999))), Widths()));
}
[Fact]
public void FindOverflows_TableWithoutIdColumn_ReportsWithoutRowId()
{
var t = new DataTable("mfr__*PartnerSet");
t.Columns.Add("Property", typeof(string));
t.Rows.Add(new string('p', 60));
var o = Assert.Single(FdsColumnWidths.FindOverflows(t, Widths(("property", 50))));
Assert.Null(o.LongestRowId);
Assert.Equal("Property: 60 > 50 chars (1 row)", o.ToString());
}
// ── Describe ─────────────────────────────────────────────────────────────
[Fact]
public void Describe_NoOverflow_SaysNoneDetected()
{
Assert.Equal("none detected", FdsColumnWidths.Describe(Array.Empty<FdsColumnWidths.Overflow>()));
}
[Fact]
public void Describe_Overflows_NameColumnLengthLimitAndId()
{
var text = FdsColumnWidths.Describe(new[]
{
new FdsColumnWidths.Overflow("Note", 255, 753, 1, LongNoteServiceObjectId),
new FdsColumnWidths.Overflow("Name", 255, 300, 3, 42L),
});
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355); Name: 300 > 255 chars (3 rows, e.g. Id=42)", text);
}
// ── Real SSDT schema: the widened Note must stay unbounded end to end ────
private static readonly Regex ColumnDecl = new(
@"\[(?<col>[^\]]+)\]\s+\[?n?varchar\]?\s*\(\s*(?<len>\d+|max)\s*\)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>Declared char widths of the bounded string columns in an SSDT object (MAX omitted, like WidthsSql).</summary>
private static Dictionary<string, int> DeclaredWidths(string relativePath, string? onlyWithin = null)
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, relativePath));
if (onlyWithin != null)
{
int start = sql.IndexOf(onlyWithin, StringComparison.OrdinalIgnoreCase);
Assert.True(start >= 0, $"'{onlyWithin}' not found in {relativePath}");
sql = sql[start..sql.IndexOf(");", start, StringComparison.Ordinal)];
}
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in ColumnDecl.Matches(sql))
if (!m.Groups["len"].Value.Equals("max", StringComparison.OrdinalIgnoreCase))
map[m.Groups["col"].Value] = int.Parse(m.Groups["len"].Value);
Assert.NotEmpty(map); // the regex still understands the file
return map;
}
public static IEnumerable<object[]> NoteCarriers() => new[]
{
new object[] { @"Tables\mfr__serviceobjects.sql", null! },
new object[] { @"User Defined Types\mfr__tt__serviceobjects.sql", null! },
new object[] { @"Tables\mfr__companies.sql", null! },
new object[] { @"User Defined Types\mfr__tt__companies.sql", null! },
// INSERT INTO @company SELECT cy.* FROM mfr__companies — must be as wide as the table.
new object[] { @"Stored Procedures\fds__getRequest_details.sql", "DECLARE @company TABLE" },
new object[] { @"Stored Procedures\fds__prepStorno_recreate.sql", "DECLARE @company TABLE" },
};
[Theory]
[MemberData(nameof(NoteCarriers))]
public void SsdtSchema_NoteColumn_IsUnbounded(string relativePath, string? onlyWithin)
{
var widths = DeclaredWidths(relativePath.Replace('\\', Path.DirectorySeparatorChar), onlyWithin);
Assert.False(widths.ContainsKey("Note"), $"{relativePath}: [Note] is bounded ({(widths.TryGetValue("Note", out int w) ? w : 0)}) — mfr notes exceed 255 chars");
}
[Theory]
[InlineData(@"Tables\mfr__serviceobjects.sql", @"User Defined Types\mfr__tt__serviceobjects.sql")]
[InlineData(@"Tables\mfr__companies.sql", @"User Defined Types\mfr__tt__companies.sql")]
public void SsdtSchema_ProductionLongNote_FitsTableAndTableType(string table, string type)
{
var widths = DeclaredWidths(table.Replace('\\', Path.DirectorySeparatorChar));
foreach (var kv in DeclaredWidths(type.Replace('\\', Path.DirectorySeparatorChar)))
widths[kv.Key] = widths.TryGetValue(kv.Key, out int w) ? Math.Min(w, kv.Value) : kv.Value;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, widths));
}
[Fact]
public void SsdtSchema_OldNarrowNote_WouldHaveBeenReported()
{
// Failing-path counterpart: with the pre-fix NVARCHAR(255) the same batch is diagnosed.
var widths = DeclaredWidths(Path.Combine("User Defined Types", "mfr__tt__serviceobjects.sql"));
widths["Note"] = 255;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355)",
FdsColumnWidths.Describe(FdsColumnWidths.FindOverflows(batch, widths)));
}
}
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using Fuchs.intranet;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// ADR 0032: one invoice date for the PDF ("Rechnungsdatum") and the e-invoice issue date —
/// DateFinalized (incl. backdated), else DateCreated — converted from the stored UTC to the German
/// calendar day; plus the backdate request/response parsing used by inv/dpreview and req/sconf.
/// </summary>
public class InvoiceDatesTests
{
private static GenericObjectDictionary Reg(object? finalized, object? created) => new(new Dictionary<string, object?>
{
["DateFinalized"] = finalized,
["DateCreated"] = created
});
[Fact]
public void IssueDate_FinalizedAndCreated_PrefersDateFinalized()
=> Assert.Equal(new DateOnly(2026, 9, 30),
InvoiceDates.IssueDate(Reg(new DateTime(2026, 9, 30, 12, 0, 0), new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_DraftWithoutFinalized_FallsBackToDateCreated()
=> Assert.Equal(new DateOnly(2026, 9, 2),
InvoiceDates.IssueDate(Reg(DBNull.Value, new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_NoRegistration_UsesProvidedUtcNow()
=> Assert.Equal(new DateOnly(2026, 3, 1),
InvoiceDates.IssueDate(null, new DateTime(2026, 3, 1, 10, 0, 0, DateTimeKind.Utc)));
[Theory]
// UTC late evening is already the next German day (summer +2h, winter +1h)
[InlineData("2026-09-30T22:30:00", "2026-10-01")]
[InlineData("2026-09-30T21:59:00", "2026-09-30")]
[InlineData("2026-01-31T23:30:00", "2026-02-01")]
[InlineData("2026-01-31T22:59:00", "2026-01-31")]
// backdated invoices are stored at 12:00 UTC and stay on their day
[InlineData("2026-08-31T12:00:00", "2026-08-31")]
[InlineData("2025-12-31T12:00:00", "2025-12-31")]
public void IssueDate_StoredUtc_ReturnsGermanCalendarDay(string storedUtc, string expected)
=> Assert.Equal(DateOnly.Parse(expected),
InvoiceDates.IssueDate(Reg(DateTime.Parse(storedUtc), null)));
[Theory]
[InlineData("2026-09-30", 2026, 9, 30)]
[InlineData(" 2025-12-31 ", 2025, 12, 31)]
public void ParseBackdate_IsoDate_ReturnsDate(string iso, int y, int m, int d)
=> Assert.Equal(new DateOnly(y, m, d), InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NULL")]
[InlineData("30.09.2026")]
[InlineData("2026-02-30")]
public void ParseBackdate_EmptyOrInvalid_ReturnsNull(string? iso)
=> Assert.Null(InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData("1", true)]
[InlineData("true", true)]
[InlineData("TRUE", true)]
[InlineData("on", true)]
[InlineData("0", false)]
[InlineData("false", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("yes please", false)]
public void IsBackdateRequested_FormValue_ParsesStrictly(string? value, bool expected)
=> Assert.Equal(expected, InvoiceDates.IsBackdateRequested(value));
// fds__setInvoiceFinal result → what req/sconf tells the user. Only 60001 is a backdate rejection;
// a 0-row result is the already-final/sent guard (double submit) and must not invite a duplicate.
[Theory]
[InlineData("", null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData(null, null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData("SQL_exception: backdating not allowed", 60001, 0, InvoiceFinalizeOutcome.BackdateNotAllowed)]
[InlineData("SQL_exception: not authorized", 60000, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: lock not acquired", 60002, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: Cannot insert NULL", 515, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: timeout", null, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("", null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
[InlineData(null, null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
public void ClassifyFinalizeResult_SqlOutcome_MapsToOutcome(string? exception, int? errorNumber, int rows, InvoiceFinalizeOutcome expected)
=> Assert.Equal(expected, InvoiceDates.ClassifyFinalizeResult(exception, errorNumber, rows));
[Fact]
public void ClassifyFinalizeResult_ErrorNumber60001WithoutExceptionText_IsNotARejection()
// the number alone never arrives without a message; a row with no exception is a success
=> Assert.Equal(InvoiceFinalizeOutcome.Finalized, InvoiceDates.ClassifyFinalizeResult("", 60001, 1));
[Theory]
[InlineData(InvoiceFinalizeOutcome.Finalized, "ok")]
[InlineData(InvoiceFinalizeOutcome.BackdateNotAllowed, "rejected")]
[InlineData(InvoiceFinalizeOutcome.AlreadyFinal, "already_final")]
[InlineData(InvoiceFinalizeOutcome.Error, "error")]
public void OutcomeTag_EveryOutcome_HasDistinctTelemetryTag(InvoiceFinalizeOutcome outcome, string expected)
=> Assert.Equal(expected, InvoiceDates.OutcomeTag(outcome));
}
+32
View File
@@ -0,0 +1,32 @@
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>fds__invoice_items.reference_InvId</c> links a CanceledInvoice / IntInvoiceAllocation line to the
/// invoice it cancels / credits. It was never persisted from the editor, so fds__setInvoiceFinal
/// could not record allocations (and a NULL reference aborted its MERGE). The posted <c>ref</c> is
/// normalised by <see cref="InvoiceService.ItemReferenceInvId"/>.
/// </summary>
public class InvoiceItemReferenceTests
{
[Theory]
[InlineData("XH6EH", "XH6EH")]
[InlineData(" 6BOY0 ", "6BOY0")]
[InlineData("12345678901234567890", "12345678901234567890")] // exactly varchar(20)
public void ItemReferenceInvId_ValidId_ReturnsTrimmedId(string raw, string expected)
=> Assert.Equal(expected, InvoiceService.ItemReferenceInvId(raw));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("NULL")]
[InlineData("123456789012345678901")] // longer than the column
[InlineData("AB'; DROP")]
[InlineData("R2025-0244")] // an invoice *number*, not an invoice id
public void ItemReferenceInvId_InvalidOrMissing_ReturnsNull(string? raw)
=> Assert.Null(InvoiceService.ItemReferenceInvId(raw));
}
+39
View File
@@ -0,0 +1,39 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/pget</c> is the first call of both storno paths. Intranet-created invoices carry an
/// alphanumeric Id (<c>fds__fn_invoice_id</c>, e.g. "XH6EH"); rejecting those with 400 left the
/// storno window empty. Only mfr invoice ids (positive integers) may trigger the mfr reset.
/// </summary>
public class InvoicePgetIdTests
{
[Theory]
[InlineData("12345", 12345L)]
[InlineData(" 987654321 ", 987654321L)]
[InlineData("9223372036854775807", long.MaxValue)]
public void TryGetMfrInvoiceId_NumericMfrId_ReturnsTrueWithValue(string raw, long expected)
{
Assert.True(IntranetController.TryGetMfrInvoiceId(raw, out long id));
Assert.Equal(expected, id);
}
[Theory]
[InlineData("XH6EH")] // Intranet invoice id
[InlineData("6BOY0")] // alphanumeric, starts with a digit
[InlineData("00000")] // numeric but not a valid (positive) mfr id
[InlineData("0")]
[InlineData("-5")]
[InlineData("1.5")]
[InlineData("1e3")]
[InlineData("9223372036854775808")] // overflow
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void TryGetMfrInvoiceId_NonMfrId_ReturnsFalse(string? raw)
{
Assert.False(IntranetController.TryGetMfrInvoiceId(raw, out _));
}
}
+298
View File
@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The C# ↔ T-SQL contract, checked against the <c>Fuchs_Database</c> SSDT project (ADR 0036).
/// Every <c>[dbo].[…]</c> object named in a C# string must exist in the project, every
/// <c>EXECUTE</c> must bind against the procedure's declared parameters, and every scalar/table
/// function call must pass the declared number of arguments. None of this is visible to the
/// compiler or to a unit test that stubs the database — it used to fail only at runtime
/// (e.g. <c>inv/datev</c> passing three positional arguments to a four-parameter proc).
/// The SSDT build covers the SQL side of the same contract (unresolved/ambiguous columns).
/// </summary>
public class SqlContractTests
{
internal sealed record SqlObject(string Name, string Kind, IReadOnlyList<(string Name, bool HasDefault)> Parameters);
internal sealed record SqlReference(string File, int Line, string Name, bool IsExecute, bool IsCall, string Args);
private const char JoinedBreak = '\u0001';
private static readonly string[] ScannedProjects = ["Fuchs", "Fuchs_DataService"];
// Signature FdsMfrClient.Update__entitytable uses for its dynamically named [mfr__updt__<table>] call.
private static readonly string[] MfrUpdtSignature = ["@tblname", "@referencetable", "@tgtid"];
private static readonly Regex Declaration = new(
@"CREATE\s+(?:OR\s+ALTER\s+)?(?<kind>PROCEDURE|PROC|FUNCTION|TYPE|TABLE|VIEW)\s+(?:\[?dbo\]?\.)?\[?(?<name>[^\]\s(]+)\]?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ParameterDeclaration = new(
@"@(?<name>\w+)\s+(?:AS\s+)?[\w\[\]\.]+(?:\s*\([^)]*\))?(?:\s+READONLY)?\s*(?<def>=)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex Reference = new(
@"(?<exec>EXEC(?:UTE)?\s+)?(?:@\w+\s*=\s*)?\[?dbo\]?\.\[?(?<name>[A-Za-z][A-Za-z0-9_\-]*)\]?(?<call>\s*\()?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// ── Locating and parsing ──────────────────────────────────────────────────────────────────
private static string RepoRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
if (Directory.Exists(Path.Combine(dir.FullName, "Fuchs_Database", "dbo"))
&& Directory.Exists(Path.Combine(dir.FullName, "Fuchs")))
return dir.FullName;
Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
return "";
}
internal static string StripSqlComments(string sql) =>
Regex.Replace(Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline), @"--[^\n]*", " ");
/// <summary>Parses one SSDT file into the object it declares, or null for non-object scripts.</summary>
internal static SqlObject? ParseObject(string sql)
{
sql = StripSqlComments(sql);
var m = Declaration.Match(sql);
if (!m.Success) return null;
string kind = m.Groups["kind"].Value.ToUpperInvariant() is "PROC" ? "PROCEDURE" : m.Groups["kind"].Value.ToUpperInvariant();
string header = "";
string rest = sql[(m.Index + m.Length)..];
if (kind == "FUNCTION")
{
int open = rest.IndexOf('('), depth = 0;
for (int i = open; open >= 0 && i < rest.Length; i++)
{
depth += rest[i] == '(' ? 1 : rest[i] == ')' ? -1 : 0;
if (depth == 0) { header = rest[(open + 1)..i]; break; }
}
}
else if (kind == "PROCEDURE")
{
header = Regex.Split(rest, @"^\s*AS\b", RegexOptions.Multiline | RegexOptions.IgnoreCase)[0];
header = Regex.Split(header, @"\bWITH\s+(?:EXECUTE|RECOMPILE|ENCRYPTION)", RegexOptions.IgnoreCase)[0];
}
var ps = ParameterDeclaration.Matches(header)
.Select(p => ("@" + p.Groups["name"].Value.ToLowerInvariant(), p.Groups["def"].Success))
.ToList();
return new SqlObject(m.Groups["name"].Value, kind, ps);
}
private static Dictionary<string, SqlObject> LoadProject()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.EnumerateFiles(Path.Combine(RepoRoot(), "Fuchs_Database", "dbo"), "*.sql", SearchOption.AllDirectories))
if (ParseObject(File.ReadAllText(file)) is { } o)
objects[o.Name] = o;
return objects;
}
/// <summary>
/// SQL object references in C# source. Comment lines are dropped (commented-out code and doc
/// comments name objects too) and adjacent concatenated literals are joined, so an
/// EXECUTE split over <c>"…" +</c> lines is seen as one statement.
/// </summary>
internal static IEnumerable<SqlReference> ScanSource(string file, string source)
{
var lines = source.Split('\n');
string code = string.Join('\n', lines.Select(l => l.TrimStart().StartsWith("//", StringComparison.Ordinal) ? "" : l));
code = Regex.Replace(code, @"/\*.*?\*/", m => new string('\n', m.Value.Count(c => c == '\n')), RegexOptions.Singleline);
// Join "a " + "b". The swallowed line breaks become JoinedBreak: it counts as a line for the
// reported line number but does not end the statement's argument list.
code = Regex.Replace(code, "\"(\\s*\\+\\s*)\\$?@?\"", m => new string(JoinedBreak, m.Groups[1].Value.Count(c => c == '\n')));
foreach (Match m in Reference.Matches(code))
{
string name = m.Groups["name"].Value;
if (name.StartsWith("sp_", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sys", StringComparison.OrdinalIgnoreCase))
continue;
int line = code.Take(m.Index).Count(c => c is '\n' or JoinedBreak) + 1;
string tail = code[(m.Index + m.Length)..];
string args;
if (m.Groups["call"].Success)
{
int depth = 1, i = 0;
while (i < tail.Length && depth > 0) depth += tail[i++] switch { '(' => 1, ')' => -1, _ => 0 };
args = tail[..Math.Max(0, i - 1)];
}
else
{
int end = tail.IndexOfAny([';', '"', '\n']);
args = end < 0 ? tail : tail[..end];
}
args = args.Replace(JoinedBreak.ToString(), "", StringComparison.Ordinal).Trim();
yield return new SqlReference(file, line, name, m.Groups["exec"].Success, m.Groups["call"].Success, args);
}
}
private static List<SqlReference> ScanRepository()
{
string root = RepoRoot();
return ScannedProjects
.SelectMany(p => Directory.EnumerateFiles(Path.Combine(root, p), "*.cs", SearchOption.AllDirectories))
.Where(f => !Regex.IsMatch(f, @"[\\/](bin|obj)[\\/]"))
.SelectMany(f => ScanSource(Path.GetRelativePath(root, f), File.ReadAllText(f)))
.ToList();
}
// ── Binding rules ─────────────────────────────────────────────────────────────────────────
/// <summary>Binding problems of an EXECUTE argument list against a procedure's parameters.</summary>
internal static List<string> ExecuteProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool sawNamed = false;
int position = 0;
foreach (var arg in args.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var named = Regex.Match(arg, @"^(@\w+)\s*=");
if (named.Success)
{
sawNamed = true;
string n = named.Groups[1].Value.ToLowerInvariant();
if (!ps.Any(p => p.Name == n)) problems.Add($"unknown parameter {n}");
supplied.Add(n);
continue;
}
if (sawNamed) problems.Add($"positional argument '{arg}' after a named one");
if (position >= ps.Count) { problems.Add($"argument '{arg}' beyond the {ps.Count} declared parameter(s)"); continue; }
supplied.Add(ps[position++].Name);
}
problems.AddRange(ps.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
/// <summary>T-SQL functions take every declared argument (DEFAULT is passed explicitly).</summary>
internal static List<string> CallProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
int count = string.IsNullOrWhiteSpace(args) ? 0 : Regex.Split(args, @",(?![^(]*\))").Length;
return count == ps.Count ? [] : [$"{count} argument(s), function declares {ps.Count}"];
}
internal static List<string> Problems(IEnumerable<SqlReference> refs, IReadOnlyDictionary<string, SqlObject> objects)
{
var problems = new List<string>();
foreach (var r in refs)
{
string at = $"{r.File}:{r.Line} [dbo].[{r.Name}]";
if (!objects.TryGetValue(r.Name, out var o)) { problems.Add($"{at}: not in Fuchs_Database"); continue; }
var found = r.IsExecute && o.Kind == "PROCEDURE" ? ExecuteProblems(r.Args, o.Parameters)
: r.IsCall && o.Kind == "FUNCTION" ? CallProblems(r.Args, o.Parameters)
: [];
problems.AddRange(found.Select(p => $"{at}: {p}"));
}
return problems;
}
// ── The contract, against the real repository ─────────────────────────────────────────────
[Fact]
public void ScanRepository_RealSources_FindsTheKnownCallSites()
{
// Guards the scanner itself: a regex regression that matched nothing would pass every check below.
var refs = ScanRepository();
Assert.True(refs.Count(r => r.IsExecute) >= 50, $"only {refs.Count(r => r.IsExecute)} EXECUTE call sites found");
Assert.Contains(refs, r => r.IsExecute && r.Name.Equals("fds__getDatevExports", StringComparison.OrdinalIgnoreCase));
Assert.Contains(refs, r => r.IsCall && r.Name.Equals("fis_getModuleAuth", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CSharpSqlReferences_AgainstSsdtProject_ExistAndBind()
{
var problems = Problems(ScanRepository(), LoadProject());
Assert.True(problems.Count == 0, "C# ↔ SQL contract violations:\n" + string.Join('\n', problems));
}
[Fact]
public void MfrUpdtProcedures_AllDeclareTheSignatureFdsMfrClientCallsDynamically()
{
var updt = LoadProject().Values.Where(o => o.Name.StartsWith("mfr__updt__", StringComparison.OrdinalIgnoreCase)).ToList();
Assert.NotEmpty(updt);
Assert.All(updt, o => Assert.Equal(MfrUpdtSignature, o.Parameters.Select(p => p.Name)));
}
[Fact]
public void LoggerSqlSinkProcedure_ExistsInSsdtProject()
// Program.cs configures the OCORE MultiLogger sink by name (ADR 0024); a missing proc silently loses every DB log row.
=> Assert.True(LoadProject().TryGetValue("fds__admin_logdebug_ext", out var o) && o.Kind == "PROCEDURE");
// ── The rules themselves: succeeding and failing inputs ───────────────────────────────────
private static readonly (string, bool)[] Datev = [("@tgtdate", false), ("@mode", true), ("@files", false), ("@authuser", false)];
[Theory]
[InlineData("@tgtdate = @tgtdate, @files = 0, @authuser = @authuser")]
[InlineData("@tgtdate, @mode, @files, @authuser")]
[InlineData("@tgtdate, @mode, @files = 1, @authuser = @u")]
public void ExecuteProblems_CorrectlyBoundArguments_None(string args)
=> Assert.Empty(ExecuteProblems(args, Datev));
[Theory]
[InlineData("@tgtdate, @mode, @authuser", "required parameter @authuser")] // the former inv/datev call
[InlineData("@tgtdate = @t, @files = 0, @user = @u", "unknown parameter @user")]
[InlineData("@tgtdate = @t, @mode, @files = 0, @authuser = @u", "after a named one")]
[InlineData("@a, @b, @c, @d, @e", "beyond the 4 declared")]
[InlineData("", "required parameter @tgtdate")]
public void ExecuteProblems_MisboundArguments_Reported(string args, string expected)
=> Assert.Contains(ExecuteProblems(args, Datev), p => p.Contains(expected, StringComparison.Ordinal));
[Theory]
[InlineData("'fds_reports', @authuser", 2, true)]
[InlineData("LEFT(@a, 3), @b", 2, true)]
[InlineData("", 0, true)]
[InlineData("@authuser", 2, false)]
[InlineData("@a, @b, @c", 2, false)]
public void CallProblems_ArgumentCount_ReportedOnlyOnMismatch(string args, int declared, bool ok)
{
var ps = Enumerable.Range(0, declared).Select(i => ($"@p{i}", false)).ToList();
Assert.Equal(ok, CallProblems(args, ps).Count == 0);
}
[Fact]
public void ScanSource_ConcatenatedCommentedAndDocText_OnlyLiveStatementsWithFullArguments()
{
const string src = """
var a = "EXECUTE [dbo].[proc_a] @x, " +
"@y;";
// "EXECUTE [dbo].[proc_commented] @z;"
/// <c>[dbo].[proc_doc]</c>
var b = "SELECT [dbo].[fn_b](@p, LEFT(@q, 2)) AS v";
""";
var refs = ScanSource("x.cs", src).ToList();
Assert.Equal(["proc_a", "fn_b"], refs.Select(r => r.Name));
Assert.Equal("@x, @y", refs[0].Args);
Assert.Equal(1, refs[0].Line);
Assert.Equal("@p, LEFT(@q, 2)", refs[1].Args);
Assert.Equal(5, refs[1].Line);
}
[Fact]
public void Problems_ObjectMissingFromProject_Reported()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
var refs = new[] { new SqlReference("x.cs", 3, "fds__gone", true, false, "@a") };
Assert.Equal(["x.cs:3 [dbo].[fds__gone]: not in Fuchs_Database"], Problems(refs, objects));
}
[Theory]
[InlineData("CREATE PROCEDURE [dbo].[p]\n\t@a as nvarchar(50)\n\t, @b int = 1 -- @c comment\nAS\nBEGIN SELECT 1 END", "PROCEDURE", "@a,@b", "False,True")]
[InlineData("create function [f](@count int = 10)\nRETURNS TABLE AS RETURN SELECT 1 as x", "FUNCTION", "@count", "True")]
[InlineData("CREATE FUNCTION [dbo].[g] (\n)\nRETURNS varchar(10) AS BEGIN RETURN '' END", "FUNCTION", "", "")]
[InlineData("CREATE TYPE [dbo].[tt] AS TABLE ([Id] int)", "TYPE", "", "")]
public void ParseObject_SsdtDeclarations_NameKindAndParameters(string sql, string kind, string names, string defaults)
{
var o = ParseObject(sql)!;
Assert.Equal(kind, o.Kind);
Assert.Equal(names, string.Join(",", o.Parameters.Select(p => p.Name)));
Assert.Equal(defaults, string.Join(",", o.Parameters.Select(p => p.HasDefault)));
}
}