Files

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;
}
}
}