Add ZIP-wrapped CAMT parsing and real bank file tests
Playwright Tests / test (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
- Support parsing camt.052 ZIP archives in CamtParser and BankingService
- Add CamtZipParserTests for ZIP parsing (single/multi/malformed/empty cases)
- Add CamtRealFileTests: integration tests for real bank exports (ZIP, XML, CAMT, MT940, STA) in git-ignored test data dir
- Update .gitignore and add README.md for test data usage
- Copy test data files in Fuchs.Tests.csproj; add .gitkeep
- Refactor BankingService CAMT mapping logic
- Overhaul connection string config: use {username}/{password} tokens, resolve from secrets at runtime, update appsettings and Key Vault keys
This commit is contained in:
+9
-1
@@ -1,4 +1,4 @@
|
|||||||
# Visual Studio
|
# Visual Studio
|
||||||
.vs/
|
.vs/
|
||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
@@ -38,3 +38,11 @@ secrets.cache
|
|||||||
|
|
||||||
# Secret values file (never commit)
|
# Secret values file (never commit)
|
||||||
Scripts/secrets.json
|
Scripts/secrets.json
|
||||||
|
|
||||||
|
# Banking test data (sensitive real bank export files — never commit)
|
||||||
|
Fuchs.Tests/TestData/Banking/*.zip
|
||||||
|
Fuchs.Tests/TestData/Banking/*.xml
|
||||||
|
Fuchs.Tests/TestData/Banking/*.camt
|
||||||
|
Fuchs.Tests/TestData/Banking/*.sta
|
||||||
|
Fuchs.Tests/TestData/Banking/*.mt940
|
||||||
|
Fuchs.Tests/TestData/Banking/*.txt
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO.Compression;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
|
|
||||||
@@ -32,6 +33,14 @@ public sealed class CamtParser
|
|||||||
xml.Contains("camt.05", StringComparison.OrdinalIgnoreCase) ||
|
xml.Contains("camt.05", StringComparison.OrdinalIgnoreCase) ||
|
||||||
xml.Contains("BkToCstmr", StringComparison.OrdinalIgnoreCase);
|
xml.Contains("BkToCstmr", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap content sniff: is this payload a ZIP archive? Some banks deliver
|
||||||
|
/// camt.052 intraday reports wrapped in a ZIP containing one or more XML files.
|
||||||
|
/// Checks for the universal PK magic bytes (0x50 0x4B).
|
||||||
|
/// </summary>
|
||||||
|
public static bool LooksLikeZip(ReadOnlySpan<byte> bytes) =>
|
||||||
|
bytes.Length >= 2 && bytes[0] == 0x50 && bytes[1] == 0x4B;
|
||||||
|
|
||||||
public List<CamtStatement> Parse(Stream stream)
|
public List<CamtStatement> Parse(Stream stream)
|
||||||
{
|
{
|
||||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||||
@@ -40,6 +49,39 @@ public sealed class CamtParser
|
|||||||
|
|
||||||
public List<CamtStatement> Parse(byte[] bytes) => Parse(Encoding.UTF8.GetString(bytes));
|
public List<CamtStatement> Parse(byte[] bytes) => Parse(Encoding.UTF8.GetString(bytes));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses all CAMT XML files found inside a ZIP archive and returns
|
||||||
|
/// the combined list of statements. Non-XML entries and malformed XML
|
||||||
|
/// entries are silently skipped. Used for camt.052 deliveries where the
|
||||||
|
/// bank wraps one or more intraday reports in a single ZIP file.
|
||||||
|
/// </summary>
|
||||||
|
public List<CamtStatement> ParseZip(byte[] bytes)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(bytes);
|
||||||
|
return ParseZip(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ParseZip(byte[])"/>
|
||||||
|
public List<CamtStatement> ParseZip(Stream stream)
|
||||||
|
{
|
||||||
|
var result = new List<CamtStatement>();
|
||||||
|
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||||
|
foreach (var entry in archive.Entries)
|
||||||
|
{
|
||||||
|
if (!entry.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
using var entryStream = entry.Open();
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
entryStream.CopyTo(buffer);
|
||||||
|
var entryBytes = buffer.ToArray();
|
||||||
|
if (!LooksLikeXml(entryBytes))
|
||||||
|
continue;
|
||||||
|
try { result.AddRange(Parse(entryBytes)); }
|
||||||
|
catch (FormatException) { /* skip malformed XML entries */ }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public List<CamtStatement> Parse(string xml)
|
public List<CamtStatement> Parse(string xml)
|
||||||
{
|
{
|
||||||
var result = new List<CamtStatement>();
|
var result = new List<CamtStatement>();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using CAMTParser;
|
using CAMTParser;
|
||||||
@@ -156,6 +157,120 @@ public class CamtParserTests
|
|||||||
=> Assert.Empty(new CamtParser().Parse("<root><foo>bar</foo></root>"));
|
=> Assert.Empty(new CamtParser().Parse("<root><foo>bar</foo></root>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Tests for ZIP-wrapped camt.052 parsing.</summary>
|
||||||
|
public class CamtZipParserTests
|
||||||
|
{
|
||||||
|
// Minimal camt.052 with one credit entry.
|
||||||
|
private const string Camt052Xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.08">
|
||||||
|
<BkToCstmrAcctRpt>
|
||||||
|
<GrpHdr><MsgId>RPT1</MsgId><CreDtTm>2023-01-31T08:00:00</CreDtTm></GrpHdr>
|
||||||
|
<Rpt>
|
||||||
|
<Id>RPT-1</Id>
|
||||||
|
<Acct><Id><IBAN>DE12345678901234567890</IBAN></Id><Ccy>EUR</Ccy></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">100.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2023-01-15</Dt></BookgDt>
|
||||||
|
<ValDt><Dt>2023-01-15</Dt></ValDt>
|
||||||
|
<NtryDtls><TxDtls>
|
||||||
|
<RltdPties><Dbtr><Nm>Test Sender</Nm></Dbtr></RltdPties>
|
||||||
|
<RmtInf><Ustrd>Test Zahlung</Ustrd></RmtInf>
|
||||||
|
</TxDtls></NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Rpt>
|
||||||
|
</BkToCstmrAcctRpt>
|
||||||
|
</Document>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static byte[] BuildZip(params (string name, string content)[] entries)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||||
|
{
|
||||||
|
foreach (var (name, content) in entries)
|
||||||
|
{
|
||||||
|
var entry = archive.CreateEntry(name);
|
||||||
|
using var s = entry.Open();
|
||||||
|
var b = Encoding.UTF8.GetBytes(content);
|
||||||
|
s.Write(b, 0, b.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LooksLikeZip ──────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void LooksLikeZip_ZipBytes_True()
|
||||||
|
{
|
||||||
|
var zip = BuildZip(("dummy.xml", Camt052Xml));
|
||||||
|
Assert.True(CamtParser.LooksLikeZip(zip));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LooksLikeZip_XmlBytes_False()
|
||||||
|
=> Assert.False(CamtParser.LooksLikeZip(Encoding.UTF8.GetBytes(Camt052Xml)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LooksLikeZip_Empty_False()
|
||||||
|
=> Assert.False(CamtParser.LooksLikeZip(Array.Empty<byte>()));
|
||||||
|
|
||||||
|
// ── ParseZip ──────────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void ParseZip_SingleXmlEntry_ReturnsCamt052Statement()
|
||||||
|
{
|
||||||
|
var zip = BuildZip(("camt052.xml", Camt052Xml));
|
||||||
|
var statements = new CamtParser().ParseZip(zip);
|
||||||
|
var stmt = Assert.Single(statements);
|
||||||
|
Assert.Equal(CamtDocumentType.Camt052, stmt.DocumentType);
|
||||||
|
Assert.Equal("DE12345678901234567890", stmt.AccountIdentification);
|
||||||
|
Assert.Single(stmt.Entries);
|
||||||
|
Assert.Equal(100m, stmt.Entries[0].Amount);
|
||||||
|
Assert.Equal(CamtDebitCreditMark.Credit, stmt.Entries[0].Mark);
|
||||||
|
Assert.Equal("Test Sender", stmt.Entries[0].CounterpartyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseZip_MultipleXmlEntries_MergesAllStatements()
|
||||||
|
{
|
||||||
|
var zip = BuildZip(
|
||||||
|
("report_a.xml", Camt052Xml),
|
||||||
|
("report_b.xml", Camt052Xml));
|
||||||
|
var statements = new CamtParser().ParseZip(zip);
|
||||||
|
Assert.Equal(2, statements.Count);
|
||||||
|
Assert.All(statements, s => Assert.Equal(CamtDocumentType.Camt052, s.DocumentType));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseZip_NonXmlEntriesSkipped_NoException()
|
||||||
|
{
|
||||||
|
var zip = BuildZip(
|
||||||
|
("readme.txt", "This is not XML"),
|
||||||
|
("camt052.xml", Camt052Xml));
|
||||||
|
var statements = new CamtParser().ParseZip(zip);
|
||||||
|
Assert.Single(statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseZip_EmptyZip_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var zip = BuildZip();
|
||||||
|
Assert.Empty(new CamtParser().ParseZip(zip));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseZip_MalformedXmlEntry_SkippedGracefully()
|
||||||
|
{
|
||||||
|
var zip = BuildZip(
|
||||||
|
("broken.xml", "<?xml version=\"1.0\"?><broken"),
|
||||||
|
("good.xml", Camt052Xml));
|
||||||
|
var statements = new CamtParser().ParseZip(zip);
|
||||||
|
// Malformed entry is skipped; the valid entry is returned.
|
||||||
|
Assert.Single(statements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>BankingService routing: CAMT and MT940 both land in the same DataTable.</summary>
|
/// <summary>BankingService routing: CAMT and MT940 both land in the same DataTable.</summary>
|
||||||
public class BankingDualFormatTests
|
public class BankingDualFormatTests
|
||||||
{
|
{
|
||||||
@@ -220,4 +335,46 @@ public class BankingDualFormatTests
|
|||||||
Assert.Equal(2, t.Columns.Count);
|
Assert.Equal(2, t.Columns.Count);
|
||||||
Assert.Equal(1, t.Rows.Count);
|
Assert.Equal(1, t.Rows.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseToDatatable_CamtZip_RoutesToCamtZipAndMapsColumns()
|
||||||
|
{
|
||||||
|
const string camt052Xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.08">
|
||||||
|
<BkToCstmrAcctRpt>
|
||||||
|
<GrpHdr><MsgId>RPT1</MsgId><CreDtTm>2023-01-31T08:00:00</CreDtTm></GrpHdr>
|
||||||
|
<Rpt>
|
||||||
|
<Acct><Id><IBAN>DE12345678901234567890</IBAN></Id><Ccy>EUR</Ccy></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">250.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2023-01-15</Dt></BookgDt>
|
||||||
|
<NtryDtls><TxDtls>
|
||||||
|
<RltdPties><Dbtr><Nm>ZIP Sender</Nm></Dbtr></RltdPties>
|
||||||
|
<RmtInf><Ustrd>ZIP Zahlung</Ustrd></RmtInf>
|
||||||
|
</TxDtls></NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Rpt>
|
||||||
|
</BkToCstmrAcctRpt>
|
||||||
|
</Document>
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true))
|
||||||
|
{
|
||||||
|
var entry = archive.CreateEntry("camt052.xml");
|
||||||
|
using var es = entry.Open();
|
||||||
|
var b = Encoding.UTF8.GetBytes(camt052Xml);
|
||||||
|
es.Write(b, 0, b.Length);
|
||||||
|
}
|
||||||
|
ms.Position = 0;
|
||||||
|
|
||||||
|
var t = Svc.ParseToDatatable(ms);
|
||||||
|
Assert.Equal(1, t.Rows.Count);
|
||||||
|
Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]);
|
||||||
|
Assert.Equal(250.00m, t.Rows[0]["Amount"]);
|
||||||
|
Assert.Equal("C", t.Rows[0]["DebitCreditMark"]);
|
||||||
|
Assert.Equal("ZIP Sender", t.Rows[0]["NameOfPayer"]);
|
||||||
|
Assert.Equal("ZIP Zahlung", t.Rows[0]["SepaRemittanceInformation"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using CAMTParser;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Smoke tests against real bank export files placed under TestData/Banking/.
|
||||||
|
/// The files are git-ignored (sensitive financial data — see README.md there).
|
||||||
|
///
|
||||||
|
/// Placement:
|
||||||
|
/// .zip → camt.052 archive from the bank (contains one or more XML reports)
|
||||||
|
/// .xml → camt.053 (or camt.052/054) plain XML
|
||||||
|
///
|
||||||
|
/// When no files are present the tests pass trivially (loop body never runs).
|
||||||
|
/// Once a file is placed and the project is rebuilt it is automatically tested.
|
||||||
|
///
|
||||||
|
/// Note: xUnit 2.x treats [Theory] with no data cases as a failure, so these
|
||||||
|
/// tests use [Fact] + a loop over the file discovery results instead.
|
||||||
|
/// </summary>
|
||||||
|
public class CamtRealFileTests
|
||||||
|
{
|
||||||
|
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
|
||||||
|
private const string TestDataDir = "TestData/Banking";
|
||||||
|
|
||||||
|
// ── File discovery ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IEnumerable<string> ZipFiles()
|
||||||
|
=> FilesWithExtension(".zip");
|
||||||
|
|
||||||
|
private static IEnumerable<string> XmlFiles()
|
||||||
|
=> FilesWithExtension(".xml", ".camt");
|
||||||
|
|
||||||
|
private static IEnumerable<string> AllBankingFiles()
|
||||||
|
=> FilesWithExtension(".zip", ".xml", ".camt", ".sta", ".mt940");
|
||||||
|
|
||||||
|
private static IEnumerable<string> FilesWithExtension(params string[] extensions)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(TestDataDir))
|
||||||
|
return Enumerable.Empty<string>();
|
||||||
|
return Directory.EnumerateFiles(TestDataDir)
|
||||||
|
.Where(f => extensions.Any(ext =>
|
||||||
|
f.EndsWith(ext, System.StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ZIP files (camt.052 bank reports wrapped in a ZIP) ───────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseZipFiles_EachReturnsAtLeastOneStatement()
|
||||||
|
{
|
||||||
|
foreach (var filePath in ZipFiles())
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(filePath);
|
||||||
|
Assert.True(CamtParser.LooksLikeZip(bytes),
|
||||||
|
$"{filePath}: expected ZIP magic bytes (PK).");
|
||||||
|
|
||||||
|
var statements = new CamtParser().ParseZip(bytes);
|
||||||
|
Assert.True(statements.Count > 0,
|
||||||
|
$"{filePath}: no CAMT statements found in ZIP.");
|
||||||
|
|
||||||
|
foreach (var stmt in statements)
|
||||||
|
AssertValidStatement(stmt, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseZipFiles_BankingService_EachReturnsRows()
|
||||||
|
{
|
||||||
|
foreach (var filePath in ZipFiles())
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(filePath);
|
||||||
|
var table = Svc.ParseToDatatable(stream);
|
||||||
|
Assert.True(table.Rows.Count > 0,
|
||||||
|
$"{filePath}: BankingService.ParseToDatatable returned no rows.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XML files (camt.053 / camt.052 plain / camt.054) ─────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseXmlFiles_EachReturnsAtLeastOneStatement()
|
||||||
|
{
|
||||||
|
foreach (var filePath in XmlFiles())
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(filePath);
|
||||||
|
Assert.True(CamtParser.LooksLikeXml(bytes),
|
||||||
|
$"{filePath}: expected XML content.");
|
||||||
|
Assert.True(CamtParser.LooksLikeCamt(Encoding.UTF8.GetString(bytes)),
|
||||||
|
$"{filePath}: XML does not look like a CAMT document (missing 'camt.05' or 'BkToCstmr').");
|
||||||
|
|
||||||
|
var statements = new CamtParser().Parse(bytes);
|
||||||
|
Assert.True(statements.Count > 0,
|
||||||
|
$"{filePath}: no CAMT statements found.");
|
||||||
|
|
||||||
|
foreach (var stmt in statements)
|
||||||
|
AssertValidStatement(stmt, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseXmlFiles_BankingService_EachReturnsRows()
|
||||||
|
{
|
||||||
|
foreach (var filePath in XmlFiles())
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(filePath);
|
||||||
|
var table = Svc.ParseToDatatable(stream);
|
||||||
|
Assert.True(table.Rows.Count > 0,
|
||||||
|
$"{filePath}: BankingService.ParseToDatatable returned no rows.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── All files: DocumentType recognised ───────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseAllBankingFiles_DocumentTypeIsKnown()
|
||||||
|
{
|
||||||
|
foreach (var filePath in AllBankingFiles())
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(filePath);
|
||||||
|
List<CamtStatement> statements;
|
||||||
|
if (CamtParser.LooksLikeZip(bytes))
|
||||||
|
statements = new CamtParser().ParseZip(bytes);
|
||||||
|
else if (CamtParser.LooksLikeXml(bytes))
|
||||||
|
statements = new CamtParser().Parse(bytes);
|
||||||
|
else
|
||||||
|
continue; // MT940 / other format — not a CAMT file
|
||||||
|
|
||||||
|
Assert.NotEmpty(statements);
|
||||||
|
Assert.All(statements, stmt =>
|
||||||
|
Assert.NotEqual(CamtDocumentType.Unknown, stmt.DocumentType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared assertions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void AssertValidStatement(CamtStatement stmt, string filePath)
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(stmt.AccountIdentification),
|
||||||
|
$"{filePath}: AccountIdentification must not be blank.");
|
||||||
|
Assert.True(stmt.Entries.Count > 0,
|
||||||
|
$"{filePath}: statement for account '{stmt.AccountIdentification}' has no entries.");
|
||||||
|
|
||||||
|
Assert.All(stmt.Entries, e =>
|
||||||
|
{
|
||||||
|
Assert.True(e.Amount is > 0,
|
||||||
|
$"{filePath}: entry amount must be positive, got {e.Amount}.");
|
||||||
|
Assert.True(e.EntryDate.HasValue || e.ValueDate.HasValue,
|
||||||
|
$"{filePath}: entry must have at least one date (EntryDate or ValueDate).");
|
||||||
|
Assert.True(
|
||||||
|
e.Mark is CamtDebitCreditMark.Credit
|
||||||
|
or CamtDebitCreditMark.Debit
|
||||||
|
or CamtDebitCreditMark.ReverseCredit
|
||||||
|
or CamtDebitCreditMark.ReverseDebit,
|
||||||
|
$"{filePath}: entry has invalid DebitCreditMark '{e.Mark}'.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,4 +30,13 @@
|
|||||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Real bank export files placed here are copied to the test output dir.
|
||||||
|
The files themselves are git-ignored (sensitive data); only README.md
|
||||||
|
and .gitkeep are tracked. -->
|
||||||
|
<None Include="TestData\Banking\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Banking Test Samples
|
||||||
|
|
||||||
|
Lege echte Bankexport-Dateien hier ab, um die Integrations-Smoketests in
|
||||||
|
`CamtRealFileTests` zu aktivieren.
|
||||||
|
|
||||||
|
> **Diese Dateien werden nie committed** (sensible Finanzdaten — gilt für alle
|
||||||
|
> `.zip`, `.xml`, `.camt`, `.sta` und `.mt940` in diesem Verzeichnis).
|
||||||
|
|
||||||
|
## Wo ablegen
|
||||||
|
|
||||||
|
| Dateiformat | Erwarteter Inhalt | Beispieldateiname |
|
||||||
|
|-------------|------------------|-------------------|
|
||||||
|
| `.zip` | camt.052-ZIP-Archiv der Bank (enthält XML-Dateien) | `camt052_jan2025.zip` |
|
||||||
|
| `.xml` | camt.053 (oder camt.052 / camt.054) als plain XML | `camt053_jan2025.xml` |
|
||||||
|
| `.camt` | alternativer Dateiname für CAMT-XML | `umsaetze.camt` |
|
||||||
|
|
||||||
|
Jede `.zip`- und `.xml`/`.camt`-Datei wird **automatisch** gefunden und getestet —
|
||||||
|
keine Anpassung am Code nötig.
|
||||||
|
|
||||||
|
## Was wird geprüft
|
||||||
|
|
||||||
|
- ZIP-Dateien: `CamtParser.ParseZip` liefert ≥ 1 Statement mit IBAN und Einträgen
|
||||||
|
- XML-Dateien: `CamtParser.Parse` erkennt das Dokument als CAMT und liefert valide Einträge
|
||||||
|
- Beide: `BankingService.ParseToDatatable` liefert ≥ 1 Zeile in der DataTable
|
||||||
|
- Dokumenttyp ist bekannt (nicht `Unknown`)
|
||||||
|
- Jeder Eintrag hat Betrag > 0 und mindestens ein Datum
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
|
||||||
|
Sind keine Dateien vorhanden, generiert xUnit 0 Testfälle (die Tests tauchen
|
||||||
|
nicht im Runner auf). Sobald du eine Datei ablegst und den Build neu startest,
|
||||||
|
erscheinen die Tests automatisch.
|
||||||
@@ -36,6 +36,9 @@ public class Program
|
|||||||
// Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
|
// Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
|
||||||
builder.AddSecretManagement();
|
builder.AddSecretManagement();
|
||||||
|
|
||||||
|
// Assemble connection strings from templates + resolved credentials
|
||||||
|
AssembleConnectionStrings(builder.Configuration);
|
||||||
|
|
||||||
// Initialize the Fuchs intranet singleton with configuration
|
// Initialize the Fuchs intranet singleton with configuration
|
||||||
FuchsOcmsIntranet.Initialize(builder.Configuration);
|
FuchsOcmsIntranet.Initialize(builder.Configuration);
|
||||||
|
|
||||||
@@ -163,4 +166,40 @@ public class Program
|
|||||||
// One-time application start
|
// One-time application start
|
||||||
Fuchs_intranet.SetPdfLicense();
|
Fuchs_intranet.SetPdfLicense();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces {username} and {password} tokens in connection string templates with the
|
||||||
|
/// resolved credential secrets, then overrides the config entries in-place.
|
||||||
|
/// When appsettings.Development.json supplies a complete connection string (no tokens),
|
||||||
|
/// the replace is a no-op and the original value is preserved.
|
||||||
|
/// </summary>
|
||||||
|
private static void AssembleConnectionStrings(ConfigurationManager config)
|
||||||
|
{
|
||||||
|
const string userToken = "{username}";
|
||||||
|
const string passToken = "{password}";
|
||||||
|
|
||||||
|
(string csName, string userKey, string passKey)[] pairs =
|
||||||
|
[
|
||||||
|
("ocms_ConnectionString", "ConnectionStrings:ocms_username", "ConnectionStrings:ocms_password"),
|
||||||
|
("fuchs_fds_ConnectionString", "ConnectionStrings:fuchs_fds_username", "ConnectionStrings:fuchs_fds_password"),
|
||||||
|
];
|
||||||
|
|
||||||
|
var overrides = new Dictionary<string, string?>();
|
||||||
|
foreach (var (csName, userKey, passKey) in pairs)
|
||||||
|
{
|
||||||
|
var template = config.GetConnectionString(csName);
|
||||||
|
if (string.IsNullOrEmpty(template)) continue;
|
||||||
|
if (!template.Contains(userToken, StringComparison.Ordinal) &&
|
||||||
|
!template.Contains(passToken, StringComparison.Ordinal)) continue;
|
||||||
|
|
||||||
|
var user = config[userKey] ?? "";
|
||||||
|
var pass = config[passKey] ?? "";
|
||||||
|
overrides[$"ConnectionStrings:{csName}"] = template
|
||||||
|
.Replace(userToken, user, StringComparison.Ordinal)
|
||||||
|
.Replace(passToken, pass, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides.Count > 0)
|
||||||
|
config.AddInMemoryCollection(overrides);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using CAMTParser;
|
using CAMTParser;
|
||||||
using Fuchs.Observability;
|
using Fuchs.Observability;
|
||||||
@@ -45,10 +45,17 @@ public class BankingService : IBankingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
string format;
|
string format;
|
||||||
if (CamtParser.LooksLikeXml(bytes))
|
if (CamtParser.LooksLikeZip(bytes))
|
||||||
|
{
|
||||||
|
format = "camt.zip";
|
||||||
|
try { MapCamtEntries(tbl, new CamtParser().ParseZip(bytes)); }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "CAMT ZIP statement parse failed."); }
|
||||||
|
}
|
||||||
|
else if (CamtParser.LooksLikeXml(bytes))
|
||||||
{
|
{
|
||||||
format = "camt";
|
format = "camt";
|
||||||
FillFromCamt(tbl, bytes);
|
try { MapCamtEntries(tbl, new CamtParser().Parse(bytes)); }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); }
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -129,16 +136,13 @@ public class BankingService : IBankingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
|
// ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
|
||||||
private void FillFromCamt(DataTable tbl, byte[] bytes)
|
private void MapCamtEntries(DataTable tbl, List<CamtStatement> statements)
|
||||||
{
|
{
|
||||||
void SetNfo(DataRow nr, string key, object? value)
|
void SetNfo(DataRow nr, string key, object? value)
|
||||||
{
|
{
|
||||||
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var statements = new CamtParser().Parse(bytes);
|
|
||||||
foreach (var stmt in statements)
|
foreach (var stmt in statements)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue;
|
if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue;
|
||||||
@@ -175,8 +179,6 @@ public class BankingService : IBankingService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DataTable BuildDefaultSchema()
|
private static DataTable BuildDefaultSchema()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"ocms_ConnectionString": "Server=localhost;Database=ocms;User Id=DEV_USERNAME;Password=DEV_PASSWORD;TrustServerCertificate=True;",
|
||||||
|
"fuchs_fds_ConnectionString": "Server=localhost;Database=fuchs_fds;User Id=DEV_USERNAME;Password=DEV_PASSWORD;TrustServerCertificate=True;"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Debug",
|
"Default": "Debug",
|
||||||
|
|||||||
+10
-4
@@ -5,8 +5,10 @@
|
|||||||
"CacheFilePath": "secrets.cache",
|
"CacheFilePath": "secrets.cache",
|
||||||
"SyncIntervalHours": 6,
|
"SyncIntervalHours": 6,
|
||||||
"ManagedSecretKeys": [
|
"ManagedSecretKeys": [
|
||||||
"ConnectionStrings--ocms-ConnectionString",
|
"ConnectionStrings--ocms-username",
|
||||||
"ConnectionStrings--fuchs-fds-ConnectionString",
|
"ConnectionStrings--ocms-password",
|
||||||
|
"ConnectionStrings--fuchs-fds-username",
|
||||||
|
"ConnectionStrings--fuchs-fds-password",
|
||||||
"Fuchs--SMS-APIKey",
|
"Fuchs--SMS-APIKey",
|
||||||
"Fuchs--Mailer--Token",
|
"Fuchs--Mailer--Token",
|
||||||
"Fuchs--fuchs-captcha-TOTP",
|
"Fuchs--fuchs-captcha-TOTP",
|
||||||
@@ -21,8 +23,12 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"ocms_ConnectionString": "MANAGED_BY_KEYVAULT",
|
"ocms_ConnectionString": "Server=DB_SERVER;Database=ocms;User Id={username};Password={password};TrustServerCertificate=True;",
|
||||||
"fuchs_fds_ConnectionString": "MANAGED_BY_KEYVAULT"
|
"fuchs_fds_ConnectionString": "Server=DB_SERVER;Database=fuchs_fds;User Id={username};Password={password};TrustServerCertificate=True;",
|
||||||
|
"ocms_username": "MANAGED_BY_KEYVAULT",
|
||||||
|
"ocms_password": "MANAGED_BY_KEYVAULT",
|
||||||
|
"fuchs_fds_username": "MANAGED_BY_KEYVAULT",
|
||||||
|
"fuchs_fds_password": "MANAGED_BY_KEYVAULT"
|
||||||
},
|
},
|
||||||
"Fuchs": {
|
"Fuchs": {
|
||||||
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
|
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
|
||||||
|
|||||||
Reference in New Issue
Block a user