Skip to content
Merged
66 changes: 66 additions & 0 deletions PanoramicData.OData.Client.Test/UnitTests/ExpandWithSelectTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,49 @@ public void ExpandWithSelect_CombinedWithOtherOptions_GeneratesCorrectUrl()
url.Should().Contain("$top=10");
}

/// <summary>
/// Tests ExpandWithSelect with a nested (dotted) property in the select selector.
/// Characterization test: GetSelectFieldNames/GetDirectMemberName read only the
/// immediate Member.Name (no chain walk), so a nested selector silently truncates
/// to the leaf segment, dropping the intermediate navigation property. Pins this
/// bug as a safety net before the MemberPathResolver consolidation.
/// </summary>
[Fact]
public void ExpandWithSelect_NestedPropertyInSelector_TruncatesToLeafSegment_CharacterizationOfExistingBehavior()
{
// Arrange & Act
var url = _client.For<ReportBatchJob>("ReportBatchJobs")
.ExpandWithSelect(
rbj => rbj.ReportSchedule,
rs => rs.Owner!.Name);

var builtUrl = url.BuildUrl();

// Assert - "Owner/" is silently dropped, leaving just the leaf segment
builtUrl.Should().Contain("$expand=ReportSchedule($select=Name)");
}

/// <summary>
/// Tests ExpandWithSelect with a NewExpression selector mixing a direct property and a
/// nested (dotted) property. Characterization test: same truncation as above, but via
/// the NewExpression/GetDirectMemberName branch - note this silently produces the same
/// output as selecting ReportSchedule's own Id/Name directly.
/// </summary>
[Fact]
public void ExpandWithSelect_NewExpressionWithNestedProperty_TruncatesToLeafSegment_CharacterizationOfExistingBehavior()
{
// Arrange & Act
var url = _client.For<ReportBatchJob>("ReportBatchJobs")
.ExpandWithSelect(
rbj => rbj.ReportSchedule,
rs => new { rs.Id, rs.Owner!.Name });

var builtUrl = url.BuildUrl();

// Assert - "Owner/" is silently dropped from the second field
builtUrl.Should().Contain("$expand=ReportSchedule($select=Id,Name)");
}

#endregion

#region Expand with NestedExpandBuilder Tests
Expand All @@ -148,6 +191,29 @@ public void Expand_WithNestedSelect_GeneratesCorrectSyntax()
url.Should().Contain("$expand=ReportSchedule($select=Id,Name)");
}

/// <summary>
/// Tests Expand with a nested Select using a nested (dotted) property.
/// Characterization test: NestedExpandBuilder.Select's private GetDirectMemberName
/// reads only the immediate Member.Name (no chain walk), so a nested selector
/// silently truncates to the leaf segment, dropping the intermediate navigation
/// property. Pins this bug as a safety net before the MemberPathResolver
/// consolidation.
/// </summary>
[Fact]
public void Expand_NestedSelectWithNestedProperty_TruncatesToLeafSegment_CharacterizationOfExistingBehavior()
{
// Arrange & Act
var query = _client.For<ReportBatchJob>("ReportBatchJobs")
.Expand(
rbj => rbj.ReportSchedule,
nested => nested.Select(rs => rs.Owner!.Name));

var url = query.BuildUrl();

// Assert - "Owner/" is silently dropped, leaving just the leaf segment
url.Should().Contain("$expand=ReportSchedule($select=Name)");
}

/// <summary>
/// Tests Expand with both nested select and nested expand.
/// </summary>
Expand Down
16 changes: 16 additions & 0 deletions PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,22 @@ public void NavigateTo_NonGenericExpr_ProducesCorrectPath()
url.Should().Be("People('russellwhyte')/Friends");
}

/// <summary>
/// Tests that non-generic NavigateTo(expr) with a nested (dotted) member path truncates
/// to the leaf segment. Characterization test: NavigateTo shares GetMemberName with
/// OrderBy, which reads only the selector body's immediate Member.Name (no chain walk).
/// Pins this bug as a safety net before the MemberPathResolver consolidation.
/// </summary>
[Fact]
public void NavigateTo_NonGenericExprNested_TruncatesToLeafSegment_CharacterizationOfExistingBehavior()
{
// Act
var url = _client.For<Person>("People").Key("russellwhyte").NavigateTo(x => x.BestFriend!.Friends).BuildUrl();

// Assert - "BestFriend/" is silently dropped, leaving just the leaf segment
url.Should().Be("People('russellwhyte')/Friends");
}

/// <summary>
/// Tests that As&lt;T&gt;() after non-generic NavigateTo preserves the navigation path.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ public void Filter_WithAnyOnNavigationProperty_GeneratesCorrectUrl()
url.Should().Contain("Friends%2Fany%28f%3A%20f%2FFirstName%20eq%20%27John%27%29");
}

/// <summary>
/// Tests that Any() with a nested (two-hop) member path inside the predicate generates
/// correct URL. Characterization test: GetLambdaMemberPath currently walks the full
/// chain correctly here, but this exact scenario was previously untested. Pins it as a
/// safety net before the MemberPathResolver consolidation (which changes the internal
/// List.Insert(0,...) walk to a Stack-based one with byte-identical output).
/// </summary>
[Fact]
public void Filter_WithAnyNestedMemberPath_GeneratesCorrectUrl()
{
// Arrange & Act
var url = _queryBuilder
.Filter(p => p.Friends!.Any(f => f.BestFriend!.FirstName == "John"))
.BuildUrl();

// Assert
// Friends/any(f: f/BestFriend/FirstName eq 'John')
url.Should().Contain("Friends%2Fany%28f%3A%20f%2FBestFriend%2FFirstName%20eq%20%27John%27%29");
}

/// <summary>
/// Tests that Any() with greater than comparison generates correct URL.
/// </summary>
Expand Down Expand Up @@ -255,6 +275,30 @@ public void Filter_AnyWithCapturedVariable_GeneratesCorrectUrl()
url.Should().Contain("f%2FAge%20ge%2021");
}

/// <summary>
/// Tests that a two-hop closure member access (a captured anonymous object's property,
/// rather than a captured local directly) works in an Any lambda. Characterization test:
/// GetLambdaMemberPath walks the chain until it hits the ConstantExpression root, then
/// discards the partially-built path and evaluates the original member expression
/// directly - this exercises that discard-and-evaluate branch with more than one hop
/// before the root, which the existing single-hop closure tests don't reach. Pins this
/// as a safety net before the MemberPathResolver consolidation.
/// </summary>
[Fact]
public void Filter_WithAnyNestedClosureMemberAccess_GeneratesCorrectUrl()
{
// Arrange
var ageFilter = new { MinAge = 21 };

// Act
var url = _queryBuilder
.Filter(p => p.Friends!.Any(f => f.Age >= ageFilter.MinAge))
.BuildUrl();

// Assert
url.Should().Contain("f%2FAge%20ge%2021");
}

/// <summary>
/// Tests that captured string variables work in Any lambda.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,4 +380,25 @@ public void Filter_WithDateTimeUtcNow_EvaluatesCurrentTime()
}

#endregion

#region Nested Member Paths

/// <summary>
/// Tests filter with a nested (dotted) member path resolves the full path.
/// Characterization test: pins the currently-correct full-chain walk in GetMemberPath
/// as a safety net before the MemberPathResolver consolidation.
/// </summary>
[Fact]
public void Filter_WithNestedMemberPath_GeneratesCorrectUrl()
{
// Arrange & Act
var url = new ODataQueryBuilder<Person>("People", NullLogger.Instance)
.Filter(p => p.BestFriend!.FirstName == "John")
.BuildUrl();

// Assert - BestFriend/FirstName eq 'John'
url.Should().Contain("BestFriend%2FFirstName%20eq%20%27John%27");
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ public void Select_MultipleCalls_CombinesFields()
url.Should().Contain("$select=ID,Name,Price");
}

/// <summary>
/// Tests select with a nested (dotted) member path.
/// Characterization test: GetMemberNames currently takes only the FIRST path segment
/// (Split('/')[0]) of the full path GetMemberPath resolves, so a nested selector
/// silently collapses to just the navigation property name. Pins this quirk as a
/// safety net before the MemberPathResolver consolidation.
/// </summary>
[Fact]
public void Select_WithNestedExpression_UsesOnlyFirstSegment_CharacterizationOfExistingBehavior()
{
var url = new ODataQueryBuilder<Person>("People", NullLogger.Instance)
.Select(p => p.BestFriend!.FirstName)
.BuildUrl();

url.Should().Contain("$select=BestFriend");
url.Should().NotContain("FirstName");
}

#endregion

#region $expand
Expand Down Expand Up @@ -207,6 +225,24 @@ public void OrderBy_WithExpressionDescending_GeneratesCorrectUrl()
url.Should().Contain("$orderby=Price desc");
}

/// <summary>
/// Tests orderby with a nested (dotted) member path.
/// Characterization test: GetMemberName reads only the selector body's immediate
/// Member.Name (no chain walk), so a nested selector silently truncates to the leaf
/// segment, dropping the navigation property. Pins this bug as a safety net before
/// the MemberPathResolver consolidation.
/// </summary>
[Fact]
public void OrderBy_WithNestedExpression_TruncatesToLeafSegment_CharacterizationOfExistingBehavior()
{
var url = new ODataQueryBuilder<Person>("People", NullLogger.Instance)
.OrderBy(p => p.BestFriend!.FirstName)
.BuildUrl();

url.Should().Contain("$orderby=FirstName");
url.Should().NotContain("BestFriend");
}

/// <summary>
/// Tests multiple orderby clauses.
/// </summary>
Expand Down
Loading