Files

320 lines
11 KiB
C#

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", "x86");
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;
});
}