feat: Implement mfr truncation fix and enhance invoice handling
Playwright Tests / test (push) Canceled after 0s
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:
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 _));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user