From ca91af96828c1ab3c30f3625c108170cb4395f5e Mon Sep 17 00:00:00 2001 From: metacube Date: Thu, 16 Apr 2026 09:45:10 +0200 Subject: [PATCH] unittests --- .../Services/ManualExcelImportService.cs | 4 +- .../ConfigTransferServiceTests.cs | 297 ++++++++++++++++++ .../ManagementCockpitServiceTests.cs | 195 ++++++++++++ .../ManualExcelImportServiceTests.cs | 221 +++++++++++++ .../RecordTransformationServiceTests.cs | 144 +++++++++ .../TrafagSalesExporter.Tests.csproj | 25 ++ .../TransformationCatalogTests.cs | 28 ++ .../TransformationStrategiesTests.cs | 50 +++ .../TrafagSalesExporter.csproj | 7 + TrafagSalesExporter/TrafagSalesExporter.sln | 28 +- 10 files changed, 996 insertions(+), 3 deletions(-) create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/TrafagSalesExporter.Tests.csproj create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationCatalogTests.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationStrategiesTests.cs diff --git a/TrafagSalesExporter/Services/ManualExcelImportService.cs b/TrafagSalesExporter/Services/ManualExcelImportService.cs index cb28db5..1accfc0 100644 --- a/TrafagSalesExporter/Services/ManualExcelImportService.cs +++ b/TrafagSalesExporter/Services/ManualExcelImportService.cs @@ -134,12 +134,12 @@ public class ManualExcelImportService : IManualExcelImportService if (string.IsNullOrWhiteSpace(text)) return 0m; - if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out decimalValue)) - return decimalValue; if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-CH"), out decimalValue)) return decimalValue; if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-DE"), out decimalValue)) return decimalValue; + if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out decimalValue)) + return decimalValue; return 0m; } diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs new file mode 100644 index 0000000..123e02e --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs @@ -0,0 +1,297 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class ConfigTransferServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly TestDbContextFactory _dbFactory; + private readonly ConfigTransferService _service; + + public ConfigTransferServiceTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using (var db = new AppDbContext(options)) + { + db.Database.EnsureCreated(); + } + + _dbFactory = new TestDbContextFactory(options); + _service = new ConfigTransferService(_dbFactory); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public async Task ExportJsonAsync_Excludes_Secrets_When_Requested() + { + await SeedExportConfigurationAsync(); + + var json = await _service.ExportJsonAsync(includeSecrets: false); + var package = JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Package missing."); + + Assert.False(package.IncludesSecrets); + Assert.NotNull(package.ExportSettings); + Assert.Null(package.ExportSettings.SapUsername); + Assert.Null(package.ExportSettings.SapPassword); + Assert.NotNull(package.SharePointConfig); + Assert.Null(package.SharePointConfig.ClientSecret); + + var server = Assert.Single(package.HanaServers); + Assert.Null(server.Username); + Assert.Null(server.Password); + + var site = Assert.Single(package.Sites); + Assert.Null(site.UsernameOverride); + Assert.Null(site.PasswordOverride); + Assert.Equal("C:\\imports\\manual.xlsx", site.ManualImportFilePath); + + var rule = Assert.Single(package.FieldTransformationRules); + Assert.Equal("Record", rule.RuleScope); + Assert.Equal("FirstNonEmpty", rule.TransformationType); + } + + [Fact] + public async Task ImportJsonAsync_Preserves_Existing_Secrets_When_Import_Excludes_Secrets() + { + await SeedExistingSecretsAsync(); + + var package = new ConfigTransferPackage + { + IncludesSecrets = false, + SharePointConfig = new ConfigTransferSharePoint + { + SiteUrl = "https://new.sharepoint.local", + ExportFolder = "/new", + TenantId = "new-tenant", + ClientId = "new-client", + ClientSecret = null + }, + ExportSettings = new ConfigTransferExportSettings + { + DateFilter = "2026-01-01", + TimerHour = 5, + TimerMinute = 30, + TimerEnabled = false, + DebugLoggingEnabled = true, + LocalSiteExportFolder = "D:\\site", + LocalConsolidatedExportFolder = "D:\\consolidated", + SapUsername = null, + SapPassword = null, + Bi1Username = null, + Bi1Password = null, + SageUsername = null, + SagePassword = null + }, + HanaServers = + [ + new ConfigTransferHanaServer + { + Key = "server-1", + Name = "Server A", + Host = "hana-a", + Port = 30015, + Username = null, + Password = null, + DatabaseName = "DB1", + UseSsl = true, + ValidateCertificate = false, + AdditionalParams = "x=y" + } + ], + Sites = + [ + new ConfigTransferSite + { + Key = "site-1", + HanaServerKey = "server-1", + Schema = "schema_a", + TSC = "TRCH", + Land = "Schweiz", + SourceSystem = "MANUAL_EXCEL", + UsernameOverride = null, + PasswordOverride = null, + ManualImportFilePath = "D:\\manual\\trch.xlsx", + ManualImportLastUploadedAtUtc = new DateTime(2026, 4, 16, 8, 0, 0, DateTimeKind.Utc), + IsActive = true + } + ], + FieldTransformationRules = + [ + new FieldTransformationRule + { + SourceSystem = "MANUAL_EXCEL", + SourceField = "", + TargetField = nameof(SalesRecord.CustomerName), + TransformationType = "FirstNonEmpty", + RuleScope = "Record", + Argument = "CustomerName|SupplierName|Name", + SortOrder = 10, + IsActive = true + } + ] + }; + + await _service.ImportJsonAsync(JsonSerializer.Serialize(package)); + + await using var db = await _dbFactory.CreateDbContextAsync(); + var settings = await db.ExportSettings.SingleAsync(); + var sharePoint = await db.SharePointConfigs.SingleAsync(); + var server = await db.HanaServers.SingleAsync(); + var site = await db.Sites.SingleAsync(); + var rule = await db.FieldTransformationRules.SingleAsync(); + + Assert.Equal("preserved-sap-user", settings.SapUsername); + Assert.Equal("preserved-sap-password", settings.SapPassword); + Assert.Equal("preserved-bi1-user", settings.Bi1Username); + Assert.Equal("preserved-sage-password", settings.SagePassword); + + Assert.Equal("preserved-sharepoint-secret", sharePoint.ClientSecret); + Assert.Equal("new-tenant", sharePoint.TenantId); + + Assert.Equal("preserved-server-user", server.Username); + Assert.Equal("preserved-server-password", server.Password); + Assert.True(server.UseSsl); + + Assert.Equal("preserved-site-user", site.UsernameOverride); + Assert.Equal("preserved-site-password", site.PasswordOverride); + Assert.Equal("D:\\manual\\trch.xlsx", site.ManualImportFilePath); + Assert.Equal("MANUAL_EXCEL", site.SourceSystem); + + Assert.Equal("Record", rule.RuleScope); + Assert.Equal("FirstNonEmpty", rule.TransformationType); + } + + private async Task SeedExportConfigurationAsync() + { + await using var db = await _dbFactory.CreateDbContextAsync(); + db.SharePointConfigs.Add(new SharePointConfig + { + SiteUrl = "https://sharepoint.local", + ExportFolder = "/exports", + TenantId = "tenant", + ClientId = "client", + ClientSecret = "secret" + }); + db.ExportSettings.Add(new ExportSettings + { + SapUsername = "sap-user", + SapPassword = "sap-password", + Bi1Username = "bi1-user", + Bi1Password = "bi1-password", + SageUsername = "sage-user", + SagePassword = "sage-password" + }); + db.HanaServers.Add(new HanaServer + { + Id = 1, + Name = "Server A", + Host = "hana-a", + Port = 30015, + Username = "server-user", + Password = "server-password", + DatabaseName = "DB1" + }); + db.Sites.Add(new Site + { + Id = 1, + HanaServerId = 1, + Schema = "schema_a", + TSC = "TRCH", + Land = "Schweiz", + SourceSystem = "MANUAL_EXCEL", + UsernameOverride = "site-user", + PasswordOverride = "site-password", + ManualImportFilePath = "C:\\imports\\manual.xlsx", + ManualImportLastUploadedAtUtc = new DateTime(2026, 4, 16, 7, 0, 0, DateTimeKind.Utc), + IsActive = true + }); + db.FieldTransformationRules.Add(new FieldTransformationRule + { + SourceSystem = "MANUAL_EXCEL", + SourceField = "", + TargetField = nameof(SalesRecord.CustomerName), + TransformationType = "FirstNonEmpty", + RuleScope = "Record", + Argument = "CustomerName|SupplierName|Name", + SortOrder = 10, + IsActive = true + }); + await db.SaveChangesAsync(); + } + + private async Task SeedExistingSecretsAsync() + { + await using var db = await _dbFactory.CreateDbContextAsync(); + db.SharePointConfigs.Add(new SharePointConfig + { + SiteUrl = "https://old.sharepoint.local", + ExportFolder = "/old", + TenantId = "old-tenant", + ClientId = "old-client", + ClientSecret = "preserved-sharepoint-secret" + }); + db.ExportSettings.Add(new ExportSettings + { + SapUsername = "preserved-sap-user", + SapPassword = "preserved-sap-password", + Bi1Username = "preserved-bi1-user", + Bi1Password = "preserved-bi1-password", + SageUsername = "preserved-sage-user", + SagePassword = "preserved-sage-password" + }); + db.HanaServers.Add(new HanaServer + { + Id = 1, + Name = "Server A", + Host = "hana-a", + Port = 30015, + Username = "preserved-server-user", + Password = "preserved-server-password", + DatabaseName = "DB1" + }); + db.Sites.Add(new Site + { + Id = 1, + HanaServerId = 1, + Schema = "schema_a", + TSC = "TRCH", + Land = "Schweiz", + SourceSystem = "MANUAL_EXCEL", + UsernameOverride = "preserved-site-user", + PasswordOverride = "preserved-site-password", + IsActive = true + }); + await db.SaveChangesAsync(); + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public AppDbContext CreateDbContext() => new(_options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AppDbContext(_options)); + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs new file mode 100644 index 0000000..bc66bdf --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs @@ -0,0 +1,195 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class ManagementCockpitServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly TestDbContextFactory _dbFactory; + private readonly ManagementCockpitService _service; + + public ManagementCockpitServiceTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using (var db = new AppDbContext(options)) + { + db.Database.EnsureCreated(); + if (!db.Sites.Any()) + { + db.Sites.Add(new Site + { + Id = 1, + HanaServerId = null, + Schema = "test", + TSC = "TEST", + Land = "Testland", + SourceSystem = "SAP", + IsActive = true + }); + db.SaveChanges(); + } + } + + _dbFactory = new TestDbContextFactory(options); + _service = new ManagementCockpitService(_dbFactory); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public async Task GetAvailableCentralYearsAsync_Returns_Distinct_Ordered_Years() + { + await SeedCentralRowsAsync( + CreateRow("SAP", "CH", "TRCH", "INV-1", "CHF", 100m, new DateTime(2025, 1, 10)), + CreateRow("SAP", "CH", "TRCH", "INV-2", "CHF", 200m, new DateTime(2026, 2, 10)), + CreateRow("SAP", "CH", "TRCH", "INV-3", "CHF", 300m, null, new DateTime(2026, 3, 5))); + + var years = await _service.GetAvailableCentralYearsAsync(); + + Assert.Equal([2025, 2026], years); + } + + [Fact] + public async Task AnalyzeCentralAsync_Uses_InvoiceDate_Or_ExtractionDate_And_Builds_Monthly_Daily_And_Source_Totals() + { + await SeedCentralRowsAsync( + CreateRow("SAP", "Schweiz", "TRCH", "INV-1", "CHF", 100m, new DateTime(2025, 1, 10)), + CreateRow("MANUAL_EXCEL", "Deutschland", "TRDE", "INV-2", "EUR", 50m, new DateTime(2025, 1, 11)), + CreateRow("SAP", "Deutschland", "TRDE", "INV-3", "EUR", 25m, null, new DateTime(2025, 1, 12)), + CreateRow("SAP", "Schweiz", "TRCH", "INV-4", "CHF", 70m, new DateTime(2026, 2, 5))); + + var result = await _service.AnalyzeCentralAsync(2025, 1); + + Assert.Equal(2025, result.Filter.Year); + Assert.Equal(1, result.Filter.Month); + Assert.Equal(3, result.Summary.RowCount); + Assert.Equal(3, result.Summary.InvoiceCount); + Assert.Equal(2, result.Summary.SiteCount); + Assert.Equal(2, result.Summary.CountryCount); + Assert.Equal(2, result.Summary.CurrencyCount); + Assert.Equal(new DateTime(2025, 1, 10), result.Summary.PeriodStart); + Assert.Equal(new DateTime(2025, 1, 12), result.Summary.PeriodEnd); + + var yearly2025Chf = Assert.Single(result.YearlyTotals, x => x.Year == 2025 && x.Currency == "CHF"); + Assert.Equal(100m, yearly2025Chf.SalesValue); + + var yearly2025Eur = Assert.Single(result.YearlyTotals, x => x.Year == 2025 && x.Currency == "EUR"); + Assert.Equal(75m, yearly2025Eur.SalesValue); + + var januaryChf = Assert.Single(result.MonthlyTotals, x => x.Label == "2025-01" && x.Currency == "CHF"); + Assert.Equal(100m, januaryChf.SalesValue); + + var januaryEur = Assert.Single(result.MonthlyTotals, x => x.Label == "2025-01" && x.Currency == "EUR"); + Assert.Equal(75m, januaryEur.SalesValue); + + Assert.Equal(3, result.DailyTotals.Count); + Assert.Contains(result.DailyTotals, x => x.Label == "2025-01-12" && x.Currency == "EUR" && x.SalesValue == 25m); + + var sapTotal = Assert.Single(result.SourceSystemTotals, x => x.Label == "SAP" && x.Currency == "CHF"); + Assert.Equal(100m, sapTotal.SalesValue); + + var manualTotal = Assert.Single(result.SourceSystemTotals, x => x.Label == "MANUAL_EXCEL" && x.Currency == "EUR"); + Assert.Equal(50m, manualTotal.SalesValue); + + var germanyEur = Assert.Single(result.CountryTotals, x => x.Label == "Deutschland" && x.Currency == "EUR"); + Assert.Equal(75m, germanyEur.SalesValue); + Assert.Equal(2, germanyEur.InvoiceCount); + } + + [Fact] + public async Task AnalyzeCentralAsync_With_Year_Only_Does_Not_Build_DailyTotals() + { + await SeedCentralRowsAsync( + CreateRow("SAP", "Schweiz", "TRCH", "INV-1", "CHF", 100m, new DateTime(2025, 1, 10)), + CreateRow("SAP", "Schweiz", "TRCH", "INV-2", "CHF", 150m, new DateTime(2025, 2, 10))); + + var result = await _service.AnalyzeCentralAsync(2025, null); + + Assert.Empty(result.DailyTotals); + Assert.Equal(2, result.MonthlyTotals.Count); + } + + [Fact] + public async Task AnalyzeCentralAsync_Throws_When_No_Rows_Exist_For_Selected_Period() + { + await SeedCentralRowsAsync( + CreateRow("SAP", "Schweiz", "TRCH", "INV-1", "CHF", 100m, new DateTime(2025, 1, 10))); + + var ex = await Assert.ThrowsAsync(() => _service.AnalyzeCentralAsync(2026, 1)); + + Assert.Contains("gewählten Zeitraum", ex.Message); + } + + private async Task SeedCentralRowsAsync(params CentralSalesRecord[] rows) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + db.CentralSalesRecords.RemoveRange(db.CentralSalesRecords); + await db.SaveChangesAsync(); + db.CentralSalesRecords.AddRange(rows); + await db.SaveChangesAsync(); + } + + private static CentralSalesRecord CreateRow(string sourceSystem, string land, string tsc, string invoiceNumber, string currency, decimal salesValue, DateTime? invoiceDate, DateTime? extractionDate = null) + { + return new CentralSalesRecord + { + SiteId = 1, + StoredAtUtc = DateTime.UtcNow, + SourceSystem = sourceSystem, + ExtractionDate = extractionDate ?? invoiceDate ?? DateTime.UtcNow.Date, + Tsc = tsc, + InvoiceNumber = invoiceNumber, + PositionOnInvoice = 1, + Material = "MAT", + Name = "Article", + ProductGroup = "PG", + Quantity = 1m, + SupplierNumber = "SUP", + SupplierName = "Supplier", + SupplierCountry = "CH", + CustomerNumber = "CUS", + CustomerName = "Customer", + CustomerCountry = "CH", + CustomerIndustry = "Industry", + StandardCost = 1m, + StandardCostCurrency = currency, + PurchaseOrderNumber = "PO", + SalesPriceValue = salesValue, + SalesCurrency = currency, + Incoterms2020 = "DAP", + SalesResponsibleEmployee = "Alice", + InvoiceDate = invoiceDate, + OrderDate = invoiceDate?.AddDays(-2), + Land = land, + DocumentType = "Invoice" + }; + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public AppDbContext CreateDbContext() => new(_options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AppDbContext(_options)); + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs new file mode 100644 index 0000000..6424862 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs @@ -0,0 +1,221 @@ +using ClosedXML.Excel; +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class ManualExcelImportServiceTests +{ + [Fact] + public async Task ReadSalesRecordsAsync_Reads_Expected_Columns_From_Exporter_Format() + { + var site = new Site + { + TSC = "TRCH", + Land = "Schweiz" + }; + var filePath = CreateWorkbook(workbook => + { + var ws = workbook.Worksheets.Add("Sales"); + WriteHeaders(ws); + ws.Cell(2, 1).Value = "15.04.2026 13:45:00"; + ws.Cell(2, 2).Value = "TRDE"; + ws.Cell(2, 3).Value = "INV-100"; + ws.Cell(2, 4).Value = 7; + ws.Cell(2, 5).Value = "MAT-1"; + ws.Cell(2, 6).Value = "Pressure Sensor"; + ws.Cell(2, 7).Value = "PG-A"; + ws.Cell(2, 8).Value = 2.5m; + ws.Cell(2, 9).Value = "SUP-1"; + ws.Cell(2, 10).Value = "Supplier"; + ws.Cell(2, 11).Value = "DE"; + ws.Cell(2, 12).Value = "CUST-1"; + ws.Cell(2, 13).Value = "Customer"; + ws.Cell(2, 14).Value = "CH"; + ws.Cell(2, 15).Value = "Industry"; + ws.Cell(2, 16).Value = 10.25m; + ws.Cell(2, 17).Value = "EUR"; + ws.Cell(2, 18).Value = "PO-1"; + ws.Cell(2, 19).Value = 21.40m; + ws.Cell(2, 20).Value = "EUR"; + ws.Cell(2, 21).Value = "DAP"; + ws.Cell(2, 22).Value = "Alice"; + ws.Cell(2, 23).Value = "14.04.2026"; + ws.Cell(2, 24).Value = "10.04.2026"; + ws.Cell(2, 25).Value = "Deutschland"; + ws.Cell(2, 26).Value = "Invoice"; + }); + + try + { + var service = new ManualExcelImportService(); + + var rows = await service.ReadSalesRecordsAsync(filePath, site); + + var row = Assert.Single(rows); + Assert.Equal("TRDE", row.Tsc); + Assert.Equal("INV-100", row.InvoiceNumber); + Assert.Equal(7, row.PositionOnInvoice); + Assert.Equal("MAT-1", row.Material); + Assert.Equal(2.5m, row.Quantity); + Assert.Equal(10.25m, row.StandardCost); + Assert.Equal(21.40m, row.SalesPriceValue); + Assert.Equal("Deutschland", row.Land); + Assert.Equal("Invoice", row.DocumentType); + Assert.Equal(new DateTime(2026, 4, 14), row.InvoiceDate); + Assert.Equal(new DateTime(2026, 4, 10), row.OrderDate); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task ReadSalesRecordsAsync_Uses_Site_Fallbacks_When_Tsc_And_Land_Are_Missing() + { + var site = new Site + { + TSC = "TRCH", + Land = "Schweiz" + }; + var filePath = CreateWorkbook(workbook => + { + var ws = workbook.Worksheets.Add("Sales"); + WriteHeaders(ws); + ws.Cell(2, 3).Value = "INV-200"; + ws.Cell(2, 5).Value = "MAT-2"; + }); + + try + { + var service = new ManualExcelImportService(); + + var rows = await service.ReadSalesRecordsAsync(filePath, site); + + var row = Assert.Single(rows); + Assert.Equal("TRCH", row.Tsc); + Assert.Equal("Schweiz", row.Land); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task ReadSalesRecordsAsync_Parses_German_Decimal_Text_And_Skips_Empty_Rows() + { + var site = new Site + { + TSC = "TRAT", + Land = "Oesterreich" + }; + var filePath = CreateWorkbook(workbook => + { + var ws = workbook.Worksheets.Add("Sales"); + WriteHeaders(ws); + ws.Cell(2, 3).Value = "INV-300"; + ws.Cell(2, 8).Value = "1,50"; + ws.Cell(2, 16).Value = "3,25"; + ws.Cell(2, 19).Value = "7,90"; + ws.Cell(3, 1).Value = ""; + ws.Cell(3, 2).Value = ""; + ws.Cell(3, 3).Value = ""; + }); + + try + { + var service = new ManualExcelImportService(); + + var rows = await service.ReadSalesRecordsAsync(filePath, site); + + var row = Assert.Single(rows); + Assert.Equal(1.50m, row.Quantity); + Assert.Equal(3.25m, row.StandardCost); + Assert.Equal(7.90m, row.SalesPriceValue); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task ReadSalesRecordsAsync_Throws_When_InvoiceNumber_Header_Is_Missing() + { + var site = new Site + { + TSC = "TRCH", + Land = "Schweiz" + }; + var filePath = CreateWorkbook(workbook => + { + var ws = workbook.Worksheets.Add("Sales"); + ws.Cell(1, 1).Value = "TSC"; + ws.Cell(1, 2).Value = "Material"; + ws.Cell(2, 1).Value = "TRCH"; + ws.Cell(2, 2).Value = "MAT-3"; + }); + + try + { + var service = new ManualExcelImportService(); + + var ex = await Assert.ThrowsAsync(() => service.ReadSalesRecordsAsync(filePath, site)); + + Assert.Contains("Invoice Number", ex.Message); + } + finally + { + File.Delete(filePath); + } + } + + private static string CreateWorkbook(Action fillWorkbook) + { + var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx"); + using var workbook = new XLWorkbook(); + fillWorkbook(workbook); + workbook.SaveAs(filePath); + return filePath; + } + + private static void WriteHeaders(IXLWorksheet ws) + { + var headers = new[] + { + "extraction date", + "TSC", + "Invoice Number", + "Position on invoice", + "Material", + "Name", + "Product Group", + "Quantity", + "Supplier number", + "Supplier name", + "Supplier country", + "Customer number", + "Customer name", + "Customer country", + "Customer Industry", + "Standard cost", + "Standard Cost Currency", + "Purchase Order number", + "Sales Price/Value", + "Sales Currency", + "Incoterms 2020", + "Sales responsible employee", + "invoice date", + "order date", + "Land", + "Document Type" + }; + + for (var i = 0; i < headers.Length; i++) + { + ws.Cell(1, i + 1).Value = headers[i]; + } + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs new file mode 100644 index 0000000..1d864d6 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs @@ -0,0 +1,144 @@ +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class RecordTransformationServiceTests +{ + private readonly RecordTransformationService _service; + + public RecordTransformationServiceTests() + { + ITransformationStrategy[] valueStrategies = + [ + new CopyTransformationStrategy(), + new UppercaseTransformationStrategy(), + new LowercaseTransformationStrategy(), + new PrefixTransformationStrategy(), + new SuffixTransformationStrategy(), + new ReplaceTransformationStrategy(), + new ConstantTransformationStrategy() + ]; + IRecordTransformationStrategy[] recordStrategies = + [ + new FirstNonEmptyRecordTransformationStrategy() + ]; + + _service = new RecordTransformationService(valueStrategies, recordStrategies); + } + + [Fact] + public void Apply_Ignores_Inactive_Rules() + { + var records = new List + { + new() { Material = "abc" } + }; + var rules = new[] + { + new FieldTransformationRule + { + IsActive = false, + RuleScope = "Value", + SourceField = nameof(SalesRecord.Material), + TargetField = nameof(SalesRecord.Material), + TransformationType = "Uppercase", + SortOrder = 10 + } + }; + + _service.Apply(records, rules); + + Assert.Equal("abc", records[0].Material); + } + + [Fact] + public void Apply_Uses_SortOrder_For_Multiple_Value_Rules() + { + var records = new List + { + new() { Material = "abc" } + }; + var rules = new[] + { + new FieldTransformationRule + { + IsActive = true, + RuleScope = "Value", + SourceField = nameof(SalesRecord.Material), + TargetField = nameof(SalesRecord.Material), + TransformationType = "Uppercase", + SortOrder = 20 + }, + new FieldTransformationRule + { + IsActive = true, + RuleScope = "Value", + SourceField = nameof(SalesRecord.Material), + TargetField = nameof(SalesRecord.Material), + TransformationType = "Prefix", + Argument = "X-", + SortOrder = 10 + } + }; + + _service.Apply(records, rules); + + Assert.Equal("X-ABC", records[0].Material); + } + + [Fact] + public void Apply_Uses_Record_Strategy_When_RuleScope_Is_Record() + { + var records = new List + { + new() + { + CustomerName = "", + SupplierName = "Supplier A", + Name = "Name A" + } + }; + var rules = new[] + { + new FieldTransformationRule + { + IsActive = true, + RuleScope = "Record", + TargetField = nameof(SalesRecord.CustomerName), + TransformationType = "FirstNonEmpty", + Argument = $"{nameof(SalesRecord.CustomerName)}|{nameof(SalesRecord.SupplierName)}|{nameof(SalesRecord.Name)}", + SortOrder = 10 + } + }; + + _service.Apply(records, rules); + + Assert.Equal("Supplier A", records[0].CustomerName); + } + + [Fact] + public void Apply_Converts_Value_To_Target_Type() + { + var records = new List + { + new() { Material = "42" } + }; + var rules = new[] + { + new FieldTransformationRule + { + IsActive = true, + RuleScope = "Value", + SourceField = nameof(SalesRecord.Material), + TargetField = nameof(SalesRecord.PositionOnInvoice), + TransformationType = "Copy", + SortOrder = 10 + } + }; + + _service.Apply(records, rules); + + Assert.Equal(42, records[0].PositionOnInvoice); + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/TrafagSalesExporter.Tests.csproj b/TrafagSalesExporter/TrafagSalesExporter.Tests/TrafagSalesExporter.Tests.csproj new file mode 100644 index 0000000..d78458d --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/TrafagSalesExporter.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationCatalogTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationCatalogTests.cs new file mode 100644 index 0000000..1d387a1 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationCatalogTests.cs @@ -0,0 +1,28 @@ +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class TransformationCatalogTests +{ + [Fact] + public void Catalog_Returns_Value_And_Record_Strategies() + { + ITransformationStrategy[] valueStrategies = + [ + new CopyTransformationStrategy(), + new ConstantTransformationStrategy() + ]; + IRecordTransformationStrategy[] recordStrategies = + [ + new FirstNonEmptyRecordTransformationStrategy() + ]; + + var catalog = new TransformationCatalog(valueStrategies, recordStrategies); + + var all = catalog.GetAll(); + + Assert.Contains(all, x => x.RuleScope == "Value" && x.Key == "Copy"); + Assert.Contains(all, x => x.RuleScope == "Value" && x.Key == "Constant"); + Assert.Contains(all, x => x.RuleScope == "Record" && x.Key == "FirstNonEmpty"); + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationStrategiesTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationStrategiesTests.cs new file mode 100644 index 0000000..7116b94 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationStrategiesTests.cs @@ -0,0 +1,50 @@ +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class TransformationStrategiesTests +{ + [Fact] + public void ReplaceStrategy_Replaces_Text_Using_Argument_Syntax() + { + var strategy = new ReplaceTransformationStrategy(); + + var result = strategy.Transform("Intercompany Kunde", "Intercompany=>Extern"); + + Assert.Equal("Extern Kunde", result); + } + + [Fact] + public void ConstantStrategy_Returns_Argument_Ignoring_SourceValue() + { + var strategy = new ConstantTransformationStrategy(); + + var result = strategy.Transform("ignored", "CHF"); + + Assert.Equal("CHF", result); + } + + [Fact] + public void FirstNonEmptyRecordStrategy_Uses_First_Non_Empty_Field_From_Argument_List() + { + var strategy = new FirstNonEmptyRecordTransformationStrategy(); + var record = new SalesRecord + { + CustomerName = "", + SupplierName = "Fallback Supplier", + Name = "Article Name" + }; + var rule = new FieldTransformationRule + { + RuleScope = "Record", + TargetField = nameof(SalesRecord.CustomerName), + TransformationType = "FirstNonEmpty", + Argument = $"{nameof(SalesRecord.CustomerName)}|{nameof(SalesRecord.SupplierName)}|{nameof(SalesRecord.Name)}" + }; + + strategy.Transform(record, rule); + + Assert.Equal("Fallback Supplier", record.CustomerName); + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.csproj b/TrafagSalesExporter/TrafagSalesExporter.csproj index c60dfbc..0f5ca2d 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.csproj +++ b/TrafagSalesExporter/TrafagSalesExporter.csproj @@ -32,6 +32,13 @@ + + + + + + + diff --git a/TrafagSalesExporter/TrafagSalesExporter.sln b/TrafagSalesExporter/TrafagSalesExporter.sln index 2e8e252..bfaccbf 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.sln +++ b/TrafagSalesExporter/TrafagSalesExporter.sln @@ -1,20 +1,46 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.37012.4 d17.14 +VisualStudioVersion = 17.14.37012.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter", "TrafagSalesExporter.csproj", "{49B56D6D-731C-6482-4A5C-82EAEEBCE593}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter.Tests", "TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj", "{A72D1AFB-E49E-4920-B783-EFFE1132435D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|x64.ActiveCfg = Debug|Any CPU + {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|x64.Build.0 = Debug|Any CPU + {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|x86.ActiveCfg = Debug|Any CPU + {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|x86.Build.0 = Debug|Any CPU {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|Any CPU.ActiveCfg = Release|Any CPU {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|Any CPU.Build.0 = Release|Any CPU + {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|x64.ActiveCfg = Release|Any CPU + {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|x64.Build.0 = Release|Any CPU + {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|x86.ActiveCfg = Release|Any CPU + {49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|x86.Build.0 = Release|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Debug|x64.ActiveCfg = Debug|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Debug|x64.Build.0 = Debug|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Debug|x86.ActiveCfg = Debug|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Debug|x86.Build.0 = Debug|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|Any CPU.Build.0 = Release|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x64.ActiveCfg = Release|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x64.Build.0 = Release|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x86.ActiveCfg = Release|Any CPU + {A72D1AFB-E49E-4920-B783-EFFE1132435D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE