495 lines
17 KiB
C#
495 lines
17 KiB
C#
using System.Collections.Concurrent;
|
|
using SmartB2B.Optima.Adapter;
|
|
using SmartB2B.Sync.Contracts;
|
|
using SmartB2B.Sync.Service.Orders;
|
|
using SmartB2B.Sync.Service.Rpc;
|
|
using WampSharp.V2.Core.Contracts;
|
|
|
|
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),
|
|
("Handler przyjmuje i pomija opcjonalne extraFields", HandlerMapsOptionalExtraFields),
|
|
("Handler odrzuca błędne extraFields", HandlerRejectsInvalidExtraFields),
|
|
("Fasada przypisuje atrybuty nagłówka przed zapisem", FacadeAddsDocumentAttributes),
|
|
("Fasada odrzuca nieznany kod atrybutu", FacadeRejectsMissingAttribute),
|
|
("Fasada zgłasza odrzuconą wartość atrybutu", FacadeRejectsAttributeValue),
|
|
("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 HandlerMapsOptionalExtraFields()
|
|
{
|
|
using var adapter = new CapturingAdapter();
|
|
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
|
|
await handler.HandleAsync(CreateInvocation(new Dictionary<string, object?>
|
|
{
|
|
["SOURCE"] = "B2B",
|
|
["NOTE"] = ""
|
|
}), CancellationToken.None);
|
|
AssertEqual("B2B", adapter.LastRequest?.ExtraFields["SOURCE"], "wartość SOURCE");
|
|
AssertEqual("", adapter.LastRequest?.ExtraFields["NOTE"], "pusta wartość");
|
|
|
|
await handler.HandleAsync(CreateInvocation(), CancellationToken.None);
|
|
AssertEqual(0, adapter.LastRequest?.ExtraFields.Count, "brak extraFields");
|
|
await handler.HandleAsync(CreateInvocation(new Dictionary<string, string>()), CancellationToken.None);
|
|
AssertEqual(0, adapter.LastRequest?.ExtraFields.Count, "puste extraFields");
|
|
}
|
|
|
|
static async Task HandlerRejectsInvalidExtraFields()
|
|
{
|
|
foreach (var invalid in new object?[]
|
|
{
|
|
null,
|
|
"text",
|
|
new[] { "SOURCE" },
|
|
new Dictionary<string, object?> { ["SOURCE"] = 123 },
|
|
new Dictionary<string, object?> { [" "] = "value" },
|
|
new Dictionary<string, object?> { ["SOURCE"] = "one", [" SOURCE "] = "two" }
|
|
})
|
|
{
|
|
using var adapter = new CapturingAdapter();
|
|
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
|
|
try
|
|
{
|
|
await handler.HandleAsync(CreateInvocation(invalid, includeExtraFields: true), CancellationToken.None);
|
|
throw new InvalidOperationException("Oczekiwano odrzucenia extraFields.");
|
|
}
|
|
catch (WampException exception) when (exception.ErrorUri == "eu.smartb2b.erp.invalid_order_lines")
|
|
{
|
|
AssertEqual(null, adapter.LastRequest, "brak zapisu zamówienia");
|
|
}
|
|
}
|
|
}
|
|
|
|
static Task FacadeAddsDocumentAttributes()
|
|
{
|
|
var session = new FakeAttributeSession(new Dictionary<string, int> { ["SOURCE"] = 7, ["NOTE"] = 8 });
|
|
var document = new FakeAttributeDocument();
|
|
LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary<string, string>
|
|
{
|
|
["SOURCE"] = "B2B",
|
|
["NOTE"] = ""
|
|
});
|
|
AssertEqual(2, document.Atrybuty.Items.Count, "liczba atrybutów");
|
|
AssertEqual(7, document.Atrybuty.Items[0].DeAID, "ID SOURCE");
|
|
AssertEqual("B2B", document.Atrybuty.Items[0].Wartosc, "wartość SOURCE");
|
|
AssertEqual(8, document.Atrybuty.Items[1].DeAID, "ID NOTE");
|
|
AssertEqual("", document.Atrybuty.Items[1].Wartosc, "pusta wartość NOTE");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static Task FacadeRejectsMissingAttribute()
|
|
{
|
|
var session = new FakeAttributeSession(new Dictionary<string, int>());
|
|
var document = new FakeAttributeDocument();
|
|
try
|
|
{
|
|
LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary<string, string>
|
|
{
|
|
["MISSING"] = "value"
|
|
});
|
|
throw new InvalidOperationException("Oczekiwano błędu nieznanego kodu.");
|
|
}
|
|
catch (ErpOperationException exception) when (exception.ErrorUri == "eu.smartb2b.erp.document_attribute_not_found")
|
|
{
|
|
AssertEqual(0, document.Atrybuty.Items.Count, "brak dodanego atrybutu");
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
static Task FacadeRejectsAttributeValue()
|
|
{
|
|
var session = new FakeAttributeSession(new Dictionary<string, int> { ["SOURCE"] = 7 });
|
|
var document = new FakeAttributeDocument();
|
|
try
|
|
{
|
|
LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary<string, string>
|
|
{
|
|
["SOURCE"] = "REJECTED"
|
|
});
|
|
throw new InvalidOperationException("Oczekiwano błędu wartości atrybutu.");
|
|
}
|
|
catch (ErpOperationException exception) when (exception.ErrorUri == "eu.smartb2b.erp.invalid_document_attribute")
|
|
{
|
|
AssertEqual("SOURCE", exception.Details["attributeCode"], "kod w błędzie");
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
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");
|
|
AssertEqual(true, info.Capabilities.Contains("extraFields"), "możliwość extraFields");
|
|
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(object? extraFields = null, bool includeExtraFields = false)
|
|
{
|
|
var arguments = 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
|
|
}
|
|
}
|
|
};
|
|
if (includeExtraFields || extraFields is not null) arguments["extraFields"] = extraFields;
|
|
return new RpcInvocation([], arguments);
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
public sealed class FakeAttributeSession
|
|
{
|
|
private readonly IReadOnlyDictionary<string, int> _definitions;
|
|
|
|
public FakeAttributeSession(IReadOnlyDictionary<string, int> definitions) => _definitions = definitions;
|
|
|
|
public FakeDefinitionCollection CreateObject(string name)
|
|
{
|
|
if (name != "CDN.DefAtrybuty") throw new InvalidOperationException($"Nieoczekiwana kolekcja: {name}");
|
|
return new FakeDefinitionCollection(_definitions);
|
|
}
|
|
}
|
|
|
|
public sealed class FakeDefinitionCollection
|
|
{
|
|
private readonly IReadOnlyDictionary<string, int> _definitions;
|
|
|
|
public FakeDefinitionCollection(IReadOnlyDictionary<string, int> definitions) => _definitions = definitions;
|
|
|
|
public FakeDefinition? Item(string filter)
|
|
{
|
|
if (!filter.StartsWith("DeA_Typ=4 AND DeA_Kod='", StringComparison.Ordinal))
|
|
throw new InvalidOperationException($"Niepoprawny filtr: {filter}");
|
|
var code = filter["DeA_Typ=4 AND DeA_Kod='".Length..^1].Replace("''", "'", StringComparison.Ordinal);
|
|
return _definitions.TryGetValue(code, out var id) ? new FakeDefinition(id) : null;
|
|
}
|
|
}
|
|
|
|
public sealed class FakeDefinition
|
|
{
|
|
public FakeDefinition(int id) => ID = id;
|
|
public int ID { get; }
|
|
}
|
|
|
|
public sealed class FakeAttributeDocument
|
|
{
|
|
public FakeAttributeCollection Atrybuty { get; } = new();
|
|
}
|
|
|
|
public sealed class FakeAttributeCollection
|
|
{
|
|
public List<FakeDocumentAttribute> Items { get; } = [];
|
|
|
|
public FakeDocumentAttribute AddNew()
|
|
{
|
|
var attribute = new FakeDocumentAttribute();
|
|
Items.Add(attribute);
|
|
return attribute;
|
|
}
|
|
}
|
|
|
|
public sealed class FakeDocumentAttribute
|
|
{
|
|
public int DeAID { get; set; }
|
|
private string? _value;
|
|
public string? Wartosc
|
|
{
|
|
get => _value;
|
|
set
|
|
{
|
|
if (value == "REJECTED") throw new ArgumentException("Niepoprawna wartość.");
|
|
_value = value;
|
|
}
|
|
}
|
|
}
|