diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index e63d41dcd..45655d0d6 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -58,6 +58,7 @@ import cwms.cda.api.CountyController; import cwms.cda.api.DownstreamLocationsGetController; import cwms.cda.api.EmbankmentController; +import cwms.cda.api.EntityController; import cwms.cda.api.ForecastFileController; import cwms.cda.api.ForecastInstanceController; import cwms.cda.api.ForecastSpecController; @@ -226,6 +227,7 @@ "/counties/*", "/location/*", "/locations/*", + "/entity/*", "/parameters/*", "/timezones/*", "/units/*", @@ -423,6 +425,8 @@ protected void configureRoutes() { get("/locations/with-kinds/", new LocationKindController(metrics)); cdaCrudCache("/locations/{location-id}", new LocationController(metrics), requiredRoles, 5, TimeUnit.MINUTES); + cdaCrudCache("/entity/{entity-id}", + new EntityController(metrics), requiredRoles, 5, TimeUnit.MINUTES); cdaCrudCache("/states/{state}", new StateController(metrics), requiredRoles, 60, TimeUnit.MINUTES); cdaCrudCache("/counties/{county}", diff --git a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java index 346030bc6..19334ae6a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java @@ -122,6 +122,10 @@ public final class Controllers { public static final String CATEGORY_ID = "category-id"; public static final String CATEGORY_ID_MASK = "category-id-mask"; public static final String VERSION_DATE = "version-date"; + public static final String LONG_NAME = "long-name"; + public static final String MATCH_NULL_PARENTS = "match-null-parents"; + public static final String ENTITY_ID = "entity-id"; + public static final String PARENT_ENTITY_ID = "parent-entity-id"; public static final String CREATE_AS_LRTS = "create-as-lrts"; public static final String STORE_RULE = "store-rule"; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/EntityController.java b/cwms-data-api/src/main/java/cwms/cda/api/EntityController.java new file mode 100644 index 000000000..e61554a3c --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/EntityController.java @@ -0,0 +1,238 @@ +package cwms.cda.api; + +import com.codahale.metrics.Histogram; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.data.dao.EntityDao; +import cwms.cda.data.dto.CwmsId; +import cwms.cda.data.dto.Entity; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.apibuilder.CrudHandler; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.plugin.openapi.annotations.*; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +import static com.codahale.metrics.MetricRegistry.name; +import static cwms.cda.api.Controllers.*; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +public class EntityController implements CrudHandler { + private static final String TAG = "Entity"; + private final MetricRegistry metrics; + private final Histogram requestResultSize; + + + public EntityController(MetricRegistry metrics) { + this.metrics = metrics; + String className = this.getClass().getName(); + requestResultSize = this.metrics.histogram(name(className, "results", "size")); + } + + private Timer.Context markAndTime(String subject) { + return Controllers.markAndTime(metrics, getClass().getName(), subject); + } + + @OpenApi( + description = "Returns all CWMS Entity Data filtered by optional masks.", + queryParams = { + @OpenApiParam(name = OFFICE, description = "Office ID to filter entities (e.g., SPK). If omitted, " + + "returns entities for all offices."), + @OpenApiParam(name = ENTITY_ID, description = "Entity ID to filter by specific entity. If omitted, " + + "returns all entities. (e.g., GOV or NWS)."), + @OpenApiParam(name = PARENT_ENTITY_ID, description = "Parent Entity ID to filter entities " + + "by parent (e.g., NOAA)."), + @OpenApiParam(name = CATEGORY_ID, description = "Category ID to filter entities by category (e.g., GOV)."), + @OpenApiParam(name = LONG_NAME, description = "Entity long name to filter entities " + + "(e.g., National Weather Service)."), + @OpenApiParam(name = MATCH_NULL_PARENTS, type = Boolean.class, description = "If true, include " + + "entities with null parent IDs. Default is true.") + }, + responses = { + @OpenApiResponse(status = STATUS_200, content = { + @OpenApiContent(isArray = true, from = Entity.class, type = Formats.JSONV2) + }) + }, + tags = {TAG} + ) + + @Override + public void getAll(@NotNull Context ctx) { + try (final Timer.Context ignored = markAndTime(GET_ALL)) { + DSLContext dsl = getDslContext(ctx); + + String officeId = ctx.queryParam(OFFICE); + String entityId = ctx.queryParam(ENTITY_ID); + String parentId = ctx.queryParam(PARENT_ENTITY_ID); + Boolean matchNullParents = ctx.queryParamAsClass(MATCH_NULL_PARENTS, Boolean.class) + .getOrDefault(true); + String categoryId = ctx.queryParam(CATEGORY_ID); + String entityName = ctx.queryParam(LONG_NAME); + + EntityDao dao = new EntityDao(dsl); + List entities = dao.retrieveEntities( + officeId, entityId, parentId, matchNullParents, categoryId, entityName); + + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, Entity.class); + ctx.contentType(contentType.toString()); + String result = Formats.format(contentType, entities, Entity.class); + ctx.result(result); + ctx.status(HttpServletResponse.SC_OK); + requestResultSize.update(result.length()); + } + } + + @OpenApi( + description = "Returns CWMS Entity data by entity id and office id.", + pathParams = { + @OpenApiParam(name = ENTITY_ID, required = true, description = "Specifies the Entity ID of the entity to be " + + " retrieved. (e.g., NWS)."), + }, + queryParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning office of " + + "the entity to be retrieved. e.g., SPK)") + }, + responses = { + @OpenApiResponse(status = STATUS_200, content = { + @OpenApiContent(from = Entity.class, type = Formats.JSONV2) + }) + }, + tags = {TAG} + ) + + @Override + public void getOne(@NotNull Context ctx, @NotNull String entityId) { + try (final Timer.Context ignored = markAndTime(GET_ONE)) { + DSLContext dsl = getDslContext(ctx); + String officeId = requiredParam(ctx, OFFICE); + + CwmsId cwmsId = new CwmsId.Builder() + .withOfficeId(officeId) + .withName(entityId) + .build(); + + EntityDao dao = new EntityDao(dsl); + Entity foundEntity = dao.retrieveEntity(cwmsId); + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, Entity.class); + ctx.contentType(contentType.toString()); + String result = Formats.format(contentType, foundEntity); + ctx.result(result); + ctx.status(HttpServletResponse.SC_OK); + requestResultSize.update(result.length()); + } + } + + @OpenApi( + description = "Create CWMS Entity", + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = Entity.class, type = Formats.JSONV2) + }, + required = true), + responses = { + @OpenApiResponse(status = STATUS_201, description = "Entity successfully stored to CWMS") + }, + method = HttpMethod.POST, + tags = {TAG} + ) + + @Override + public void create(@NotNull Context ctx) { + try (final Timer.Context ignored = markAndTime(CREATE)) { + DSLContext dsl = getDslContext(ctx); + + String formatHeader = ctx.req.getContentType(); + ContentType contentType = Formats.parseHeader(formatHeader, Entity.class); + Entity entity = Formats.parseContent(contentType, ctx.body(), Entity.class); + EntityDao dao = new EntityDao(dsl); + dao.createEntity(entity); + ctx.status(HttpServletResponse.SC_CREATED); + } + } + + // update() openApi setup + @OpenApi( + description = "Update an existing Entity.", + requestBody = @OpenApiRequestBody( + content = {@OpenApiContent(from = Entity.class, type = Formats.JSONV2)}, + required = true), + pathParams = { + @OpenApiParam(name = ENTITY_ID, required = true, description = "Specifies the entity ID of the " + + " Entity to be updated. (e.g., NWS)") + }, + queryParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning office "+ + " of the entity to be updated. (e.g., SPK)") + }, + method = HttpMethod.PATCH, + tags = {TAG}, + responses = { + @OpenApiResponse(status = STATUS_200, description = "Entity updated successfully in CWMS"), + } + ) + + @Override + public void update(@NotNull Context ctx, @NotNull String entityId) { + try (final Timer.Context ignored = markAndTime(UPDATE)) { + DSLContext dsl = getDslContext(ctx); + String formatHeader = ctx.req.getContentType(); + ContentType contentType = Formats.parseHeader(formatHeader, Entity.class); + Entity entity = Formats.parseContent(contentType, ctx.bodyAsInputStream(), Entity.class); + if (entity.getId() == null || entity.getId().getOfficeId() == null || entity.getId().getName() == null) { + ctx.status(HttpServletResponse.SC_BAD_REQUEST); + ctx.result("Entity ID and Office ID must be provided in the request body."); + return; + } + EntityDao dao = new EntityDao(dsl); + dao.updateEntity(entity); + ctx.status(HttpServletResponse.SC_OK); + } + } + + @OpenApi( + description = "Delete CWMS Entity.", + pathParams = { + @OpenApiParam(name = ENTITY_ID, required = true, description = "Specifies the entity ID " + + "of the Entity to be deleted (e.g., NWS).") + + }, + queryParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning office "+ + " of the entity to be updated. (e.g., SPK)"), + @OpenApiParam(name = CASCADE_DELETE, required = true, type = Boolean.class, + description = "If true, also delete all descendant child entities.") + }, + responses = { + @OpenApiResponse(status = STATUS_204, description = "Entity deleted successfully"), + @OpenApiResponse(status = STATUS_404, description = "Entity not found for the given parameters."), + }, + method = HttpMethod.DELETE, + tags = {TAG} + + ) + + @Override + public void delete(@NotNull Context ctx, @NotNull String entityId) { + try (final Timer.Context ignored = markAndTime(DELETE)) { + DSLContext dsl = getDslContext(ctx); + String officeId = requiredParam(ctx, OFFICE); + Boolean deleteEntityAndChildren = requiredParamAs(ctx, CASCADE_DELETE, Boolean.class); + + CwmsId cwmsId = new CwmsId.Builder() + .withOfficeId(officeId) + .withName(entityId) + .build(); + + EntityDao dao = new EntityDao(dsl); + dao.deleteEntity(cwmsId, deleteEntityAndChildren); + ctx.status(HttpServletResponse.SC_NO_CONTENT); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/EntityDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/EntityDao.java index 69f908076..1fbff708a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/EntityDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/EntityDao.java @@ -48,6 +48,14 @@ public void createEntity(Entity entity) { } public void updateEntity(Entity entity) { + // verify entity exists before updating + CwmsId id = new CwmsId.Builder() + .withOfficeId(entity.getId().getOfficeId()) + .withName(entity.getId().getName()) + .build(); + + retrieveEntity(id); + storeEntity(entity, false, false); } diff --git a/cwms-data-api/src/test/java/cwms/cda/api/EntityControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/EntityControllerTestIT.java new file mode 100644 index 000000000..0f7db103d --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/EntityControllerTestIT.java @@ -0,0 +1,401 @@ +package cwms.cda.api; + +import cwms.cda.formatters.Formats; +import fixtures.TestAccounts; +import io.restassured.filter.log.LogDetail; +import io.restassured.path.json.JsonPath; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import javax.servlet.http.HttpServletResponse; +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +import static cwms.cda.security.ApiKeyIdentityProvider.AUTH_HEADER; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; + +@Tag("integration") +final class EntityControllerTestIT extends DataApiTestIT { + private static final String OFFICE_ID = TestAccounts.KeyUser.SPK_NORMAL.getOperatingOffice(); + private static final String ENTITY_ID = "NWS"; + private static final String CASCADE_DELETE = "true"; + private static final String PARENT_ID = "NOAA"; + + + @AfterEach + void tearDown() { + given() + .accept(Formats.JSONV2) + .queryParam(Controllers.OFFICE, OFFICE_ID) + .queryParam(Controllers.CASCADE_DELETE, true) + .header(AUTH_HEADER, TestAccounts.KeyUser.SPK_NORMAL.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .delete("/entity/" + ENTITY_ID) + .then() + .statusCode(isOneOf( + HttpServletResponse.SC_NO_CONTENT, + HttpServletResponse.SC_NOT_FOUND, + HttpServletResponse.SC_BAD_REQUEST + )); + } + + + // Test CRUD + // create -> getOne -> update -> getOne to verify updated + // delete -> getOne to verify deleted + @ParameterizedTest + @ValueSource(strings = {Formats.JSONV2, Formats.DEFAULT}) + void test_entity_create_get_update_delete(String format) throws Exception { + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + InputStream in = this.getClass().getResourceAsStream("/cwms/cda/data/dto/entity.json"); + assertNotNull(in); + String entityJson = IOUtils.toString(in, java.nio.charset.StandardCharsets.UTF_8); + + // CREATE + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.JSONV2) + .body(entityJson) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/entity") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // GET + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(Controllers.OFFICE, OFFICE_ID) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/entity/" + ENTITY_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("id.name", equalTo(ENTITY_ID)) + .body("id.office-id", equalTo(OFFICE_ID)) + .body("long-name", equalTo("National Weather Service")); + + // UPDATE — modify long-name to verify persistence + String updatedEntityJson = entityJson.replace( + "\"National Weather Service\"", + "\"National Weather Service (Updated)\""); + + given() + .contentType(Formats.JSONV2) + .body(updatedEntityJson) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(Controllers.OFFICE, OFFICE_ID) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch("/entity/" + ENTITY_ID) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + // GET to confirm updated field persisted + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(Controllers.OFFICE, OFFICE_ID) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/entity/" + ENTITY_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("id.name", equalTo(ENTITY_ID)) + .body("id.office-id", equalTo(OFFICE_ID)) + .body("long-name", equalTo("National Weather Service (Updated)")); + + // DELETE + given() + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(Controllers.OFFICE, OFFICE_ID) + .queryParam(Controllers.CASCADE_DELETE, CASCADE_DELETE) + .when() + .redirects().follow(true) + .redirects().max(3) + .delete("/entity/" + ENTITY_ID) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + + // verify deleted + given() + .accept(format) + .queryParam(Controllers.OFFICE, OFFICE_ID) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/entity/" + ENTITY_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + } + + + // create fails if entity already exists + @Test + void create_duplicate_entity_bad_request() throws Exception { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + InputStream in = this.getClass().getResourceAsStream("/cwms/cda/data/dto/entity.json"); + assertNotNull(in); + String entityJson = IOUtils.toString(in, java.nio.charset.StandardCharsets.UTF_8); + + // CREATE + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.JSONV2) + .body(entityJson) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/entity") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + + // CREATE + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.JSONV2) + .body(entityJson) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/entity") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CONFLICT)); + + } + + + // Controller-owned validation: missing required query param + @Test + void get_one_missing_office_bad_request() { + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/entity/" + ENTITY_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_BAD_REQUEST)); + } + + + // Entity ID in the URL must match the id.name in the request body + @Test + void update_non_existing_entity_id_or_missing_office_id_400_or_404() throws Exception { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + InputStream in = this.getClass().getResourceAsStream("/cwms/cda/data/dto/entity.json"); + assertNotNull(in); + String entity = IOUtils.toString(in, java.nio.charset.StandardCharsets.UTF_8); + + // UPDATE - non-existing entity id - 404 + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .contentType(Formats.JSONV2) + .body(entity) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(Controllers.OFFICE, OFFICE_ID) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch("/entity/" + "different") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + + // UPDATE - missing office id - 400 + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .contentType(Formats.JSONV2) + .body(entity) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch("/entity/" + ENTITY_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_BAD_REQUEST)); + } + + + // getAll with no query params: must return 200 and a list (empty allowed) + @ParameterizedTest + @ValueSource(strings = {Formats.JSONV2, Formats.DEFAULT}) + void getAll_no_params_returns_200_empty_list_ok(String format) { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/entity") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("entities", notNullValue()) + .body("entities.size()", greaterThanOrEqualTo(0)); + } + + + // Show simple filtering works with getAll and parent entity id only + @Test + void getAll_with_parent_filter_returns_200_empty_list_ok() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .queryParam(Controllers.PARENT_ENTITY_ID, PARENT_ID) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/entity") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + } + + + @Test + void getAll_match_null_parents_flag_() throws Exception { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + InputStream in = this.getClass().getResourceAsStream("/cwms/cda/data/dto/entity.json"); + assertNotNull(in); + String entity = IOUtils.toString(in, java.nio.charset.StandardCharsets.UTF_8); + // make parent-entity-id null + String nullParentEntity = entity.replace( + "\"parent-entity-id\" : \"NOAA\",", ""); + + // CREATE entity with null parent - default match-null-parents = true + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.JSONV2) + .body(nullParentEntity) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/entity") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // GET - verify getAll includes nullParentEntity when match-null-parents = true (default) + String json = + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/entity") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(HttpServletResponse.SC_OK) + .extract().asString(); + + List> items = JsonPath.from(json).getList(""); + Map target = null; + for (Map m : items) { + Map id = (Map) m.get("id"); + if (id != null + && "SPK".equals(id.get("office-id")) + && "NWS".equals(id.get("name"))) { + target = m; + break; + } + } + + assertNotNull(target, "Entity with null parent-id should be present when match-null-parents=true"); + assertTrue(!target.containsKey("parent-entity-id") || target.get("parent-entity-id") == null, + "parent-entity-id should be null/absent" + ); + + // GET - verify getAll does NOT include nullParentEntity when match-null-parents = false + String json2 = + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .queryParam(Controllers.MATCH_NULL_PARENTS, false) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/entity") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(HttpServletResponse.SC_OK) + .extract().asString(); + + List> items2 = JsonPath.from(json2).getList(""); + boolean present = false; + for (Map m : items2) { + Map id = (Map) m.get("id"); + if (id != null + && "SPK".equals(id.get("office-id")) + && "NWS".equals(id.get("name"))) { + present = true; + break; + } + } + + assertFalse(present, "Entity with null parent-id should be filtered out when match-null-parents=false"); + + // DELETE nullParentEntity + given() + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(Controllers.OFFICE, OFFICE_ID) + .queryParam(Controllers.CASCADE_DELETE, CASCADE_DELETE) + .when() + .redirects().follow(true) + .redirects().max(3) + .delete("/entity/" + ENTITY_ID) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + } +} \ No newline at end of file