Add shared ERP sync core and Optima adapter
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
using System.Data.Common;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using SmartB2B.Enova.Contracts;
|
||||
using SmartB2B.Sync.Contracts;
|
||||
using SmartB2B.Sync.Service;
|
||||
using SmartB2B.Enova.Service;
|
||||
using SmartB2B.Enova.Service.Configuration;
|
||||
using SmartB2B.Enova.Service.Orders;
|
||||
using SmartB2B.Enova.Service.Rpc;
|
||||
using SmartB2B.Sync.Service.Orders;
|
||||
using SmartB2B.Sync.Service.Rpc;
|
||||
using SmartB2B.Enova.Service.Runtime;
|
||||
using SmartB2B.Enova.Service.Sql;
|
||||
using SmartB2B.Sync.Service.Sql;
|
||||
using WampSharp.Core.Serialization;
|
||||
using WampSharp.V2.Core;
|
||||
using WampSharp.V2.Core.Contracts;
|
||||
@@ -84,19 +85,25 @@ static Task SqlProviderIsSupported()
|
||||
static Task DatabaseRegistrationUsesConnectionString()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
string? installationPath = null;
|
||||
var installationCandidates = new List<string>();
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, "enova365 2604.4.4");
|
||||
if (File.Exists(Path.Combine(candidate, "Soneta.Business.dll")))
|
||||
{
|
||||
installationPath = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
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.");
|
||||
@@ -188,7 +195,7 @@ static Task MissingProduct()
|
||||
|
||||
static Task MissingItems()
|
||||
{
|
||||
var request = new OrderRequest { CustomerCode = "Abc", Items = [] };
|
||||
var request = new OrderRequest { CurrencyIso = "PLN", CustomerCode = "Abc", Items = [] };
|
||||
AssertHasError(request, "co najmniej jedną");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -214,7 +221,7 @@ static async Task PlaceOrderMapping()
|
||||
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].UnitPrice, "cena netto");
|
||||
AssertEqual(120m, request.Items[0].UnitPriceNet, "cena netto");
|
||||
}
|
||||
|
||||
static async Task PlaceOrderKeywordResult()
|
||||
@@ -259,7 +266,7 @@ static async Task MissingCurrency()
|
||||
static async Task UnsupportedCurrency()
|
||||
{
|
||||
var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration());
|
||||
var invocation = new RpcInvocation([], new Dictionary<string, object?> { ["currency_iso"] = "EUR" });
|
||||
var invocation = CreateInvocation("EUR");
|
||||
await AssertWampError(
|
||||
handler.HandleAsync(invocation, CancellationToken.None),
|
||||
"eu.smartb2b.erp.currency_not_supported");
|
||||
@@ -376,14 +383,14 @@ static Task PartialCredentialsDisablePlaceOrder()
|
||||
|
||||
static Task DebugSwitchEnablesVerboseLogging()
|
||||
{
|
||||
var options = CommandLineOptions.Parse(["/debug"]);
|
||||
var options = ServiceCommandLine.Parse(["/debug"], "enova.json");
|
||||
AssertEqual(true, options.VerboseLogging, "tryb szczegółowy");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static Task VerboseSwitchCanBeDisabled()
|
||||
{
|
||||
var options = CommandLineOptions.Parse(["/verbose", "/noverbose"]);
|
||||
var options = ServiceCommandLine.Parse(["/verbose", "/noverbose"], "enova.json");
|
||||
AssertEqual(false, options.VerboseLogging, "wyłączony tryb szczegółowy");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -424,11 +431,11 @@ static RuntimeSettings LoadRuntimeSettings(string credentialsJson, bool verboseL
|
||||
}
|
||||
}
|
||||
|
||||
static RpcInvocation CreateInvocation() => new(
|
||||
static RpcInvocation CreateInvocation(string currency = "PLN") => new(
|
||||
[],
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["currency_iso"] = "PLN",
|
||||
["currency_iso"] = currency,
|
||||
["companyErpId"] = "ABC",
|
||||
["warehouseErpId"] = "MAG",
|
||||
["purchase_order_number"] = "PO/1",
|
||||
@@ -440,8 +447,8 @@ static RpcInvocation CreateInvocation() => new(
|
||||
}
|
||||
});
|
||||
|
||||
static EnovaAdapterConfiguration CreateConfiguration() => new(
|
||||
new EnovaConnectionOptions(
|
||||
static ErpAdapterConfiguration CreateConfiguration() => new(
|
||||
new ErpConnectionOptions(
|
||||
"Firma demo",
|
||||
"Administrator",
|
||||
"",
|
||||
@@ -457,6 +464,7 @@ static OrderRequest CreateValidRequest(
|
||||
string productCode = "BIKINI") =>
|
||||
new()
|
||||
{
|
||||
CurrencyIso = "PLN",
|
||||
CustomerCode = "Abc",
|
||||
CustomerReference = "KLIENT/TEST/1",
|
||||
Notes = "Test",
|
||||
@@ -468,7 +476,7 @@ static OrderRequest CreateValidRequest(
|
||||
{
|
||||
ProductCode = productCode,
|
||||
Quantity = quantity,
|
||||
UnitPrice = unitPrice,
|
||||
UnitPriceNet = unitPrice,
|
||||
Discount = discount
|
||||
}
|
||||
]
|
||||
@@ -514,15 +522,24 @@ static void AssertEqual(object? expected, object? actual, string name)
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeAdapter : IEnovaOrderAdapter
|
||||
file sealed class FakeAdapter : IErpOrderAdapter
|
||||
{
|
||||
public OrderRequest? LastRequest { get; private set; }
|
||||
|
||||
public EnovaAdapterInfo Initialize(EnovaAdapterConfiguration configuration) =>
|
||||
new("1.0.0", "2604.4.4.0", "2604.4.4.0", configuration.Connection.DatabaseName);
|
||||
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,
|
||||
@@ -539,13 +556,17 @@ file sealed class FakeAdapter : IEnovaOrderAdapter
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FailingAdapter : IEnovaOrderAdapter
|
||||
file sealed class FailingAdapter : IErpOrderAdapter
|
||||
{
|
||||
public EnovaAdapterInfo Initialize(EnovaAdapterConfiguration configuration) =>
|
||||
new("1.0.0", "2604.4.4.0", "2604.4.4.0", configuration.Connection.DatabaseName);
|
||||
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 EnovaOperationException(
|
||||
throw new ErpOperationException(
|
||||
"eu.smartb2b.company_not_found",
|
||||
$"Nie znaleziono kontrahenta o kodzie '{request.CustomerCode}'.");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj" />
|
||||
<ProjectReference Include="../../src/SmartB2B.Enova.Contracts/SmartB2B.Enova.Contracts.csproj" />
|
||||
<ProjectReference Include="../../src/SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" />
|
||||
<ProjectReference Include="../../src/SmartB2B.Sync.Service/SmartB2B.Sync.Service.csproj" />
|
||||
<ProjectReference Include="../../src/SmartB2B.Enova.Service/SmartB2B.Enova.Service.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
209
tests/SmartB2B.Optima.IntegrationTests/Program.cs
Normal file
209
tests/SmartB2B.Optima.IntegrationTests/Program.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using System.Text.Json;
|
||||
using SmartB2B.Optima.Adapter;
|
||||
using SmartB2B.Optima.Service.Configuration;
|
||||
using SmartB2B.Sync.Contracts;
|
||||
using SmartB2B.Sync.Service.Rpc;
|
||||
using SmartB2B.Sync.Service.Sql;
|
||||
using WampSharp.V2.Core.Contracts;
|
||||
|
||||
var options = ParseArguments(args);
|
||||
if (!options.TryGetValue("config", out var configPath) || string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Użycie: --config <optima.json> [--negative-tests] [--write-order [--approved]] " +
|
||||
"[--customer-id <ID> --product-code <KOD>]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var settings = OptimaServiceSettings.Load(Path.GetFullPath(configPath));
|
||||
var runtime = settings.ResolveRuntimeSettings(verboseLogging: true);
|
||||
using var adapter = new OptimaOrderAdapter(runtime.InstallationPath);
|
||||
var info = adapter.Initialize(runtime.AdapterConfiguration);
|
||||
adapter.CheckConnection();
|
||||
Console.WriteLine(
|
||||
$"PASS check-config: {info.ErpName} {info.ErpVersion}, baza {info.DatabaseName}, " +
|
||||
$"architektura {info.Details?["Process bitness"]}.");
|
||||
|
||||
var sql = new SqlRawHandler(runtime.SqlConnectionString, runtime.SqlCommandTimeoutSeconds);
|
||||
var sqlResult = (SqlRawResult?)await sql.HandleAsync(
|
||||
new RpcInvocation(
|
||||
[],
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["query"] = "SELECT @1 AS parameter_value; SELECT CAST(0x0102 AS varbinary(2)) AS binary_value;",
|
||||
["params"] = new object?[] { 123 }
|
||||
}),
|
||||
CancellationToken.None);
|
||||
if (sqlResult?.Recordsets.Count != 2 ||
|
||||
sqlResult.Recordsets[0].Count != 1 ||
|
||||
sqlResult.Recordsets[1].Count != 1 ||
|
||||
Convert.ToInt32(sqlResult.Recordsets[0][0]["parameter_value"]) != 123 ||
|
||||
sqlResult.Recordsets[1][0]["binary_value"] is not IReadOnlyDictionary<string, object> binary ||
|
||||
!string.Equals(Convert.ToString(binary["type"]), "Buffer", StringComparison.Ordinal) ||
|
||||
binary["data"] is not int[] { Length: 2 } bytes ||
|
||||
bytes[0] != 1 ||
|
||||
bytes[1] != 2)
|
||||
{
|
||||
throw new InvalidOperationException("sql_raw nie zwrócił oczekiwanych parametrów, recordsetów lub danych binarnych.");
|
||||
}
|
||||
await ExpectSqlErrorAsync(sql);
|
||||
Console.WriteLine("PASS sql_raw: parametry, dwa recordsety, wartość binarna i stabilny błąd SQL.");
|
||||
|
||||
var needsTestData = options.ContainsKey("negative-tests") || options.ContainsKey("write-order");
|
||||
options.TryGetValue("customer-id", out var customerId);
|
||||
options.TryGetValue("product-code", out var productCode);
|
||||
if (needsTestData && (string.IsNullOrWhiteSpace(customerId) || string.IsNullOrWhiteSpace(productCode)))
|
||||
{
|
||||
Console.Error.WriteLine("Przy --negative-tests lub --write-order wymagane są --customer-id i --product-code.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (options.ContainsKey("negative-tests"))
|
||||
{
|
||||
RunNegativeTests(adapter, runtime.AdapterConfiguration, customerId!, productCode!);
|
||||
Console.WriteLine("PASS błędy ERP: kontrahent, magazyn, waluta i towar.");
|
||||
}
|
||||
|
||||
if (!options.ContainsKey("write-order"))
|
||||
{
|
||||
Console.WriteLine("Test zakończony bez zapisu. Dodaj --write-order, aby utworzyć RO w buforze.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var saveMode = options.ContainsKey("approved") ? OrderSaveMode.Approved : OrderSaveMode.Buffer;
|
||||
if (saveMode == OrderSaveMode.Buffer && runtime.AdapterConfiguration.SaveMode != OrderSaveMode.Buffer)
|
||||
{
|
||||
Console.Error.WriteLine("Runner integracyjny zezwala na zapis wyłącznie przy optima.saveMode=Buffer.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var reference = $"SMARTB2B-IT-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
|
||||
var request = CreateRequest(runtime.AdapterConfiguration, customerId!, productCode!, "PLN", reference, saveMode);
|
||||
|
||||
var result = adapter.CreateOrder(request);
|
||||
Console.WriteLine("PASS place_order:");
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
order_erp_id = result.Id,
|
||||
order_erp_symbol = result.Number,
|
||||
value_netto = result.Net,
|
||||
value_brutto = result.Gross,
|
||||
reference,
|
||||
save_mode = result.SaveMode.ToString()
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return 0;
|
||||
|
||||
static void RunNegativeTests(
|
||||
OptimaOrderAdapter adapter,
|
||||
ErpAdapterConfiguration configuration,
|
||||
string customerId,
|
||||
string productCode)
|
||||
{
|
||||
ExpectError(
|
||||
() => adapter.CreateOrder(CreateRequest(configuration, "2147483647", productCode, "PLN", "SMARTB2B-ERR-KNT")),
|
||||
"eu.smartb2b.company_not_found");
|
||||
ExpectError(
|
||||
() => adapter.CreateOrder(CreateRequest(
|
||||
configuration with { DefaultWarehouseCode = "2147483647" },
|
||||
customerId,
|
||||
productCode,
|
||||
"PLN",
|
||||
"SMARTB2B-ERR-MAG")),
|
||||
"eu.smartb2b.erp.warehouse_not_found");
|
||||
ExpectError(
|
||||
() => adapter.CreateOrder(CreateRequest(configuration, customerId, productCode, "ZZZ", "SMARTB2B-ERR-WAL")),
|
||||
"eu.smartb2b.erp.currency_not_found");
|
||||
ExpectError(
|
||||
() => adapter.CreateOrder(CreateRequest(configuration, customerId, "SMARTB2B_MISSING", "PLN", "SMARTB2B-ERR-TWR")),
|
||||
"eu.smartb2b.product_not_found");
|
||||
}
|
||||
|
||||
static OrderRequest CreateRequest(
|
||||
ErpAdapterConfiguration configuration,
|
||||
string customerId,
|
||||
string productCode,
|
||||
string currency,
|
||||
string reference,
|
||||
OrderSaveMode saveMode = OrderSaveMode.Buffer) => new()
|
||||
{
|
||||
CurrencyIso = currency,
|
||||
CustomerCode = customerId,
|
||||
CustomerReference = reference,
|
||||
Notes = "Automatyczny test integracyjny SmartB2B",
|
||||
DocumentDefinition = configuration.DocumentDefinition,
|
||||
WarehouseCode = configuration.DefaultWarehouseCode,
|
||||
SaveMode = saveMode,
|
||||
Items =
|
||||
[
|
||||
new OrderLineRequest
|
||||
{
|
||||
ProductCode = productCode,
|
||||
Quantity = 2m,
|
||||
UnitPriceNet = 10m,
|
||||
UnitPriceGross = 10m,
|
||||
Discount = 5m
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
static void ExpectError(Action action, string expectedUri)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
throw new InvalidOperationException($"Oczekiwano błędu {expectedUri}.");
|
||||
}
|
||||
catch (ErpOperationException exception) when (exception.ErrorUri == expectedUri)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
static async Task ExpectSqlErrorAsync(SqlRawHandler sql)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = await sql.HandleAsync(
|
||||
new RpcInvocation(
|
||||
[],
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["query"] = "SELECT * FROM CDN.__SMARTB2B_MISSING_TABLE__"
|
||||
}),
|
||||
CancellationToken.None);
|
||||
throw new InvalidOperationException("Oczekiwano błędu eu.smartb2b.sql_error.");
|
||||
}
|
||||
catch (WampException exception) when (exception.ErrorUri == "eu.smartb2b.sql_error")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
static Dictionary<string, string?> ParseArguments(string[] arguments)
|
||||
{
|
||||
var result = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < arguments.Length; index++)
|
||||
{
|
||||
var argument = arguments[index];
|
||||
if (!argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Nieznany argument: {argument}");
|
||||
}
|
||||
|
||||
var name = argument[2..];
|
||||
if (string.Equals(name, "write-order", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "approved", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "negative-tests", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result[name] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (++index >= arguments.Length)
|
||||
{
|
||||
throw new ArgumentException($"Brak wartości argumentu {argument}.");
|
||||
}
|
||||
|
||||
result[name] = arguments[index];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/SmartB2B.Optima.Adapter/SmartB2B.Optima.Adapter.csproj" />
|
||||
<ProjectReference Include="../../src/SmartB2B.Optima.Service/SmartB2B.Optima.Service.csproj" />
|
||||
<ProjectReference Include="../../src/SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
319
tests/SmartB2B.Optima.Tests/Program.cs
Normal file
319
tests/SmartB2B.Optima.Tests/Program.cs
Normal file
@@ -0,0 +1,319 @@
|
||||
using System.Collections.Concurrent;
|
||||
using SmartB2B.Optima.Adapter;
|
||||
using SmartB2B.Sync.Contracts;
|
||||
using SmartB2B.Sync.Service.Orders;
|
||||
using SmartB2B.Sync.Service.Rpc;
|
||||
|
||||
var tests = new (string Name, Func<Task> Run)[]
|
||||
{
|
||||
("Cena netto pozostaje zgodna wstecznie", NetPriceIsBackwardCompatible),
|
||||
("Cena brutto jest obsługiwana addytywnie", GrossPriceIsAccepted),
|
||||
("Brak obu cen jest odrzucany", MissingPricesAreRejected),
|
||||
("Ujemna cena brutto jest odrzucana", NegativeGrossPriceIsRejected),
|
||||
("Tryb netto wybiera price_netto", NetDocumentSelectsNetPrice),
|
||||
("Tryb brutto wybiera price_brutto", GrossDocumentSelectsGrossPrice),
|
||||
("Brak ceny wymaganej przez definicję jest stabilnym błędem", RequiredPriceIsEnforced),
|
||||
("Handler przekazuje pełny kontrakt do Optimy", HandlerMapsOptimaContract),
|
||||
("Wynik Optimy zachowuje kwargs WAMP", HandlerReturnsKeywordResult),
|
||||
("Operacje zamówień są serializowane", ConcurrentOrdersAreSerialized),
|
||||
("Fasada COM działa na jednym wątku STA", FacadeRunsOnSingleStaThread),
|
||||
("Adapter serializuje bezpośrednie wywołania COM", AdapterSerializesComCalls)
|
||||
};
|
||||
|
||||
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 NetPriceIsBackwardCompatible()
|
||||
{
|
||||
AssertNoErrors(CreateRequest(net: 10m, gross: null));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static Task GrossPriceIsAccepted()
|
||||
{
|
||||
AssertNoErrors(CreateRequest(net: null, gross: 12.30m));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static Task MissingPricesAreRejected()
|
||||
{
|
||||
AssertHasError(CreateRequest(net: null, gross: null), "price_netto lub price_brutto");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static Task NegativeGrossPriceIsRejected()
|
||||
{
|
||||
AssertHasError(CreateRequest(net: null, gross: -0.01m), "price_brutto");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static Task NetDocumentSelectsNetPrice()
|
||||
{
|
||||
AssertEqual(10m, OptimaOrderAdapter.SelectUnitPrice(1, CreateLine(10m, 12.30m), 0), "cena netto");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static Task GrossDocumentSelectsGrossPrice()
|
||||
{
|
||||
AssertEqual(12.30m, OptimaOrderAdapter.SelectUnitPrice(2, CreateLine(10m, 12.30m), 0), "cena brutto");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static Task RequiredPriceIsEnforced()
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = OptimaOrderAdapter.SelectUnitPrice(2, CreateLine(10m, null), 3);
|
||||
throw new InvalidOperationException("Oczekiwano błędu wymaganej ceny brutto.");
|
||||
}
|
||||
catch (ErpOperationException exception) when (
|
||||
exception.ErrorUri == "eu.smartb2b.erp.invalid_order_lines" &&
|
||||
exception.Message.Contains("lines[3].price_brutto", StringComparison.Ordinal))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task HandlerMapsOptimaContract()
|
||||
{
|
||||
using var adapter = new CapturingAdapter();
|
||||
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
|
||||
await handler.HandleAsync(CreateInvocation(), CancellationToken.None);
|
||||
var request = adapter.LastRequest ?? throw new InvalidOperationException("Adapter nie otrzymał zamówienia.");
|
||||
AssertEqual("EUR", request.CurrencyIso, "waluta");
|
||||
AssertEqual("123", request.CustomerCode, "kontrahent");
|
||||
AssertEqual("7", request.WarehouseCode, "magazyn");
|
||||
AssertEqual(100m, request.Items[0].UnitPriceNet, "netto");
|
||||
AssertEqual(123m, request.Items[0].UnitPriceGross, "brutto");
|
||||
}
|
||||
|
||||
static async Task HandlerReturnsKeywordResult()
|
||||
{
|
||||
using var adapter = new CapturingAdapter();
|
||||
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
|
||||
var result = await handler.HandleAsync(CreateInvocation(), CancellationToken.None) as KeywordResult
|
||||
?? throw new InvalidOperationException("Brak wyniku kwargs.");
|
||||
AssertEqual(42, result.Values["order_erp_id"], "ID");
|
||||
AssertEqual("RO/1/2026", result.Values["order_erp_symbol"], "numer");
|
||||
AssertEqual(100m, result.Values["value_netto"], "netto");
|
||||
AssertEqual(123m, result.Values["value_brutto"], "brutto");
|
||||
}
|
||||
|
||||
static async Task ConcurrentOrdersAreSerialized()
|
||||
{
|
||||
using var adapter = new CapturingAdapter(delayMilliseconds: 100);
|
||||
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
|
||||
await Task.WhenAll(
|
||||
handler.HandleAsync(CreateInvocation(), CancellationToken.None),
|
||||
handler.HandleAsync(CreateInvocation(), CancellationToken.None));
|
||||
AssertEqual(1, adapter.MaximumConcurrency, "maksymalna równoległość adaptera");
|
||||
}
|
||||
|
||||
static Task FacadeRunsOnSingleStaThread()
|
||||
{
|
||||
var facade = new CapturingComFacade();
|
||||
using var adapter = new OptimaOrderAdapter("C:\\Optima-Test", facade);
|
||||
var info = adapter.Initialize(CreateConfiguration());
|
||||
adapter.CheckConnection();
|
||||
_ = adapter.CreateOrder(CreateRequest(10m, 12.30m));
|
||||
|
||||
AssertEqual(1, facade.ThreadIds.Distinct().Count(), "liczba wątków COM");
|
||||
AssertEqual(ApartmentState.STA, facade.ApartmentStates.Distinct().Single(), "apartment COM");
|
||||
AssertEqual("Buffer", info.Details?["Save mode"], "tryb zapisu w diagnostyce");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static async Task AdapterSerializesComCalls()
|
||||
{
|
||||
var facade = new CapturingComFacade(delayMilliseconds: 100);
|
||||
using var adapter = new OptimaOrderAdapter("C:\\Optima-Test", facade);
|
||||
adapter.Initialize(CreateConfiguration());
|
||||
var request = CreateRequest(10m, 12.30m);
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(() => adapter.CreateOrder(request)),
|
||||
Task.Run(() => adapter.CreateOrder(request)));
|
||||
|
||||
AssertEqual(1, facade.MaximumConcurrency, "maksymalna równoległość fasady COM");
|
||||
}
|
||||
|
||||
static RpcInvocation CreateInvocation() => new(
|
||||
[],
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["currency_iso"] = "eur",
|
||||
["companyErpId"] = "123",
|
||||
["warehouseErpId"] = "7",
|
||||
["purchase_order_number"] = "PO/OPTIMA/1",
|
||||
["notes"] = "Test",
|
||||
["lines"] = new[]
|
||||
{
|
||||
new PlaceOrderLine
|
||||
{
|
||||
Symbol = "TOWAR",
|
||||
Quantity = 2m,
|
||||
PriceNetto = 100m,
|
||||
PriceBrutto = 123m,
|
||||
Discount = 12.5m
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static ErpAdapterConfiguration CreateConfiguration() => new(
|
||||
new ErpConnectionOptions("Firma demo", "ADMIN", "", "Server=.;Database=demo"),
|
||||
"RO",
|
||||
"1",
|
||||
OrderSaveMode.Buffer);
|
||||
|
||||
static OrderRequest CreateRequest(decimal? net, decimal? gross) => new()
|
||||
{
|
||||
CurrencyIso = "PLN",
|
||||
CustomerCode = "123",
|
||||
DocumentDefinition = "RO",
|
||||
WarehouseCode = "1",
|
||||
Items = [CreateLine(net, gross)]
|
||||
};
|
||||
|
||||
static OrderLineRequest CreateLine(decimal? net, decimal? gross) => new()
|
||||
{
|
||||
ProductCode = "TOWAR",
|
||||
Quantity = 1m,
|
||||
UnitPriceNet = net,
|
||||
UnitPriceGross = gross,
|
||||
Discount = 0m
|
||||
};
|
||||
|
||||
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 text)
|
||||
{
|
||||
var errors = OrderValidator.Validate(request);
|
||||
if (!errors.Any(error => error.Contains(text, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidOperationException($"Oczekiwano '{text}', otrzymano: {string.Join(" | ", errors)}");
|
||||
}
|
||||
}
|
||||
|
||||
static void AssertEqual(object? expected, object? actual, string name)
|
||||
{
|
||||
if (!Equals(expected, actual))
|
||||
{
|
||||
throw new InvalidOperationException($"{name}: oczekiwano '{expected}', otrzymano '{actual}'.");
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class CapturingAdapter : IErpOrderAdapter
|
||||
{
|
||||
private readonly int _delayMilliseconds;
|
||||
private int _concurrency;
|
||||
private int _maximumConcurrency;
|
||||
|
||||
public CapturingAdapter(int delayMilliseconds = 0) => _delayMilliseconds = delayMilliseconds;
|
||||
|
||||
public OrderRequest? LastRequest { get; private set; }
|
||||
public int MaximumConcurrency => _maximumConcurrency;
|
||||
|
||||
public ErpAdapterInfo Initialize(ErpAdapterConfiguration configuration) =>
|
||||
new("1.0", "Comarch ERP Optima", "2026.5.1", configuration.Connection.DatabaseName, []);
|
||||
|
||||
public void CheckConnection() { }
|
||||
|
||||
public OrderResult CreateOrder(OrderRequest request)
|
||||
{
|
||||
var active = Interlocked.Increment(ref _concurrency);
|
||||
InterlockedExtensions.Max(ref _maximumConcurrency, active);
|
||||
try
|
||||
{
|
||||
LastRequest = request;
|
||||
if (_delayMilliseconds > 0) Thread.Sleep(_delayMilliseconds);
|
||||
return new OrderResult(
|
||||
42, "RO/1/2026", request.SaveMode, request.CustomerCode,
|
||||
request.WarehouseCode ?? "1", request.CustomerReference, request.Items.Count,
|
||||
100m, 23m, 123m, request.Items.Any(line => line.Discount > 0));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _concurrency);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
file static class InterlockedExtensions
|
||||
{
|
||||
public static void Max(ref int target, int value)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = Volatile.Read(ref target);
|
||||
if (current >= value || Interlocked.CompareExchange(ref target, value, current) == current) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class CapturingComFacade : IOptimaComFacade
|
||||
{
|
||||
private readonly int _delayMilliseconds;
|
||||
private int _concurrency;
|
||||
private int _maximumConcurrency;
|
||||
|
||||
public CapturingComFacade(int delayMilliseconds = 0) => _delayMilliseconds = delayMilliseconds;
|
||||
|
||||
public ConcurrentBag<int> ThreadIds { get; } = [];
|
||||
public ConcurrentBag<ApartmentState> ApartmentStates { get; } = [];
|
||||
public int MaximumConcurrency => _maximumConcurrency;
|
||||
|
||||
public OptimaRuntimeInfo InspectRuntime(string installationPath) =>
|
||||
new("2026.5.1.6382", "CDNBase.Application", "x64");
|
||||
|
||||
public void CheckConnection(ErpAdapterConfiguration configuration) => Capture(() => { });
|
||||
|
||||
public OrderResult CreateOrder(ErpAdapterConfiguration configuration, OrderRequest request) => Capture(() =>
|
||||
new OrderResult(
|
||||
84, "RO/2/2026", request.SaveMode, request.CustomerCode,
|
||||
request.WarehouseCode ?? "1", request.CustomerReference, request.Items.Count,
|
||||
10m, 2.30m, 12.30m, false));
|
||||
|
||||
private T Capture<T>(Func<T> action)
|
||||
{
|
||||
var active = Interlocked.Increment(ref _concurrency);
|
||||
InterlockedExtensions.Max(ref _maximumConcurrency, active);
|
||||
try
|
||||
{
|
||||
ThreadIds.Add(Environment.CurrentManagedThreadId);
|
||||
ApartmentStates.Add(Thread.CurrentThread.GetApartmentState());
|
||||
if (_delayMilliseconds > 0) Thread.Sleep(_delayMilliseconds);
|
||||
return action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _concurrency);
|
||||
}
|
||||
}
|
||||
|
||||
private void Capture(Action action) => Capture(() =>
|
||||
{
|
||||
action();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
13
tests/SmartB2B.Optima.Tests/SmartB2B.Optima.Tests.csproj
Normal file
13
tests/SmartB2B.Optima.Tests/SmartB2B.Optima.Tests.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/SmartB2B.Optima.Adapter/SmartB2B.Optima.Adapter.csproj" />
|
||||
<ProjectReference Include="../../src/SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" />
|
||||
<ProjectReference Include="../../src/SmartB2B.Sync.Service/SmartB2B.Sync.Service.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user