From 4adc7b0f169a7719445ce9f790dfebf1891779aa Mon Sep 17 00:00:00 2001 From: David Jimenez Date: Mon, 25 May 2026 11:18:23 -0600 Subject: [PATCH 1/3] v3 specification - Adds PUT-request-aligned validation - Cover more scenarios. --- .../ValidatorExtensionsTests.cs | 41 +++++++++++++ .../Constants/Constants.cs | 2 +- .../Infrastructure/ValidatorExtensions.cs | 2 +- .../PUT - ApiClients - ID Mismatch.bru | 2 +- .../PUT - ApiClients - ID Missing in Body.bru | 44 ++++++++++++++ .../PUT - Applications - ID Mismatch.bru | 2 +- ...UT - Applications - ID Missing in Body.bru | 45 +++++++++++++++ .../PUT - ClaimSets - ID Mismatch.bru | 2 +- .../PUT - ClaimSets - ID Missing in Body.bru | 39 +++++++++++++ ...ts - Modify Action ClaimSetId Mismatch.bru | 2 +- ...dify Action ClaimSetId Missing in Body.bru | 57 +++++++++++++++++++ ...Modify Action ResourceClaimId Mismatch.bru | 2 +- ...Action ResourceClaimId Missing in Body.bru | 57 +++++++++++++++++++ .../PUT - DataStoreContexts - ID Mismatch.bru | 2 +- ...DataStoreContexts - ID Missing in Body.bru | 41 +++++++++++++ ...T - DataStoreDerivatives - ID Mismatch.bru | 2 +- ...aStoreDerivatives - ID Missing in Body.bru | 41 +++++++++++++ .../PUT - DataStores - ID Mismatch.bru | 2 +- .../PUT - DataStores - ID Missing in Body.bru | 41 +++++++++++++ .../Profiles/PUT - Profiles - ID Mismatch.bru | 2 +- .../PUT - Profiles - ID Missing in Body.bru | 40 +++++++++++++ .../Vendors/PUT - Vendors - ID Mismatch.bru | 2 +- .../PUT - Vendors - ID Missing in Body.bru | 42 ++++++++++++++ 23 files changed, 500 insertions(+), 12 deletions(-) create mode 100644 Application/EdFi.Ods.AdminApi.Common.UnitTests/Infrastructure/ValidatorExtensionsTests.cs create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ApiClient/PUT - ApiClients - ID Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Application/PUT - Applications - ID Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ClaimSets - ID Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ClaimSetId Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ResourceClaimId Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreContexts/PUT - DataStoreContexts - ID Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreDerivatives/PUT - DataStoreDerivatives - ID Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStores/PUT - DataStores - ID Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Profiles/PUT - Profiles - ID Missing in Body.bru create mode 100644 Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Vendors/PUT - Vendors - ID Missing in Body.bru diff --git a/Application/EdFi.Ods.AdminApi.Common.UnitTests/Infrastructure/ValidatorExtensionsTests.cs b/Application/EdFi.Ods.AdminApi.Common.UnitTests/Infrastructure/ValidatorExtensionsTests.cs new file mode 100644 index 000000000..53dad4868 --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.Common.UnitTests/Infrastructure/ValidatorExtensionsTests.cs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using EdFi.Ods.AdminApi.Common.Constants; +using EdFi.Ods.AdminApi.Common.Infrastructure; +using FluentValidation; +using Shouldly; + +namespace EdFi.Ods.AdminApi.Common.UnitTests.Infrastructure; + +[TestFixture] +public class ValidatorExtensionsTests +{ + [Test] + public void GuardRouteIdMatchesBodyId_WhenIdsMatch_ShouldNotThrow() + { + Should.NotThrow(() => ValidatorExtensions.GuardRouteIdMatchesBodyId(10, 10, "Id")); + } + + [Test] + public void GuardRouteIdMatchesBodyId_WhenBodyIdIsZeroAndRouteIdDiffers_ShouldThrowValidationException() + { + var exception = Should.Throw(() => + ValidatorExtensions.GuardRouteIdMatchesBodyId(10, 0, "Id")); + + exception.Errors.Single(x => x.PropertyName == "Id").ErrorMessage + .ShouldBe(ErrorMessagesConstants.RequestBodyIdMismatch); + } + + [Test] + public void GuardRouteIdMatchesBodyId_WhenRouteIdIsZeroAndBodyIdDiffers_ShouldThrowValidationException() + { + var exception = Should.Throw(() => + ValidatorExtensions.GuardRouteIdMatchesBodyId(0, 10, "Id")); + + exception.Errors.Single(x => x.PropertyName == "Id").ErrorMessage + .ShouldBe(ErrorMessagesConstants.RequestBodyIdMismatch); + } +} diff --git a/Application/EdFi.Ods.AdminApi.Common/Constants/Constants.cs b/Application/EdFi.Ods.AdminApi.Common/Constants/Constants.cs index 1613bc05b..9b70b343c 100644 --- a/Application/EdFi.Ods.AdminApi.Common/Constants/Constants.cs +++ b/Application/EdFi.Ods.AdminApi.Common/Constants/Constants.cs @@ -55,7 +55,7 @@ public class ErrorMessagesConstants /// /// Identifier provided in the request body does not match the identifier provided as a parameter in the request URL. /// - public const string RequestBodyIdMismatch = "The ID in the request body must match the ID in the URL."; + public const string RequestBodyIdMismatch = "Request body id must match the id in the url."; } public enum AdminApiMode diff --git a/Application/EdFi.Ods.AdminApi.Common/Infrastructure/ValidatorExtensions.cs b/Application/EdFi.Ods.AdminApi.Common/Infrastructure/ValidatorExtensions.cs index 460302540..be08ea441 100644 --- a/Application/EdFi.Ods.AdminApi.Common/Infrastructure/ValidatorExtensions.cs +++ b/Application/EdFi.Ods.AdminApi.Common/Infrastructure/ValidatorExtensions.cs @@ -21,7 +21,7 @@ public static async Task GuardAsync(this IValidator validato public static void GuardRouteIdMatchesBodyId(int routeId, int requestId, string propertyName) { - if (routeId > 0 && requestId > 0 && routeId != requestId) + if (routeId != requestId) { throw new ValidationException([new ValidationFailure(propertyName, ErrorMessagesConstants.RequestBodyIdMismatch)]); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ApiClient/PUT - ApiClients - ID Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ApiClient/PUT - ApiClients - ID Mismatch.bru index 71902cf1a..d81b80793 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ApiClient/PUT - ApiClients - ID Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ApiClient/PUT - ApiClients - ID Mismatch.bru @@ -36,7 +36,7 @@ script:post-response { test("PUT ApiClients ID Mismatch: Response includes Id mismatch error", function () { expect(response.errors["Id"].length).to.equal(1); - expect(response.errors["Id"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ApiClient/PUT - ApiClients - ID Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ApiClient/PUT - ApiClients - ID Missing in Body.bru new file mode 100644 index 000000000..1bf5c2e85 --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ApiClient/PUT - ApiClients - ID Missing in Body.bru @@ -0,0 +1,44 @@ +meta { + name: ApiClients - ID Missing in Body + type: http + seq: 18.1 +} + +put { + url: {{API_URL}}/v3/apiclients/{{CreatedApiClientId}} + body: json + auth: inherit +} + +body:json { + { + "name": "Updated Test ApiClient", + "isApproved": false, + "applicationId": {{CreatedApplicationId}}, + "dataStoreIds": [ + {{DataStoreId}} + ] + } +} + +script:post-response { + test("PUT ApiClients ID Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT ApiClients ID Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT ApiClients ID Missing in Body: Response includes Id mismatch error", function () { + expect(response.errors["Id"].length).to.equal(1); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Application/PUT - Applications - ID Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Application/PUT - Applications - ID Mismatch.bru index 2cb33285e..36bce1894 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Application/PUT - Applications - ID Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Application/PUT - Applications - ID Mismatch.bru @@ -37,7 +37,7 @@ script:post-response { test("PUT Applications ID Mismatch: Response includes Id mismatch error", function () { expect(response.errors["Id"].length).to.equal(1); - expect(response.errors["Id"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Application/PUT - Applications - ID Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Application/PUT - Applications - ID Missing in Body.bru new file mode 100644 index 000000000..a1f61862c --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Application/PUT - Applications - ID Missing in Body.bru @@ -0,0 +1,45 @@ +meta { + name: Applications - ID Missing in Body + type: http + seq: 21.6 +} + +put { + url: {{API_URL}}/v3/applications/{{CreatedApplicationId}} + body: json + auth: inherit +} + +body:json { + { + "applicationName": "Updated Application Name", + "vendorId": {{OtherApplicationVendorId}}, + "claimSetName": "Ed-Fi ODS Admin App", + "profileIds": [], + "educationOrganizationIds": [1234], + "dataStoreIds": [ {{DataStoreId}} ], + "enabled": false + } +} + +script:post-response { + test("PUT Applications ID Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT Applications ID Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT Applications ID Missing in Body: Response includes Id mismatch error", function () { + expect(response.errors["Id"].length).to.equal(1); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ClaimSets - ID Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ClaimSets - ID Mismatch.bru index 864590d28..85c4c036d 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ClaimSets - ID Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ClaimSets - ID Mismatch.bru @@ -31,7 +31,7 @@ script:post-response { test("PUT ClaimSets ID Mismatch: Response includes Id mismatch error", function () { expect(response.errors["Id"].length).to.equal(1); - expect(response.errors["Id"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ClaimSets - ID Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ClaimSets - ID Missing in Body.bru new file mode 100644 index 000000000..6bcf357da --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ClaimSets - ID Missing in Body.bru @@ -0,0 +1,39 @@ +meta { + name: ClaimSets - ID Missing in Body + type: http + seq: 50.6 +} + +put { + url: {{API_URL}}/v3/claimSets/{{CreatedClaimSetId}} + body: json + auth: inherit +} + +body:json { + { + "name": "Updated Test ClaimSet" + } +} + +script:post-response { + test("PUT ClaimSets ID Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT ClaimSets ID Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT ClaimSets ID Missing in Body: Response includes Id mismatch error", function () { + expect(response.errors["Id"].length).to.equal(1); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ClaimSetId Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ClaimSetId Mismatch.bru index 99653ee61..c8e42f72d 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ClaimSetId Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ClaimSetId Mismatch.bru @@ -49,7 +49,7 @@ script:post-response { test("PUT ResourceClaimClaimSets ClaimSetId Mismatch: Response includes ClaimSetId mismatch error", function () { expect(response.errors["ClaimSetId"].length).to.equal(1); - expect(response.errors["ClaimSetId"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["ClaimSetId"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ClaimSetId Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ClaimSetId Missing in Body.bru new file mode 100644 index 000000000..9a8d7bccb --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ClaimSetId Missing in Body.bru @@ -0,0 +1,57 @@ +meta { + name: ResourceClaimClaimSets - Modify Action ClaimSetId Missing in Body + type: http + seq: 25.3 +} + +put { + url: {{API_URL}}/v3/claimSets/{{CreatedClaimSetId}}/resourceClaimActions/4 + body: json + auth: inherit +} + +body:json { + { + "resourceClaimId": 4, + "resourceClaimActions": [ + { + "name": "read", + "enabled": true + }, + { + "name": "create", + "enabled": true + }, + { + "name": "update", + "enabled": false + }, + { + "name": "delete", + "enabled": false + } + ] + } +} + +script:post-response { + test("PUT ResourceClaimClaimSets ClaimSetId Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT ResourceClaimClaimSets ClaimSetId Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT ResourceClaimClaimSets ClaimSetId Missing in Body: Response includes ClaimSetId mismatch error", function () { + expect(response.errors["ClaimSetId"].length).to.equal(1); + expect(response.errors["ClaimSetId"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ResourceClaimId Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ResourceClaimId Mismatch.bru index 9bedf5a0c..7c457abed 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ResourceClaimId Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ResourceClaimId Mismatch.bru @@ -49,7 +49,7 @@ script:post-response { test("PUT ResourceClaimClaimSets ResourceClaimId Mismatch: Response includes ResourceClaimId mismatch error", function () { expect(response.errors["ResourceClaimId"].length).to.equal(1); - expect(response.errors["ResourceClaimId"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["ResourceClaimId"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ResourceClaimId Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ResourceClaimId Missing in Body.bru new file mode 100644 index 000000000..351589a84 --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/ClaimSets/PUT - ResourceClaimClaimSets - Modify Action ResourceClaimId Missing in Body.bru @@ -0,0 +1,57 @@ +meta { + name: ResourceClaimClaimSets - Modify Action ResourceClaimId Missing in Body + type: http + seq: 25.4 +} + +put { + url: {{API_URL}}/v3/claimSets/{{CreatedClaimSetId}}/resourceClaimActions/4 + body: json + auth: inherit +} + +body:json { + { + "claimSetId": {{CreatedClaimSetId}}, + "resourceClaimActions": [ + { + "name": "read", + "enabled": true + }, + { + "name": "create", + "enabled": true + }, + { + "name": "update", + "enabled": false + }, + { + "name": "delete", + "enabled": false + } + ] + } +} + +script:post-response { + test("PUT ResourceClaimClaimSets ResourceClaimId Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT ResourceClaimClaimSets ResourceClaimId Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT ResourceClaimClaimSets ResourceClaimId Missing in Body: Response includes ResourceClaimId mismatch error", function () { + expect(response.errors["ResourceClaimId"].length).to.equal(1); + expect(response.errors["ResourceClaimId"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreContexts/PUT - DataStoreContexts - ID Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreContexts/PUT - DataStoreContexts - ID Mismatch.bru index c0e6b19c5..ba43bd81a 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreContexts/PUT - DataStoreContexts - ID Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreContexts/PUT - DataStoreContexts - ID Mismatch.bru @@ -33,7 +33,7 @@ script:post-response { test("PUT DataStoreContexts ID Mismatch: Response includes Id mismatch error", function () { expect(response.errors["Id"].length).to.equal(1); - expect(response.errors["Id"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreContexts/PUT - DataStoreContexts - ID Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreContexts/PUT - DataStoreContexts - ID Missing in Body.bru new file mode 100644 index 000000000..216b70003 --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreContexts/PUT - DataStoreContexts - ID Missing in Body.bru @@ -0,0 +1,41 @@ +meta { + name: DataStoreContexts - ID Missing in Body + type: http + seq: 21.1 +} + +put { + url: {{API_URL}}/v3/dataStoreContexts/{{CreatedDataStoreContextId}} + body: json + auth: inherit +} + +body:json { + { + "dataStoreId": {{DataStoreId}}, + "contextKey": "ckey", + "contextValue": "cvalue" + } +} + +script:post-response { + test("PUT DataStoreContexts ID Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT DataStoreContexts ID Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT DataStoreContexts ID Missing in Body: Response includes Id mismatch error", function () { + expect(response.errors["Id"].length).to.equal(1); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreDerivatives/PUT - DataStoreDerivatives - ID Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreDerivatives/PUT - DataStoreDerivatives - ID Mismatch.bru index fded4257b..387ca36af 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreDerivatives/PUT - DataStoreDerivatives - ID Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreDerivatives/PUT - DataStoreDerivatives - ID Mismatch.bru @@ -33,7 +33,7 @@ script:post-response { test("PUT DataStoreDerivatives ID Mismatch: Response includes Id mismatch error", function () { expect(response.errors["Id"].length).to.equal(1); - expect(response.errors["Id"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreDerivatives/PUT - DataStoreDerivatives - ID Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreDerivatives/PUT - DataStoreDerivatives - ID Missing in Body.bru new file mode 100644 index 000000000..daf7c8439 --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStoreDerivatives/PUT - DataStoreDerivatives - ID Missing in Body.bru @@ -0,0 +1,41 @@ +meta { + name: DataStoreDerivatives - ID Missing in Body + type: http + seq: 25.1 +} + +put { + url: {{API_URL}}/v3/dataStoreDerivatives/{{CreatedDataStoreDerivativeId}} + body: json + auth: inherit +} + +body:json { + { + "dataStoreId": {{DataStoreId}}, + "derivativeType": "ReadReplica", + "connectionString": "{{connectionString}}" + } +} + +script:post-response { + test("PUT DataStoreDerivatives ID Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT DataStoreDerivatives ID Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT DataStoreDerivatives ID Missing in Body: Response includes Id mismatch error", function () { + expect(response.errors["Id"].length).to.equal(1); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStores/PUT - DataStores - ID Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStores/PUT - DataStores - ID Mismatch.bru index 936c7d52c..a11ad910c 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStores/PUT - DataStores - ID Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStores/PUT - DataStores - ID Mismatch.bru @@ -33,7 +33,7 @@ script:post-response { test("PUT DataStores ID Mismatch: Response includes Id mismatch error", function () { expect(response.errors["Id"].length).to.equal(1); - expect(response.errors["Id"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStores/PUT - DataStores - ID Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStores/PUT - DataStores - ID Missing in Body.bru new file mode 100644 index 000000000..bd56ab0fb --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/DataStores/PUT - DataStores - ID Missing in Body.bru @@ -0,0 +1,41 @@ +meta { + name: DataStores - ID Missing in Body + type: http + seq: 20.1 +} + +put { + url: {{API_URL}}/v3/dataStores/{{CreatedDataStoreId}} + body: json + auth: inherit +} + +body:json { + { + "name": "Updated-Test-DataStore-{{DataStoreGUID}}", + "dataStoreType": "Updated-Test-OdsdataStoreType", + "connectionString": "{{connectionString}}" + } +} + +script:post-response { + test("PUT DataStores ID Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT DataStores ID Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT DataStores ID Missing in Body: Response includes Id mismatch error", function () { + expect(response.errors["Id"].length).to.equal(1); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Profiles/PUT - Profiles - ID Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Profiles/PUT - Profiles - ID Mismatch.bru index adb1a98c7..6a32df26c 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Profiles/PUT - Profiles - ID Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Profiles/PUT - Profiles - ID Mismatch.bru @@ -32,7 +32,7 @@ script:post-response { test("PUT Profiles ID Mismatch: Response includes Id mismatch error", function () { expect(response.errors["Id"].length).to.equal(1); - expect(response.errors["Id"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Profiles/PUT - Profiles - ID Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Profiles/PUT - Profiles - ID Missing in Body.bru new file mode 100644 index 000000000..72166d92a --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Profiles/PUT - Profiles - ID Missing in Body.bru @@ -0,0 +1,40 @@ +meta { + name: Profiles - ID Missing in Body + type: http + seq: 21.1 +} + +put { + url: {{API_URL}}/v3/profiles/{{CreatedProfileId}} + body: json + auth: inherit +} + +body:json { + { + "Name": "Updated-Test-Profile", + "Definition": "" + } +} + +script:post-response { + test("PUT Profiles ID Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT Profiles ID Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT Profiles ID Missing in Body: Response includes Id mismatch error", function () { + expect(response.errors["Id"].length).to.equal(1); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Vendors/PUT - Vendors - ID Mismatch.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Vendors/PUT - Vendors - ID Mismatch.bru index 6d23ad023..f71176b60 100644 --- a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Vendors/PUT - Vendors - ID Mismatch.bru +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Vendors/PUT - Vendors - ID Mismatch.bru @@ -34,7 +34,7 @@ script:post-response { test("PUT Vendors ID Mismatch: Response includes Id mismatch error", function () { expect(response.errors["Id"].length).to.equal(1); - expect(response.errors["Id"][0]).to.contain("must match the ID in the URL"); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); }); } diff --git a/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Vendors/PUT - Vendors - ID Missing in Body.bru b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Vendors/PUT - Vendors - ID Missing in Body.bru new file mode 100644 index 000000000..33f434c9a --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.V3/E2E Tests/Bruno Admin API E2E 3.0/v3/Vendors/PUT - Vendors - ID Missing in Body.bru @@ -0,0 +1,42 @@ +meta { + name: Vendors - ID Missing in Body + type: http + seq: 14.6 +} + +put { + url: {{API_URL}}/v3/vendors/{{CreatedVendorId}} + body: json + auth: inherit +} + +body:json { + { + "company": "Updated Test Company", + "namespacePrefixes": "uri://academicbenchmarks.com", + "contactName": "Updated User", + "contactEmailAddress": "updated@example.com" + } +} + +script:post-response { + test("PUT Vendors ID Missing in Body: Status code is Bad Request", function () { + expect(res.getStatus()).to.equal(400); + }); + + const response = res.getBody(); + + test("PUT Vendors ID Missing in Body: Response matches error format", function () { + expect(response).to.have.property("title"); + expect(response).to.have.property("errors"); + }); + + test("PUT Vendors ID Missing in Body: Response includes Id mismatch error", function () { + expect(response.errors["Id"].length).to.equal(1); + expect(response.errors["Id"][0]).to.contain("Request body id must match the id in the url."); + }); +} + +settings { + encodeUrl: true +} From 9014bf7de9809df61eaa105487bc2373e8dce74d Mon Sep 17 00:00:00 2001 From: Jorge David Jimenez Barrantes Date: Fri, 24 Jul 2026 08:53:11 -0600 Subject: [PATCH 2/3] Endpoints are not returning all instances/data stores --- .../QueryTests/GetDbInstancesQueryTests.cs | 23 ++++++++++++ .../ReadEducationOrganizationsTests.cs | 28 ++++++++++++++ .../Services/Tenants/TenantServiceTests.cs | 37 +++++++++++++++++++ .../QueryTests/GetDbDataStoresQueryTests.cs | 23 +++++++++++- .../ReadEducationOrganizationsTests.cs | 27 ++++++++++++++ .../Services/Tenants/TenantServiceTests.cs | 35 +++++++++++++++++- .../DataStores/ReadEducationOrganizations.cs | 6 ++- .../Services/Tenants/TenantService.cs | 9 ++++- .../ReadEducationOrganizations.cs | 6 ++- .../Services/Tenants/TenantService.cs | 8 +++- 10 files changed, 195 insertions(+), 7 deletions(-) diff --git a/Application/EdFi.Ods.AdminApi.DBTests/Database/QueryTests/GetDbInstancesQueryTests.cs b/Application/EdFi.Ods.AdminApi.DBTests/Database/QueryTests/GetDbInstancesQueryTests.cs index f91dd1264..00ff76613 100644 --- a/Application/EdFi.Ods.AdminApi.DBTests/Database/QueryTests/GetDbInstancesQueryTests.cs +++ b/Application/EdFi.Ods.AdminApi.DBTests/Database/QueryTests/GetDbInstancesQueryTests.cs @@ -4,7 +4,9 @@ // See the LICENSE and NOTICES files in the project root for more information. using System; +using System.Collections.Generic; using System.Linq; +using EdFi.Ods.AdminApi.Common.Constants; using EdFi.Ods.AdminApi.Common.Infrastructure; using EdFi.Ods.AdminApi.Common.Infrastructure.Models; using EdFi.Ods.AdminApi.Infrastructure.Database.Queries; @@ -16,6 +18,9 @@ namespace EdFi.Ods.AdminApi.DBTests.Database.QueryTests; [TestFixture] public class GetDbInstancesQueryTests : AdminApiDbContextTestBase { + private static IEnumerable AllStatuses => + Enum.GetValues().Select(status => status.ToString()); + [Test] public void ShouldRetrieveDbInstances() { @@ -150,4 +155,22 @@ public void ShouldRetrieveDbInstancesOrderedById() ids.ShouldBe(ids.OrderBy(x => x).ToList()); }); } + + [Test] + [TestCaseSource(nameof(AllStatuses))] + public void ShouldIncludeDbInstances_ForAllStatuses_WhenNoFiltersApplied(string status) + { + Save( + new DbInstance { Name = $"Status-{status}", OdsInstanceId = 111, Status = status, DatabaseTemplate = "Minimal", LastRefreshed = DateTime.UtcNow }, + new DbInstance { Name = "Active Instance", OdsInstanceId = 222, Status = "Created", DatabaseTemplate = "Minimal", LastRefreshed = DateTime.UtcNow } + ); + + Transaction(context => + { + var query = new GetDbInstancesQuery(context, Testing.GetAppSettings()); + var results = query.Execute(new CommonQueryParams(0, null), null, null); + results.Count.ShouldBe(2); + results.ShouldContain(r => r.Name == $"Status-{status}" && r.Status == status); + }); + } } diff --git a/Application/EdFi.Ods.AdminApi.UnitTests/Features/OdsInstances/ReadEducationOrganizationsTests.cs b/Application/EdFi.Ods.AdminApi.UnitTests/Features/OdsInstances/ReadEducationOrganizationsTests.cs index c7e33d92b..083e7bda9 100644 --- a/Application/EdFi.Ods.AdminApi.UnitTests/Features/OdsInstances/ReadEducationOrganizationsTests.cs +++ b/Application/EdFi.Ods.AdminApi.UnitTests/Features/OdsInstances/ReadEducationOrganizationsTests.cs @@ -4,6 +4,8 @@ // See the LICENSE and NOTICES files in the project root for more information. using System.Collections.Generic; +using System; +using System.Linq; using System.Threading.Tasks; using EdFi.Admin.DataAccess.Models; using EdFi.Ods.AdminApi.Common.Constants; @@ -22,6 +24,10 @@ namespace EdFi.Ods.AdminApi.UnitTests.Features.OdsInstances; [TestFixture] public class ReadEducationOrganizationsTests { + private static IEnumerable AllStatuses => + Enum.GetValues() + .Select(status => status.ToString()); + private IGetEducationOrganizationsQuery _getEdOrgsQuery = null!; private IGetDbInstancesQuery _getDbInstancesQuery = null!; private IGetOdsInstanceQuery _getOdsInstanceQuery = null!; @@ -110,6 +116,28 @@ public async Task GetEducationOrganizations_AppendsUnlinkedDbInstances_WithNegat ok.Value.ShouldAllBe(i => i.EducationOrganizations.Count == 0); } + [Test] + [TestCaseSource(nameof(AllStatuses))] + public async Task GetEducationOrganizations_AppendsOrphanedLinkedDbInstance_ForAllStatuses(string status) + { + A.CallTo(() => _getEdOrgsQuery.ExecuteAsync(_queryParams, null)) + .Returns(new List()); + A.CallTo(() => _getDbInstancesQuery.Execute(A._, null, null)) + .Returns(new List + { + new DbInstance { Id = 10, Name = $"Orphan-{status}", OdsInstanceId = 999, Status = status, DatabaseTemplate = "Minimal", DatabaseName = "EdFi_Ods_AnyStatus" } + }); + + var result = await ReadEducationOrganizations.GetEducationOrganizations(_getEdOrgsQuery, _getDbInstancesQuery, _queryParams); + + var ok = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + ok.ShouldNotBeNull(); + ok.Value!.Count.ShouldBe(1); + ok.Value[0].Id.ShouldBe(-1); + ok.Value[0].DbInstanceId.ShouldBe(10); + ok.Value[0].Status.ShouldBe(status); + } + [Test] public async Task GetEducationOrganizationsByInstance_DoesNotAppendUnlinkedDbInstances() { diff --git a/Application/EdFi.Ods.AdminApi.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs b/Application/EdFi.Ods.AdminApi.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs index 00a5cbe74..879ccedf9 100644 --- a/Application/EdFi.Ods.AdminApi.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs +++ b/Application/EdFi.Ods.AdminApi.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs @@ -4,6 +4,7 @@ // See the LICENSE and NOTICES files in the project root for more information. using System.Collections.Generic; +using System; using System.Linq; using System.Threading.Tasks; using EdFi.Admin.DataAccess.Models; @@ -29,6 +30,10 @@ namespace EdFi.Ods.AdminApi.UnitTests.Infrastructure.Services.Tenants; [TestFixture] internal class TenantServiceTests { + private static IEnumerable AllStatuses => + Enum.GetValues() + .Select(status => status.ToString()); + private IOptionsSnapshot _options = null!; private IMemoryCache _memoryCache = null!; private AppSettingsFile _appSettings = null!; @@ -372,4 +377,36 @@ public async Task GetTenantEdOrgsByInstancesAsync_MixedScenario_LinkedAndUnlinke unlinkedInstance.Name.ShouldBe("Unlinked-C"); unlinkedInstance.Status.ShouldBe(DbInstanceStatus.PendingCreate.ToString()); } + + [Test] + [TestCaseSource(nameof(AllStatuses))] + public async Task GetTenantEdOrgsByInstancesAsync_AddsDbInstance_WhenLinkedToMissingOdsInstance_ForAllStatuses(string status) + { + _appSettings.AppSettings.MultiTenancy = false; + var service = new TenantService(_options, _memoryCache); + + A.CallTo(() => _getOdsInstancesQuery.Execute()).Returns([]); + + var orphan = new DbInstance + { + Id = 42, + Name = $"Orphan-{status}", + OdsInstanceId = 9002, + Status = status, + DatabaseTemplate = "Minimal", + DatabaseName = "EdFi_ODS_9002", + LastRefreshed = System.DateTime.UtcNow + }; + A.CallTo(() => _getDbInstancesQuery.Execute(A._, A._, A.Ignored)) + .Returns([orphan]); + + var result = await service.GetTenantEdOrgsByInstancesAsync( + _getOdsInstancesQuery, _getEducationOrganizationQuery, _getDbInstancesQuery, Constants.DefaultTenantName); + + result.ShouldNotBeNull(); + result!.OdsInstances.Count.ShouldBe(1); + result.OdsInstances[0].OdsInstanceId.ShouldBe(-1); + result.OdsInstances[0].DbInstanceId.ShouldBe(42); + result.OdsInstances[0].Status.ShouldBe(status); + } } diff --git a/Application/EdFi.Ods.AdminApi.V3.DBTests/Database/QueryTests/GetDbDataStoresQueryTests.cs b/Application/EdFi.Ods.AdminApi.V3.DBTests/Database/QueryTests/GetDbDataStoresQueryTests.cs index bbfc4db82..41ad4e446 100644 --- a/Application/EdFi.Ods.AdminApi.V3.DBTests/Database/QueryTests/GetDbDataStoresQueryTests.cs +++ b/Application/EdFi.Ods.AdminApi.V3.DBTests/Database/QueryTests/GetDbDataStoresQueryTests.cs @@ -4,7 +4,9 @@ // See the LICENSE and NOTICES files in the project root for more information. using System; +using System.Collections.Generic; using System.Linq; +using EdFi.Ods.AdminApi.Common.Constants; using EdFi.Ods.AdminApi.Common.Infrastructure; using EdFi.Ods.AdminApi.Common.Infrastructure.Models; using EdFi.Ods.AdminApi.V3.Infrastructure.Database.Queries; @@ -16,6 +18,9 @@ namespace EdFi.Ods.AdminApi.V3.DBTests.Database.QueryTests; [TestFixture] public class GetDbDataStoresQueryTests : AdminApiDbContextTestBase { + private static IEnumerable AllStatuses => + Enum.GetValues().Select(status => status.ToString()); + [Test] public void ShouldRetrieveDbInstances() { @@ -150,7 +155,23 @@ public void ShouldRetrieveDbInstancesOrderedById() ids.ShouldBe(ids.OrderBy(x => x).ToList()); }); } -} + [Test] + [TestCaseSource(nameof(AllStatuses))] + public void ShouldIncludeDbDataStores_ForAllStatuses_WhenNoFiltersApplied(string status) + { + Save( + new DbInstance { Name = $"Status-{status}", OdsInstanceId = 111, Status = status, DatabaseTemplate = "Minimal", LastRefreshed = DateTime.UtcNow }, + new DbInstance { Name = "Active DataStore", OdsInstanceId = 222, Status = "Created", DatabaseTemplate = "Minimal", LastRefreshed = DateTime.UtcNow } + ); + Transaction(context => + { + var query = new GetDbDataStoresQuery(context, Testing.GetAppSettings()); + var results = query.Execute(new CommonQueryParams(0, null), null, null); + results.Count.ShouldBe(2); + results.ShouldContain(r => r.Name == $"Status-{status}" && r.Status == status); + }); + } +} diff --git a/Application/EdFi.Ods.AdminApi.V3.UnitTests/Features/DataStores/ReadEducationOrganizationsTests.cs b/Application/EdFi.Ods.AdminApi.V3.UnitTests/Features/DataStores/ReadEducationOrganizationsTests.cs index 376a106fe..bbe5e9140 100644 --- a/Application/EdFi.Ods.AdminApi.V3.UnitTests/Features/DataStores/ReadEducationOrganizationsTests.cs +++ b/Application/EdFi.Ods.AdminApi.V3.UnitTests/Features/DataStores/ReadEducationOrganizationsTests.cs @@ -4,6 +4,8 @@ // See the LICENSE and NOTICES files in the project root for more information. using System.Collections.Generic; +using System; +using System.Linq; using System.Threading.Tasks; using EdFi.Ods.AdminApi.Common.Constants; using EdFi.Ods.AdminApi.Common.Infrastructure; @@ -20,6 +22,10 @@ namespace EdFi.Ods.AdminApi.V3.UnitTests.Features.DataStores; [TestFixture] public class ReadEducationOrganizationsTests { + private static IEnumerable AllStatuses => + Enum.GetValues() + .Select(status => status.ToString()); + private IGetEducationOrganizationsQuery _getEdOrgsQuery = null!; private IGetDbDataStoresQuery _getDbDataStoresQuery = null!; private IGetDataStoreQuery _getDataStoreQuery = null!; @@ -104,6 +110,27 @@ public async Task GetEducationOrganizations_AppendsUnlinkedDbDataStores_WithNega ok.Value.ShouldAllBe(i => i.EducationOrganizations.Count == 0); } + [Test] + [TestCaseSource(nameof(AllStatuses))] + public async Task GetEducationOrganizations_AppendsOrphanedLinkedDbDataStore_ForAllStatuses(string status) + { + A.CallTo(() => _getEdOrgsQuery.ExecuteAsync(_queryParams, null)) + .Returns(new List()); + A.CallTo(() => _getDbDataStoresQuery.Execute(A._, null, null)) + .Returns(new List + { + new DbInstance { Id = 10, Name = $"Orphan-{status}", OdsInstanceId = 999, Status = status, DatabaseTemplate = "Minimal", DatabaseName = "EdFi_Ods_AnyStatus" } + }); + + var result = await ReadEducationOrganizations.GetEducationOrganizations(_getEdOrgsQuery, _getDbDataStoresQuery, _queryParams); + + var ok = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + ok.ShouldNotBeNull(); + ok.Value!.Count.ShouldBe(1); + ok.Value[0].Id.ShouldBe(-1); + ok.Value[0].Status.ShouldBe(status); + } + [Test] public async Task GetEducationOrganizationsByDataStore_DoesNotAppendUnlinkedDbDataStores() { diff --git a/Application/EdFi.Ods.AdminApi.V3.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs b/Application/EdFi.Ods.AdminApi.V3.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs index f13c893a7..bb4ad4852 100644 --- a/Application/EdFi.Ods.AdminApi.V3.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs +++ b/Application/EdFi.Ods.AdminApi.V3.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs @@ -4,6 +4,7 @@ // See the LICENSE and NOTICES files in the project root for more information. using System.Collections.Generic; +using System; using System.Linq; using System.Threading.Tasks; using EdFi.Admin.DataAccess.Models; @@ -29,6 +30,10 @@ namespace EdFi.Ods.AdminApi.V3.UnitTests.Infrastructure.Services.Tenants; [TestFixture] internal class TenantServiceTests { + private static IEnumerable AllStatuses => + Enum.GetValues() + .Select(status => status.ToString()); + private IOptionsSnapshot _options = null!; private IMemoryCache _memoryCache = null!; private AppSettingsFile _appSettings = null!; @@ -367,9 +372,37 @@ public async Task GetTenantEdOrgsByInstancesAsync_MixedScenario_LinkedAndUnlinke unlinkedDataStore.Name.ShouldBe("Unlinked-C"); unlinkedDataStore.Status.ShouldBe(DbInstanceStatus.PendingCreate.ToString()); } -} + [Test] + [TestCaseSource(nameof(AllStatuses))] + public async Task GetTenantEdOrgsByInstancesAsync_AddsDbDataStore_WhenLinkedToMissingDataStore_ForAllStatuses(string status) + { + _appSettings.AppSettings.MultiTenancy = false; + var service = new TenantService(_options, _memoryCache); + + A.CallTo(() => _getDataStoresQuery.Execute()).Returns([]); + + var orphan = new DbInstance + { + Id = 42, + Name = $"Orphan-{status}", + OdsInstanceId = 9002, + Status = status, + DatabaseTemplate = "Minimal", + DatabaseName = "EdFi_ODS_9002", + LastRefreshed = System.DateTime.UtcNow + }; + A.CallTo(() => _getDbDataStoresQuery.Execute(A._, A._, A.Ignored)) + .Returns([orphan]); + var result = await service.GetTenantEdOrgsByInstancesAsync( + _getDataStoresQuery, _getEducationOrganizationQuery, _getDbDataStoresQuery, Constants.DefaultTenantName); + result.ShouldNotBeNull(); + result!.DataStores.Count.ShouldBe(1); + result.DataStores[0].DataStoreId.ShouldBe(-1); + result.DataStores[0].Status.ShouldBe(status); + } +} diff --git a/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/ReadEducationOrganizations.cs b/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/ReadEducationOrganizations.cs index f5eb3996f..c22600b11 100644 --- a/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/ReadEducationOrganizations.cs +++ b/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/ReadEducationOrganizations.cs @@ -94,8 +94,12 @@ private static void MergeDbDataStoreData( if (includeUnlinked) { + var existingDataStoreIds = instances + .Select(i => i.Id) + .ToHashSet(); + var negativeId = -1; - foreach (var dbDataStore in allDbDataStores.Where(d => d.OdsInstanceId is null)) + foreach (var dbDataStore in allDbDataStores.Where(d => d.OdsInstanceId is null || !existingDataStoreIds.Contains(d.OdsInstanceId.Value))) { instances.Add(new DataStoreWithEducationOrganizationsModel { diff --git a/Application/EdFi.Ods.AdminApi.V3/Infrastructure/Services/Tenants/TenantService.cs b/Application/EdFi.Ods.AdminApi.V3/Infrastructure/Services/Tenants/TenantService.cs index a92844636..c85b88feb 100644 --- a/Application/EdFi.Ods.AdminApi.V3/Infrastructure/Services/Tenants/TenantService.cs +++ b/Application/EdFi.Ods.AdminApi.V3/Infrastructure/Services/Tenants/TenantService.cs @@ -160,7 +160,13 @@ public async Task> GetTenantsAsync(bool fromCache = false) } } - var unlinkedDbDataStores = allDbDataStores.Where(d => d.OdsInstanceId is null).ToList(); + var existingDataStoreIds = tenantDetails.DataStores + .Select(i => i.DataStoreId) + .ToHashSet(); + + var unlinkedDbDataStores = allDbDataStores + .Where(d => d.OdsInstanceId is null || !existingDataStoreIds.Contains(d.OdsInstanceId.Value)) + .ToList(); var negativeId = -1; foreach (var dbDataStore in unlinkedDbDataStores) { @@ -181,4 +187,3 @@ private async Task> GetTenantsFromCacheAsync() } - diff --git a/Application/EdFi.Ods.AdminApi/Features/OdsInstances/ReadEducationOrganizations.cs b/Application/EdFi.Ods.AdminApi/Features/OdsInstances/ReadEducationOrganizations.cs index ced942d5b..307b194db 100644 --- a/Application/EdFi.Ods.AdminApi/Features/OdsInstances/ReadEducationOrganizations.cs +++ b/Application/EdFi.Ods.AdminApi/Features/OdsInstances/ReadEducationOrganizations.cs @@ -95,8 +95,12 @@ private static void MergeDbInstanceData( if (includeUnlinked) { + var existingOdsInstanceIds = instances + .Select(i => i.Id) + .ToHashSet(); + var negativeId = -1; - foreach (var dbInstance in allDbInstances.Where(d => d.OdsInstanceId is null)) + foreach (var dbInstance in allDbInstances.Where(d => d.OdsInstanceId is null || !existingOdsInstanceIds.Contains(d.OdsInstanceId.Value))) { instances.Add(new OdsInstanceWithEducationOrganizationsModel { diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/Services/Tenants/TenantService.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/Services/Tenants/TenantService.cs index 70b6a7f6e..2a4fc5f74 100644 --- a/Application/EdFi.Ods.AdminApi/Infrastructure/Services/Tenants/TenantService.cs +++ b/Application/EdFi.Ods.AdminApi/Infrastructure/Services/Tenants/TenantService.cs @@ -161,7 +161,13 @@ public async Task> GetTenantsAsync(bool fromCache = false) } } - var unlinkedDbInstances = allDbInstances.Where(d => d.OdsInstanceId is null).ToList(); + var existingOdsInstanceIds = tenantDetails.OdsInstances + .Select(i => i.OdsInstanceId) + .ToHashSet(); + + var unlinkedDbInstances = allDbInstances + .Where(d => d.OdsInstanceId is null || !existingOdsInstanceIds.Contains(d.OdsInstanceId.Value)) + .ToList(); var negativeId = -1; foreach (var dbInstance in unlinkedDbInstances) { From a2c3a00e111cff33da512460e5750f3a74d75746 Mon Sep 17 00:00:00 2001 From: Jorge David Jimenez Barrantes Date: Fri, 24 Jul 2026 09:22:59 -0600 Subject: [PATCH 3/3] Addresses comments from Copilot --- .../ReadEducationOrganizationsTests.cs | 22 ++++++++++ .../Services/Tenants/TenantServiceTests.cs | 43 +++++++++++++++++++ .../ReadEducationOrganizationsTests.cs | 21 +++++++++ .../Services/Tenants/TenantServiceTests.cs | 43 ++++++++++++++++++- .../DataStores/ReadEducationOrganizations.cs | 9 +++- .../Services/Tenants/TenantService.cs | 7 ++- .../ReadEducationOrganizations.cs | 9 +++- .../Services/Tenants/TenantService.cs | 6 ++- 8 files changed, 154 insertions(+), 6 deletions(-) diff --git a/Application/EdFi.Ods.AdminApi.UnitTests/Features/OdsInstances/ReadEducationOrganizationsTests.cs b/Application/EdFi.Ods.AdminApi.UnitTests/Features/OdsInstances/ReadEducationOrganizationsTests.cs index 083e7bda9..c5166492e 100644 --- a/Application/EdFi.Ods.AdminApi.UnitTests/Features/OdsInstances/ReadEducationOrganizationsTests.cs +++ b/Application/EdFi.Ods.AdminApi.UnitTests/Features/OdsInstances/ReadEducationOrganizationsTests.cs @@ -138,6 +138,28 @@ public async Task GetEducationOrganizations_AppendsOrphanedLinkedDbInstance_ForA ok.Value[0].Status.ShouldBe(status); } + [Test] + public async Task GetEducationOrganizations_AppendsLatestDbInstancePerMissingOdsInstanceId() + { + A.CallTo(() => _getEdOrgsQuery.ExecuteAsync(_queryParams, null)) + .Returns(new List()); + A.CallTo(() => _getDbInstancesQuery.Execute(A._, null, null)) + .Returns(new List + { + new DbInstance { Id = 20, Name = "Orphan-Older", OdsInstanceId = 999, Status = DbInstanceStatus.CreateFailed.ToString(), DatabaseTemplate = "Minimal", DatabaseName = "EdFi_Ods_Old", LastRefreshed = DateTime.UtcNow.AddMinutes(-10) }, + new DbInstance { Id = 21, Name = "Orphan-Newer", OdsInstanceId = 999, Status = DbInstanceStatus.Deleted.ToString(), DatabaseTemplate = "Minimal", DatabaseName = "EdFi_Ods_New", LastModifiedDate = DateTime.UtcNow } + }); + + var result = await ReadEducationOrganizations.GetEducationOrganizations(_getEdOrgsQuery, _getDbInstancesQuery, _queryParams); + + var ok = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + ok.ShouldNotBeNull(); + ok.Value!.Count.ShouldBe(1); + ok.Value[0].DbInstanceId.ShouldBe(21); + ok.Value[0].Status.ShouldBe(DbInstanceStatus.Deleted.ToString()); + ok.Value[0].Name.ShouldBe("Orphan-Newer"); + } + [Test] public async Task GetEducationOrganizationsByInstance_DoesNotAppendUnlinkedDbInstances() { diff --git a/Application/EdFi.Ods.AdminApi.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs b/Application/EdFi.Ods.AdminApi.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs index 879ccedf9..2a6954688 100644 --- a/Application/EdFi.Ods.AdminApi.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs +++ b/Application/EdFi.Ods.AdminApi.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs @@ -409,4 +409,47 @@ public async Task GetTenantEdOrgsByInstancesAsync_AddsDbInstance_WhenLinkedToMis result.OdsInstances[0].DbInstanceId.ShouldBe(42); result.OdsInstances[0].Status.ShouldBe(status); } + + [Test] + public async Task GetTenantEdOrgsByInstancesAsync_AppendsLatestDbInstancePerMissingOdsInstanceId() + { + _appSettings.AppSettings.MultiTenancy = false; + var service = new TenantService(_options, _memoryCache); + + A.CallTo(() => _getOdsInstancesQuery.Execute()).Returns([]); + + var older = new DbInstance + { + Id = 50, + Name = "Orphan-Older", + OdsInstanceId = 9003, + Status = DbInstanceStatus.CreateFailed.ToString(), + DatabaseTemplate = "Minimal", + DatabaseName = "EdFi_ODS_9003_old", + LastRefreshed = System.DateTime.UtcNow.AddMinutes(-10) + }; + + var newer = new DbInstance + { + Id = 51, + Name = "Orphan-Newer", + OdsInstanceId = 9003, + Status = DbInstanceStatus.Deleted.ToString(), + DatabaseTemplate = "Minimal", + DatabaseName = "EdFi_ODS_9003_new", + LastModifiedDate = System.DateTime.UtcNow + }; + + A.CallTo(() => _getDbInstancesQuery.Execute(A._, A._, A.Ignored)) + .Returns([older, newer]); + + var result = await service.GetTenantEdOrgsByInstancesAsync( + _getOdsInstancesQuery, _getEducationOrganizationQuery, _getDbInstancesQuery, Constants.DefaultTenantName); + + result.ShouldNotBeNull(); + result!.OdsInstances.Count.ShouldBe(1); + result.OdsInstances[0].DbInstanceId.ShouldBe(51); + result.OdsInstances[0].Status.ShouldBe(DbInstanceStatus.Deleted.ToString()); + result.OdsInstances[0].Name.ShouldBe("Orphan-Newer"); + } } diff --git a/Application/EdFi.Ods.AdminApi.V3.UnitTests/Features/DataStores/ReadEducationOrganizationsTests.cs b/Application/EdFi.Ods.AdminApi.V3.UnitTests/Features/DataStores/ReadEducationOrganizationsTests.cs index bbe5e9140..bded87b9e 100644 --- a/Application/EdFi.Ods.AdminApi.V3.UnitTests/Features/DataStores/ReadEducationOrganizationsTests.cs +++ b/Application/EdFi.Ods.AdminApi.V3.UnitTests/Features/DataStores/ReadEducationOrganizationsTests.cs @@ -131,6 +131,27 @@ public async Task GetEducationOrganizations_AppendsOrphanedLinkedDbDataStore_For ok.Value[0].Status.ShouldBe(status); } + [Test] + public async Task GetEducationOrganizations_AppendsLatestDbDataStorePerMissingDataStoreId() + { + A.CallTo(() => _getEdOrgsQuery.ExecuteAsync(_queryParams, null)) + .Returns(new List()); + A.CallTo(() => _getDbDataStoresQuery.Execute(A._, null, null)) + .Returns(new List + { + new DbInstance { Id = 20, Name = "Orphan-Older", OdsInstanceId = 999, Status = DbInstanceStatus.CreateFailed.ToString(), DatabaseTemplate = "Minimal", DatabaseName = "EdFi_Ods_Old", LastRefreshed = DateTime.UtcNow.AddMinutes(-10) }, + new DbInstance { Id = 21, Name = "Orphan-Newer", OdsInstanceId = 999, Status = DbInstanceStatus.Deleted.ToString(), DatabaseTemplate = "Minimal", DatabaseName = "EdFi_Ods_New", LastModifiedDate = DateTime.UtcNow } + }); + + var result = await ReadEducationOrganizations.GetEducationOrganizations(_getEdOrgsQuery, _getDbDataStoresQuery, _queryParams); + + var ok = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + ok.ShouldNotBeNull(); + ok.Value!.Count.ShouldBe(1); + ok.Value[0].Status.ShouldBe(DbInstanceStatus.Deleted.ToString()); + ok.Value[0].Name.ShouldBe("Orphan-Newer"); + } + [Test] public async Task GetEducationOrganizationsByDataStore_DoesNotAppendUnlinkedDbDataStores() { diff --git a/Application/EdFi.Ods.AdminApi.V3.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs b/Application/EdFi.Ods.AdminApi.V3.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs index bb4ad4852..43597c76a 100644 --- a/Application/EdFi.Ods.AdminApi.V3.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs +++ b/Application/EdFi.Ods.AdminApi.V3.UnitTests/Infrastructure/Services/Tenants/TenantServiceTests.cs @@ -403,6 +403,47 @@ public async Task GetTenantEdOrgsByInstancesAsync_AddsDbDataStore_WhenLinkedToMi result.DataStores[0].DataStoreId.ShouldBe(-1); result.DataStores[0].Status.ShouldBe(status); } -} + [Test] + public async Task GetTenantEdOrgsByInstancesAsync_AppendsLatestDbDataStorePerMissingDataStoreId() + { + _appSettings.AppSettings.MultiTenancy = false; + var service = new TenantService(_options, _memoryCache); + + A.CallTo(() => _getDataStoresQuery.Execute()).Returns([]); + + var older = new DbInstance + { + Id = 50, + Name = "Orphan-Older", + OdsInstanceId = 9003, + Status = DbInstanceStatus.CreateFailed.ToString(), + DatabaseTemplate = "Minimal", + DatabaseName = "EdFi_ODS_9003_old", + LastRefreshed = System.DateTime.UtcNow.AddMinutes(-10) + }; + + var newer = new DbInstance + { + Id = 51, + Name = "Orphan-Newer", + OdsInstanceId = 9003, + Status = DbInstanceStatus.Deleted.ToString(), + DatabaseTemplate = "Minimal", + DatabaseName = "EdFi_ODS_9003_new", + LastModifiedDate = System.DateTime.UtcNow + }; + + A.CallTo(() => _getDbDataStoresQuery.Execute(A._, A._, A.Ignored)) + .Returns([older, newer]); + + var result = await service.GetTenantEdOrgsByInstancesAsync( + _getDataStoresQuery, _getEducationOrganizationQuery, _getDbDataStoresQuery, Constants.DefaultTenantName); + + result.ShouldNotBeNull(); + result!.DataStores.Count.ShouldBe(1); + result.DataStores[0].Status.ShouldBe(DbInstanceStatus.Deleted.ToString()); + result.DataStores[0].Name.ShouldBe("Orphan-Newer"); + } +} diff --git a/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/ReadEducationOrganizations.cs b/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/ReadEducationOrganizations.cs index c22600b11..ba510daf8 100644 --- a/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/ReadEducationOrganizations.cs +++ b/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/ReadEducationOrganizations.cs @@ -99,7 +99,14 @@ private static void MergeDbDataStoreData( .ToHashSet(); var negativeId = -1; - foreach (var dbDataStore in allDbDataStores.Where(d => d.OdsInstanceId is null || !existingDataStoreIds.Contains(d.OdsInstanceId.Value))) + var unlinkedOrOrphanedDbDataStores = allDbDataStores + .Where(d => d.OdsInstanceId is null) + .Concat(allDbDataStores + .Where(d => d.OdsInstanceId is not null && !existingDataStoreIds.Contains(d.OdsInstanceId.Value)) + .GroupBy(d => d.OdsInstanceId!.Value) + .Select(g => g.OrderByDescending(d => d.LastModifiedDate ?? d.LastRefreshed).First())); + + foreach (var dbDataStore in unlinkedOrOrphanedDbDataStores) { instances.Add(new DataStoreWithEducationOrganizationsModel { diff --git a/Application/EdFi.Ods.AdminApi.V3/Infrastructure/Services/Tenants/TenantService.cs b/Application/EdFi.Ods.AdminApi.V3/Infrastructure/Services/Tenants/TenantService.cs index c85b88feb..51fc54ed9 100644 --- a/Application/EdFi.Ods.AdminApi.V3/Infrastructure/Services/Tenants/TenantService.cs +++ b/Application/EdFi.Ods.AdminApi.V3/Infrastructure/Services/Tenants/TenantService.cs @@ -165,7 +165,11 @@ public async Task> GetTenantsAsync(bool fromCache = false) .ToHashSet(); var unlinkedDbDataStores = allDbDataStores - .Where(d => d.OdsInstanceId is null || !existingDataStoreIds.Contains(d.OdsInstanceId.Value)) + .Where(d => d.OdsInstanceId is null) + .Concat(allDbDataStores + .Where(d => d.OdsInstanceId is not null && !existingDataStoreIds.Contains(d.OdsInstanceId.Value)) + .GroupBy(d => d.OdsInstanceId!.Value) + .Select(g => g.OrderByDescending(d => d.LastModifiedDate ?? d.LastRefreshed).First())) .ToList(); var negativeId = -1; foreach (var dbDataStore in unlinkedDbDataStores) @@ -186,4 +190,3 @@ private async Task> GetTenantsFromCacheAsync() } } - diff --git a/Application/EdFi.Ods.AdminApi/Features/OdsInstances/ReadEducationOrganizations.cs b/Application/EdFi.Ods.AdminApi/Features/OdsInstances/ReadEducationOrganizations.cs index 307b194db..152299ca6 100644 --- a/Application/EdFi.Ods.AdminApi/Features/OdsInstances/ReadEducationOrganizations.cs +++ b/Application/EdFi.Ods.AdminApi/Features/OdsInstances/ReadEducationOrganizations.cs @@ -100,7 +100,14 @@ private static void MergeDbInstanceData( .ToHashSet(); var negativeId = -1; - foreach (var dbInstance in allDbInstances.Where(d => d.OdsInstanceId is null || !existingOdsInstanceIds.Contains(d.OdsInstanceId.Value))) + var unlinkedOrOrphanedDbInstances = allDbInstances + .Where(d => d.OdsInstanceId is null) + .Concat(allDbInstances + .Where(d => d.OdsInstanceId is not null && !existingOdsInstanceIds.Contains(d.OdsInstanceId.Value)) + .GroupBy(d => d.OdsInstanceId!.Value) + .Select(g => g.OrderByDescending(d => d.LastModifiedDate ?? d.LastRefreshed).First())); + + foreach (var dbInstance in unlinkedOrOrphanedDbInstances) { instances.Add(new OdsInstanceWithEducationOrganizationsModel { diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/Services/Tenants/TenantService.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/Services/Tenants/TenantService.cs index 2a4fc5f74..055ff4ab1 100644 --- a/Application/EdFi.Ods.AdminApi/Infrastructure/Services/Tenants/TenantService.cs +++ b/Application/EdFi.Ods.AdminApi/Infrastructure/Services/Tenants/TenantService.cs @@ -166,7 +166,11 @@ public async Task> GetTenantsAsync(bool fromCache = false) .ToHashSet(); var unlinkedDbInstances = allDbInstances - .Where(d => d.OdsInstanceId is null || !existingOdsInstanceIds.Contains(d.OdsInstanceId.Value)) + .Where(d => d.OdsInstanceId is null) + .Concat(allDbInstances + .Where(d => d.OdsInstanceId is not null && !existingOdsInstanceIds.Contains(d.OdsInstanceId.Value)) + .GroupBy(d => d.OdsInstanceId!.Value) + .Select(g => g.OrderByDescending(d => d.LastModifiedDate ?? d.LastRefreshed).First())) .ToList(); var negativeId = -1; foreach (var dbInstance in unlinkedDbInstances)