From 0da1548da0439fa9ad07f77a6d1e618c5646fc45 Mon Sep 17 00:00:00 2001 From: Jan Trejbal Date: Tue, 7 Apr 2026 16:10:37 +0200 Subject: [PATCH 1/6] Drop unused run config --- .run/Aviationexam.MoneyErp.Tests.run.xml | 25 ------------------------ 1 file changed, 25 deletions(-) delete mode 100644 .run/Aviationexam.MoneyErp.Tests.run.xml diff --git a/.run/Aviationexam.MoneyErp.Tests.run.xml b/.run/Aviationexam.MoneyErp.Tests.run.xml deleted file mode 100644 index 0b82fd4..0000000 --- a/.run/Aviationexam.MoneyErp.Tests.run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - \ No newline at end of file From bf56dae1f0abac6eb9def3fc075d1a3486cefb00 Mon Sep 17 00:00:00 2001 From: Jan Trejbal Date: Tue, 7 Apr 2026 16:11:48 +0200 Subject: [PATCH 2/6] Add in API failing tests --- ...Tests.ValueContainingOperatorCharacters.cs | 68 +++++++++++++++++++ .../FilterForTests.cs | 12 ++++ 2 files changed, 80 insertions(+) create mode 100644 src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.ValueContainingOperatorCharacters.cs diff --git a/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.ValueContainingOperatorCharacters.cs b/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.ValueContainingOperatorCharacters.cs new file mode 100644 index 0000000..f559f7c --- /dev/null +++ b/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.ValueContainingOperatorCharacters.cs @@ -0,0 +1,68 @@ +using Aviationexam.MoneyErp.Common.Filters; +using Xunit; + +namespace Aviationexam.MoneyErp.Common.Tests; + +public partial class FilterForTests +{ + /// + /// Reproduces a real-world failure where a street address value contains '#', + /// which is also the AND operator in the filter DSL. + /// The filter string becomes ambiguous without proper escaping. + /// + /// Original (anonymized) request: + /// AProperty~sw~ABC123#BProperty~eq~John Doe#CProperty~eq~Street 80A # 17-85#DProperty~eq~Prague#EProperty~eq~11000#BoolProperty~eq~false#AProperty~eq~b679009e-4d3c-40a0-9031-bc00b24ec9d6#AProperty~eq~ + /// + /// The '#' inside "Street 80A # 17-85" is incorrectly parsed as an AND operator. + /// + [Fact] + public void AndWithValueContainingHashIsAmbiguous() + { + var filter = FilterFor.And( + x => x.StartWith(m => m.AProperty, "ABC123"), + x => x.Equal(m => m.BProperty, "John Doe"), + x => x.Equal(m => m.CProperty, "Street 80A # 17-85"), + x => x.Equal(m => m.DProperty, "Prague"), + x => x.Equal(m => m.EProperty, "11000"), + x => x.Equal(m => m.BoolProperty, false), + x => x.Equal(m => m.AProperty, "b679009e-4d3c-40a0-9031-bc00b24ec9d6"), + x => x.Empty(m => m.AProperty) + ); + + // TODO: This assert documents the BROKEN output - the '#' inside the street address + // is indistinguishable from the AND operator. The server will parse 9 clauses instead of 8, + // splitting "Street 80A # 17-85" into "Street 80A " and an invalid " 17-85". + Assert.Equal( + "AProperty~sw~ABC123#BProperty~eq~John Doe#CProperty~eq~Street 80A # 17-85#DProperty~eq~Prague#EProperty~eq~11000#BoolProperty~eq~false#AProperty~eq~b679009e-4d3c-40a0-9031-bc00b24ec9d6#AProperty~eq~", + filter + ); + } + + [Fact] + public void EqualWithValueContainingHash() + { + var filter = FilterFor.Equal(x => x.AProperty, "Street 80A # 17-85"); + + // Currently produces unescaped output - the '#' will be misinterpreted as AND operator + // when combined with other filters + Assert.Equal("AProperty~eq~Street 80A # 17-85", filter); + } + + [Fact] + public void EqualWithValueContainingTilde() + { + var filter = FilterFor.Equal(x => x.AProperty, "value~with~tildes"); + + // The '~' in the value will be misinterpreted as operator delimiter + Assert.Equal("AProperty~eq~value~with~tildes", filter); + } + + [Fact] + public void EqualWithValueContainingPipe() + { + var filter = FilterFor.Equal(x => x.AProperty, "option A|option B"); + + // The '|' in the value will be misinterpreted as OR operator + Assert.Equal("AProperty~eq~option A|option B", filter); + } +} diff --git a/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.cs b/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.cs index d037b24..c44b722 100644 --- a/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.cs +++ b/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.cs @@ -20,6 +20,14 @@ private sealed class ApiModel { public required string AProperty { get; set; } + public string? BProperty { get; set; } + + public string? CProperty { get; set; } + + public string? DProperty { get; set; } + + public string? EProperty { get; set; } + public int IntProperty { get; set; } public int? NullableIntProperty { get; set; } @@ -43,5 +51,9 @@ private sealed class ApiModel public DateOnly DateOnlyProperty { get; set; } public DateOnly? NullableDateOnlyProperty { get; set; } + + public Guid GuidProperty { get; set; } + + public Guid? NullableGuidProperty { get; set; } } } From f0ff65dcd304b355c6e24d39888b21bd0cb5b47f Mon Sep 17 00:00:00 2001 From: Jan Trejbal Date: Tue, 7 Apr 2026 16:15:18 +0200 Subject: [PATCH 3/6] Downgrade OpenTelemetry.Api.ProviderBuilderExtensions for net9 --- src/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index fcf5a16..b3cf8e6 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -26,7 +26,7 @@ - + From d18850c1097927160e2e150c7b178fff7776814b Mon Sep 17 00:00:00 2001 From: Jan Trejbal Date: Tue, 7 Apr 2026 16:15:59 +0200 Subject: [PATCH 4/6] Update packages.lock.json --- .../packages.lock.json | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Aviationexam.MoneyErp.Graphql/packages.lock.json b/src/Aviationexam.MoneyErp.Graphql/packages.lock.json index 0d4cc91..2d7b07e 100644 --- a/src/Aviationexam.MoneyErp.Graphql/packages.lock.json +++ b/src/Aviationexam.MoneyErp.Graphql/packages.lock.json @@ -421,12 +421,12 @@ }, "OpenTelemetry.Api.ProviderBuilderExtensions": { "type": "Direct", - "requested": "[1.15.1, )", - "resolved": "1.15.1", - "contentHash": "aZedpOfXtHmVSWlebxJBeJg2DCdzds86mMowBTS6l+sjwV9LvQuZa0JDU9+S7FQvta4hnauxlCEYplbiDiYGeg==", + "requested": "[1.13.1, )", + "resolved": "1.13.1", + "contentHash": "x8QXMsrIyp+XzDUFQAM+C4upAPNbwaBIPjTWEoonziWAav6weS8OxsMKrE4wz7Zly8ATlsoxk0mWZ+PHO3Wg0w==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "OpenTelemetry.Api": "1.15.1" + "OpenTelemetry.Api": "1.13.1" } }, "ZeroQL": { @@ -516,16 +516,8 @@ }, "OpenTelemetry.Api": { "type": "Transitive", - "resolved": "1.15.1", - "contentHash": "+LJP0YBrysh4kPCRZhEyTUTcd+FFP0NPDvV4AzULBmiInGt6fp+RgBieRhUzVX/yyVEyshg3s82RWFYZJIkeGQ==", - "dependencies": { - "System.Diagnostics.DiagnosticSource": "10.0.0" - } - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "0KdBK+h7G13PuOSC2R/DalAoFMvdYMznvGRuICtkdcUMXgl/gYXsG6z4yUvTxHSMACorWgHCU1Faq0KUHU6yAQ==" + "resolved": "1.13.1", + "contentHash": "tieglRERo7Rgu8oE8aamnuXCMPEW5fXIqO5ngTMCNk9pOEXanc0SdQ86ZAD1goNiGcjWHn+P3WMZp0FZSJgCoQ==" }, "aviationexam.moneyerp.common": { "type": "Project", From 8f90df07ae82e82c0a0d95fc8ad8d3f289c5cd7e Mon Sep 17 00:00:00 2001 From: Jan Trejbal Date: Tue, 7 Apr 2026 17:10:53 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A7=AA=20feat:=20escape=20special=20c?= =?UTF-8?q?haracters=20(#,=20|,=20~)=20in=20filter=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MoneyERP filter DSL uses #, |, ~ as structural operators (AND, OR, operator delimiter). When field values contain these characters (e.g. street address "CRA 80A # 17-85"), the filter string becomes ambiguous and the server misparses it. Add EscapeFilterValue() to FilterFor that replaces operator characters with configurable escape sequences. Currently uses backslash escaping (\#, \|, \~) but this does NOT work with the MoneyERP server - they appear to use a naive Split('#') approach with no escape support. The escape rules are configurable via the EscapeRules array for when/if a working scheme is found. Also adds: - Unit tests for all operator characters in filter values - Integration test (MoneyErpCompanyFilterTests) that creates a company with '#' in the street address and verifies the filter round-trip, exposing the server-side parsing limitation. URL encoding (%23, %7C) was also tested and does not work. This is likely a MoneyERP server limitation requiring their fix. --- ...Tests.ValueContainingOperatorCharacters.cs | 67 ++++-- .../Filters/FilterFor.cs | 84 ++++++- .../MoneyErpCompanyFilterTests.cs | 210 ++++++++++++++++++ 3 files changed, 332 insertions(+), 29 deletions(-) create mode 100644 src/Aviationexam.MoneyErp.Graphql.Tests/MoneyErpCompanyFilterTests.cs diff --git a/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.ValueContainingOperatorCharacters.cs b/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.ValueContainingOperatorCharacters.cs index f559f7c..8dda490 100644 --- a/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.ValueContainingOperatorCharacters.cs +++ b/src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.ValueContainingOperatorCharacters.cs @@ -1,4 +1,5 @@ using Aviationexam.MoneyErp.Common.Filters; +using System; using Xunit; namespace Aviationexam.MoneyErp.Common.Tests; @@ -8,15 +9,12 @@ public partial class FilterForTests /// /// Reproduces a real-world failure where a street address value contains '#', /// which is also the AND operator in the filter DSL. - /// The filter string becomes ambiguous without proper escaping. /// - /// Original (anonymized) request: - /// AProperty~sw~ABC123#BProperty~eq~John Doe#CProperty~eq~Street 80A # 17-85#DProperty~eq~Prague#EProperty~eq~11000#BoolProperty~eq~false#AProperty~eq~b679009e-4d3c-40a0-9031-bc00b24ec9d6#AProperty~eq~ - /// - /// The '#' inside "Street 80A # 17-85" is incorrectly parsed as an AND operator. + /// Original (anonymized) request had "Street 80A # 17-85" as FaktUlice value. + /// Without escaping, the '#' was misinterpreted as an AND operator. /// [Fact] - public void AndWithValueContainingHashIsAmbiguous() + public void AndWithValueContainingHashIsEscaped() { var filter = FilterFor.And( x => x.StartWith(m => m.AProperty, "ABC123"), @@ -29,11 +27,8 @@ public void AndWithValueContainingHashIsAmbiguous() x => x.Empty(m => m.AProperty) ); - // TODO: This assert documents the BROKEN output - the '#' inside the street address - // is indistinguishable from the AND operator. The server will parse 9 clauses instead of 8, - // splitting "Street 80A # 17-85" into "Street 80A " and an invalid " 17-85". Assert.Equal( - "AProperty~sw~ABC123#BProperty~eq~John Doe#CProperty~eq~Street 80A # 17-85#DProperty~eq~Prague#EProperty~eq~11000#BoolProperty~eq~false#AProperty~eq~b679009e-4d3c-40a0-9031-bc00b24ec9d6#AProperty~eq~", + @"AProperty~sw~ABC123#BProperty~eq~John Doe#CProperty~eq~Street 80A \# 17-85#DProperty~eq~Prague#EProperty~eq~11000#BoolProperty~eq~false#AProperty~eq~b679009e-4d3c-40a0-9031-bc00b24ec9d6#AProperty~eq~", filter ); } @@ -43,9 +38,7 @@ public void EqualWithValueContainingHash() { var filter = FilterFor.Equal(x => x.AProperty, "Street 80A # 17-85"); - // Currently produces unescaped output - the '#' will be misinterpreted as AND operator - // when combined with other filters - Assert.Equal("AProperty~eq~Street 80A # 17-85", filter); + Assert.Equal(@"AProperty~eq~Street 80A \# 17-85", filter); } [Fact] @@ -53,8 +46,7 @@ public void EqualWithValueContainingTilde() { var filter = FilterFor.Equal(x => x.AProperty, "value~with~tildes"); - // The '~' in the value will be misinterpreted as operator delimiter - Assert.Equal("AProperty~eq~value~with~tildes", filter); + Assert.Equal(@"AProperty~eq~value\~with\~tildes", filter); } [Fact] @@ -62,7 +54,48 @@ public void EqualWithValueContainingPipe() { var filter = FilterFor.Equal(x => x.AProperty, "option A|option B"); - // The '|' in the value will be misinterpreted as OR operator - Assert.Equal("AProperty~eq~option A|option B", filter); + Assert.Equal(@"AProperty~eq~option A\|option B", filter); + } + + [Fact] + public void EqualWithValueContainingBackslash() + { + var filter = FilterFor.Equal(x => x.AProperty, @"path\to\file"); + + Assert.Equal(@"AProperty~eq~path\\to\\file", filter); + } + + [Fact] + public void EqualWithValueContainingMultipleSpecialCharacters() + { + var filter = FilterFor.Equal(x => x.AProperty, "a#b|c~d"); + + Assert.Equal(@"AProperty~eq~a\#b\|c\~d", filter); + } + + [Fact] + public void EqualWithValueWithoutSpecialCharactersIsUnchanged() + { + var filter = FilterFor.Equal(x => x.AProperty, "normal value 123"); + + Assert.Equal("AProperty~eq~normal value 123", filter); + } + + [Fact] + public void EscapeFilterValueReturnsInputWhenNoSpecialChars() + { + ReadOnlySpan input = "hello world"; + var result = FilterFor.EscapeFilterValue(input); + + Assert.Equal("hello world", result); + } + + [Fact] + public void EscapeFilterValueEscapesAllOperatorCharacters() + { + ReadOnlySpan input = @"a#b|c~d\e"; + var result = FilterFor.EscapeFilterValue(input); + + Assert.Equal(@"a\#b\|c\~d\\e", result); } } diff --git a/src/Aviationexam.MoneyErp.Common/Filters/FilterFor.cs b/src/Aviationexam.MoneyErp.Common/Filters/FilterFor.cs index 578ec4b..d563d71 100644 --- a/src/Aviationexam.MoneyErp.Common/Filters/FilterFor.cs +++ b/src/Aviationexam.MoneyErp.Common/Filters/FilterFor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Numerics; using System.Reflection; @@ -11,6 +12,16 @@ public partial class FilterFor where T : class { public const char AndOperator = '#'; public const char OrOperator = '|'; + private const char OperatorDelimiter = '~'; + + [SuppressMessage("ReSharper", "StaticMemberInGenericType")] + private static readonly IReadOnlyCollection<(char Character, string Replacement)> EscapeRules = + [ + ('\\', "\\\\"), + (AndOperator, $"\\{AndOperator}"), + (OrOperator, $"\\{OrOperator}"), + (OperatorDelimiter, $"\\{OperatorDelimiter}"), + ]; private static ReadOnlySpan CombineExpressions( char joiningCharacter, @@ -94,19 +105,68 @@ public static ReadOnlySpan GetFilterClause( EFilterOperator filterOperator, ReadOnlySpan property, ReadOnlySpan value - ) => filterOperator switch + ) { - EFilterOperator.Equal => $"{property}~eq~{value}", - EFilterOperator.NotEqual => $"{property}~ne~{value}", - EFilterOperator.LessThan => $"{property}~lt~{value}", - EFilterOperator.LessThanOrEqual => $"{property}~lte~{value}", - EFilterOperator.GreaterThan => $"{property}~gt~{value}", - EFilterOperator.GreaterThanOrEqual => $"{property}~gte~{value}", - EFilterOperator.StartWith => $"{property}~sw~{value}", - EFilterOperator.Contains => $"{property}~ct~{value}", - EFilterOperator.EndWith => $"{property}~ew~{value}", - _ => throw new ArgumentOutOfRangeException(nameof(filterOperator), filterOperator, null), - }; + var escapedValue = EscapeFilterValue(value); + + return filterOperator switch + { + EFilterOperator.Equal => $"{property}~eq~{escapedValue}", + EFilterOperator.NotEqual => $"{property}~ne~{escapedValue}", + EFilterOperator.LessThan => $"{property}~lt~{escapedValue}", + EFilterOperator.LessThanOrEqual => $"{property}~lte~{escapedValue}", + EFilterOperator.GreaterThan => $"{property}~gt~{escapedValue}", + EFilterOperator.GreaterThanOrEqual => $"{property}~gte~{escapedValue}", + EFilterOperator.StartWith => $"{property}~sw~{escapedValue}", + EFilterOperator.Contains => $"{property}~ct~{escapedValue}", + EFilterOperator.EndWith => $"{property}~ew~{escapedValue}", + _ => throw new ArgumentOutOfRangeException(nameof(filterOperator), filterOperator, null), + }; + } + + internal static ReadOnlySpan EscapeFilterValue(ReadOnlySpan value) + { + if (!ContainsAnyEscapableCharacter(value)) + { + return value; + } + + var sb = new StringBuilder(value.Length + 4); + + foreach (var ch in value) + { + var replaced = false; + foreach (var (character, replacement) in EscapeRules) + { + if (ch == character) + { + sb.Append(replacement); + replaced = true; + break; + } + } + + if (!replaced) + { + sb.Append(ch); + } + } + + return sb.ToString(); + } + + private static bool ContainsAnyEscapableCharacter(ReadOnlySpan value) + { + foreach (var (character, _) in EscapeRules) + { + if (value.Contains(character)) + { + return true; + } + } + + return false; + } public static ReadOnlySpan GetFilterClause( EFilterOperator filterOperator, diff --git a/src/Aviationexam.MoneyErp.Graphql.Tests/MoneyErpCompanyFilterTests.cs b/src/Aviationexam.MoneyErp.Graphql.Tests/MoneyErpCompanyFilterTests.cs new file mode 100644 index 0000000..4d27af8 --- /dev/null +++ b/src/Aviationexam.MoneyErp.Graphql.Tests/MoneyErpCompanyFilterTests.cs @@ -0,0 +1,210 @@ +using Aviationexam.MoneyErp.Common.Filters; +using Aviationexam.MoneyErp.Graphql.Client; +using Aviationexam.MoneyErp.Graphql.Extensions; +using Aviationexam.MoneyErp.Graphql.Tests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; +using Xunit; +using ZLinq; + +namespace Aviationexam.MoneyErp.Graphql.Tests; + +/// +/// Integration test verifying that company filter queries work correctly +/// when filter values contain special characters (#, ~, |) that are also +/// used as operators in the MoneyERP filter DSL. +/// +public class MoneyErpCompanyFilterTests( + ITestOutputHelper testOutputHelper +) +{ + private const string TestCompanyKod = "TEST_SPECIAL_CHAR"; + private const string TestCompanyStreet = "Street 80A # 17-85"; + private const string TestCompanyName = "Test Company Special Chars"; + private const string TestCompanyTown = "Prague"; + private const string TestCompanyZip = "11000"; + + [Theory] + [ClassData(typeof(MoneyErpAuthenticationsClassData), Explicit = false)] + public async Task QueryCompanyWithHashInFilterValueWorks( + MoneyErpAuthenticationsClassData.AuthenticationData? authenticationData + ) + { + await using var serviceProvider = ServiceProviderFactory.Create( + authenticationData!, testOutputHelper, shouldRedactHeaderValue: true + ); + + var graphqlClient = serviceProvider.GetRequiredService(); + + // Step 1: Verify connectivity + var version = await graphqlClient.Query( + x => x.Version, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.NotNull(version.Data); + Assert.NotEmpty(version.Data); + + // Step 2: Resolve country ID for CZ + var countryFilter = new + { + countryFilter = FilterFor.Equal(m => m.Kod, "CZ").ToString(), + numericalSeriesFilter = FilterFor.Equal(m => m.Kod, "USER_ID").ToString(), + }; + var metadataResponse = await graphqlClient.Query( + countryFilter, + static (f, x) => new + { + Countries = x.Countries( + Filter: f.countryFilter, + selector: c => new { c.ID, c.Deleted, c.Kod } + ), + NumericalSeries = x.NumericalSeries( + Filter: f.numericalSeriesFilter, + selector: c => new { c.ID, c.Deleted, c.Kod } + ), + }, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Empty(metadataResponse.Errors ?? []); + + var countryId = Assert.Single( + metadataResponse.Data!.Countries!, + c => c!.Deleted is false + )!.ID.AsGuid()!.Value; + + var numericalSerieId = Assert.Single( + metadataResponse.Data!.NumericalSeries!, c => c!.Deleted is false + )!.ID.AsGuid()!.Value; + + // Step 3: Query company with '#' in FaktUlice value + var companyWithHashFilter = new + { + companyFilter = FilterFor.And( + x => x.StartWith(m => m.Kod, TestCompanyKod), + x => x.Equal(m => m.FaktUlice, TestCompanyStreet), + x => x.Equal(m => m.FaktMisto, TestCompanyTown) + ) + .ToString(), + }; + var companyResponse = await graphqlClient.Query( + companyWithHashFilter, + static (f, x) => new + { + Companies = x.Companies( + Filter: f.companyFilter, + selector: c => new { c.ID, c.Deleted, c.Kod, c.FaktUlice } + ), + }, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Empty(companyResponse.Errors ?? []); + + var existingCompany = companyResponse.Data?.Companies? + .AsValueEnumerable() + .Where(c => c!.Deleted is false) + .FirstOrDefault(); + + if (existingCompany is not null) + { + // Company already exists with '#' in street - the filter query works + Assert.Equal(TestCompanyStreet, existingCompany.FaktUlice); + return; + } + + // Step 4: Verify the filter isn't silently broken — query without '#' to distinguish + // "filter returned nothing because of escaping bug" vs "company doesn't exist" + var companyWithoutHashFilter = new + { + companyFilter = FilterFor.And( + x => x.StartWith(m => m.Kod, TestCompanyKod), + x => x.Equal(m => m.FaktMisto, TestCompanyTown) + ) + .ToString(), + }; + var fallbackResponse = await graphqlClient.Query( + companyWithoutHashFilter, + static (f, x) => new + { + Companies = x.Companies( + Filter: f.companyFilter, + selector: c => new { c.ID, c.Deleted, c.Kod, c.FaktUlice } + ), + }, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Empty(fallbackResponse.Errors ?? []); + + var fallbackCompany = fallbackResponse.Data?.Companies? + .AsValueEnumerable() + .Where(c => c!.Deleted is false) + .FirstOrDefault(); + + if (fallbackCompany is not null) + { + // Company exists but the '#' filter didn't find it — escaping bug confirmed + Assert.Fail( + $"Company '{fallbackCompany.Kod}' with FaktUlice='{fallbackCompany.FaktUlice}' exists " + + "but was NOT found when '#' was included in the filter value. " + + "This confirms the '#' character in filter values is misinterpreted as the AND operator." + ); + } + + // Step 5: Company doesn't exist — create it with '#' in street + var companyInput = new + { + companyInput = new CompanyInput + { + CiselnaRada_ID = numericalSerieId, + Kod = TestCompanyKod, + Nazev = TestCompanyName, + PlatceDPH = false, + FaktNazev = TestCompanyName, + FaktUlice = TestCompanyStreet, + FaktMisto = TestCompanyTown, + FaktPsc = TestCompanyZip, + FaktStat_ID = countryId, + ObchNazev = TestCompanyName, + ObchUlice = TestCompanyStreet, + ObchMisto = TestCompanyTown, + ObchPsc = TestCompanyZip, + ObchStat_ID = countryId, + }, + }; + var createResponse = await graphqlClient.Mutation( + companyInput, + static (f, x) => new + { + Company = x.CreateCompany( + f.companyInput, + selector: c => new { c.ID, c.Deleted, c.Kod, c.FaktUlice } + ), + }, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Empty(createResponse.Errors ?? []); + Assert.NotNull(createResponse.Data?.Company); + Assert.Equal(TestCompanyStreet, createResponse.Data!.Company!.FaktUlice); + + // Step 6: Re-query with the '#' filter — this is the real test + var verifyResponse = await graphqlClient.Query( + companyWithHashFilter, + static (f, x) => new + { + Companies = x.Companies( + Filter: f.companyFilter, + selector: c => new { c.ID, c.Deleted, c.Kod, c.FaktUlice } + ), + }, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Empty(verifyResponse.Errors ?? []); + + var foundCompany = verifyResponse.Data?.Companies? + .AsValueEnumerable() + .Where(c => c!.Deleted is false) + .FirstOrDefault(); + + Assert.NotNull(foundCompany); + Assert.Equal(TestCompanyStreet, foundCompany.FaktUlice); + } +} From 843213173f3452c04d24d2e0783784f2296844c3 Mon Sep 17 00:00:00 2001 From: Jan Trejbal Date: Tue, 7 Apr 2026 17:21:37 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=85=20test:=20add=20integration=20tes?= =?UTF-8?q?ts=20for=20~=20and=20|=20in=20filter=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor MoneyErpCompanyFilterTests to extract shared logic into a parameterized helper. Add two new test methods covering tilde and pipe characters in company street address filter values, alongside the existing hash test. --- .../MoneyErpCompanyFilterTests.cs | 113 ++++++++++-------- 1 file changed, 62 insertions(+), 51 deletions(-) diff --git a/src/Aviationexam.MoneyErp.Graphql.Tests/MoneyErpCompanyFilterTests.cs b/src/Aviationexam.MoneyErp.Graphql.Tests/MoneyErpCompanyFilterTests.cs index 4d27af8..35a0fa9 100644 --- a/src/Aviationexam.MoneyErp.Graphql.Tests/MoneyErpCompanyFilterTests.cs +++ b/src/Aviationexam.MoneyErp.Graphql.Tests/MoneyErpCompanyFilterTests.cs @@ -3,6 +3,7 @@ using Aviationexam.MoneyErp.Graphql.Extensions; using Aviationexam.MoneyErp.Graphql.Tests.Infrastructure; using Microsoft.Extensions.DependencyInjection; +using System; using System.Threading.Tasks; using Xunit; using ZLinq; @@ -18,16 +19,29 @@ public class MoneyErpCompanyFilterTests( ITestOutputHelper testOutputHelper ) { - private const string TestCompanyKod = "TEST_SPECIAL_CHAR"; - private const string TestCompanyStreet = "Street 80A # 17-85"; - private const string TestCompanyName = "Test Company Special Chars"; - private const string TestCompanyTown = "Prague"; - private const string TestCompanyZip = "11000"; + [Theory] + [ClassData(typeof(MoneyErpAuthenticationsClassData), Explicit = false)] + public Task QueryCompanyWithHashInFilterValueWorks( + MoneyErpAuthenticationsClassData.AuthenticationData? authenticationData + ) => QueryCompanyWithSpecialCharInFilterValue(authenticationData, "TEST_HASH", "Street 80A # 17-85", '#'); + + [Theory] + [ClassData(typeof(MoneyErpAuthenticationsClassData), Explicit = false)] + public Task QueryCompanyWithTildeInFilterValueWorks( + MoneyErpAuthenticationsClassData.AuthenticationData? authenticationData + ) => QueryCompanyWithSpecialCharInFilterValue(authenticationData, "TEST_TILDE", "Street~80A~17", '~'); [Theory] [ClassData(typeof(MoneyErpAuthenticationsClassData), Explicit = false)] - public async Task QueryCompanyWithHashInFilterValueWorks( + public Task QueryCompanyWithPipeInFilterValueWorks( MoneyErpAuthenticationsClassData.AuthenticationData? authenticationData + ) => QueryCompanyWithSpecialCharInFilterValue(authenticationData, "TEST_PIPE", "Street 80A|17-85", '|'); + + private async Task QueryCompanyWithSpecialCharInFilterValue( + MoneyErpAuthenticationsClassData.AuthenticationData? authenticationData, + string testCompanyKod, + string testCompanyStreet, + char specialChar ) { await using var serviceProvider = ServiceProviderFactory.Create( @@ -44,14 +58,14 @@ public async Task QueryCompanyWithHashInFilterValueWorks( Assert.NotNull(version.Data); Assert.NotEmpty(version.Data); - // Step 2: Resolve country ID for CZ - var countryFilter = new + // Step 2: Resolve country ID for CZ and numerical series + var metadataFilter = new { countryFilter = FilterFor.Equal(m => m.Kod, "CZ").ToString(), numericalSeriesFilter = FilterFor.Equal(m => m.Kod, "USER_ID").ToString(), }; var metadataResponse = await graphqlClient.Query( - countryFilter, + metadataFilter, static (f, x) => new { Countries = x.Countries( @@ -67,27 +81,29 @@ public async Task QueryCompanyWithHashInFilterValueWorks( ); Assert.Empty(metadataResponse.Errors ?? []); - var countryId = Assert.Single( - metadataResponse.Data!.Countries!, - c => c!.Deleted is false - )!.ID.AsGuid()!.Value; + var country = Assert.Single(metadataResponse.Data!.Countries! + .AsValueEnumerable() + .Where(c => c!.Deleted is false) + .ToArray()); + var countryId = country!.ID.AsGuid()!.Value; - var numericalSerieId = Assert.Single( - metadataResponse.Data!.NumericalSeries!, c => c!.Deleted is false - )!.ID.AsGuid()!.Value; + var numericalSerie = Assert.Single(metadataResponse.Data!.NumericalSeries! + .AsValueEnumerable() + .Where(c => c!.Deleted is false) + .ToArray()); + var numericalSerieId = numericalSerie!.ID.AsGuid()!.Value; - // Step 3: Query company with '#' in FaktUlice value - var companyWithHashFilter = new + // Step 3: Query company with special char in FaktUlice value + var companyWithSpecialCharFilter = new { companyFilter = FilterFor.And( - x => x.StartWith(m => m.Kod, TestCompanyKod), - x => x.Equal(m => m.FaktUlice, TestCompanyStreet), - x => x.Equal(m => m.FaktMisto, TestCompanyTown) + x => x.StartWith(m => m.Kod, testCompanyKod), + x => x.Equal(m => m.FaktUlice, testCompanyStreet) ) .ToString(), }; var companyResponse = await graphqlClient.Query( - companyWithHashFilter, + companyWithSpecialCharFilter, static (f, x) => new { Companies = x.Companies( @@ -106,23 +122,18 @@ public async Task QueryCompanyWithHashInFilterValueWorks( if (existingCompany is not null) { - // Company already exists with '#' in street - the filter query works - Assert.Equal(TestCompanyStreet, existingCompany.FaktUlice); + Assert.Equal(testCompanyStreet, existingCompany.FaktUlice); return; } - // Step 4: Verify the filter isn't silently broken — query without '#' to distinguish - // "filter returned nothing because of escaping bug" vs "company doesn't exist" - var companyWithoutHashFilter = new + // Step 4: Query without the special char field to distinguish + // "filter broken by special char" vs "company doesn't exist" + var fallbackFilter = new { - companyFilter = FilterFor.And( - x => x.StartWith(m => m.Kod, TestCompanyKod), - x => x.Equal(m => m.FaktMisto, TestCompanyTown) - ) - .ToString(), + companyFilter = FilterFor.StartWith(m => m.Kod, testCompanyKod).ToString(), }; var fallbackResponse = await graphqlClient.Query( - companyWithoutHashFilter, + fallbackFilter, static (f, x) => new { Companies = x.Companies( @@ -141,32 +152,32 @@ public async Task QueryCompanyWithHashInFilterValueWorks( if (fallbackCompany is not null) { - // Company exists but the '#' filter didn't find it — escaping bug confirmed Assert.Fail( $"Company '{fallbackCompany.Kod}' with FaktUlice='{fallbackCompany.FaktUlice}' exists " - + "but was NOT found when '#' was included in the filter value. " - + "This confirms the '#' character in filter values is misinterpreted as the AND operator." + + $"but was NOT found when '{specialChar}' was included in the filter value. " + + $"This confirms the '{specialChar}' character in filter values is misinterpreted as an operator." ); } - // Step 5: Company doesn't exist — create it with '#' in street + // Step 5: Company doesn't exist — create it + var testCompanyName = $"Test Company {specialChar}"; var companyInput = new { companyInput = new CompanyInput { CiselnaRada_ID = numericalSerieId, - Kod = TestCompanyKod, - Nazev = TestCompanyName, + Kod = testCompanyKod, + Nazev = testCompanyName, PlatceDPH = false, - FaktNazev = TestCompanyName, - FaktUlice = TestCompanyStreet, - FaktMisto = TestCompanyTown, - FaktPsc = TestCompanyZip, + FaktNazev = testCompanyName, + FaktUlice = testCompanyStreet, + FaktMisto = "Prague", + FaktPsc = "11000", FaktStat_ID = countryId, - ObchNazev = TestCompanyName, - ObchUlice = TestCompanyStreet, - ObchMisto = TestCompanyTown, - ObchPsc = TestCompanyZip, + ObchNazev = testCompanyName, + ObchUlice = testCompanyStreet, + ObchMisto = "Prague", + ObchPsc = "11000", ObchStat_ID = countryId, }, }; @@ -183,11 +194,11 @@ public async Task QueryCompanyWithHashInFilterValueWorks( ); Assert.Empty(createResponse.Errors ?? []); Assert.NotNull(createResponse.Data?.Company); - Assert.Equal(TestCompanyStreet, createResponse.Data!.Company!.FaktUlice); + Assert.Equal(testCompanyStreet, createResponse.Data!.Company!.FaktUlice); - // Step 6: Re-query with the '#' filter — this is the real test + // Step 6: Re-query with the special char filter var verifyResponse = await graphqlClient.Query( - companyWithHashFilter, + companyWithSpecialCharFilter, static (f, x) => new { Companies = x.Companies( @@ -205,6 +216,6 @@ public async Task QueryCompanyWithHashInFilterValueWorks( .FirstOrDefault(); Assert.NotNull(foundCompany); - Assert.Equal(TestCompanyStreet, foundCompany.FaktUlice); + Assert.Equal(testCompanyStreet, foundCompany.FaktUlice); } }