using System.Data.Common; using System.Reflection; using System.Runtime.Loader; using SmartB2B.Sync.Contracts; using SmartB2B.Sync.Service; using SmartB2B.Enova.Service; using SmartB2B.Enova.Service.Configuration; using SmartB2B.Sync.Service.Orders; using SmartB2B.Sync.Service.Rpc; using SmartB2B.Enova.Service.Runtime; using SmartB2B.Sync.Service.Sql; using WampSharp.Core.Serialization; using WampSharp.V2.Core; using WampSharp.V2.Core.Contracts; using WampSharp.V2.Rpc; var tests = new (string Name, Func Run)[] { ("Poprawne zamówienie z rabatem ułamkowym", ValidFractionalDiscount), ("Rabat 100 procent jest dozwolony", FullDiscount), ("Rabat ujemny jest odrzucany", NegativeDiscount), ("Rabat powyżej 100 jest odrzucany", DiscountAboveOneHundred), ("Ilość zerowa jest odrzucana", ZeroQuantity), ("Cena ujemna jest odrzucana", NegativePrice), ("Brak towaru jest odrzucany", MissingProduct), ("Brak pozycji jest odrzucany", MissingItems), ("Powtarzająca się referencja nie jest walidowana", ReferenceMayRepeat), ("Kontrakt place_order jest mapowany na Enovę", PlaceOrderMapping), ("place_order zwraca nazwany wynik", PlaceOrderKeywordResult), ("Warstwa WAMP wysyła wynik jako kwargs", WampKeywordWireResult), ("Brak waluty zwraca stabilny błąd", MissingCurrency), ("Waluta inna niż PLN jest odrzucana", UnsupportedCurrency), ("Błąd adaptera jest wypisywany na stderr", AdapterErrorIsWrittenToStderr), ("Brak katalogu Enovy jest wykrywany", MissingEnovaDirectory), ("Sekrety są odczytywane z pliku konfiguracji", SecretsComeFromConfigurationFile), ("Brak danych operatora wyłącza place_order", MissingCredentialsDisablePlaceOrder), ("Częściowe dane operatora wyłączają place_order", PartialCredentialsDisablePlaceOrder), ("Parametr /debug włącza szczegółowe logowanie", DebugSwitchEnablesVerboseLogging), ("Parametr /verbose można wyłączyć", VerboseSwitchCanBeDisabled), ("Tryb szczegółowy jest przekazywany do adaptera", VerboseModeReachesAdapter), ("Rejestracja bazy przenosi parametry connection stringa", DatabaseRegistrationUsesConnectionString), ("Provider SQL działa na bieżącej platformie", SqlProviderIsSupported) }; var failures = 0; foreach (var test in tests) { try { await test.Run(); Console.WriteLine($"PASS: {test.Name}"); } catch (Exception exception) { failures++; Console.Error.WriteLine($"FAIL: {test.Name}: {exception.Message}"); } } Console.WriteLine($"Wynik: {tests.Length - failures}/{tests.Length} testów zaliczonych."); return failures == 0 ? 0 : 1; static Task SqlProviderIsSupported() { var handler = new SqlRawHandler( "Server=localhost;Database=master;Integrated Security=true;TrustServerCertificate=true", 30); var createConnection = typeof(SqlRawHandler).GetMethod( "CreateConnection", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Nie znaleziono metody tworzącej połączenie SQL."); using var connection = (DbConnection)(createConnection.Invoke(handler, null) ?? throw new InvalidOperationException("Provider nie utworzył połączenia SQL.")); if (connection.GetType().FullName != "Microsoft.Data.SqlClient.SqlConnection") { throw new InvalidOperationException( $"Załadowano nieoczekiwany provider SQL: {connection.GetType().AssemblyQualifiedName}."); } return Task.CompletedTask; } static Task DatabaseRegistrationUsesConnectionString() { var directory = new DirectoryInfo(AppContext.BaseDirectory); var installationCandidates = new List(); while (directory is not null) { installationCandidates.Add(Path.Combine(directory.FullName, "enova365 2604.4.4")); directory = directory.Parent; } installationCandidates.Add(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Soneta", "enova365 2604.4.4")); installationCandidates.Add(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Soneta", "enova365 2604.4.4")); var installationPath = installationCandidates.FirstOrDefault( candidate => File.Exists(Path.Combine(candidate, "Soneta.Business.dll"))); if (installationPath is null) { throw new InvalidOperationException("Nie znaleziono lokalnej instalacji enova365 do testu adaptera."); } Assembly? ResolveSonetaAssembly(AssemblyLoadContext context, AssemblyName name) { var candidate = Path.Combine(installationPath, $"{name.Name}.dll"); return File.Exists(candidate) ? context.LoadFromAssemblyPath(candidate) : null; } AssemblyLoadContext.Default.Resolving += ResolveSonetaAssembly; var adapterAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath( Path.Combine(AppContext.BaseDirectory, "SmartB2B.Enova.Adapter.dll")); var adapterType = adapterAssembly.GetType("SmartB2B.Enova.Adapter.EnovaOrderAdapter", throwOnError: true) ?? throw new InvalidOperationException("Nie znaleziono typu adaptera Enovy."); var parseConnectionString = adapterType.GetMethod( "ParseSqlConnectionString", BindingFlags.Static | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Nie znaleziono metody analizującej connection string Enovy."); object settings; try { settings = parseConnectionString.Invoke( null, ["Server=sql01;Database=Moms_Care;User ID=smartb2b;Password=haslo-sql;TrustServerCertificate=True"]) ?? throw new InvalidOperationException("Adapter nie odczytał konfiguracji połączenia Enovy."); } catch (TargetInvocationException exception) when (exception.InnerException is not null) { throw exception.InnerException; } AssertEqual("sql01", GetProperty(settings, "DataSource"), "serwer SQL"); AssertEqual("Moms_Care", GetProperty(settings, "InitialCatalog"), "nazwa bazy SQL"); AssertEqual("smartb2b", GetProperty(settings, "UserID"), "użytkownik SQL"); AssertEqual("haslo-sql", GetProperty(settings, "Password"), "hasło SQL"); AssertEqual(false, GetProperty(settings, "IntegratedSecurity"), "uwierzytelnianie zintegrowane"); AssertEqual(true, GetProperty(settings, "TrustServerCertificate"), "zaufanie certyfikatowi SQL"); return Task.CompletedTask; } static object? GetProperty(object instance, string name) => instance.GetType().GetProperty(name)?.GetValue(instance) ?? throw new InvalidOperationException($"Nie znaleziono wartości właściwości '{name}'."); static Task ValidFractionalDiscount() { AssertNoErrors(CreateValidRequest(12.5m)); return Task.CompletedTask; } static Task FullDiscount() { AssertNoErrors(CreateValidRequest(100m)); return Task.CompletedTask; } static Task NegativeDiscount() { AssertHasError(CreateValidRequest(-0.01m), "discount"); return Task.CompletedTask; } static Task DiscountAboveOneHundred() { AssertHasError(CreateValidRequest(100.01m), "discount"); return Task.CompletedTask; } static Task ZeroQuantity() { AssertHasError(CreateValidRequest(quantity: 0m), "quantity"); return Task.CompletedTask; } static Task NegativePrice() { AssertHasError(CreateValidRequest(unitPrice: -0.01m), "price_netto"); return Task.CompletedTask; } static Task MissingProduct() { AssertHasError(CreateValidRequest(productCode: ""), "symbol"); return Task.CompletedTask; } static Task MissingItems() { var request = new OrderRequest { CurrencyIso = "PLN", CustomerCode = "Abc", Items = [] }; AssertHasError(request, "co najmniej jedną"); return Task.CompletedTask; } static Task ReferenceMayRepeat() { AssertNoErrors(CreateValidRequest()); AssertNoErrors(CreateValidRequest()); return Task.CompletedTask; } static async Task PlaceOrderMapping() { var adapter = new FakeAdapter(); var handler = new PlaceOrderHandler(adapter, CreateConfiguration()); var invocation = CreateInvocation(); await handler.HandleAsync(invocation, CancellationToken.None); var request = adapter.LastRequest ?? throw new InvalidOperationException("Adapter nie otrzymał zamówienia."); AssertEqual("ABC", request.CustomerCode, "companyErpId"); AssertEqual("MAG", request.WarehouseCode, "warehouseErpId"); AssertEqual("PO/1", request.CustomerReference, "purchase_order_number"); AssertEqual(2, request.Items.Count, "liczba pozycji"); AssertEqual(12.5m, request.Items[0].Discount, "rabat"); AssertEqual(120m, request.Items[0].UnitPriceNet, "cena netto"); } static async Task PlaceOrderKeywordResult() { var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration()); var result = await handler.HandleAsync(CreateInvocation(), CancellationToken.None); var keywords = result as KeywordResult ?? throw new InvalidOperationException("Wynik nie jest wynikiem nazwanym WAMP."); AssertEqual("ZO/1", keywords.Values["order_erp_symbol"], "order_erp_symbol"); AssertEqual(100m, keywords.Values["value_netto"], "value_netto"); AssertEqual(123m, keywords.Values["value_brutto"], "value_brutto"); } static async Task WampKeywordWireResult() { var operation = new DelegateRpcOperation( "test.keyword", (_, _) => Task.FromResult(new KeywordResult( new Dictionary { ["answer"] = 42 }))); var callback = new FakeRouterCallback(); _ = operation.Invoke( callback, WampObjectFormatter.Value, new InvocationDetails(), Array.Empty(), new Dictionary()); var result = await callback.Completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); AssertEqual(0, result.Arguments.Length, "liczba argumentów pozycyjnych WAMP"); AssertEqual(42, result.KeywordArguments["answer"], "argument nazwany WAMP"); } static async Task MissingCurrency() { var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration()); var invocation = new RpcInvocation([], new Dictionary()); await AssertWampError(handler.HandleAsync(invocation, CancellationToken.None), "eu.smartb2b.missing_currency"); } static async Task UnsupportedCurrency() { var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration()); var invocation = CreateInvocation("EUR"); await AssertWampError( handler.HandleAsync(invocation, CancellationToken.None), "eu.smartb2b.erp.currency_not_supported"); } static async Task AdapterErrorIsWrittenToStderr() { var previousError = Console.Error; using var error = new StringWriter(); try { Console.SetError(error); var handler = new PlaceOrderHandler(new FailingAdapter(), CreateConfiguration()); await AssertWampError( handler.HandleAsync(CreateInvocation(), CancellationToken.None), "eu.smartb2b.company_not_found"); } finally { Console.SetError(previousError); } if (!error.ToString().Contains("Nie znaleziono kontrahenta", StringComparison.Ordinal)) { throw new InvalidOperationException($"Brak komunikatu błędu na stderr: {error}"); } } static Task MissingEnovaDirectory() { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); try { _ = new EnovaAdapterLoader(path, Path.Combine(path, "adapter.dll")); throw new InvalidOperationException("Oczekiwano DirectoryNotFoundException."); } catch (DirectoryNotFoundException) { return Task.CompletedTask; } } static Task SecretsComeFromConfigurationFile() { var path = Path.Combine(Path.GetTempPath(), $"enova-settings-{Guid.NewGuid():N}.json"); try { File.WriteAllText(path, """ { "portal": "demo", "wamp": { "serverUrl": "ws://localhost:8080/", "reconnectDelaySeconds": 10 }, "enova": { "installationPath": "C:/Enova", "database": "Firma demo", "operator": "Administrator", "password": "haslo-z-pliku", "documentDefinition": "ZO", "saveMode": "Buffer" }, "sql": { "connectionString": "Server=db;Database=enova;User ID=user;Password=sql-z-pliku", "commandTimeoutSeconds": 30 }, "sentry": { "dsn": "https://public@example.com/1" }, "diagnostics": { "logDirectory": "C:/logs", "logPrefix": "test" } } """); var settings = ServiceSettings.Load(path); var runtime = settings.ResolveRuntimeSettings(); AssertEqual("https://public@example.com/1", settings.Sentry.Dsn, "DSN Sentry"); AssertEqual( "haslo-z-pliku", runtime.EnovaConfiguration?.Connection.Password, "hasło operatora"); AssertEqual( "Server=db;Database=enova;User ID=user;Password=sql-z-pliku", runtime.SqlConnectionString, "connection string SQL"); AssertEqual( runtime.SqlConnectionString, runtime.EnovaConfiguration?.Connection.SqlConnectionString, "connection string rejestracji bazy Enovy"); return Task.CompletedTask; } finally { File.Delete(path); } } static Task MissingCredentialsDisablePlaceOrder() { var runtime = LoadRuntimeSettings(string.Empty); AssertEqual(null, runtime.EnovaConfiguration, "konfiguracja endpointu place_order"); return Task.CompletedTask; } static Task PartialCredentialsDisablePlaceOrder() { var runtime = LoadRuntimeSettings("\"operator\": \"Administrator\","); AssertEqual(null, runtime.EnovaConfiguration, "konfiguracja endpointu place_order"); return Task.CompletedTask; } static Task DebugSwitchEnablesVerboseLogging() { var options = ServiceCommandLine.Parse(["/debug"], "enova.json"); AssertEqual(true, options.VerboseLogging, "tryb szczegółowy"); return Task.CompletedTask; } static Task VerboseSwitchCanBeDisabled() { var options = ServiceCommandLine.Parse(["/verbose", "/noverbose"], "enova.json"); AssertEqual(false, options.VerboseLogging, "wyłączony tryb szczegółowy"); return Task.CompletedTask; } static Task VerboseModeReachesAdapter() { var runtime = LoadRuntimeSettings( "\"operator\": \"Administrator\", \"password\": \"haslo\",", verboseLogging: true); AssertEqual(true, runtime.EnovaConfiguration?.VerboseLogging, "tryb adaptera"); return Task.CompletedTask; } static RuntimeSettings LoadRuntimeSettings(string credentialsJson, bool verboseLogging = false) { var path = Path.Combine(Path.GetTempPath(), $"enova-settings-{Guid.NewGuid():N}.json"); try { File.WriteAllText(path, $$""" { "portal": "demo", "wamp": { "serverUrl": "ws://localhost:8080/" }, "enova": { "installationPath": "C:/Enova", "database": "Firma demo", {{credentialsJson}} "documentDefinition": "ZO" }, "sql": { "connectionString": "Server=db;Database=enova;User ID=user;Password=secret" } } """); return ServiceSettings.Load(path).ResolveRuntimeSettings(verboseLogging); } finally { File.Delete(path); } } static RpcInvocation CreateInvocation(string currency = "PLN") => new( [], new Dictionary { ["currency_iso"] = currency, ["companyErpId"] = "ABC", ["warehouseErpId"] = "MAG", ["purchase_order_number"] = "PO/1", ["notes"] = "Test", ["lines"] = new[] { new PlaceOrderLine { Symbol = "T1", Quantity = 2m, PriceNetto = 120m, Discount = 12.5m }, new PlaceOrderLine { Symbol = "T2", Quantity = 1m, PriceNetto = 50m, Discount = 0m } } }); static ErpAdapterConfiguration CreateConfiguration() => new( new ErpConnectionOptions( "Firma demo", "Administrator", "", "Server=localhost;Database=enova;Integrated Security=true;TrustServerCertificate=true"), "ZO", null, OrderSaveMode.Buffer); static OrderRequest CreateValidRequest( decimal discount = 0m, decimal quantity = 1m, decimal unitPrice = 100m, string productCode = "BIKINI") => new() { CurrencyIso = "PLN", CustomerCode = "Abc", CustomerReference = "KLIENT/TEST/1", Notes = "Test", DocumentDefinition = "ZO", SaveMode = OrderSaveMode.Buffer, Items = [ new OrderLineRequest { ProductCode = productCode, Quantity = quantity, UnitPriceNet = unitPrice, Discount = discount } ] }; static void AssertNoErrors(OrderRequest request) { var errors = OrderValidator.Validate(request); if (errors.Count > 0) { throw new InvalidOperationException(string.Join(" | ", errors)); } } static void AssertHasError(OrderRequest request, string expectedText) { var errors = OrderValidator.Validate(request); if (!errors.Any(error => error.Contains(expectedText, StringComparison.OrdinalIgnoreCase))) { throw new InvalidOperationException( $"Oczekiwano błędu zawierającego '{expectedText}', otrzymano: {string.Join(" | ", errors)}"); } } static async Task AssertWampError(Task task, string expectedUri) { try { await task; throw new InvalidOperationException($"Oczekiwano błędu {expectedUri}."); } catch (WampException exception) when (exception.ErrorUri == expectedUri) { // Oczekiwany błąd. } } static void AssertEqual(object? expected, object? actual, string name) { if (!Equals(expected, actual)) { throw new InvalidOperationException($"{name}: oczekiwano '{expected}', otrzymano '{actual}'."); } } file sealed class FakeAdapter : IErpOrderAdapter { public OrderRequest? LastRequest { get; private set; } public ErpAdapterInfo Initialize(ErpAdapterConfiguration configuration) => new("1.0.0", "enova365", "2604.4.4.0", configuration.Connection.DatabaseName, ["PLN"]); public void CheckConnection() { } public void Dispose() { } public OrderResult CreateOrder(OrderRequest request) { if (!string.Equals(request.CurrencyIso, "PLN", StringComparison.OrdinalIgnoreCase)) { throw new ErpOperationException("eu.smartb2b.erp.currency_not_supported", "Waluta nie jest obsługiwana."); } LastRequest = request; return new OrderResult( 1, "ZO/1", request.SaveMode, request.CustomerCode, request.WarehouseCode ?? "FIRMA", request.CustomerReference, request.Items.Count, 100m, 23m, 123m, request.Items.Any(line => line.Discount > 0)); } } file sealed class FailingAdapter : IErpOrderAdapter { public ErpAdapterInfo Initialize(ErpAdapterConfiguration configuration) => new("1.0.0", "enova365", "2604.4.4.0", configuration.Connection.DatabaseName, ["PLN"]); public void CheckConnection() { } public void Dispose() { } public OrderResult CreateOrder(OrderRequest request) => throw new ErpOperationException( "eu.smartb2b.company_not_found", $"Nie znaleziono kontrahenta o kodzie '{request.CustomerCode}'."); } file sealed class FakeRouterCallback : IWampRawRpcOperationRouterCallback { public long RequestId => 1; public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public void Result(IWampFormatter formatter, YieldOptions details) => Completion.TrySetResult(new CapturedResult([], new Dictionary())); public void Result( IWampFormatter formatter, YieldOptions details, TMessage[] arguments) => Completion.TrySetResult(new CapturedResult( arguments.Select(formatter.Deserialize).ToArray(), new Dictionary())); public void Result( IWampFormatter formatter, YieldOptions details, TMessage[] arguments, IDictionary argumentsKeywords) => Completion.TrySetResult(new CapturedResult( arguments.Select(formatter.Deserialize).ToArray(), argumentsKeywords.ToDictionary( pair => pair.Key, pair => (object?)formatter.Deserialize(pair.Value)))); public void Error(IWampFormatter formatter, TMessage details, string error) => Completion.TrySetException(new InvalidOperationException(error)); public void Error( IWampFormatter formatter, TMessage details, string error, TMessage[] arguments) => Completion.TrySetException(new InvalidOperationException(error)); public void Error( IWampFormatter formatter, TMessage details, string error, TMessage[] arguments, TMessage argumentsKeywords) => Completion.TrySetException(new InvalidOperationException(error)); } file sealed record CapturedResult( object?[] Arguments, IReadOnlyDictionary KeywordArguments);