diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs index c0347ba46..90f3d151e 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs @@ -581,7 +581,18 @@ public virtual ODataResource CreateResource(SelectExpandNode selectExpandNode, R if (resourceContext.StructuredType.TypeKind == EdmTypeKind.Entity && resourceContext.NavigationSource != null) { - if (!(resourceContext.NavigationSource is IEdmContainedEntitySet)) + // Condition 1. If resourceContext.NavigationSource is a contained entity set + // and a contained resource is being written, the id/read/edit links can be derived + // from the entity set or parent resource, i.e., no need to use link builder to build the links. + // Condition 2. If resourceContext.NavigationSource is a contained entity set + // but an expanded non-contained resource is being written, + // deriving the id/read/edit links from the entity set or parent resource will + // most likely result into invalid links. + // A navigation property binding should exist and we should try + // to use the navigation link builder to build the links. + // NOTE: resourceContext.SerializerContext.NavigationProperty will not be null when writing an expanded resource + if (!(resourceContext.NavigationSource is IEdmContainedEntitySet) + || resourceContext.SerializerContext.NavigationProperty?.ContainsTarget == false) { IEdmModel model = resourceContext.SerializerContext.Model; NavigationSourceLinkBuilderAnnotation linkBuilder = EdmModelLinkBuilderExtensions.GetNavigationSourceLinkBuilder(model, resourceContext.NavigationSource); diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesController.cs new file mode 100644 index 000000000..31097a9fe --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesController.cs @@ -0,0 +1,98 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties +{ + [Route("NonContainedNavPropInContainedNavSource")] + [Route("ContainedNavPropInContainedNavSource")] + public class SitesController : ODataController + { + [EnableQuery] + [HttpGet("Sites")] + public ActionResult> Get() + { + return MetadataPropertiesDataSource.Sites; + } + + [EnableQuery] + [HttpGet("Sites({key})")] + public ActionResult Get(int key) + { + var site = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == key); + + if (site == null) + { + return NotFound(); + } + + return site; + } + + [EnableQuery] + [HttpGet("Sites({key})/Plants")] + public ActionResult> GetPlants(int key) + { + var site = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == key); + + if (site == null || site.Plants == null) + { + return Enumerable.Empty().ToList(); + } + + return site.Plants.ToList(); + } + + [EnableQuery] + [HttpGet("Sites({siteKey})/Plants({plantKey})")] + public ActionResult GetPlant(int siteKey, int plantKey) + { + var plant = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == siteKey)?.Plants?.SingleOrDefault(d => d.Id == plantKey); + + if (plant == null) + { + return NotFound(); + } + + return plant; + } + + [EnableQuery] + [HttpGet("Sites({siteKey})/Plants({plantKey})/Pipelines")] + public ActionResult> GetPipelines(int siteKey, int plantKey) + { + var plant = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == siteKey)?.Plants?.SingleOrDefault(d => d.Id == plantKey); + + if (plant == null || plant.Pipelines == null) + { + return Enumerable.Empty().ToList(); + } + + return plant.Pipelines.ToList(); + } + + [EnableQuery] + [HttpGet("Sites({siteKey})/Plants({plantKey})/Pipelines({pipelineKey})")] + public ActionResult GetPlantPipeline(int siteKey, int plantKey, int pipelineKey) + { + var pipeline = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == siteKey)?.Plants?.SingleOrDefault(d => d.Id == plantKey)?.Pipelines?.SingleOrDefault(d => d.Id == pipelineKey); + + if (pipeline == null) + { + return NotFound(); + } + + return pipeline; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataModel.cs new file mode 100644 index 000000000..d0fd98ecc --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataModel.cs @@ -0,0 +1,50 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core +{ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using Microsoft.OData.ModelBuilder; + + public abstract class EntityBase + { + [Key] + public int Id { get; set; } + public string Name { get; set; } + public Dictionary Attributes { get; set; } + } + + public class Site : EntityBase + { + [Contained] + public IEnumerable Plants { get; set; } + } + + public class Plant : EntityBase + { + public Site Site { get; set; } + [Contained] + public IEnumerable Pipelines { get; set; } + } + + public abstract class PipelineBase : EntityBase + { + public Plant Plant { get; set; } + } +} + +// NOTE: Pipeline class defined in a different namespace to repro a reported scenario +namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Plant1 +{ + using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core; + + public class Pipeline : PipelineBase + { + public int? Length { get; set; } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataSource.cs new file mode 100644 index 000000000..ffd90a5d3 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataSource.cs @@ -0,0 +1,57 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core; +using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Plant1; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties +{ + internal static class MetadataPropertiesDataSource + { + private readonly static List sites; + private readonly static List plants; + private readonly static List pipelines; + + static MetadataPropertiesDataSource() + { + pipelines = new List(Enumerable.Range(1, 8).Select(idx => new Pipeline + { + Id = idx, + Name = $"Pipeline {idx}", + Length = idx * 100 + })); + + plants = new List(Enumerable.Range(1, 4).Select(idx => new Plant + { + Id = idx, + Name = $"Plant {idx}", + Pipelines = pipelines.Skip((idx - 1) * 2).Take(2) + })); + + sites = new List(Enumerable.Range(1, 2).Select(idx => new Site + { + Id = idx, + Name = $"Site {idx}", + Plants = plants.Skip((idx - 1) * 2).Take(2) + })); + + for (var i = 0; i < plants.Count; i++) + { + plants[i].Site = sites[i / 2]; + } + + for (var i = 0; i < pipelines.Count; i++) + { + pipelines[i].Plant = plants[i / 2]; + } + } + + public static List Sites => sites; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesEdmModel.cs new file mode 100644 index 000000000..a76f4e62e --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesEdmModel.cs @@ -0,0 +1,68 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq; +using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core; +using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Plant1; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties +{ + public class MetadataPropertiesEdmModel + { + /// + /// Returns model where Site and Plant navigation properties are non-contained and navigation source is contained. + /// + /// Returns Edm model. + public static IEdmModel GetEdmModelWithNonContainedNavPropInContainedNavSource() + { + var modelBuilder = new ODataConventionModelBuilder(); + modelBuilder.EntityType(); + modelBuilder.EntityType(); + modelBuilder.EntityType(); + modelBuilder.EntityType(); + modelBuilder.EntityType(); + modelBuilder.EntitySet("Sites"); + + var model = modelBuilder.GetEdmModel(); + + var sitesEntitySet = (EdmEntitySet)model.FindDeclaredEntitySet("Default.Container.Sites"); + var plantsNavigationProperty = sitesEntitySet.EntityType().DeclaredNavigationProperties().Single(d => d.Name.Equals("Plants")); + var plantsContainedEntitySet = sitesEntitySet.FindNavigationTarget(plantsNavigationProperty); + var siteNavigationProperty = plantsContainedEntitySet.EntityType().DeclaredNavigationProperties().Single(d => d.Name.Equals("Site")); + var pipelinesNavigationProperty = plantsContainedEntitySet.EntityType().DeclaredNavigationProperties().Single(d => d.Name.Equals("Pipelines")); + var pipelineContainedEntitySet = plantsContainedEntitySet.FindNavigationTarget(pipelinesNavigationProperty, new EdmPathExpression("Plants", "Pipelines")); + var plantNavigationProperty = pipelineContainedEntitySet.EntityType().DeclaredNavigationProperties().Single(d => d.Name.Equals("Plant")); + + sitesEntitySet.AddNavigationTarget(siteNavigationProperty, sitesEntitySet, new EdmPathExpression("Plants", "Site")); + sitesEntitySet.AddNavigationTarget(plantNavigationProperty, plantsContainedEntitySet, new EdmPathExpression("Plants", "Pipelines", "Plant")); + + return model; + } + + /// + /// Returns model where Site and Plant navigation properties are contained and navigation source is contained. + /// + /// Returns Edm model. + public static IEdmModel GetEdmModelWithContainedNavPropInContainedNavSource() + { + var modelBuilder = new ODataConventionModelBuilder(); + modelBuilder.EntityType(); + modelBuilder.EntityType(); + // Make Site and Plant navigation properties contained + modelBuilder.EntityType().ContainsRequired(d => d.Site).Contained(); + modelBuilder.EntityType().ContainsRequired(d => d.Plant).Contained(); + modelBuilder.EntityType(); + modelBuilder.EntitySet("Sites"); + + var model = modelBuilder.GetEdmModel(); + + return model; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesTests.cs new file mode 100644 index 000000000..49d205924 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesTests.cs @@ -0,0 +1,262 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core; +using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Plant1; +using Microsoft.AspNetCore.OData.E2E.Tests.Extensions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties +{ + public class MetadataPropertiesTests : WebApiTestBase + { + public MetadataPropertiesTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var nonContainedNavPropInContainedNavSourceModel = MetadataPropertiesEdmModel.GetEdmModelWithNonContainedNavPropInContainedNavSource(); + var containedNavPropertyInContainedNavSourceModel = MetadataPropertiesEdmModel.GetEdmModelWithContainedNavPropInContainedNavSource(); + + services.ConfigureControllers(typeof(SitesController)); + + services.AddControllers().AddOData( + options => options.EnableQueryFeatures() + .AddRouteComponents( + routePrefix: "NonContainedNavPropInContainedNavSource", + model: nonContainedNavPropInContainedNavSourceModel) + .AddRouteComponents( + routePrefix: "ContainedNavPropInContainedNavSource", + model: containedNavPropertyInContainedNavSourceModel)); + } + + [Theory] + [InlineData("NonContainedNavPropInContainedNavSource", "Sites(1)/Plants(1)")] + [InlineData("ContainedNavPropInContainedNavSource", "Sites(1)/Plants(1)/Pipelines(1)/Plant")] + public async Task TestExpandPlantNavigationPropertyOnContainedNavigationSource(string routePrefix, string plantResourceBase) + { + // Arrange + var requestUri = $"{routePrefix}/Sites(1)/Plants(1)/Pipelines(1)?$expand=Plant"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=full")); + var client = CreateClient(); + var typeofPipeline = typeof(Pipeline); + var typeofPlant = typeof(Plant); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsObject(); + + Assert.EndsWith($"{routePrefix}/$metadata#Sites(1)/Plants(1)/Pipelines/{typeofPipeline}(Plant())/$entity", result.Value("@odata.context")); + Assert.Equal($"#{typeofPipeline}", result.Value("@odata.type")); + Assert.EndsWith("Sites(1)/Plants(1)/Pipelines(1)", result.Value("@odata.id")); + Assert.EndsWith($"Sites(1)/Plants(1)/Pipelines(1)/{typeofPipeline}", result.Value("@odata.editLink")); + Assert.Equal(1, result.Value("Id")); + Assert.Equal("Pipeline 1", result.Value("Name")); + Assert.Equal(100, result.Value("Length")); + Assert.EndsWith($"/Sites(1)/Plants(1)/Pipelines(1)/{typeofPipeline}/Plant/$ref", result.Value("Plant@odata.associationLink")); + Assert.EndsWith($"/Sites(1)/Plants(1)/Pipelines(1)/{typeofPipeline}/Plant", result.Value("Plant@odata.navigationLink")); + + var plant = result.GetValue("Plant") as JObject; + Assert.NotNull(plant); + Assert.Equal($"#{typeofPlant}", plant.Value("@odata.type")); + Assert.EndsWith($"{plantResourceBase}", plant.Value("@odata.id")); + Assert.EndsWith($"{plantResourceBase}", plant.Value("@odata.editLink")); + Assert.Equal(1, plant.Value("Id")); + Assert.Equal("Plant 1", plant.Value("Name")); + Assert.EndsWith($"{routePrefix}/{plantResourceBase}/Site/$ref", plant.Value("Site@odata.associationLink")); + Assert.EndsWith($"{routePrefix}/{plantResourceBase}/Site", plant.Value("Site@odata.navigationLink")); + Assert.EndsWith($"{routePrefix}/{plantResourceBase}/Pipelines/$ref", plant.Value("Pipelines@odata.associationLink")); + Assert.EndsWith($"{routePrefix}/{plantResourceBase}/Pipelines", plant.Value("Pipelines@odata.navigationLink")); + } + + [Theory] + [InlineData("NonContainedNavPropInContainedNavSource", "Sites(1)")] + [InlineData("ContainedNavPropInContainedNavSource", "Sites(1)/Plants(1)/Site")] + public async Task TestExpandSiteNavigationPropertyOnContainedNavigationSource(string routePrefix, string siteResourceBase) + { + // Arrange + var requestUri = $"{routePrefix}/Sites(1)/Plants(1)?$expand=Site"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=full")); + var client = CreateClient(); + var typeofPlant = typeof(Plant); + var typeofSite = typeof(Site); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsObject(); + + Assert.EndsWith($"{routePrefix}/$metadata#Sites(1)/Plants(Site())/$entity", result.Value("@odata.context")); + Assert.Equal($"#{typeofPlant}", result.Value("@odata.type")); + Assert.EndsWith("Sites(1)/Plants(1)", result.Value("@odata.id")); + Assert.EndsWith("Sites(1)/Plants(1)", result.Value("@odata.editLink")); + Assert.Equal(1, result.Value("Id")); + Assert.Equal("Plant 1", result.Value("Name")); + Assert.EndsWith($"{routePrefix}/Sites(1)/Plants(1)/Pipelines/$ref", result.Value("Pipelines@odata.associationLink")); + Assert.EndsWith($"{routePrefix}/Sites(1)/Plants(1)/Pipelines", result.Value("Pipelines@odata.navigationLink")); + Assert.EndsWith($"{routePrefix}/Sites(1)/Plants(1)/Site/$ref", result.Value("Site@odata.associationLink")); + Assert.EndsWith($"{routePrefix}/Sites(1)/Plants(1)/Site", result.Value("Site@odata.navigationLink")); + + var site = result.GetValue("Site") as JObject; + Assert.NotNull(site); + Assert.Equal($"#{typeofSite}", site.Value("@odata.type")); + Assert.EndsWith($"{siteResourceBase}", site.Value("@odata.id")); + Assert.EndsWith($"{siteResourceBase}", site.Value("@odata.editLink")); + Assert.Equal(1, site.Value("Id")); + Assert.Equal("Site 1", site.Value("Name")); + Assert.EndsWith($"{routePrefix}/{siteResourceBase}/Plants/$ref", site.Value("Plants@odata.associationLink")); + Assert.EndsWith($"{routePrefix}/{siteResourceBase}/Plants", site.Value("Plants@odata.navigationLink")); + } + + [Theory] + [InlineData("NonContainedNavPropInContainedNavSource", "Sites(1)/Plants(2)")] + [InlineData("ContainedNavPropInContainedNavSource", "Sites(1)/Plants(2)/Pipelines({0})/Plant")] + public async Task TestExpandPipelinesNavigationPropertyOnContainedNavigationSource(string routePrefix, string plantResourceBase) + { + // Arrange + var requestUri = $"{routePrefix}/Sites(1)/Plants(2)?$expand=Pipelines($expand=Plant)"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=full")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsObject(); + + Action verifyPlantAction = (plant, localRoutePrefix, localPlantResourceBase) => + { + Assert.NotNull(plant); + Assert.Equal($"#{typeof(Plant)}", plant.Value("@odata.type")); + Assert.EndsWith($"{localPlantResourceBase}", plant.Value("@odata.id")); + Assert.EndsWith($"{localPlantResourceBase}", plant.Value("@odata.editLink")); + Assert.Equal(2, plant.Value("Id")); + Assert.Equal($"Plant 2", plant.Value("Name")); + Assert.EndsWith($"{localRoutePrefix}/{localPlantResourceBase}/Pipelines/$ref", plant.Value("Pipelines@odata.associationLink")); + Assert.EndsWith($"{localRoutePrefix}/{localPlantResourceBase}/Pipelines", plant.Value("Pipelines@odata.navigationLink")); + Assert.EndsWith($"{localRoutePrefix}/{localPlantResourceBase}/Site/$ref", plant.Value("Site@odata.associationLink")); + Assert.EndsWith($"{localRoutePrefix}/{localPlantResourceBase}/Site", plant.Value("Site@odata.navigationLink")); + }; + + verifyPlantAction(result, routePrefix, "Sites(1)/Plants(2)"); + + var pipelines = result.GetValue("Pipelines") as JArray; + Assert.NotNull(pipelines); + Assert.Equal(2, pipelines.Count); + + var pipelineAt0 = pipelines[0] as JObject; + var pipelineAt1 = pipelines[1] as JObject; + + Action verifyPipelineAction = (pipeline, localRoutePrefix, pipelineId) => + { + var typeofPipeline = typeof(Pipeline); + + Assert.NotNull(pipeline); + Assert.Equal($"#{typeofPipeline}", pipeline.Value("@odata.type")); + Assert.EndsWith($"Sites(1)/Plants(2)/Pipelines({pipelineId})", pipeline.Value("@odata.id")); + Assert.EndsWith($"Sites(1)/Plants(2)/Pipelines({pipelineId})/{typeofPipeline}", pipeline.Value("@odata.editLink")); + Assert.Equal(pipelineId, pipeline.Value("Id")); + Assert.Equal($"Pipeline {pipelineId}", pipeline.Value("Name")); + Assert.Equal(pipelineId * 100, pipeline.Value("Length")); + Assert.EndsWith($"/Sites(1)/Plants(2)/Pipelines({pipelineId})/{typeofPipeline}/Plant/$ref", pipeline.Value("Plant@odata.associationLink")); + Assert.EndsWith($"/Sites(1)/Plants(2)/Pipelines({pipelineId})/{typeofPipeline}/Plant", pipeline.Value("Plant@odata.navigationLink")); + + var plant = pipeline.GetValue("Plant") as JObject; + verifyPlantAction(plant, localRoutePrefix, string.Format(plantResourceBase, pipelineId)); + }; + + verifyPipelineAction(pipelineAt0, routePrefix, 3); // Pipeline Id = 3 + verifyPipelineAction(pipelineAt1, routePrefix, 4); // Pipeline Id = 4 + } + + [Theory] + [InlineData("NonContainedNavPropInContainedNavSource", "Sites(2)")] + [InlineData("ContainedNavPropInContainedNavSource", "Sites(2)/Plants({0})/Site")] + public async Task TestExpandPlantsNavigationPropertyOnNonContainedNavigationSource(string routePrefix, string siteResourceBase) + { + // Arrange + var requestUri = $"{routePrefix}/Sites(2)?$expand=Plants($expand=Site)"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=full")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsObject(); + + Action verifySiteAction = (site, localRoutePrefix, localSiteResourceBase) => + { + Assert.Equal($"#{typeof(Site)}", site.Value("@odata.type")); + Assert.EndsWith($"{localSiteResourceBase}", site.Value("@odata.id")); + Assert.EndsWith($"{localSiteResourceBase}", site.Value("@odata.editLink")); + Assert.Equal(2, site.Value("Id")); + Assert.Equal("Site 2", site.Value("Name")); + Assert.EndsWith($"{localRoutePrefix}/{localSiteResourceBase}/Plants/$ref", site.Value("Plants@odata.associationLink")); + Assert.EndsWith($"{localRoutePrefix}/{localSiteResourceBase}/Plants", site.Value("Plants@odata.navigationLink")); + }; + + verifySiteAction(result, routePrefix, "Sites(2)"); + + var plants = result.GetValue("Plants") as JArray; + Assert.NotNull(plants); + Assert.Equal(2, plants.Count); + + var plantAt0 = plants[0] as JObject; + var plantAt1 = plants[1] as JObject; + + Action verifyPlantAction = (plant, localRoutePrefix, plantId) => + { + Assert.NotNull(plant); + Assert.Equal($"#{typeof(Plant)}", plant.Value("@odata.type")); + Assert.EndsWith($"Sites(2)/Plants({plantId})", plant.Value("@odata.id")); + Assert.EndsWith($"Sites(2)/Plants({plantId})", plant.Value("@odata.editLink")); + Assert.Equal(plantId, plant.Value("Id")); + Assert.Equal($"Plant {plantId}", plant.Value("Name")); + Assert.EndsWith($"{localRoutePrefix}/Sites(2)/Plants({plantId})/Pipelines/$ref", plant.Value("Pipelines@odata.associationLink")); + Assert.EndsWith($"{localRoutePrefix}/Sites(2)/Plants({plantId})/Pipelines", plant.Value("Pipelines@odata.navigationLink")); + Assert.EndsWith($"{localRoutePrefix}/Sites(2)/Plants({plantId})/Site/$ref", plant.Value("Site@odata.associationLink")); + Assert.EndsWith($"{localRoutePrefix}/Sites(2)/Plants({plantId})/Site", plant.Value("Site@odata.navigationLink")); + + var site = plant.GetValue("Site") as JObject; + verifySiteAction(site, localRoutePrefix, string.Format(siteResourceBase, plantId)); + }; + + verifyPlantAction(plantAt0, routePrefix, 3); // Plant Id = 3 + verifyPlantAction(plantAt1, routePrefix, 4); // Plant Id = 4 + } + } +}