Add ZIP-wrapped CAMT parsing and real bank file tests
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:
Stefan
2026-07-01 22:06:29 +02:00
parent 8ecf97ed29
commit 3035ef1719
11 changed files with 507 additions and 45 deletions
+158 -1
View File
@@ -1,6 +1,7 @@
using System;
using System;
using System.Data;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using CAMTParser;
@@ -156,6 +157,120 @@ public class CamtParserTests
=> 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>
public class BankingDualFormatTests
{
@@ -220,4 +335,46 @@ public class BankingDualFormatTests
Assert.Equal(2, t.Columns.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"]);
}
}
+162
View File
@@ -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}'.");
});
}
}
+9
View File
@@ -30,4 +30,13 @@
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
</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>
+1
View File
@@ -0,0 +1 @@

+32
View File
@@ -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.