diff --git a/OPTIMA-INTEGRATION-TESTS.md b/OPTIMA-INTEGRATION-TESTS.md index f90b2ee..5053a4f 100644 --- a/OPTIMA-INTEGRATION-TESTS.md +++ b/OPTIMA-INTEGRATION-TESTS.md @@ -45,5 +45,14 @@ Po pomyślnym `--check-config` uruchom usługę z tym samym plikiem konfiguracji - błędy dla brakującego kontrahenta, produktu, magazynu, waluty i wymaganej ceny; - parametryzowane `sql_raw`, wiele recordsetów, dane binarne oraz błąd SQL; - dwa równoczesne wywołania `place_order` — dokumenty mają zostać utworzone kolejno na jednym wątku COM. +- `extraFields` z dwoma istniejącymi atrybutami dokumentu: wartości mają być widoczne na nagłówku RO; nieznany kod ma zwrócić błąd bez zapisu dokumentu. + +Runner może sprawdzić ostatni scenariusz po podaniu dwóch kodów atrybutów tekstowych istniejących w firmie testowej: + +```powershell +dotnet run --project .\tests\SmartB2B.Optima.IntegrationTests -- --config .\src\SmartB2B.Optima.Service\config\optima.json --write-order --customer-id 2 --product-code SMARTB2B_TEST_TWR --attribute-code-1 SOURCE --attribute-value-1 B2B --attribute-code-2 NOTE --attribute-value-2 test +``` + +Test odczytuje zapisane wartości z `CDN.DokAtrybuty` i sprawdza, że próba użycia nieznanego kodu nie utworzyła RO. Przed testami trybu `Approved` wykonaj kopię bezpieczeństwa bazy lub użyj firmy przeznaczonej wyłącznie do testów. diff --git a/README.md b/README.md index 421e1df..24ba3f5 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Wycofanie polega na uruchomieniu `release-enova/daemon/uninstall.cmd` i ponownym ## Procedury WAMP -- `eu.smartb2b.place_order` przyjmuje `currency_iso`, `companyErpId`, `warehouseErpId`, `purchase_order_number`, `notes` i `lines[]` z polami `symbol`, `quantity`, opcjonalnymi `price_netto` i `price_brutto` oraz `discount`. Enova wymaga `price_netto`; Optima wybiera cenę zgodnie z `TypNB` definicji dokumentu. Wynik jest zwracany jako kwargs: `order_erp_id`, `order_erp_symbol`, `value_netto`, `value_brutto`, `stocks`. +- `eu.smartb2b.place_order` przyjmuje `currency_iso`, `companyErpId`, `warehouseErpId`, `purchase_order_number`, `notes` i `lines[]` z polami `symbol`, `quantity`, opcjonalnymi `price_netto` i `price_brutto` oraz `discount`. Dla Optimy opcjonalne `extraFields` jest mapą kodów atrybutów dokumentu (`DefAtrybuty.DeA_Kod`) na wartości tekstowe, np. `{"ŹRÓDŁO":"B2B"}`. Nieznany kod lub wartość odrzucona przez Optimę przerywa zapis zamówienia. Enova wymaga `price_netto`; Optima wybiera cenę zgodnie z `TypNB` definicji dokumentu. Wynik jest zwracany jako kwargs: `order_erp_id`, `order_erp_symbol`, `value_netto`, `value_brutto`, `stocks`. - `eu.smartb2b.sql_raw` przyjmuje `query` i opcjonalne `params`; parametry odpowiadają kolejno `@1`, `@2`, itd. Wynik ma pola `recordsets`, `recordset`, `output`, `rowsAffected`. - Diagnostyka: `eu.smartb2b.sync.get_version`, `get_info`, `get_log`, `get_error_log`. diff --git a/src/SmartB2B.Optima.Adapter/LateBoundOptimaComFacade.cs b/src/SmartB2B.Optima.Adapter/LateBoundOptimaComFacade.cs index 97826fc..dedef04 100644 --- a/src/SmartB2B.Optima.Adapter/LateBoundOptimaComFacade.cs +++ b/src/SmartB2B.Optima.Adapter/LateBoundOptimaComFacade.cs @@ -151,6 +151,7 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade doc.Uwagi = request.Notes?.Trim() ?? string.Empty; doc.WalutaSymbol = request.CurrencyIso; doc.Export = 0; + AddDocumentAttributes(comSession, doc, request.ExtraFields); elements = doc.Elementy; var calculationMode = Convert.ToInt32(doc.TypNB); @@ -184,7 +185,19 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade } Verbose(verboseLogging, "Optima: zapis dokumentu przez session.Save()."); - comSession.Save(); + try + { + comSession.Save(); + } + catch (Exception exception) when (request.ExtraFields.Count > 0) + { + var codes = request.ExtraFields.Keys.ToArray(); + throw new ErpOperationException( + "eu.smartb2b.erp.invalid_document_attribute", + $"Nie udało się zapisać zamówienia z atrybutami dokumentu: {string.Join(", ", codes)}.", + new Dictionary { ["attributeCodes"] = codes }, + exception); + } var net = Convert.ToDecimal(doc.RazemNettoWal); var gross = Convert.ToDecimal(doc.RazemBruttoWal); return new OrderResult( @@ -212,6 +225,58 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade } } + internal static void AddDocumentAttributes( + dynamic session, + dynamic document, + IReadOnlyDictionary extraFields) + { + if (extraFields.Count == 0) return; + + object? attributes = null; + try + { + attributes = document.Atrybuty; + foreach (var (code, value) in extraFields) + { + object? definition = null; + object? attribute = null; + try + { + definition = FindRequired( + session, + "CDN.DefAtrybuty", + $"DeA_Typ=4 AND DeA_Kod='{EscapeFilter(code)}'", + "eu.smartb2b.erp.document_attribute_not_found", + $"Nie znaleziono atrybutu dokumentu o kodzie '{code}'."); + attribute = ((dynamic)attributes).AddNew(); + ((dynamic)attribute).DeAID = Convert.ToInt32(((dynamic)definition).ID); + ((dynamic)attribute).Wartosc = value; + } + catch (ErpOperationException) + { + throw; + } + catch (Exception exception) + { + throw new ErpOperationException( + "eu.smartb2b.erp.invalid_document_attribute", + $"Nie można przypisać wartości atrybutu dokumentu '{code}'.", + new Dictionary { ["attributeCode"] = code }, + exception); + } + finally + { + ReleaseComObject(attribute); + ReleaseComObject(definition); + } + } + } + finally + { + ReleaseComObject(attributes); + } + } + private static object FindRequired( dynamic session, string collectionName, diff --git a/src/SmartB2B.Optima.Adapter/OptimaOrderAdapter.cs b/src/SmartB2B.Optima.Adapter/OptimaOrderAdapter.cs index b9112b5..c8e1050 100644 --- a/src/SmartB2B.Optima.Adapter/OptimaOrderAdapter.cs +++ b/src/SmartB2B.Optima.Adapter/OptimaOrderAdapter.cs @@ -41,7 +41,7 @@ public sealed class OptimaOrderAdapter : IErpOrderAdapter "Comarch ERP Optima", runtime.ErpVersion, configuration.Connection.DatabaseName, - ["price_netto", "price_brutto", "multi_currency", "Buffer", "Approved", "COM STA"], + ["price_netto", "price_brutto", "multi_currency", "extraFields", "Buffer", "Approved", "COM STA"], new Dictionary { ["COM ProgID"] = runtime.ComProgId, diff --git a/src/SmartB2B.Optima.Adapter/SmartB2B.Optima.Adapter.csproj b/src/SmartB2B.Optima.Adapter/SmartB2B.Optima.Adapter.csproj index 457bf4d..61cac22 100644 --- a/src/SmartB2B.Optima.Adapter/SmartB2B.Optima.Adapter.csproj +++ b/src/SmartB2B.Optima.Adapter/SmartB2B.Optima.Adapter.csproj @@ -7,5 +7,6 @@ + diff --git a/src/SmartB2B.Sync.Contracts/SyncContracts.cs b/src/SmartB2B.Sync.Contracts/SyncContracts.cs index 963c835..de69c66 100644 --- a/src/SmartB2B.Sync.Contracts/SyncContracts.cs +++ b/src/SmartB2B.Sync.Contracts/SyncContracts.cs @@ -36,6 +36,8 @@ public sealed class OrderRequest public OrderSaveMode SaveMode { get; init; } = OrderSaveMode.Buffer; public IReadOnlyList Items { get; init; } = []; + + public IReadOnlyDictionary ExtraFields { get; init; } = new Dictionary(); } public sealed class OrderLineRequest @@ -126,6 +128,12 @@ public static class OrderValidator errors.Add("Definicja dokumentu jest wymagana."); } + foreach (var field in request.ExtraFields) + { + if (string.IsNullOrWhiteSpace(field.Key)) errors.Add("Kod w extraFields nie może być pusty."); + if (field.Value is null) errors.Add($"extraFields['{field.Key}'] musi być tekstem."); + } + if (request.Items is null || request.Items.Count == 0) { errors.Add("Zamówienie musi zawierać co najmniej jedną pozycję."); diff --git a/src/SmartB2B.Sync.Service/Orders/PlaceOrderHandler.cs b/src/SmartB2B.Sync.Service/Orders/PlaceOrderHandler.cs index a50401f..669acaf 100644 --- a/src/SmartB2B.Sync.Service/Orders/PlaceOrderHandler.cs +++ b/src/SmartB2B.Sync.Service/Orders/PlaceOrderHandler.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using SmartB2B.Sync.Contracts; using SmartB2B.Sync.Service.Diagnostics; using SmartB2B.Sync.Service.Rpc; @@ -34,6 +35,7 @@ public sealed class PlaceOrderHandler DocumentDefinition = _configuration.DocumentDefinition, WarehouseCode = invocation.Get("warehouseErpId") ?? _configuration.DefaultWarehouseCode, SaveMode = _configuration.SaveMode, + ExtraFields = ReadExtraFields(invocation), Items = inputLines.Select(line => new OrderLineRequest { ProductCode = line.Symbol ?? string.Empty, @@ -93,6 +95,37 @@ public sealed class PlaceOrderHandler _orderLock.Release(); } } + + private static IReadOnlyDictionary ReadExtraFields(RpcInvocation invocation) + { + if (!invocation.Contains("extraFields")) return new Dictionary(); + + JToken? token; + try { token = invocation.Get("extraFields"); } + catch (Exception exception) when (exception is JsonException or ArgumentException or InvalidCastException or FormatException) + { + throw InvalidExtraFields("extraFields musi być mapą kodów atrybutów na wartości tekstowe."); + } + + if (token is not JObject fields) + throw InvalidExtraFields("extraFields musi być mapą kodów atrybutów na wartości tekstowe."); + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var field in fields.Properties()) + { + var code = field.Name.Trim(); + if (code.Length == 0) throw InvalidExtraFields("Kod w extraFields nie może być pusty."); + if (field.Value.Type != JTokenType.String) + throw InvalidExtraFields($"extraFields['{code}'] musi być tekstem."); + if (!result.TryAdd(code, field.Value.Value()!)) + throw InvalidExtraFields($"Kod '{code}' występuje w extraFields więcej niż raz."); + } + + return result; + } + + private static Exception InvalidExtraFields(string message) => + WampErrorFactory.Create("eu.smartb2b.erp.invalid_order_lines", message); } public sealed class PlaceOrderLine diff --git a/tests/SmartB2B.Optima.IntegrationTests/Program.cs b/tests/SmartB2B.Optima.IntegrationTests/Program.cs index 5b393e4..b3dd056 100644 --- a/tests/SmartB2B.Optima.IntegrationTests/Program.cs +++ b/tests/SmartB2B.Optima.IntegrationTests/Program.cs @@ -11,7 +11,8 @@ if (!options.TryGetValue("config", out var configPath) || string.IsNullOrWhiteSp { Console.Error.WriteLine( "Użycie: --config [--negative-tests] [--write-order [--approved]] " + - "[--customer-id --product-code ]"); + "[--customer-id --product-code ] " + + "[--attribute-code-1 --attribute-value-1 --attribute-code-2 --attribute-value-2 ]"); return 2; } @@ -78,9 +79,28 @@ if (saveMode == OrderSaveMode.Buffer && runtime.AdapterConfiguration.SaveMode != } var reference = $"SMARTB2B-IT-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; -var request = CreateRequest(runtime.AdapterConfiguration, customerId!, productCode!, "PLN", reference, saveMode); +var extraFields = ReadTestAttributes(options); +if (extraFields.Count > 0) +{ + var missingReference = reference + "-MISSING"; + var missingCode = "__SMARTB2B_MISSING_ATTR__"; + ExpectError( + () => adapter.CreateOrder(CreateRequest( + runtime.AdapterConfiguration, customerId!, productCode!, "PLN", missingReference, + OrderSaveMode.Buffer, new Dictionary { [missingCode] = "test" })), + "eu.smartb2b.erp.document_attribute_not_found"); + await AssertNoOrderWithReferenceAsync(sql, missingReference); + Console.WriteLine("PASS extraFields: nieznany kod nie utworzył zamówienia."); +} + +var request = CreateRequest(runtime.AdapterConfiguration, customerId!, productCode!, "PLN", reference, saveMode, extraFields); var result = adapter.CreateOrder(request); +if (extraFields.Count > 0) +{ + await AssertSavedAttributesAsync(sql, result.Id, extraFields); + Console.WriteLine("PASS extraFields: dwa atrybuty zapisano na nagłówku zamówienia."); +} Console.WriteLine("PASS place_order:"); Console.WriteLine(JsonSerializer.Serialize(new { @@ -124,7 +144,8 @@ static OrderRequest CreateRequest( string productCode, string currency, string reference, - OrderSaveMode saveMode = OrderSaveMode.Buffer) => new() + OrderSaveMode saveMode = OrderSaveMode.Buffer, + IReadOnlyDictionary? extraFields = null) => new() { CurrencyIso = currency, CustomerCode = customerId, @@ -133,6 +154,7 @@ static OrderRequest CreateRequest( DocumentDefinition = configuration.DocumentDefinition, WarehouseCode = configuration.DefaultWarehouseCode, SaveMode = saveMode, + ExtraFields = extraFields ?? new Dictionary(), Items = [ new OrderLineRequest @@ -146,6 +168,56 @@ static OrderRequest CreateRequest( ] }; +static IReadOnlyDictionary ReadTestAttributes(Dictionary options) +{ + var names = new[] { "attribute-code-1", "attribute-value-1", "attribute-code-2", "attribute-value-2" }; + if (!names.Any(options.ContainsKey)) return new Dictionary(); + if (names.Any(name => !options.ContainsKey(name))) + throw new ArgumentException("Test extraFields wymaga dwóch kodów i dwóch wartości atrybutów tekstowych."); + + var first = options["attribute-code-1"]?.Trim(); + var second = options["attribute-code-2"]?.Trim(); + if (string.IsNullOrWhiteSpace(first) || string.IsNullOrWhiteSpace(second) || first == second) + throw new ArgumentException("Kody dwóch atrybutów muszą być różne i niepuste."); + return new Dictionary + { + [first] = options["attribute-value-1"] ?? string.Empty, + [second] = options["attribute-value-2"] ?? string.Empty + }; +} + +static async Task AssertNoOrderWithReferenceAsync(SqlRawHandler sql, string reference) +{ + var result = (SqlRawResult?)await sql.HandleAsync( + new RpcInvocation([], new Dictionary + { + ["query"] = "SELECT COUNT(*) AS order_count FROM CDN.TraNag WHERE TrN_NumerObcy=@1", + ["params"] = new object?[] { reference } + }), CancellationToken.None); + if (Convert.ToInt32(result?.Recordsets[0][0]["order_count"]) != 0) + throw new InvalidOperationException("Nieznany atrybut pozostawił zapisane zamówienie."); +} + +static async Task AssertSavedAttributesAsync( + SqlRawHandler sql, int orderId, IReadOnlyDictionary expected) +{ + var result = (SqlRawResult?)await sql.HandleAsync( + new RpcInvocation([], new Dictionary + { + ["query"] = "SELECT DeA_Kod, DAt_WartoscTxt FROM CDN.DokAtrybuty JOIN CDN.DefAtrybuty ON DAt_DeAId=DeA_DeAId WHERE DAt_TrNId=@1", + ["params"] = new object?[] { orderId } + }), CancellationToken.None); + var actual = result?.Recordsets[0].ToDictionary( + row => Convert.ToString(row["DeA_Kod"])!, + row => Convert.ToString(row["DAt_WartoscTxt"]) ?? string.Empty, + StringComparer.Ordinal); + foreach (var (code, value) in expected) + { + if (actual is null || !actual.TryGetValue(code, out var saved) || saved != value) + throw new InvalidOperationException($"Atrybut '{code}' nie ma oczekiwanej wartości na dokumencie {orderId}."); + } +} + static void ExpectError(Action action, string expectedUri) { try diff --git a/tests/SmartB2B.Optima.Tests/Program.cs b/tests/SmartB2B.Optima.Tests/Program.cs index d1130ca..0c04fa3 100644 --- a/tests/SmartB2B.Optima.Tests/Program.cs +++ b/tests/SmartB2B.Optima.Tests/Program.cs @@ -3,6 +3,7 @@ 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 Run)[] { @@ -14,6 +15,11 @@ var tests = new (string Name, Func Run)[] ("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), @@ -102,6 +108,105 @@ static async Task HandlerMapsOptimaContract() 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 + { + ["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()), 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 { ["SOURCE"] = 123 }, + new Dictionary { [" "] = "value" }, + new Dictionary { ["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 { ["SOURCE"] = 7, ["NOTE"] = 8 }); + var document = new FakeAttributeDocument(); + LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary + { + ["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()); + var document = new FakeAttributeDocument(); + try + { + LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary + { + ["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 { ["SOURCE"] = 7 }); + var document = new FakeAttributeDocument(); + try + { + LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary + { + ["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(); @@ -135,6 +240,7 @@ static Task FacadeRunsOnSingleStaThread() 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; } @@ -152,9 +258,9 @@ static async Task AdapterSerializesComCalls() AssertEqual(1, facade.MaximumConcurrency, "maksymalna równoległość fasady COM"); } -static RpcInvocation CreateInvocation() => new( - [], - new Dictionary +static RpcInvocation CreateInvocation(object? extraFields = null, bool includeExtraFields = false) +{ + var arguments = new Dictionary { ["currency_iso"] = "eur", ["companyErpId"] = "123", @@ -172,7 +278,10 @@ static RpcInvocation CreateInvocation() => new( 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"), @@ -317,3 +426,69 @@ file sealed class CapturingComFacade : IOptimaComFacade return true; }); } + +public sealed class FakeAttributeSession +{ + private readonly IReadOnlyDictionary _definitions; + + public FakeAttributeSession(IReadOnlyDictionary 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 _definitions; + + public FakeDefinitionCollection(IReadOnlyDictionary 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 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; + } + } +}