diff --git a/src/test/java/com/iemr/common/identity/IdentityApplicationTests.java b/src/test/java/com/iemr/common/identity/IdentityApplicationTests.java index 86ea05fe..7931078f 100644 --- a/src/test/java/com/iemr/common/identity/IdentityApplicationTests.java +++ b/src/test/java/com/iemr/common/identity/IdentityApplicationTests.java @@ -1,8 +1,8 @@ /* -* AMRIT – Accessible Medical Records via Integrated Technology -* Integrated EHR (Electronic Health Records) Solution +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution * -* Copyright (C) "Piramal Swasthya Management and Research Institute" +* Copyright (C) "Piramal Swasthya Management and Research Institute" * * This file is part of AMRIT. * @@ -21,18 +21,55 @@ */ package com.iemr.common.identity; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.annotation.ComponentScan; -@ExtendWith(MockitoExtension.class) -@SpringBootTest +/** + * Unit tests for the application entry point. + * + *

+ * These deliberately do not start a Spring context: the WAR needs a reachable + * MySQL, Redis and Elasticsearch, none of which exist on a build agent. What is + * worth asserting without them is the servlet-initializer wiring the Wildfly + * deployment depends on, and the component scan the rest of the app assumes. + */ class IdentityApplicationTests { - @InjectMocks - IdentityApplication identityApplication; - + + @Test + @DisplayName("configure() registers the application class as the WAR deployment source") + void configureRegistersApplicationSource() { + SpringApplicationBuilder builder = mock(SpringApplicationBuilder.class); + when(builder.sources(any(Class[].class))).thenReturn(builder); + + SpringApplicationBuilder result = new IdentityApplication().configure(builder); + + assertSame(builder, result); + verify(builder).sources(IdentityApplication.class); + } + + @Test + @DisplayName("instantiateBeans() exposes the IEMR helper bean") + void instantiateBeansReturnsHelperBean() { + assertNotNull(new IdentityApplication().instantiateBeans()); + } + + @Test + @DisplayName("the entry point is a Spring Boot application scanning the identity packages") + void applicationIsAnnotatedForComponentScanning() { + assertNotNull(IdentityApplication.class.getAnnotation(SpringBootApplication.class)); + ComponentScan componentScan = IdentityApplication.class.getAnnotation(ComponentScan.class); + assertNotNull(componentScan); + org.junit.jupiter.api.Assertions.assertArrayEquals(new String[] { "com.iemr.common.identity" }, + componentScan.basePackages()); + } } diff --git a/src/test/java/com/iemr/common/identity/TestJson.java b/src/test/java/com/iemr/common/identity/TestJson.java new file mode 100644 index 00000000..7204d88f --- /dev/null +++ b/src/test/java/com/iemr/common/identity/TestJson.java @@ -0,0 +1,61 @@ +package com.iemr.common.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.json.JSONObject; + +/** + * Helpers for asserting on the {@code OutputResponse} JSON that the controllers return. + * {@code org.json} declares {@code JSONException} as checked, so parsing is wrapped here to keep + * the test bodies free of {@code throws} clauses. + */ +public final class TestJson { + + private TestJson() { + } + + public static JSONObject parse(String raw) { + try { + return new JSONObject(raw); + } catch (Exception e) { + throw new IllegalArgumentException("not valid JSON: " + raw, e); + } + } + + public static String str(JSONObject json, String key) { + try { + return json.getString(key); + } catch (Exception e) { + throw new IllegalArgumentException("missing string key " + key + " in " + json, e); + } + } + + public static int statusCode(String raw) { + try { + return parse(raw).getInt("statusCode"); + } catch (Exception e) { + throw new IllegalArgumentException("missing statusCode in " + raw, e); + } + } + + /** Asserts a 200 OutputResponse whose serialized data contains {@code dataFragment}. */ + public static void assertSuccess(String raw, String dataFragment) { + JSONObject json = parse(raw); + assertEquals(200, statusCode(raw), () -> "expected success but got: " + raw); + assertTrue(json.toString().contains(dataFragment), + () -> "expected data to contain '" + dataFragment + "' but was: " + raw); + } + + /** Asserts a failing OutputResponse whose errorMessage contains {@code messageFragment}. */ + public static void assertFailure(String raw, int expectedStatusCode, String messageFragment) { + JSONObject json = parse(raw); + assertEquals(expectedStatusCode, statusCode(raw), () -> "unexpected status for: " + raw); + assertTrue(str(json, "errorMessage").contains(messageFragment), + () -> "expected errorMessage to contain '" + messageFragment + "' but was: " + raw); + } + + public static void assertFailure(String raw, String messageFragment) { + assertFailure(raw, 5000, messageFragment); + } +} diff --git a/src/test/java/com/iemr/common/identity/config/ConverterAndBeanConfigTest.java b/src/test/java/com/iemr/common/identity/config/ConverterAndBeanConfigTest.java new file mode 100644 index 00000000..4d06772b --- /dev/null +++ b/src/test/java/com/iemr/common/identity/config/ConverterAndBeanConfigTest.java @@ -0,0 +1,162 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.sql.Timestamp; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.session.data.redis.config.ConfigureRedisAction; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +import com.iemr.common.identity.utils.http.HTTPRequestInterceptor; +import com.iemr.common.identity.utils.redis.RedisConfig; + +/** + * Tests for the request-binding converters and the infrastructure beans. + * + *

+ * The converters sit on the MVC binding path, so a request carrying a + * date the pattern does not match must bind as null rather than fail the whole + * request with a 400 the caller cannot interpret. The bean definitions are + * asserted for the specific settings the rest of the service depends on - + * notably that Redis session configuration is a no-op, since the managed Redis + * these deployments use rejects the CONFIG command. + */ +class ConverterAndBeanConfigTest { + + @Nested + @DisplayName("string to timestamp binding") + class StringToTimestampBinding { + + private final StringtoSQLDateConverter converter = new StringtoSQLDateConverter(); + + @Test + @DisplayName("a timestamp in the expected pattern is converted") + void timestampInTheExpectedPatternIsConverted() { + assertEquals(Timestamp.valueOf("1996-01-15 10:30:00.000"), + converter.convert("1996-01-15 10:30:00.000")); + } + + @ParameterizedTest + @ValueSource(strings = { "15/01/1996", "not a date", "1996-01-15T10:30:00Z", "" }) + @DisplayName("an unparseable value binds as null rather than failing the request") + void unparseableValueBindsAsNull(String supplied) { + assertNull(converter.convert(supplied)); + } + + @Test + @DisplayName("an absent value binds as null") + void absentValueBindsAsNull() { + assertNull(converter.convert(null)); + } + } + + @Nested + @DisplayName("timestamp to string binding") + class TimestampToStringBinding { + + private final SQLDateToStringConverter converter = new SQLDateToStringConverter(); + + @Test + @DisplayName("a timestamp is rendered as a date string") + void timestampIsRenderedAsADateString() { + String rendered = converter.convert(Timestamp.valueOf("1996-01-15 10:30:00")); + + assertNotNull(rendered); + assertEquals(new java.util.Date(Timestamp.valueOf("1996-01-15 10:30:00").getTime()).toString(), + rendered); + } + + @Test + @DisplayName("an absent timestamp is rejected rather than rendered as text") + void absentTimestampIsRejected() { + assertThrows(NullPointerException.class, () -> converter.convert(null)); + } + } + + @Nested + @DisplayName("Redis beans") + class RedisBeans { + + private final RedisConfig config = new RedisConfig(); + + @Test + @DisplayName("Redis session configuration is disabled because managed Redis rejects CONFIG") + void redisSessionConfigurationIsDisabled() { + assertSame(ConfigureRedisAction.NO_OP, config.configureRedisAction()); + } + + @Test + @DisplayName("the template serialises values as JSON so cached users survive a restart") + void templateSerialisesValuesAsJson() { + RedisConnectionFactory factory = mock(RedisConnectionFactory.class); + + RedisTemplate template = config.redisTemplate(factory); + + assertSame(factory, template.getConnectionFactory()); + assertNotNull(template.getValueSerializer()); + assertEquals("Jackson2JsonRedisSerializer", + template.getValueSerializer().getClass().getSimpleName()); + } + } + + @Test + @DisplayName("the session-refreshing interceptor is registered for every request") + void sessionRefreshingInterceptorIsRegistered() { + InterceptorConfig config = new InterceptorConfig(); + HTTPRequestInterceptor interceptor = mock(HTTPRequestInterceptor.class); + ReflectionTestUtils.setField(config, "requestInterceptor", interceptor); + InterceptorRegistry registry = new InterceptorRegistry(); + + config.addInterceptors(registry); + + assertEquals(1, ((java.util.List) ReflectionTestUtils.getField(registry, "registrations")).size()); + } + + @Test + @DisplayName("the Elasticsearch client is built against the configured host and credentials") + void elasticsearchClientIsBuiltAgainstConfiguredHost() { + ElasticsearchConfig config = new ElasticsearchConfig(); + ReflectionTestUtils.setField(config, "esHost", "localhost"); + ReflectionTestUtils.setField(config, "esPort", 9200); + ReflectionTestUtils.setField(config, "esUsername", "elastic"); + ReflectionTestUtils.setField(config, "esPassword", "changeme"); + ReflectionTestUtils.setField(config, "indexName", "beneficiary"); + + assertNotNull(config.elasticsearchClient()); + } +} diff --git a/src/test/java/com/iemr/common/identity/config/CorsConfigTest.java b/src/test/java/com/iemr/common/identity/config/CorsConfigTest.java new file mode 100644 index 00000000..d678ccdb --- /dev/null +++ b/src/test/java/com/iemr/common/identity/config/CorsConfigTest.java @@ -0,0 +1,130 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.config; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_SELF; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.servlet.config.annotation.CorsRegistration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; + +/** + * Tests for the framework-level CORS registration. + * + *

+ * These responses carry credentials, so the allow-list has to come from + * configuration and an unset or blank property must register no origins at all - + * a wildcard there would let any site read a beneficiary's record with the + * caller's own cookies. + */ +class CorsConfigTest { + + private CorsConfig config; + private CorsRegistry registry; + private CorsRegistration registration; + + @BeforeEach + void setUp() { + config = new CorsConfig(); + registry = mock(CorsRegistry.class); + registration = mock(CorsRegistration.class, RETURNS_SELF); + when(registry.addMapping(any())).thenReturn(registration); + } + + private String[] registeredOrigins() { + ArgumentCaptor captor = ArgumentCaptor.forClass(String[].class); + verify(registration).allowedOriginPatterns(captor.capture()); + return captor.getValue(); + } + + @Test + @DisplayName("the configured origins are registered for every path") + void configuredOriginsAreRegisteredForEveryPath() { + ReflectionTestUtils.setField(config, "allowedOrigins", + "https://amrit.piramalswasthya.org,https://uat.piramalswasthya.org"); + + config.addCorsMappings(registry); + + verify(registry).addMapping("/**"); + assertArrayEquals(new String[] { "https://amrit.piramalswasthya.org", "https://uat.piramalswasthya.org" }, + registeredOrigins()); + } + + @Test + @DisplayName("whitespace and empty entries in the configured list are discarded") + void whitespaceAndEmptyEntriesAreDiscarded() { + ReflectionTestUtils.setField(config, "allowedOrigins", + " https://amrit.piramalswasthya.org , ,https://uat.piramalswasthya.org "); + + config.addCorsMappings(registry); + + assertArrayEquals(new String[] { "https://amrit.piramalswasthya.org", "https://uat.piramalswasthya.org" }, + registeredOrigins()); + } + + @ParameterizedTest + @ValueSource(strings = { "", " " }) + @DisplayName("a blank allow-list registers no origins rather than a wildcard") + void blankAllowListRegistersNoOrigins(String configured) { + ReflectionTestUtils.setField(config, "allowedOrigins", configured); + + config.addCorsMappings(registry); + + assertEquals(0, registeredOrigins().length); + } + + @Test + @DisplayName("an unset allow-list registers no origins") + void unsetAllowListRegistersNoOrigins() { + config.addCorsMappings(registry); + + assertEquals(0, registeredOrigins().length); + } + + @Test + @DisplayName("the methods, headers and credential policy the front end needs are registered") + void methodsHeadersAndCredentialPolicyAreRegistered() { + ReflectionTestUtils.setField(config, "allowedOrigins", "https://amrit.piramalswasthya.org"); + + config.addCorsMappings(registry); + + verify(registration).allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"); + verify(registration).exposedHeaders("Authorization", "Jwttoken"); + verify(registration).allowCredentials(true); + verify(registration).maxAge(3600); + ArgumentCaptor headers = ArgumentCaptor.forClass(String[].class); + verify(registration).allowedHeaders(headers.capture()); + assertEquals("Authorization", headers.getValue()[0]); + } +} diff --git a/src/test/java/com/iemr/common/identity/config/ElasticsearchSyncConfigTest.java b/src/test/java/com/iemr/common/identity/config/ElasticsearchSyncConfigTest.java new file mode 100644 index 00000000..83f4bb9d --- /dev/null +++ b/src/test/java/com/iemr/common/identity/config/ElasticsearchSyncConfigTest.java @@ -0,0 +1,95 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.Executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Tests for the thread pools the background index rebuilds run on. + * + *

+ * A rebuild saturates both the database and Elasticsearch, so the sync pool is + * deliberately narrow and, when its queue fills, rejects new work with an + * explanatory error rather than silently discarding a job an operator believes + * is queued. + */ +class ElasticsearchSyncConfigTest { + + private final ElasticsearchSyncConfig config = new ElasticsearchSyncConfig(); + + @Test + @DisplayName("the sync pool is kept narrow so a rebuild cannot saturate the database") + void syncPoolIsKeptNarrow() { + ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) config.elasticsearchSyncExecutor(); + + assertEquals(2, executor.getCorePoolSize()); + assertEquals(4, executor.getMaxPoolSize()); + assertEquals(60, executor.getKeepAliveSeconds()); + assertTrue(executor.getThreadNamePrefix().startsWith("es-sync-")); + executor.shutdown(); + } + + @Test + @DisplayName("work rejected by a full sync queue is reported rather than dropped") + void rejectedWorkIsReportedRatherThanDropped() { + ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) config.elasticsearchSyncExecutor(); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> executor + .getThreadPoolExecutor().getRejectedExecutionHandler().rejectedExecution(() -> { + }, executor.getThreadPoolExecutor())); + + assertTrue(thrown.getMessage().contains("queue is full"), thrown.getMessage()); + executor.shutdown(); + } + + @Test + @DisplayName("the general async pool is wider than the sync pool") + void generalAsyncPoolIsWiderThanTheSyncPool() { + ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) config.taskExecutor(); + + assertEquals(5, executor.getCorePoolSize()); + assertEquals(10, executor.getMaxPoolSize()); + assertTrue(executor.getThreadNamePrefix().startsWith("async-")); + executor.shutdown(); + } + + @Test + @DisplayName("both pools are initialised and ready to accept work") + void bothPoolsAreInitialisedAndReady() throws Exception { + Executor syncExecutor = config.elasticsearchSyncExecutor(); + Executor asyncExecutor = config.taskExecutor(); + + assertNotNull(((ThreadPoolTaskExecutor) syncExecutor).getThreadPoolExecutor()); + assertNotNull(((ThreadPoolTaskExecutor) asyncExecutor).getThreadPoolExecutor()); + ((ThreadPoolTaskExecutor) syncExecutor).shutdown(); + ((ThreadPoolTaskExecutor) asyncExecutor).shutdown(); + } +} diff --git a/src/test/java/com/iemr/common/identity/config/SwaggerConfigTest.java b/src/test/java/com/iemr/common/identity/config/SwaggerConfigTest.java new file mode 100644 index 00000000..342cc2e5 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/config/SwaggerConfigTest.java @@ -0,0 +1,94 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityScheme; + +/** + * Tests for the OpenAPI document served at {@code /swagger-ui.html}. + * + *

+ * The document lists the deployment's own environment URLs and declares bearer + * authentication, so a "Try it out" call from the docs page reaches the right + * host with a token attached. Both come from configuration, with a localhost + * fallback for a developer running the service directly. + */ +class SwaggerConfigTest { + + private final SwaggerConfig config = new SwaggerConfig(); + + @Test + @DisplayName("the configured environment URLs are published as servers") + void configuredEnvironmentUrlsArePublished() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("api.dev.url", "https://dev.piramalswasthya.org"); + environment.setProperty("api.uat.url", "https://uat.piramalswasthya.org"); + environment.setProperty("api.demo.url", "https://demo.piramalswasthya.org"); + + OpenAPI openApi = config.customOpenAPI(environment); + + assertEquals(3, openApi.getServers().size()); + assertEquals("https://dev.piramalswasthya.org", openApi.getServers().get(0).getUrl()); + assertEquals("Dev", openApi.getServers().get(0).getDescription()); + assertEquals("https://uat.piramalswasthya.org", openApi.getServers().get(1).getUrl()); + assertEquals("https://demo.piramalswasthya.org", openApi.getServers().get(2).getUrl()); + } + + @Test + @DisplayName("an unconfigured environment falls back to localhost rather than publishing nothing") + void unconfiguredEnvironmentFallsBackToLocalhost() { + OpenAPI openApi = config.customOpenAPI(new MockEnvironment()); + + assertEquals(3, openApi.getServers().size()); + openApi.getServers().forEach(server -> assertEquals("http://localhost:9090", server.getUrl())); + } + + @Test + @DisplayName("bearer authentication is declared so the docs page can call a secured endpoint") + void bearerAuthenticationIsDeclared() { + OpenAPI openApi = config.customOpenAPI(new MockEnvironment()); + + assertEquals(1, openApi.getSecurity().size()); + SecurityScheme scheme = openApi.getComponents().getSecuritySchemes().get("my security"); + assertNotNull(scheme); + assertEquals(SecurityScheme.Type.HTTP, scheme.getType()); + assertEquals("bearer", scheme.getScheme()); + } + + @Test + @DisplayName("the document identifies the service") + void documentIdentifiesTheService() { + OpenAPI openApi = config.customOpenAPI(new MockEnvironment()); + + assertEquals("Identity API", openApi.getInfo().getTitle()); + assertTrue(openApi.getInfo().getDescription().contains("beneficiaries")); + } +} diff --git a/src/test/java/com/iemr/common/identity/controller/IdentityControllerTest.java b/src/test/java/com/iemr/common/identity/controller/IdentityControllerTest.java new file mode 100644 index 00000000..42004466 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/controller/IdentityControllerTest.java @@ -0,0 +1,756 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.math.BigInteger; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.iemr.common.identity.data.rmnch.RMNCHBeneficiaryDetailsRmnch; +import com.iemr.common.identity.dto.BenDetailDTO; +import com.iemr.common.identity.dto.BeneficiariesDTO; +import com.iemr.common.identity.dto.BeneficiariesPartialDTO; +import com.iemr.common.identity.dto.BeneficiaryCreateResp; +import com.iemr.common.identity.dto.IdentityEditDTO; +import com.iemr.common.identity.dto.IdentitySearchDTO; +import com.iemr.common.identity.exception.MissingMandatoryFieldsException; +import com.iemr.common.identity.service.IdentityService; +import com.iemr.common.identity.utils.exception.IEMRException; + +/** + * Tests for the beneficiary identity REST layer. + * + *

+ * Every endpoint here hand-rolls the same envelope: parse a raw JSON string, + * call the service, then wrap the outcome in an {@code OutputResponse} that + * always carries HTTP 200 and signals failure through a {@code statusCode} in + * the body. Clients branch on that body, so the tests assert on the envelope - + * which status code and message a given outcome produces - as much as on the + * data. They also pin the input guards, since several endpoints accept a bare + * JSON literal rather than a typed body. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class IdentityControllerTest { + + @Mock + private IdentityService svc; + + @InjectMocks + private IdentityController controller; + + private static final BigInteger BEN_REG_ID = BigInteger.valueOf(100200300L); + private static final BigInteger BEN_ID = BigInteger.valueOf(4001L); + + private BeneficiariesDTO beneficiary(String firstName) { + BeneficiariesDTO dto = new BeneficiariesDTO(); + dto.setBenId(BEN_ID); + dto.setBenRegId(BEN_REG_ID); + BenDetailDTO details = new BenDetailDTO(); + details.setFirstName(firstName); + dto.setBeneficiaryDetails(details); + return dto; + } + + private void assertSuccess(String response) { + assertTrue(response.contains("\"statusCode\":200"), response); + assertTrue(response.contains("\"statusMessage\":\"success\""), response); + } + + private void assertFailure(String response, String expectedFragment) { + assertTrue(response.contains(expectedFragment), response); + } + + @Nested + @DisplayName("advance search") + class AdvanceSearch { + + @Test + @DisplayName("search criteria are parsed out of the raw body and passed to the service") + void criteriaAreParsedAndPassedToTheService() throws Exception { + when(svc.getBeneficiaries(any(IdentitySearchDTO.class))) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + String response = controller.getBeneficiaries( + "{\"firstName\":\"Asha\",\"genderId\":2,\"currentAddress\":{\"stateId\":101}}"); + + assertSuccess(response); + assertTrue(response.contains("Asha")); + ArgumentCaptor captor = ArgumentCaptor.forClass(IdentitySearchDTO.class); + verify(svc).getBeneficiaries(captor.capture()); + assertEquals("Asha", captor.getValue().getFirstName()); + assertEquals(2, captor.getValue().getGenderId()); + assertEquals(101, captor.getValue().getCurrentAddress().getStateId()); + } + + @Test + @DisplayName("null entries from the service are dropped before the results are sorted") + void nullEntriesAreDropped() throws Exception { + // Collections.sort would throw on a null element, so the filter is + // what keeps a partially failed assembly from failing the request. + when(svc.getBeneficiaries(any(IdentitySearchDTO.class))) + .thenReturn(new java.util.ArrayList<>(Arrays.asList(beneficiary("Asha"), null))); + + assertSuccess(controller.getBeneficiaries("{\"firstName\":\"Asha\"}")); + } + + @Test + @DisplayName("a service failure is reported in the body rather than as an HTTP error") + void serviceFailureIsReportedInTheBody() throws Exception { + when(svc.getBeneficiaries(any(IdentitySearchDTO.class))) + .thenThrow(new IllegalStateException("query timeout")); + + String response = controller.getBeneficiaries("{\"firstName\":\"Asha\"}"); + + assertFailure(response, "5000"); + assertFailure(response, "error in beneficiary advance search"); + } + + @Test + @DisplayName("an unparseable body is reported as a failure") + void unparseableBodyIsReportedAsFailure() throws Exception { + String response = controller.getBeneficiaries("not json at all {"); + + assertFailure(response, "5000"); + verifyNoInteractions(svc); + } + } + + @Nested + @DisplayName("lookup by identifier") + class LookupByIdentifier { + + @Test + @DisplayName("a registration ID is looked up as a number") + void registrationIdIsLookedUpAsANumber() throws Exception { + when(svc.getBeneficiariesByBenRegId(BEN_REG_ID)) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + String response = controller.getBeneficiariesByBeneficiaryRegId("100200300"); + + assertSuccess(response); + verify(svc).getBeneficiariesByBenRegId(BEN_REG_ID); + } + + @Test + @DisplayName("an empty registration ID is rejected without touching the service") + void emptyRegistrationIdIsRejected() throws Exception { + String response = controller.getBeneficiariesByBeneficiaryRegId(""); + + assertFailure(response, "Null/Empty Beneficiary Id."); + verify(svc, never()).getBeneficiariesByBenRegId(any()); + } + + @Test + @DisplayName("a whitespace-only registration ID falls through the guard and fails on conversion") + void whitespaceRegistrationIdFailsOnConversion() throws Exception { + // The guard is a length check, so whitespace reaches new BigInteger + // and the caller gets a number-format message instead of the + // intended "Null/Empty Beneficiary Id." + String response = controller.getBeneficiariesByBeneficiaryRegId(" "); + + assertFailure(response, "5000"); + verify(svc, never()).getBeneficiariesByBenRegId(any()); + } + + @Test + @DisplayName("a null registration ID is rejected") + void nullRegistrationIdIsRejected() throws Exception { + assertFailure(controller.getBeneficiariesByBeneficiaryRegId(null), "Null/Empty Beneficiary Id."); + } + + @Test + @DisplayName("a non-numeric registration ID is reported as a failure") + void nonNumericRegistrationIdIsReportedAsFailure() throws Exception { + assertFailure(controller.getBeneficiariesByBeneficiaryRegId("abc"), "5000"); + } + + @Test + @DisplayName("a beneficiary ID sent as a bare number is looked up") + void beneficiaryIdSentAsBareNumberIsLookedUp() throws Exception { + when(svc.getBeneficiariesByBenId(BEN_ID)) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + assertSuccess(controller.getBeneficiariesByBeneficiaryId("4001")); + + verify(svc).getBeneficiariesByBenId(BEN_ID); + } + + @Test + @DisplayName("a beneficiary ID sent as a quoted JSON string is rejected") + void beneficiaryIdSentAsQuotedStringIsRejected() throws Exception { + // A quoted value parses to a JsonPrimitive, and that branch keeps the + // raw text - quotes included - so it never reaches the unquoting + // branch below it and fails number conversion instead. + String response = controller.getBeneficiariesByBeneficiaryId("\"4001\""); + + assertFailure(response, "5000"); + verify(svc, never()).getBeneficiariesByBenId(any()); + } + + @Test + @DisplayName("a JSON null beneficiary ID is rejected") + void jsonNullBeneficiaryIdIsRejected() throws Exception { + assertFailure(controller.getBeneficiariesByBeneficiaryId("null"), "Null/Empty Beneficiary Id."); + verify(svc, never()).getBeneficiariesByBenId(any()); + } + + @Test + @DisplayName("a failing beneficiary lookup is reported in the body") + void failingBeneficiaryLookupIsReportedInTheBody() throws Exception { + when(svc.getBeneficiariesByBenId(BEN_ID)).thenThrow(new IllegalStateException("view down")); + + assertFailure(controller.getBeneficiariesByBeneficiaryId("4001"), "5000"); + } + } + + @Nested + @DisplayName("lookup by phone number") + class LookupByPhoneNumber { + + @Test + @DisplayName("a phone number sent as a quoted JSON string is looked up") + void quotedPhoneNumberIsLookedUp() { + when(svc.getBeneficiariesByPhoneNum("9000000000")) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + assertSuccess(controller.getBeneficiariesByPhoneNum("\"9000000000\"")); + } + + @Test + @DisplayName("a JSON null phone number is rejected") + void jsonNullPhoneNumberIsRejected() { + assertFailure(controller.getBeneficiariesByPhoneNum("null"), "Null/Empty Phone Number."); + verify(svc, never()).getBeneficiariesByPhoneNum(any()); + } + + @Test + @DisplayName("a failing phone lookup is reported in the body") + void failingPhoneLookupIsReportedInTheBody() { + when(svc.getBeneficiariesByPhoneNum(any())).thenThrow(new IllegalStateException("contact table down")); + + assertFailure(controller.getBeneficiariesByPhoneNum("\"9000000000\""), "5000"); + } + } + + @Nested + @DisplayName("lookup by ABHA and government identifiers") + class LookupByExternalIdentifier { + + @Test + @DisplayName("an ABHA address is looked up") + void abhaAddressIsLookedUp() throws Exception { + when(svc.getBeneficiaryByHealthIDAbhaAddress("asha@abdm")) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + assertSuccess(controller.searhBeneficiaryByABHAAddress("\"asha@abdm\"")); + } + + @Test + @DisplayName("a JSON null ABHA address is rejected") + void jsonNullAbhaAddressIsRejected() throws Exception { + assertFailure(controller.searhBeneficiaryByABHAAddress("null"), "Null/Empty Health ID / ABHA Address."); + } + + @Test + @DisplayName("a failing ABHA address lookup is reported in the body") + void failingAbhaAddressLookupIsReported() throws Exception { + when(svc.getBeneficiaryByHealthIDAbhaAddress(any())).thenThrow(new IllegalStateException("view down")); + + assertFailure(controller.searhBeneficiaryByABHAAddress("\"asha@abdm\""), "5000"); + } + + @Test + @DisplayName("an ABHA number is looked up") + void abhaNumberIsLookedUp() throws Exception { + when(svc.getBeneficiaryByHealthIDNoAbhaIdNo("12-3456-7890-1234")) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + assertSuccess(controller.searhBeneficiaryByABHAIdNo("\"12-3456-7890-1234\"")); + } + + @Test + @DisplayName("a JSON null ABHA number is rejected") + void jsonNullAbhaNumberIsRejected() throws Exception { + assertFailure(controller.searhBeneficiaryByABHAIdNo("null"), "Null/Empty Health ID No / ABHA Id No."); + } + + @Test + @DisplayName("a failing ABHA number lookup is reported in the body") + void failingAbhaNumberLookupIsReported() throws Exception { + when(svc.getBeneficiaryByHealthIDNoAbhaIdNo(any())).thenThrow(new IllegalStateException("view down")); + + assertFailure(controller.searhBeneficiaryByABHAIdNo("\"12-3456\""), "5000"); + } + + @Test + @DisplayName("a government identity number is looked up") + void governmentIdentityIsLookedUp() { + when(svc.searhBeneficiaryByGovIdentity(any())) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + assertSuccess(controller.searhBeneficiaryByGovIdentity("\"AADHAAR-1\"")); + } + + @Test + @DisplayName("a JSON null government identity number is rejected") + void jsonNullGovernmentIdentityIsRejected() { + assertFailure(controller.searhBeneficiaryByGovIdentity("null"), "Null/Empty Gov Identity No."); + } + + @Test + @DisplayName("a failing government identity lookup is reported in the body") + void failingGovernmentIdentityLookupIsReported() { + when(svc.searhBeneficiaryByGovIdentity(any())).thenThrow(new IllegalStateException("view down")); + + assertFailure(controller.searhBeneficiaryByGovIdentity("\"AADHAAR-1\""), "5000"); + } + + @Test + @DisplayName("a family ID is looked up") + void familyIdIsLookedUp() { + when(svc.searhBeneficiaryByFamilyId(any())) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + assertSuccess(controller.searhBeneficiaryByFamilyId("\"FAM-1\"")); + } + + @Test + @DisplayName("a JSON null family ID is rejected") + void jsonNullFamilyIdIsRejected() { + assertFailure(controller.searhBeneficiaryByFamilyId("null"), "Null/Empty Family Id."); + } + + @Test + @DisplayName("a failing family lookup is reported in the body") + void failingFamilyLookupIsReported() { + when(svc.searhBeneficiaryByFamilyId(any())).thenThrow(new IllegalStateException("view down")); + + assertFailure(controller.searhBeneficiaryByFamilyId("\"FAM-1\""), "5000"); + } + } + + @Nested + @DisplayName("CHO app village sync") + class VillageSync { + + private static final String SYNC_REQUEST = "{\"villageID\":[401,402],\"lastModifiedDate\":1767225600000}"; + + @Test + @DisplayName("the requested villages and watermark are passed through to the service") + void villagesAndWatermarkArePassedThrough() { + when(svc.searchBeneficiaryByVillageIdAndLastModifyDate(anyList(), any())) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + assertSuccess(controller.searchBeneficiaryByVillageIdAndLastModDate(SYNC_REQUEST)); + + ArgumentCaptor> villages = captor(); + ArgumentCaptor watermark = ArgumentCaptor.forClass(Timestamp.class); + verify(svc).searchBeneficiaryByVillageIdAndLastModifyDate(villages.capture(), watermark.capture()); + assertEquals(List.of(401, 402), villages.getValue()); + assertEquals(new Timestamp(1767225600000L), watermark.getValue()); + } + + @Test + @DisplayName("a request with no watermark is reported as a failure rather than syncing everything") + void requestWithNoWatermarkIsReportedAsFailure() { + // The service would receive a null timestamp and return the whole + // village, so the request has to fail instead. + assertFailure(controller.searchBeneficiaryByVillageIdAndLastModDate("{\"villageID\":[401]}"), "5000"); + } + + @Test + @DisplayName("a failing sync query is reported in the body") + void failingSyncQueryIsReportedInTheBody() { + when(svc.searchBeneficiaryByVillageIdAndLastModifyDate(anyList(), any())) + .thenThrow(new IllegalStateException("timeout")); + + assertFailure(controller.searchBeneficiaryByVillageIdAndLastModDate(SYNC_REQUEST), "5000"); + } + + @Test + @DisplayName("the count endpoint returns the number the service reports") + void countEndpointReturnsTheServiceCount() { + when(svc.countBeneficiaryByVillageIdAndLastModifyDate(anyList(), any())).thenReturn(42L); + + String response = controller.countBeneficiaryByVillageIdAndLastModDate(SYNC_REQUEST); + + assertSuccess(response); + assertTrue(response.contains("42")); + } + + @Test + @DisplayName("a failing count is reported in the body") + void failingCountIsReportedInTheBody() { + when(svc.countBeneficiaryByVillageIdAndLastModifyDate(anyList(), any())) + .thenThrow(new IllegalStateException("timeout")); + + assertFailure(controller.countBeneficiaryByVillageIdAndLastModDate(SYNC_REQUEST), "5000"); + } + } + + @Nested + @DisplayName("RMNCH lookup") + class RmnchLookup { + + @Test + @DisplayName("the RMNCH record is returned with HTTP 200") + void rmnchRecordIsReturnedWithOk() { + RMNCHBeneficiaryDetailsRmnch record = new RMNCHBeneficiaryDetailsRmnch(); + record.setRchid("RCH-1"); + when(svc.getRmnchDataByBenID(BEN_REG_ID)).thenReturn(record); + + ResponseEntity response = controller.getRmnchDataByBenID(BEN_REG_ID); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("RCH-1", response.getBody().getRchid()); + } + + @Test + @DisplayName("a failing lookup is the one endpoint that answers with a 500") + void failingLookupAnswersWithServerError() { + when(svc.getRmnchDataByBenID(any())).thenThrow(new IllegalStateException("table locked")); + + ResponseEntity response = controller.getRmnchDataByBenID(BEN_REG_ID); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertEquals(null, response.getBody()); + } + } + + @Nested + @DisplayName("create and edit") + class CreateAndEdit { + + @Test + @DisplayName("a create request returns the identifiers the service allocated") + void createReturnsAllocatedIdentifiers() throws Exception { + BeneficiaryCreateResp created = new BeneficiaryCreateResp(); + created.setBenId(BEN_ID); + created.setBenRegId(BEN_REG_ID); + when(svc.createIdentity(any())).thenReturn(created); + + String response = controller.createIdentity("{\"firstName\":\"Asha\",\"agentName\":\"field.worker\"}"); + + assertSuccess(response); + assertTrue(response.contains("4001")); + } + + @Test + @DisplayName("a create failure is propagated rather than swallowed into a success envelope") + void createFailureIsPropagated() { + // Unlike the read endpoints, a failed create must not answer 200 with + // a success envelope - the caller would treat the beneficiary as + // registered. + when(svc.createIdentity(any())).thenThrow(new IllegalStateException("no IDs left in the pool")); + + assertThrows(IllegalStateException.class, + () -> controller.createIdentity("{\"firstName\":\"Asha\"}")); + } + + @Test + @DisplayName("an edit request is passed to the service and acknowledged") + void editIsPassedToTheServiceAndAcknowledged() throws Exception { + String response = controller.editIdentity("{\"beneficiaryRegId\":100200300,\"firstName\":\"Asha\"}"); + + assertSuccess(response); + assertTrue(response.contains("Updated successfully")); + ArgumentCaptor captor = ArgumentCaptor.forClass(IdentityEditDTO.class); + verify(svc).editIdentity(captor.capture()); + assertEquals(BEN_REG_ID, captor.getValue().getBeneficiaryRegId()); + } + + @Test + @DisplayName("an edit missing a mandatory field reports the reason in the body") + void editMissingMandatoryFieldReportsTheReason() throws Exception { + org.mockito.Mockito.doThrow(new MissingMandatoryFieldsException("Beneficiary Reg Id is mandatory.")) + .when(svc).editIdentity(any()); + + String response = controller.editIdentity("{\"firstName\":\"Asha\"}"); + + assertFailure(response, "Beneficiary Reg Id is mandatory."); + } + + @Test + @DisplayName("an education or community edit is passed to the service") + void educationOrCommunityEditIsPassedToTheService() throws Exception { + String response = controller + .editIdentityEducationOrCommunity("{\"beneficiaryRegId\":100200300,\"communityId\":5}"); + + assertSuccess(response); + verify(svc).editIdentityEducationOrCommunity(any()); + } + + @ParameterizedTest + @ValueSource(strings = { "null", "\"\"", "123" }) + @DisplayName("an education or community edit with a bare JSON literal body is rejected") + void educationOrCommunityEditWithLiteralBodyIsRejected(String body) throws Exception { + String response = controller.editIdentityEducationOrCommunity(body); + + assertFailure(response, "Null/Empty Identity Edit Data."); + verify(svc, never()).editIdentityEducationOrCommunity(any()); + } + + @Test + @DisplayName("an education or community edit missing a mandatory field reports the reason") + void educationOrCommunityEditMissingMandatoryFieldReportsTheReason() throws Exception { + org.mockito.Mockito.doThrow(new MissingMandatoryFieldsException("Either of BeneficiaryID or Beneficiary Reg Id is mandatory.")) + .when(svc).editIdentityEducationOrCommunity(any()); + + assertFailure(controller.editIdentityEducationOrCommunity("{\"firstName\":\"Asha\"}"), + "Beneficiary Reg Id is mandatory."); + } + } + + @Nested + @DisplayName("reserving identifiers") + class ReservingIdentifiers { + + @Test + @DisplayName("a reserve request is passed to the service") + void reserveIsPassedToTheService() { + when(svc.reserveIdentity(any())).thenReturn("Successfully Completed"); + + String response = controller + .reserveIdentity("{\"providerServiceMapID\":11,\"vehicalNo\":\"KA-01-1234\",\"reserveCount\":5}"); + + assertSuccess(response); + assertTrue(response.contains("Successfully Completed")); + } + + @ParameterizedTest + @ValueSource(strings = { "null", "\"\"", "5" }) + @DisplayName("a reserve request with a bare JSON literal body is rejected") + void reserveWithLiteralBodyIsRejected(String body) { + assertFailure(controller.reserveIdentity(body), "Null/Empty Identity Create Data."); + verify(svc, never()).reserveIdentity(any()); + } + + @Test + @DisplayName("an unreserve request is passed to the service") + void unreserveIsPassedToTheService() { + when(svc.unReserveIdentity(any())).thenReturn("Successfully Completed"); + + assertSuccess(controller + .unreserveIdentity("{\"providerServiceMapID\":11,\"vehicalNo\":\"KA-01-1234\"}")); + } + + @ParameterizedTest + @ValueSource(strings = { "null", "\"\"" }) + @DisplayName("an unreserve request with a bare JSON literal body is rejected") + void unreserveWithLiteralBodyIsRejected(String body) { + assertFailure(controller.unreserveIdentity(body), "Null/Empty Identity Create Data."); + verify(svc, never()).unReserveIdentity(any()); + } + } + + @Nested + @DisplayName("bulk lookups") + class BulkLookups { + + @Test + @DisplayName("a partial-details request passes the whole identifier list through") + void partialDetailsRequestPassesTheWholeList() { + BeneficiariesPartialDTO partial = new BeneficiariesPartialDTO(); + partial.setBenId(BEN_ID); + partial.setFirstName("Asha"); + when(svc.getBeneficiariesPartialDeatilsByBenRegIdList(anyList())) + .thenReturn(new java.util.ArrayList<>(List.of(partial))); + + String response = controller.getPartialBeneficiariesByBenRegIds("[100200300,100200301]"); + + assertSuccess(response); + assertTrue(response.contains("Asha")); + ArgumentCaptor> captor = captor(); + verify(svc).getBeneficiariesPartialDeatilsByBenRegIdList(captor.capture()); + assertEquals(2, captor.getValue().size()); + } + + @Test + @DisplayName("a JSON null partial-details request is rejected") + void jsonNullPartialDetailsRequestIsRejected() { + assertFailure(controller.getPartialBeneficiariesByBenRegIds("null"), "Null/Empty Phone Number."); + verify(svc, never()).getBeneficiariesPartialDeatilsByBenRegIdList(any()); + } + + @Test + @DisplayName("a full-details request converts the identifier array for the service") + void fullDetailsRequestConvertsTheIdentifierArray() { + when(svc.getBeneficiariesDeatilsByBenRegIdList(anyList())) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + String response = controller.getBeneficiariesByBenRegIds(new Long[] { 100200300L }); + + assertSuccess(response); + ArgumentCaptor> captor = captor(); + verify(svc).getBeneficiariesDeatilsByBenRegIdList(captor.capture()); + assertEquals(BEN_REG_ID, captor.getValue().get(0)); + } + + @Test + @DisplayName("an empty full-details request is rejected with a client-error status code") + void emptyFullDetailsRequestIsRejected() { + String response = controller.getBeneficiariesByBenRegIds(new Long[0]); + + assertFailure(response, "No beneficiary registration IDs provided"); + assertFailure(response, "400"); + verify(svc, never()).getBeneficiariesDeatilsByBenRegIdList(any()); + } + } + + @Nested + @DisplayName("finite search and image retrieval") + class FiniteSearchAndImages { + + @Test + @DisplayName("the finite-search endpoint runs the same advance search, not the service's finite search") + void finiteSearchRunsTheAdvanceSearch() throws Exception { + // Despite the name and the finiteSearch query on the repository, this + // endpoint parses an IdentitySearchDTO and calls the same advance + // search as /advanceSearch. + when(svc.getBeneficiaries(any(IdentitySearchDTO.class))) + .thenReturn(new java.util.ArrayList<>(List.of(beneficiary("Asha")))); + + String response = controller.getFiniteBeneficiaries("{\"firstName\":\"Asha\"}"); + + assertSuccess(response); + assertTrue(response.contains("Asha")); + verify(svc, never()).getBeneficiaries(any(com.iemr.common.identity.dto.IdentityDTO.class)); + } + + @Test + @DisplayName("a failing finite search is reported in the body") + void failingFiniteSearchIsReportedInTheBody() throws Exception { + when(svc.getBeneficiaries(any(IdentitySearchDTO.class))) + .thenThrow(new IllegalStateException("query timeout")); + + assertFailure(controller.getFiniteBeneficiaries("{\"firstName\":\"Asha\"}"), "5000"); + } + + @Test + @DisplayName("the stored image response is returned unchanged") + void storedImageResponseIsReturnedUnchanged() { + when(svc.getBeneficiaryImage(any())).thenReturn("{\"data\":\"base64\"}"); + + assertEquals("{\"data\":\"base64\"}", + controller.getBeneficiaryImageByBenRegID("{\"beneficiaryRegID\":100200300}")); + } + + @Test + @DisplayName("a failing image lookup yields no image rather than an error body") + void failingImageLookupYieldsNoImage() { + when(svc.getBeneficiaryImage(any())).thenThrow(new IllegalStateException("blob store down")); + + assertEquals(null, controller.getBeneficiaryImageByBenRegID("{\"beneficiaryRegID\":100200300}")); + } + } + + @Nested + @DisplayName("local identifier pool") + class LocalIdentifierPool { + + @Test + @DisplayName("the available count is reported") + void availableCountIsReported() { + when(svc.checkBenIDAvailabilityLocal()).thenReturn(120L); + + String response = controller.checkAvailablBenIDLocalServer(); + + assertTrue(response.contains("120"), response); + } + + @Test + @DisplayName("a failing availability check is reported as an error") + void failingAvailabilityCheckIsReportedAsAnError() { + when(svc.checkBenIDAvailabilityLocal()).thenThrow(new IllegalStateException("pool table down")); + + assertFailure(controller.checkAvailablBenIDLocalServer(), "5000"); + } + + @Test + @DisplayName("an import reports how many identifiers were stored") + void importReportsHowManyIdentifiersWereStored() { + when(svc.importBenIdToLocalServer(anyList())).thenReturn(2); + + String response = controller.saveGeneratedBenIDToLocalServer( + "[{\"benRegId\":100200300,\"beneficiaryId\":4001},{\"benRegId\":100200301,\"beneficiaryId\":4002}]"); + + assertTrue(response.contains("2 Unique benid imported"), response); + } + + @Test + @DisplayName("an import that stores nothing is reported as invalid data") + void importThatStoresNothingIsReportedAsInvalidData() { + when(svc.importBenIdToLocalServer(anyList())).thenReturn(0); + + assertTrue(controller.saveGeneratedBenIDToLocalServer("[]").contains("Empty or invalid data")); + } + + @Test + @DisplayName("a failing import is reported as an error") + void failingImportIsReportedAsAnError() { + when(svc.importBenIdToLocalServer(anyList())).thenThrow(new IllegalStateException("batch failed")); + + assertFailure(controller.saveGeneratedBenIDToLocalServer("[{\"benRegId\":100200300}]"), "5000"); + } + } + + @Test + @DisplayName("an object is serialised to JSON, and an unserialisable one yields an empty string") + void objectIsSerialisedToJson() { + assertTrue(controller.getJsonAsString(beneficiary("Asha")).contains("Asha")); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> captor() { + return ArgumentCaptor.forClass(List.class); + } +} diff --git a/src/test/java/com/iemr/common/identity/controller/IdentityESControllerTest.java b/src/test/java/com/iemr/common/identity/controller/IdentityESControllerTest.java new file mode 100644 index 00000000..74160d04 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/controller/IdentityESControllerTest.java @@ -0,0 +1,300 @@ +package com.iemr.common.identity.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.iemr.common.identity.service.IdentityService; +import com.iemr.common.identity.service.elasticsearch.ElasticsearchService; +import com.iemr.common.identity.utils.JwtUtil; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; + +@ExtendWith(MockitoExtension.class) +class IdentityESControllerTest { + + @Mock + private ElasticsearchService elasticsearchService; + + @Mock + private JwtUtil jwtUtil; + + @Mock + private IdentityService idService; + + @InjectMocks + private IdentityESController controller; + + private HttpServletRequest request; + + @BeforeEach + void setUp() { + request = mock(HttpServletRequest.class); + } + + private void withJwtCookie(String token) { + when(request.getCookies()).thenReturn(new Cookie[] { new Cookie("Jwttoken", token) }); + } + + // ---------- /search ---------- + + @Test + @DisplayName("search returns the Elasticsearch hits with a success envelope") + void searchReturnsResults() { + withJwtCookie("jwt-1"); + when(jwtUtil.getUserIdFromToken("jwt-1")).thenReturn("42"); + List> hits = List.of(Map.of("beneficiaryId", "B1")); + when(elasticsearchService.universalSearch("vani", 42)).thenReturn(hits); + + ResponseEntity> response = controller.search("vani", request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + Map body = response.getBody(); + assertEquals(hits, body.get("data")); + assertEquals(200, body.get("statusCode")); + assertEquals("Success", body.get("status")); + assertEquals("Success", body.get("errorMessage")); + } + + @Test + @DisplayName("search passes the userId decoded from the Jwttoken cookie") + void searchUsesUserIdFromCookie() { + withJwtCookie("jwt-2"); + when(jwtUtil.getUserIdFromToken("jwt-2")).thenReturn("7"); + when(elasticsearchService.universalSearch(anyString(), anyInt())).thenReturn(new ArrayList<>()); + + controller.search("9876543210", request); + + verify(elasticsearchService).universalSearch("9876543210", 7); + } + + @Test + @DisplayName("search returns 500 when no Jwttoken cookie is present") + void searchWithoutCookieReturnsError() { + when(request.getCookies()).thenReturn(null); + when(jwtUtil.getUserIdFromToken(isNull())).thenThrow(new IllegalArgumentException("Invalid or denylisted token")); + + ResponseEntity> response = controller.search("vani", request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + Map body = response.getBody(); + assertEquals(500, body.get("statusCode")); + assertEquals("Error", body.get("status")); + assertEquals(Collections.emptyList(), body.get("data")); + } + + @Test + @DisplayName("search returns 500 when the userId claim is not numeric") + void searchWithNonNumericUserIdReturnsError() { + withJwtCookie("jwt-3"); + when(jwtUtil.getUserIdFromToken("jwt-3")).thenReturn("not-a-number"); + + ResponseEntity> response = controller.search("vani", request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + @Test + @DisplayName("search returns 500 when Elasticsearch fails") + void searchWhenElasticsearchFails() { + withJwtCookie("jwt-4"); + when(jwtUtil.getUserIdFromToken("jwt-4")).thenReturn("1"); + when(elasticsearchService.universalSearch(anyString(), anyInt())) + .thenThrow(new RuntimeException("cluster unavailable")); + + ResponseEntity> response = controller.search("vani", request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertEquals("cluster unavailable", response.getBody().get("errorMessage")); + } + + // ---------- /advancedSearchES ---------- + + private Map serviceResult() { + Map result = new HashMap<>(); + result.put("data", List.of(Map.of("beneficiaryId", "B9"))); + result.put("count", 1); + result.put("source", "elasticsearch"); + return result; + } + + @Test + @DisplayName("advancedSearchES forwards every top-level filter to the service") + void advancedSearchForwardsFilters() throws Exception { + withJwtCookie("jwt-5"); + when(jwtUtil.getUserIdFromToken("jwt-5")).thenReturn("42"); + when(idService.advancedSearchBeneficiariesES(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(serviceResult()); + + String filter = "{\"firstName\":\"Vani\",\"middleName\":\"S\",\"lastName\":\"R\",\"genderId\":2," + + "\"dob\":\"1990-05-17\",\"stateId\":1,\"districtId\":2,\"blockId\":3,\"villageId\":4," + + "\"fatherName\":\"F\",\"spouseName\":\"Sp\",\"maritalStatus\":\"Married\"," + + "\"phoneNumber\":\"9876543210\",\"beneficiaryId\":\"B9\",\"healthId\":\"H1\"," + + "\"aadharNo\":\"1234\",\"is1097\":true}"; + + ResponseEntity> response = controller.advanceSearchBeneficiariesES(filter, request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(idService).advancedSearchBeneficiariesES(eq("Vani"), eq("S"), eq("R"), eq(2), + eq(new SimpleDateFormat("yyyy-MM-dd").parse("1990-05-17")), eq(1), eq(2), eq(3), eq(4), eq("F"), + eq("Sp"), eq("Married"), eq("9876543210"), eq("B9"), eq("H1"), eq("1234"), eq(42), isNull(), eq(true)); + } + + @Test + @DisplayName("advancedSearchES copies data, count and source into the response") + void advancedSearchCopiesServiceResult() throws Exception { + withJwtCookie("jwt-6"); + when(jwtUtil.getUserIdFromToken("jwt-6")).thenReturn("1"); + when(idService.advancedSearchBeneficiariesES(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(serviceResult()); + + Map body = controller.advanceSearchBeneficiariesES("{\"firstName\":\"Vani\"}", request) + .getBody(); + + assertEquals(1, body.get("count")); + assertEquals("elasticsearch", body.get("source")); + assertEquals(200, body.get("statusCode")); + assertEquals("Success", body.get("status")); + assertNotNull(body.get("data")); + } + + @Test + @DisplayName("advancedSearchES treats absent and JSON-null fields as null filters") + void advancedSearchWithAbsentAndNullFields() throws Exception { + withJwtCookie("jwt-7"); + when(jwtUtil.getUserIdFromToken("jwt-7")).thenReturn("1"); + when(idService.advancedSearchBeneficiariesES(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(serviceResult()); + + controller.advanceSearchBeneficiariesES("{\"firstName\":null,\"genderId\":null,\"is1097\":null}", request); + + verify(idService).advancedSearchBeneficiariesES(isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + eq(1), isNull(), isNull()); + } + + @Test + @DisplayName("advancedSearchES falls back to currentAddress for location ids") + void advancedSearchReadsLocationFromCurrentAddress() throws Exception { + withJwtCookie("jwt-8"); + when(jwtUtil.getUserIdFromToken("jwt-8")).thenReturn("1"); + when(idService.advancedSearchBeneficiariesES(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(serviceResult()); + + controller.advanceSearchBeneficiariesES( + "{\"currentAddress\":{\"stateId\":11,\"districtId\":22,\"blockId\":33,\"villageId\":44}}", request); + + verify(idService).advancedSearchBeneficiariesES(isNull(), isNull(), isNull(), isNull(), isNull(), eq(11), + eq(22), eq(33), eq(44), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), eq(1), + isNull(), isNull()); + } + + @Test + @DisplayName("advancedSearchES falls back to permanentAddress when currentAddress lacks the id") + void advancedSearchReadsLocationFromPermanentAddress() throws Exception { + withJwtCookie("jwt-9"); + when(jwtUtil.getUserIdFromToken("jwt-9")).thenReturn("1"); + ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(Integer.class); + when(idService.advancedSearchBeneficiariesES(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(serviceResult()); + + controller.advanceSearchBeneficiariesES( + "{\"currentAddress\":{\"districtId\":2},\"permanentAddress\":{\"stateId\":99}}", request); + + verify(idService).advancedSearchBeneficiariesES(isNull(), isNull(), isNull(), isNull(), isNull(), + stateCaptor.capture(), eq(2), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), eq(1), isNull(), isNull()); + assertEquals(99, stateCaptor.getValue()); + } + + @Test + @DisplayName("advancedSearchES prefers a top-level location id over a nested one") + void advancedSearchPrefersTopLevelLocationId() throws Exception { + withJwtCookie("jwt-10"); + when(jwtUtil.getUserIdFromToken("jwt-10")).thenReturn("1"); + when(idService.advancedSearchBeneficiariesES(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(serviceResult()); + + controller.advanceSearchBeneficiariesES("{\"stateId\":5,\"currentAddress\":{\"stateId\":77}}", request); + + verify(idService).advancedSearchBeneficiariesES(isNull(), isNull(), isNull(), isNull(), isNull(), eq(5), + isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + eq(1), isNull(), isNull()); + } + + @Test + @DisplayName("advancedSearchES ignores an unparsable dob instead of failing") + void advancedSearchWithInvalidDob() throws Exception { + withJwtCookie("jwt-11"); + when(jwtUtil.getUserIdFromToken("jwt-11")).thenReturn("1"); + when(idService.advancedSearchBeneficiariesES(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(serviceResult()); + + ResponseEntity> response = controller + .advanceSearchBeneficiariesES("{\"dob\":\"not-a-date\"}", request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + ArgumentCaptor dobCaptor = ArgumentCaptor.forClass(Date.class); + verify(idService).advancedSearchBeneficiariesES(isNull(), isNull(), isNull(), isNull(), dobCaptor.capture(), + isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(1), isNull(), isNull()); + assertNull(dobCaptor.getValue()); + } + + @Test + @DisplayName("advancedSearchES returns a 500 envelope when the search filter is not JSON") + void advancedSearchWithMalformedFilter() { + ResponseEntity> response = controller.advanceSearchBeneficiariesES("not-json{", request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + Map body = response.getBody(); + assertEquals(500, body.get("statusCode")); + assertEquals("error", body.get("source")); + assertEquals(0, body.get("count")); + assertEquals(Collections.emptyList(), body.get("data")); + } + + @Test + @DisplayName("advancedSearchES returns a 500 envelope when the service throws") + void advancedSearchWhenServiceFails() throws Exception { + withJwtCookie("jwt-12"); + when(jwtUtil.getUserIdFromToken("jwt-12")).thenReturn("1"); + when(idService.advancedSearchBeneficiariesES(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new Exception("index missing")); + + ResponseEntity> response = controller + .advanceSearchBeneficiariesES("{\"firstName\":\"Vani\"}", request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertEquals("index missing", response.getBody().get("errorMessage")); + } +} diff --git a/src/test/java/com/iemr/common/identity/controller/elasticsearch/ElasticsearchSyncControllerTest.java b/src/test/java/com/iemr/common/identity/controller/elasticsearch/ElasticsearchSyncControllerTest.java new file mode 100644 index 00000000..b9327ca1 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/controller/elasticsearch/ElasticsearchSyncControllerTest.java @@ -0,0 +1,520 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.controller.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.math.BigInteger; +import java.sql.Timestamp; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.indices.ElasticsearchIndicesClient; +import co.elastic.clients.elasticsearch.indices.RefreshRequest; +import co.elastic.clients.elasticsearch.indices.RefreshResponse; +import co.elastic.clients.util.ObjectBuilder; + +import com.iemr.common.identity.data.elasticsearch.ElasticsearchSyncJob; +import com.iemr.common.identity.domain.MBeneficiarydetail; +import com.iemr.common.identity.domain.MBeneficiarymapping; +import com.iemr.common.identity.repo.BenMappingRepo; +import com.iemr.common.identity.service.elasticsearch.ElasticsearchIndexingService; +import com.iemr.common.identity.service.elasticsearch.ElasticsearchSyncService; +import com.iemr.common.identity.service.elasticsearch.SyncJobService; +import com.iemr.common.identity.utils.response.OutputResponse; + +/** + * Tests for the operations endpoints that drive an index rebuild. + * + *

+ * These are the endpoints an operator hits during an incident, so the status + * code carries the meaning: a rebuild refused because one is already running is + * a 409 rather than a 500, an unknown job is a 404, and a rebuild that finished + * with partial failures is a 206 so a dashboard does not report it as clean. + * The tests pin those mappings along with the payload each one returns. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ElasticsearchSyncControllerTest { + + @Mock + private ElasticsearchSyncService syncService; + @Mock + private SyncJobService syncJobService; + @Mock + private BenMappingRepo mappingRepo; + @Mock + private ElasticsearchIndexingService indexingService; + @Mock + private ElasticsearchClient esClient; + @Mock + private ElasticsearchIndicesClient indicesClient; + + @InjectMocks + private ElasticsearchSyncController controller; + + private static final Long JOB_ID = 55L; + + @BeforeEach + void configureController() { + ReflectionTestUtils.setField(controller, "beneficiaryIndex", "beneficiary"); + when(esClient.indices()).thenReturn(indicesClient); + } + + private ElasticsearchSyncJob job(String status) { + ElasticsearchSyncJob job = new ElasticsearchSyncJob(); + job.setJobId(JOB_ID); + job.setJobType("FULL_SYNC"); + job.setStatus(status); + job.setTotalRecords(1000L); + job.setProcessedRecords(400L); + job.setSuccessCount(390L); + job.setFailureCount(10L); + job.setCurrentOffset(400); + job.setProcessingSpeed(120.5); + job.setEstimatedTimeRemaining(5L); + job.setStartedAt(new Timestamp(System.currentTimeMillis())); + return job; + } + + @Nested + @DisplayName("starting a rebuild") + class StartingARebuild { + + @Test + @DisplayName("a started rebuild returns its job id and where to poll it") + void startedRebuildReturnsJobIdAndPollUrl() { + when(syncJobService.startFullSyncJob("API")).thenReturn(job("PENDING")); + + ResponseEntity> response = controller.startAsyncFullSync("API"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("success", response.getBody().get("status")); + assertEquals(JOB_ID, response.getBody().get("jobId")); + assertEquals("PENDING", response.getBody().get("jobStatus")); + assertEquals("/elasticsearch/status/55", response.getBody().get("checkStatusUrl")); + } + + @Test + @DisplayName("a rebuild refused because one is already running answers 409, not 500") + void refusedRebuildAnswersConflict() { + // A dashboard retries on 5xx; 409 tells it to wait instead. + when(syncJobService.startFullSyncJob(anyString())) + .thenThrow(new RuntimeException("A full sync job is already running.")); + + ResponseEntity> response = controller.startAsyncFullSync("API"); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertEquals("error", response.getBody().get("status")); + assertTrue(response.getBody().get("message").toString().contains("already running")); + } + + @Test + @DisplayName("the triggering user is recorded with the job") + void triggeringUserIsRecorded() { + when(syncJobService.startFullSyncJob("ops.admin")).thenReturn(job("PENDING")); + + controller.startAsyncFullSync("ops.admin"); + + verify(syncJobService).startFullSyncJob("ops.admin"); + } + } + + @Nested + @DisplayName("job status and listings") + class JobStatusAndListings { + + @Test + @DisplayName("a job's progress is reported with a formatted percentage") + void jobProgressIsReportedWithFormattedPercentage() { + when(syncJobService.getJobStatus(JOB_ID)).thenReturn(job("RUNNING")); + + ResponseEntity> response = controller.getAsyncJobStatus(JOB_ID); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("RUNNING", response.getBody().get("status")); + assertEquals(1000L, response.getBody().get("totalRecords")); + assertEquals(390L, response.getBody().get("successCount")); + assertEquals("40.00", response.getBody().get("progressPercentage")); + assertEquals(120.5, response.getBody().get("processingSpeed")); + assertEquals(5L, response.getBody().get("estimatedTimeRemaining")); + } + + @Test + @DisplayName("an unknown job id answers 404") + void unknownJobIdAnswersNotFound() { + when(syncJobService.getJobStatus(JOB_ID)).thenThrow(new RuntimeException("Job not found: 55")); + + ResponseEntity> response = controller.getAsyncJobStatus(JOB_ID); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertEquals("error", response.getBody().get("status")); + } + + @Test + @DisplayName("active and recent job listings are returned as-is") + void jobListingsAreReturnedAsIs() { + List active = Collections.singletonList(job("RUNNING")); + List recent = Collections.singletonList(job("COMPLETED")); + when(syncJobService.getActiveJobs()).thenReturn(active); + when(syncJobService.getRecentJobs()).thenReturn(recent); + + assertEquals(active, controller.getActiveJobs().getBody()); + assertEquals(recent, controller.getRecentJobs().getBody()); + } + } + + @Nested + @DisplayName("resuming and cancelling") + class ResumingAndCancelling { + + @Test + @DisplayName("a resumed job reports the offset it will continue from") + void resumedJobReportsItsOffset() { + when(syncJobService.resumeJob(JOB_ID, "API")).thenReturn(job("PENDING")); + + ResponseEntity> response = controller.resumeJob(JOB_ID, "API"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(400, response.getBody().get("resumedFromOffset")); + } + + @Test + @DisplayName("a job that cannot be resumed answers 400 with the reason") + void unresumableJobAnswersBadRequest() { + when(syncJobService.resumeJob(anyLong(), anyString())) + .thenThrow(new RuntimeException("Can only resume FAILED jobs. Current status: RUNNING")); + + ResponseEntity> response = controller.resumeJob(JOB_ID, "API"); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody().get("message").toString().contains("Can only resume FAILED")); + } + + @Test + @DisplayName("a cancelled job answers 200") + void cancelledJobAnswersOk() { + when(syncJobService.cancelJob(JOB_ID)).thenReturn(true); + + ResponseEntity> response = controller.cancelJob(JOB_ID); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("success", response.getBody().get("status")); + } + + @Test + @DisplayName("a job that cannot be cancelled answers 400") + void uncancellableJobAnswersBadRequest() { + when(syncJobService.cancelJob(JOB_ID)).thenReturn(false); + + ResponseEntity> response = controller.cancelJob(JOB_ID); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody().get("message").toString().contains("may not be active")); + } + } + + @Nested + @DisplayName("the blocking legacy rebuild") + class BlockingLegacyRebuild { + + @Test + @DisplayName("a clean run answers 200 with its counts and a warning about blocking") + void cleanRunAnswersOk() { + ElasticsearchSyncService.SyncResult result = new ElasticsearchSyncService.SyncResult(); + result.addSuccess(700); + when(syncService.syncAllBeneficiaries()).thenReturn(result); + + ResponseEntity> response = controller.syncAllBeneficiaries(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("completed", response.getBody().get("status")); + assertEquals(700, response.getBody().get("successCount")); + assertNotNull(response.getBody().get("warning")); + } + + @Test + @DisplayName("a run that hit an error answers 206 so it is not reported as clean") + void runWithAnErrorAnswersPartialContent() { + ElasticsearchSyncService.SyncResult result = new ElasticsearchSyncService.SyncResult(); + result.addSuccess(400); + result.setError("connection reset"); + when(syncService.syncAllBeneficiaries()).thenReturn(result); + + ResponseEntity> response = controller.syncAllBeneficiaries(); + + assertEquals(HttpStatus.PARTIAL_CONTENT, response.getStatusCode()); + assertEquals("connection reset", response.getBody().get("error")); + } + + @Test + @DisplayName("an unexpected failure answers 500") + void unexpectedFailureAnswersServerError() { + when(syncService.syncAllBeneficiaries()).thenThrow(new IllegalStateException("out of memory")); + + ResponseEntity> response = controller.syncAllBeneficiaries(); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + } + + @Nested + @DisplayName("single beneficiary sync") + class SingleBeneficiarySync { + + @Test + @DisplayName("a synced beneficiary answers 200 and says so") + void syncedBeneficiaryAnswersOk() { + when(syncService.syncSingleBeneficiary("100200300")).thenReturn(true); + + ResponseEntity> response = controller.syncSingleBeneficiary("100200300"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("success", response.getBody().get("status")); + assertEquals(true, response.getBody().get("synced")); + } + + @Test + @DisplayName("a beneficiary that could not be synced still answers 200, reporting the failure in the body") + void unsyncedBeneficiaryAnswersOkWithFailureInBody() { + when(syncService.syncSingleBeneficiary("100200300")).thenReturn(false); + + ResponseEntity> response = controller.syncSingleBeneficiary("100200300"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("failed", response.getBody().get("status")); + assertEquals(false, response.getBody().get("synced")); + assertTrue(response.getBody().get("message").toString().contains("not found")); + } + + @Test + @DisplayName("an unexpected failure answers 500") + void unexpectedFailureAnswersServerError() { + when(syncService.syncSingleBeneficiary(anyString())) + .thenThrow(new IllegalStateException("index unavailable")); + + ResponseEntity> response = controller.syncSingleBeneficiary("100200300"); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertEquals(false, response.getBody().get("synced")); + } + } + + @Nested + @DisplayName("status, health and diagnostics") + class StatusHealthAndDiagnostics { + + @Test + @DisplayName("the sync status comparison is returned as-is") + void syncStatusComparisonIsReturnedAsIs() { + ElasticsearchSyncService.SyncStatus status = new ElasticsearchSyncService.SyncStatus(); + status.setDatabaseCount(500L); + status.setElasticsearchCount(500L); + status.setSynced(true); + when(syncService.checkSyncStatus()).thenReturn(status); + + ResponseEntity response = controller.checkSyncStatus(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().isSynced()); + } + + @Test + @DisplayName("a failing status check answers 500 carrying the error") + void failingStatusCheckAnswersServerError() { + when(syncService.checkSyncStatus()).thenThrow(new IllegalStateException("index unavailable")); + + ResponseEntity response = controller.checkSyncStatus(); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertNotNull(response.getBody().getError()); + } + + @Test + @DisplayName("the health check reports whether a rebuild is in flight") + void healthCheckReportsWhetherARebuildIsInFlight() { + when(syncJobService.isFullSyncRunning()).thenReturn(true); + when(syncJobService.getActiveJobs()).thenReturn(Collections.singletonList(job("RUNNING"))); + + ResponseEntity> response = controller.healthCheck(); + + assertEquals("UP", response.getBody().get("status")); + assertEquals(true, response.getBody().get("asyncJobsRunning")); + assertEquals(1, response.getBody().get("activeJobs")); + } + + @Test + @DisplayName("the diagnostic check reports what the database holds for a beneficiary") + void diagnosticCheckReportsWhatTheDatabaseHolds() { + MBeneficiarymapping mapping = new MBeneficiarymapping(); + mapping.setBenMapId(BigInteger.ONE); + mapping.setDeleted(false); + mapping.setMBeneficiarydetail(new MBeneficiarydetail()); + when(mappingRepo.countActiveByBenRegId(BigInteger.valueOf(100200300L))).thenReturn(1L); + when(mappingRepo.findByBenRegId(BigInteger.valueOf(100200300L))).thenReturn(mapping); + + ResponseEntity> response = controller.checkBeneficiaryExists("100200300"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(true, response.getBody().get("existsInDatabase")); + assertEquals(true, response.getBody().get("hasDetails")); + assertEquals(false, response.getBody().get("hasContact")); + } + + @Test + @DisplayName("the diagnostic check says so when a beneficiary is absent") + void diagnosticCheckSaysSoWhenBeneficiaryIsAbsent() { + when(mappingRepo.countActiveByBenRegId(any())).thenReturn(0L); + + ResponseEntity> response = controller.checkBeneficiaryExists("100200300"); + + assertEquals(false, response.getBody().get("existsInDatabase")); + assertTrue(response.getBody().get("message").toString().contains("NOT found")); + verify(mappingRepo, never()).findByBenRegId(any()); + } + + @Test + @DisplayName("a non-numeric identifier answers 500 rather than being treated as absent") + void nonNumericIdentifierAnswersServerError() { + ResponseEntity> response = controller.checkBeneficiaryExists("not-a-number"); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + } + + @Nested + @DisplayName("index lifecycle endpoints") + class IndexLifecycleEndpoints { + + @Test + @DisplayName("creating the index answers 200") + void creatingTheIndexAnswersOk() throws Exception { + ResponseEntity response = controller.createIndex(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(indexingService).createIndexWithMapping(); + assertTrue(response.getBody().toString().contains("Index created successfully")); + } + + @Test + @DisplayName("a failing index creation answers 500 with the reason") + void failingIndexCreationAnswersServerError() throws Exception { + org.mockito.Mockito.doThrow(new IOException("cluster unavailable")).when(indexingService) + .createIndexWithMapping(); + + ResponseEntity response = controller.createIndex(); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertTrue(response.getBody().toString().contains("Error creating index")); + } + + @Test + @DisplayName("recreate-and-sync rebuilds the index before loading it, and reports both counts") + void recreateAndSyncRebuildsThenLoads() throws Exception { + when(indexingService.indexAllBeneficiaries()).thenReturn(Map.of("success", 700, "failed", 3)); + + ResponseEntity response = controller.recreateAndSync(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(indexingService); + inOrder.verify(indexingService).createIndexWithMapping(); + inOrder.verify(indexingService).indexAllBeneficiaries(); + assertTrue(response.getBody().toString().contains("700")); + } + + @Test + @DisplayName("a failing recreate-and-sync answers 500 without loading data") + void failingRecreateAnswersServerErrorWithoutLoading() throws Exception { + org.mockito.Mockito.doThrow(new IOException("cluster unavailable")).when(indexingService) + .createIndexWithMapping(); + + ResponseEntity response = controller.recreateAndSync(); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + verify(indexingService, never()).indexAllBeneficiaries(); + } + + @Test + @DisplayName("the index-info endpoint answers 200 with its placeholder") + void indexInfoAnswersOk() { + ResponseEntity response = controller.getIndexInfo(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + } + + @Test + @DisplayName("a manual refresh makes newly indexed documents searchable") + @SuppressWarnings("unchecked") + void manualRefreshMakesDocumentsSearchable() throws Exception { + when(indicesClient.refresh(any(Function.class))).thenAnswer(invocation -> { + Function> builder = invocation.getArgument(0); + RefreshRequest request = builder.apply(new RefreshRequest.Builder()).build(); + assertEquals(Collections.singletonList("beneficiary"), request.index()); + return RefreshResponse.of(r -> r.shards(s -> s.total(1.0).successful(1.0).failed(0.0))); + }); + + ResponseEntity> response = controller.refreshIndex(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("success", response.getBody().get("status")); + } + + @Test + @DisplayName("a failing refresh reports the error") + @SuppressWarnings("unchecked") + void failingRefreshReportsTheError() throws Exception { + when(indicesClient.refresh(any(Function.class))).thenThrow(new IOException("cluster unavailable")); + + ResponseEntity> response = controller.refreshIndex(); + + assertEquals("error", response.getBody().get("status")); + assertTrue(response.getBody().get("message").toString().contains("Refresh failed")); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/controller/familyTagging/FamilyTaggingControllerTest.java b/src/test/java/com/iemr/common/identity/controller/familyTagging/FamilyTaggingControllerTest.java new file mode 100644 index 00000000..c4b825c0 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/controller/familyTagging/FamilyTaggingControllerTest.java @@ -0,0 +1,143 @@ +package com.iemr.common.identity.controller.familyTagging; + +import static com.iemr.common.identity.TestJson.assertFailure; +import static com.iemr.common.identity.TestJson.assertSuccess; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.iemr.common.identity.service.familyTagging.FamilyTagService; + +@ExtendWith(MockitoExtension.class) +class FamilyTaggingControllerTest { + + @Mock + private FamilyTagService familyTagService; + + @InjectMocks + private FamilyTaggingController controller; + + private static final String REQUEST = "{\"benId\":1}"; + + @Test + @DisplayName("saveFamilyTagging returns service payload on success") + void saveFamilyTaggingSuccess() throws Exception { + when(familyTagService.addTag(REQUEST)).thenReturn("{\"familyId\":\"F1\"}"); + + assertSuccess(controller.saveFamilyTagging(REQUEST), "F1"); + verify(familyTagService).addTag(REQUEST); + } + + @Test + @DisplayName("saveFamilyTagging wraps service failure") + void saveFamilyTaggingFailure() throws Exception { + when(familyTagService.addTag(anyString())).thenThrow(new RuntimeException("tag failed")); + + assertFailure(controller.saveFamilyTagging(REQUEST), "Error in saving family tagging"); + } + + @Test + @DisplayName("createFamily returns service payload on success") + void createFamilySuccess() throws Exception { + when(familyTagService.createFamily(REQUEST)).thenReturn("{\"familyId\":\"F2\"}"); + + assertSuccess(controller.createFamily(REQUEST), "F2"); + } + + @Test + @DisplayName("createFamily wraps service failure") + void createFamilyFailure() throws Exception { + when(familyTagService.createFamily(anyString())).thenThrow(new RuntimeException("create failed")); + + assertFailure(controller.createFamily(REQUEST), "Error in saving family tagging"); + } + + @Test + @DisplayName("searchFamily returns service payload on success") + void searchFamilySuccess() throws Exception { + when(familyTagService.searchFamily(REQUEST)).thenReturn("[{\"familyId\":\"F3\"}]"); + + assertSuccess(controller.searchFamily(REQUEST), "F3"); + } + + @Test + @DisplayName("searchFamily wraps service failure") + void searchFamilyFailure() throws Exception { + when(familyTagService.searchFamily(anyString())).thenThrow(new RuntimeException("search failed")); + + assertFailure(controller.searchFamily(REQUEST), "Error in searching family"); + } + + @Test + @DisplayName("getFamilyDatails returns service payload on success") + void getFamilyDetailsSuccess() throws Exception { + when(familyTagService.getFamilyDetails(REQUEST)).thenReturn("[{\"benId\":9}]"); + + assertSuccess(controller.getFamilyDatails(REQUEST), "benId"); + } + + @Test + @DisplayName("getFamilyDatails wraps service failure") + void getFamilyDetailsFailure() throws Exception { + when(familyTagService.getFamilyDetails(anyString())).thenThrow(new RuntimeException("details failed")); + + assertFailure(controller.getFamilyDatails(REQUEST), "Error in searching family members"); + } + + @Test + @DisplayName("untagFamily returns service payload on success") + void untagFamilySuccess() throws Exception { + when(familyTagService.doFamilyUntag(REQUEST)).thenReturn("untagged"); + + assertSuccess(controller.untagFamily(REQUEST), "untagged"); + } + + @Test + @DisplayName("untagFamily wraps service failure") + void untagFamilyFailure() throws Exception { + when(familyTagService.doFamilyUntag(anyString())).thenThrow(new RuntimeException("untag failed")); + + assertFailure(controller.untagFamily(REQUEST), "Error in untagging family"); + } + + @Test + @DisplayName("getFamilyDetailsByBeneficiaryId returns service payload on success") + void getFamilyDetailsByBeneficiaryIdSuccess() throws Exception { + when(familyTagService.getFamilyDetailsByBeneficiaryId(REQUEST)).thenReturn("{\"familyId\":\"F4\"}"); + + assertSuccess(controller.getFamilyDetailsByBeneficiaryId(REQUEST), "F4"); + } + + @Test + @DisplayName("getFamilyDetailsByBeneficiaryId wraps service failure") + void getFamilyDetailsByBeneficiaryIdFailure() throws Exception { + when(familyTagService.getFamilyDetailsByBeneficiaryId(anyString())) + .thenThrow(new RuntimeException("lookup failed")); + + assertFailure(controller.getFamilyDetailsByBeneficiaryId(REQUEST), + "Error in fetching family details by beneficiary ID"); + } + + @Test + @DisplayName("editFamilyDetails returns service payload on success") + void editFamilyDetailsSuccess() throws Exception { + when(familyTagService.editFamilyDetails(REQUEST)).thenReturn("edited"); + + assertSuccess(controller.editFamilyDetails(REQUEST), "edited"); + } + + @Test + @DisplayName("editFamilyDetails wraps service failure") + void editFamilyDetailsFailure() throws Exception { + when(familyTagService.editFamilyDetails(anyString())).thenThrow(new RuntimeException("edit failed")); + + assertFailure(controller.editFamilyDetails(REQUEST), "Error in editing family details"); + } +} diff --git a/src/test/java/com/iemr/common/identity/controller/health/HealthControllerTest.java b/src/test/java/com/iemr/common/identity/controller/health/HealthControllerTest.java new file mode 100644 index 00000000..fbfc1660 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/controller/health/HealthControllerTest.java @@ -0,0 +1,96 @@ +package com.iemr.common.identity.controller.health; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.iemr.common.identity.service.health.HealthService; +import com.iemr.common.identity.utils.JwtAuthenticationUtil; + +class HealthControllerTest { + + private HealthService healthService; + private JwtAuthenticationUtil jwtAuthenticationUtil; + private HealthController healthController; + + @BeforeEach + void setUp() { + healthService = mock(HealthService.class); + jwtAuthenticationUtil = mock(JwtAuthenticationUtil.class); + healthController = new HealthController(healthService, jwtAuthenticationUtil); + } + + private Map statusMap(String status) { + Map map = new HashMap<>(); + map.put("status", status); + return map; + } + + @Test + @DisplayName("UP status maps to HTTP 200") + void upStatusReturnsOk() { + when(healthService.checkHealth()).thenReturn(statusMap("UP")); + + ResponseEntity> response = healthController.checkHealth(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("UP", response.getBody().get("status")); + } + + @Test + @DisplayName("DEGRADED status still maps to HTTP 200") + void degradedStatusReturnsOk() { + when(healthService.checkHealth()).thenReturn(statusMap("DEGRADED")); + + ResponseEntity> response = healthController.checkHealth(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("DEGRADED", response.getBody().get("status")); + } + + @Test + @DisplayName("DOWN status maps to HTTP 503") + void downStatusReturnsServiceUnavailable() { + when(healthService.checkHealth()).thenReturn(statusMap("DOWN")); + + ResponseEntity> response = healthController.checkHealth(); + + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + } + + @Test + @DisplayName("null status is treated as not-DOWN and maps to HTTP 200") + void nullStatusReturnsOk() { + when(healthService.checkHealth()).thenReturn(new HashMap<>()); + + ResponseEntity> response = healthController.checkHealth(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + @DisplayName("service exception yields sanitized 503 body") + void serviceExceptionReturnsSanitizedError() { + when(healthService.checkHealth()).thenThrow(new RuntimeException("jdbc://secret@host boom")); + + ResponseEntity> response = healthController.checkHealth(); + + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + Map body = response.getBody(); + assertNotNull(body); + assertEquals("DOWN", body.get("status")); + assertNotNull(body.get("timestamp")); + assertEquals(2, body.size()); + } +} diff --git a/src/test/java/com/iemr/common/identity/controller/rmnch/RMNCHMobileAppControllerTest.java b/src/test/java/com/iemr/common/identity/controller/rmnch/RMNCHMobileAppControllerTest.java new file mode 100644 index 00000000..341844eb --- /dev/null +++ b/src/test/java/com/iemr/common/identity/controller/rmnch/RMNCHMobileAppControllerTest.java @@ -0,0 +1,193 @@ +package com.iemr.common.identity.controller.rmnch; + +import static com.iemr.common.identity.TestJson.assertFailure; +import static com.iemr.common.identity.TestJson.assertSuccess; +import static com.iemr.common.identity.TestJson.parse; +import static com.iemr.common.identity.TestJson.str; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.iemr.common.identity.service.rmnch.RmnchDataSyncService; + +@ExtendWith(MockitoExtension.class) +class RMNCHMobileAppControllerTest { + + @Mock + private RmnchDataSyncService rmnchDataSyncService; + + @InjectMocks + private RMNCHMobileAppController controller; + + private static final String AUTH = "Bearer token"; + + @Test + @DisplayName("syncDataToAmrit returns service payload on success") + void syncDataToAmritSuccess() throws Exception { + when(rmnchDataSyncService.syncDataToAmrit("{\"a\":1}")).thenReturn("synced"); + + assertSuccess(controller.syncDataToAmrit("{\"a\":1}"), "synced"); + } + + @Test + @DisplayName("syncDataToAmrit reports null request as an error") + void syncDataToAmritNullRequest() throws Exception { + assertFailure(controller.syncDataToAmrit(null), "Invalid/NULL request obj"); + } + + @Test + @DisplayName("syncDataToAmrit wraps service failure") + void syncDataToAmritFailure() throws Exception { + when(rmnchDataSyncService.syncDataToAmrit(anyString())).thenThrow(new RuntimeException("sync boom")); + + assertFailure(controller.syncDataToAmrit("{}"), "Error in RMNCH mobile data sync"); + } + + @Test + @DisplayName("syncDataToAmritHwc returns 200 with the service result") + void syncDataToAmritHwcSuccess() { + String request = "{\"benficieryid\":11,\"benRegId\":22}"; + when(rmnchDataSyncService.saveBeneficiaryDetailsAfterRegistration(11L, 22L, request)).thenReturn("saved"); + + ResponseEntity response = controller.syncDataToAmritHwc(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("saved", response.getBody()); + verify(rmnchDataSyncService).saveBeneficiaryDetailsAfterRegistration(11L, 22L, request); + } + + @Test + @DisplayName("syncDataToAmritHwc rejects a null request body") + void syncDataToAmritHwcNullRequest() { + ResponseEntity response = controller.syncDataToAmritHwc(null); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("Invalid/NULL request obj", response.getBody()); + } + + @Test + @DisplayName("syncDataToAmritHwc rejects an empty request body") + void syncDataToAmritHwcEmptyRequest() { + ResponseEntity response = controller.syncDataToAmritHwc(""); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("syncDataToAmritHwc rejects a request missing benficieryid") + void syncDataToAmritHwcMissingBeneficiaryId() { + ResponseEntity response = controller.syncDataToAmritHwc("{\"benRegId\":22}"); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("beneficiaryID or beneficiaryRegID is missing", response.getBody()); + } + + @Test + @DisplayName("syncDataToAmritHwc treats an explicit JSON null id as missing") + void syncDataToAmritHwcJsonNullBeneficiaryId() { + ResponseEntity response = controller.syncDataToAmritHwc("{\"benficieryid\":null,\"benRegId\":22}"); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("syncDataToAmritHwc rejects a request missing benRegId") + void syncDataToAmritHwcMissingRegId() { + ResponseEntity response = controller.syncDataToAmritHwc("{\"benficieryid\":11}"); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("syncDataToAmritHwc returns 500 when the service throws") + void syncDataToAmritHwcServiceFailure() { + when(rmnchDataSyncService.saveBeneficiaryDetailsAfterRegistration(anyLong(), anyLong(), anyString())) + .thenThrow(new RuntimeException("hwc boom")); + + ResponseEntity response = controller.syncDataToAmritHwc("{\"benficieryid\":11,\"benRegId\":22}"); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertTrue(String.valueOf(response.getBody()).contains("hwc boom")); + } + + @Test + @DisplayName("syncDataToAmritHwc returns 500 on malformed JSON") + void syncDataToAmritHwcMalformedJson() { + ResponseEntity response = controller.syncDataToAmritHwc("not-json{"); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + @Test + @DisplayName("getBeneficiaryData returns service payload on success") + void getBeneficiaryDataSuccess() throws Exception { + when(rmnchDataSyncService.getBenData("{\"villageID\":1}", AUTH)).thenReturn("[{\"benId\":1}]"); + + assertSuccess(controller.getBeneficiaryData("{\"villageID\":1}", AUTH), "benId"); + } + + @Test + @DisplayName("getBeneficiaryData reports no record when the service returns null") + void getBeneficiaryDataNoRecord() throws Exception { + when(rmnchDataSyncService.getBenData(anyString(), eq(AUTH))).thenReturn(null); + + assertFailure(controller.getBeneficiaryData("{}", AUTH), "No record found"); + } + + @Test + @DisplayName("getBeneficiaryData reports null request as an error") + void getBeneficiaryDataNullRequest() { + assertFailure(controller.getBeneficiaryData(null, AUTH), "Invalid/NULL request obj"); + } + + @Test + @DisplayName("getBeneficiaryData wraps service failure") + void getBeneficiaryDataFailure() throws Exception { + when(rmnchDataSyncService.getBenData(anyString(), eq(AUTH))).thenThrow(new RuntimeException("village boom")); + + assertFailure(controller.getBeneficiaryData("{}", AUTH), "Error in get data"); + } + + @Test + @DisplayName("getBeneficiaryDataByAsha returns service payload on success") + void getBeneficiaryDataByAshaSuccess() throws Exception { + when(rmnchDataSyncService.getBenDataByAsha("{\"AshaId\":1}", AUTH)).thenReturn("[{\"benId\":2}]"); + + assertSuccess(controller.getBeneficiaryDataByAsha("{\"AshaId\":1}", AUTH), "benId"); + } + + @Test + @DisplayName("getBeneficiaryDataByAsha reports no record when the service returns null") + void getBeneficiaryDataByAshaNoRecord() throws Exception { + when(rmnchDataSyncService.getBenDataByAsha(anyString(), eq(AUTH))).thenReturn(null); + + assertFailure(controller.getBeneficiaryDataByAsha("{}", AUTH), "No record found"); + } + + @Test + @DisplayName("getBeneficiaryDataByAsha reports null request as an error") + void getBeneficiaryDataByAshaNullRequest() { + assertFailure(controller.getBeneficiaryDataByAsha(null, AUTH), "Invalid/NULL request obj"); + } + + @Test + @DisplayName("getBeneficiaryDataByAsha wraps service failure") + void getBeneficiaryDataByAshaFailure() throws Exception { + when(rmnchDataSyncService.getBenDataByAsha(anyString(), eq(AUTH))).thenThrow(new RuntimeException("asha boom")); + + assertFailure(controller.getBeneficiaryDataByAsha("{}", AUTH), "Error in get data"); + } +} diff --git a/src/test/java/com/iemr/common/identity/controller/version/VersionControllerTest.java b/src/test/java/com/iemr/common/identity/controller/version/VersionControllerTest.java new file mode 100644 index 00000000..ad225410 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/controller/version/VersionControllerTest.java @@ -0,0 +1,56 @@ +package com.iemr.common.identity.controller.version; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +class VersionControllerTest { + + private VersionController versionController; + + @BeforeEach + void setUp() { + versionController = new VersionController(); + } + + @Test + @DisplayName("versionInformation returns 200 with all four keys") + void versionInformationReturnsOkWithAllKeys() { + ResponseEntity> response = versionController.versionInformation(); + + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + Map body = response.getBody(); + assertNotNull(body); + assertTrue(body.containsKey("buildTimestamp")); + assertTrue(body.containsKey("version")); + assertTrue(body.containsKey("branch")); + assertTrue(body.containsKey("commitHash")); + } + + @Test + @DisplayName("versionInformation never returns null values") + void versionInformationValuesAreNeverNull() { + Map body = versionController.versionInformation().getBody(); + + assertNotNull(body); + body.values().forEach(org.junit.jupiter.api.Assertions::assertNotNull); + } + + @Test + @DisplayName("versionInformation is idempotent across calls") + void versionInformationIsIdempotent() { + Map first = versionController.versionInformation().getBody(); + Map second = versionController.versionInformation().getBody(); + + assertEquals(first, second); + } +} diff --git a/src/test/java/com/iemr/common/identity/domain/DomainAccessorSweepTest.java b/src/test/java/com/iemr/common/identity/domain/DomainAccessorSweepTest.java new file mode 100644 index 00000000..bbd09867 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/domain/DomainAccessorSweepTest.java @@ -0,0 +1,113 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.domain; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import java.security.CodeSource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AssignableTypeFilter; +import org.springframework.core.type.filter.RegexPatternTypeFilter; + +import com.iemr.common.identity.fixture.PojoFixture; + +/** + * Structural sweep over the entity, DTO and data-model layers. + * + *

+ * Hibernate instantiates entities through a no-argument constructor and then + * populates them through their accessors, and Jackson does the same for the DTOs + * on the wire. Neither reports a class that has lost its default constructor, + * gained a setter with no matching getter, or acquired an accessor that throws - + * the failure surfaces at runtime as a mapping or deserialisation error instead. + * + *

+ * This test walks every class in those packages, sets each writable property, + * reads it back, and exercises the generated {@code equals}/{@code hashCode}/ + * {@code toString}. Classes whose behaviour is more than a property bag have + * their own tests - see {@link MBeneficiarydetailTest} and {@link PhoneTest}. + */ +class DomainAccessorSweepTest { + + private static final List PACKAGES = Arrays.asList("com.iemr.common.identity.domain", + "com.iemr.common.identity.dto", "com.iemr.common.identity.data"); + + @TestFactory + List everyModelClassRoundTripsItsProperties() { + List tests = new ArrayList<>(); + for (Class type : modelClasses()) { + tests.add(dynamicTest(type.getSimpleName(), () -> { + Object populated = PojoFixture.roundTrip(type); + assertNotNull(populated, type.getName() + " could not be instantiated and populated"); + assertTrue(PojoFixture.exerciseAccessors(populated) > 0, + type.getName() + " exposes no readable properties"); + })); + } + assertFalse(tests.isEmpty(), "the model packages should not be empty - has the scan path changed?"); + return tests; + } + + private List> modelClasses() { + ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new RegexPatternTypeFilter(java.util.regex.Pattern.compile(".*"))); + scanner.addExcludeFilter(new AssignableTypeFilter(Throwable.class)); + List> classes = new ArrayList<>(); + for (String basePackage : PACKAGES) { + for (BeanDefinition definition : scanner.findCandidateComponents(basePackage)) { + String name = definition.getBeanClassName(); + if (name == null || name.contains("$")) { + continue; + } + try { + Class type = Class.forName(name); + if (!type.isEnum() && !type.isInterface() && isProductionClass(type)) { + classes.add(type); + } + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Scanned class is not loadable: " + name, e); + } + } + } + classes.sort(java.util.Comparator.comparing(Class::getName)); + return classes; + } + + /** + * The scan runs against the test classpath, which also contains the tests + * that live in these packages; only classes built from {@code src/main} are + * the subject here. + */ + private boolean isProductionClass(Class type) { + CodeSource codeSource = type.getProtectionDomain().getCodeSource(); + return codeSource != null && codeSource.getLocation().getPath().endsWith("/target/classes/"); + } +} diff --git a/src/test/java/com/iemr/common/identity/domain/MBeneficiarydetailTest.java b/src/test/java/com/iemr/common/identity/domain/MBeneficiarydetailTest.java new file mode 100644 index 00000000..760fcc64 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/domain/MBeneficiarydetailTest.java @@ -0,0 +1,179 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Calendar; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests for the derived values on the beneficiary-detail entity. + * + *

+ * Field-app records arrive with an inconsistent mix of date of birth, marriage + * date and age-at-marriage - whichever the worker was able to collect - so the + * entity derives the missing ones. It also stores HIV status as a code while the + * mobile app sends it as text. Both conversions are only meaningful in terms of + * their edge cases: absent inputs, and text the app did not expect. + */ +class MBeneficiarydetailTest { + + /** The codes persisted in {@code i_beneficiarydetails.IsHIVPositive}. */ + private static final int POSITIVE_CODE = 1; + private static final int NEGATIVE_CODE = 2; + private static final int NOT_DISCLOSED_CODE = 3; + + @Test + @DisplayName("age is derived from the date of birth") + void ageIsDerivedFromDateOfBirth() { + Timestamp dob = Timestamp.valueOf(LocalDateTime.now().minus(30, ChronoUnit.YEARS)); + + assertEquals(30, MBeneficiarydetail.calculateAge(dob)); + } + + @Test + @DisplayName("a beneficiary born today is zero years old") + void newbornIsZeroYearsOld() { + assertEquals(0, MBeneficiarydetail.calculateAge(Timestamp.valueOf(LocalDateTime.now()))); + } + + @Test + @DisplayName("an unknown date of birth yields no age rather than zero") + void unknownDateOfBirthYieldsNoAge() { + // Zero would be indistinguishable from an infant, so a missing DOB has + // to stay null all the way out to the API response. + assertNull(MBeneficiarydetail.calculateAge(null)); + } + + @Test + @DisplayName("a recorded marriage date is used as-is, even when age at marriage is also present") + void recordedMarriageDateWins() { + Timestamp dob = Timestamp.valueOf("1990-06-15 00:00:00"); + Timestamp marriageDate = Timestamp.valueOf("2015-02-20 00:00:00"); + + assertSame(marriageDate, MBeneficiarydetail.getMarriageDateCalc(dob, marriageDate, 25)); + } + + @Test + @DisplayName("a missing marriage date is derived from the date of birth and age at marriage") + void marriageDateIsDerivedFromAgeAtMarriage() { + Timestamp dob = Timestamp.valueOf("1990-06-15 00:00:00"); + + Timestamp derived = MBeneficiarydetail.getMarriageDateCalc(dob, null, 25); + + Calendar calendar = Calendar.getInstance(); + calendar.setTime(derived); + assertEquals(2015, calendar.get(Calendar.YEAR)); + assertEquals(Calendar.JUNE, calendar.get(Calendar.MONTH)); + assertEquals(15, calendar.get(Calendar.DAY_OF_MONTH)); + } + + @Test + @DisplayName("the marriage date stays unknown when neither the date nor the age is recorded") + void marriageDateStaysUnknownWithoutInputs() { + Timestamp dob = Timestamp.valueOf("1990-06-15 00:00:00"); + + assertNull(MBeneficiarydetail.getMarriageDateCalc(dob, null, null)); + assertNull(MBeneficiarydetail.getMarriageDateCalc(null, null, 25)); + } + + @Test + @DisplayName("a recorded age at marriage is used as-is") + void recordedAgeAtMarriageWins() { + assertEquals(22, MBeneficiarydetail.getAgeAtMarriageCalc(Timestamp.valueOf("1990-06-15 00:00:00"), + Timestamp.valueOf("2015-02-20 00:00:00"), 22)); + } + + @Test + @DisplayName("a missing age at marriage is derived from the two dates") + void ageAtMarriageIsDerivedFromDates() { + Integer derived = MBeneficiarydetail.getAgeAtMarriageCalc(Timestamp.valueOf("1990-06-15 00:00:00"), + Timestamp.valueOf("2015-06-15 00:00:00"), null); + + assertEquals(25, derived); + } + + @Test + @DisplayName("the age at marriage stays unknown when a date is missing") + void ageAtMarriageStaysUnknownWithoutBothDates() { + assertNull(MBeneficiarydetail.getAgeAtMarriageCalc(null, Timestamp.valueOf("2015-06-15 00:00:00"), null)); + assertNull(MBeneficiarydetail.getAgeAtMarriageCalc(Timestamp.valueOf("1990-06-15 00:00:00"), null, null)); + } + + @ParameterizedTest + @CsvSource({ "yes, 1", "YES, 1", "Yes, 1", "no, 2", "NO, 2", "No, 2" }) + @DisplayName("HIV status text maps to its stored code, case-insensitively") + void hivStatusTextMapsToStoredCode(String status, int expectedCode) { + assertEquals(expectedCode, MBeneficiarydetail.setIsHIVPositive(status)); + } + + @ParameterizedTest + @ValueSource(strings = { "unknown", "", "not disclosed", "positive" }) + @DisplayName("unrecognised HIV status text falls back to not-disclosed") + void unrecognisedHivStatusFallsBackToNotDisclosed(String status) { + assertEquals(NOT_DISCLOSED_CODE, MBeneficiarydetail.setIsHIVPositive(status)); + } + + @ParameterizedTest + @NullSource + @DisplayName("a missing HIV status falls back to not-disclosed") + void missingHivStatusFallsBackToNotDisclosed(String status) { + assertEquals(NOT_DISCLOSED_CODE, MBeneficiarydetail.setIsHIVPositive(status)); + } + + @Test + @DisplayName("stored HIV codes are rendered back as the text the app sent") + void storedHivCodesRenderBackAsText() { + assertEquals("yes", MBeneficiarydetail.getIsHIVPositive(POSITIVE_CODE)); + assertEquals("no", MBeneficiarydetail.getIsHIVPositive(NEGATIVE_CODE)); + } + + @Test + @DisplayName("an unknown or absent HIV code renders as blank rather than a code") + void unknownHivCodeRendersBlank() { + assertEquals("", MBeneficiarydetail.getIsHIVPositive(NOT_DISCLOSED_CODE)); + assertEquals("", MBeneficiarydetail.getIsHIVPositive(99)); + assertEquals("", MBeneficiarydetail.getIsHIVPositive(null)); + } + + @Test + @DisplayName("the HIV status code round-trips through the instance accessor") + void hivStatusCodeRoundTrips() { + MBeneficiarydetail detail = new MBeneficiarydetail(); + + detail.setIsHIVPositive(MBeneficiarydetail.setIsHIVPositive("yes")); + + assertEquals(POSITIVE_CODE, detail.getIsHIVPositive()); + } +} diff --git a/src/test/java/com/iemr/common/identity/domain/PhoneTest.java b/src/test/java/com/iemr/common/identity/domain/PhoneTest.java new file mode 100644 index 00000000..f1910926 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/domain/PhoneTest.java @@ -0,0 +1,82 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for the fan-out from the six flat phone columns on + * {@link MBeneficiarycontact} to the {@link Phone} list the API returns. + */ +class PhoneTest { + + @Test + @DisplayName("each populated phone column becomes one entry, in column order") + void populatedColumnsBecomeEntriesInColumnOrder() { + MBeneficiarycontact contact = new MBeneficiarycontact(); + contact.setPreferredPhoneNum("9000000000"); + contact.setPreferredPhoneTyp("Mobile"); + contact.setPhoneNum1("9000000001"); + contact.setPhoneTyp1("Landline"); + contact.setPhoneNum5("9000000005"); + contact.setPhoneTyp5("Work"); + + List phones = Phone.createContactList(contact, "12345", "Asha Devi"); + + assertEquals(3, phones.size()); + assertEquals("9000000000", phones.get(0).getPhoneNum()); + assertEquals("Mobile", phones.get(0).getPhoneType()); + assertEquals("9000000001", phones.get(1).getPhoneNum()); + assertEquals("9000000005", phones.get(2).getPhoneNum()); + } + + @Test + @DisplayName("every entry is stamped with the owning beneficiary") + void everyEntryIsStampedWithTheOwner() { + MBeneficiarycontact contact = new MBeneficiarycontact(); + contact.setPhoneNum2("9000000002"); + contact.setPhoneNum3("9000000003"); + contact.setPhoneNum4("9000000004"); + + List phones = Phone.createContactList(contact, "12345", "Asha Devi"); + + assertEquals(3, phones.size()); + phones.forEach(phone -> { + assertEquals("12345", phone.getBelongsToBenRegId()); + assertEquals("Asha Devi", phone.getBelongsToName()); + }); + } + + @Test + @DisplayName("a contact row with no numbers yields no list at all") + void contactWithNoNumbersYieldsNoList() { + // Callers distinguish "no contact details recorded" from "an empty set of + // numbers", so this returns null rather than an empty list. + assertNull(Phone.createContactList(new MBeneficiarycontact(), "12345", "Asha Devi")); + } +} diff --git a/src/test/java/com/iemr/common/identity/dto/BeneficiaryOrderingTest.java b/src/test/java/com/iemr/common/identity/dto/BeneficiaryOrderingTest.java new file mode 100644 index 00000000..41e1419a --- /dev/null +++ b/src/test/java/com/iemr/common/identity/dto/BeneficiaryOrderingTest.java @@ -0,0 +1,101 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.dto; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for the ordering the search endpoints sort their results by. + * + *

+ * Both result DTOs are {@link Comparable} and every search endpoint calls + * {@code Collections.sort} before responding, so the agent sees a stable order + * across repeated searches. The two types order on different keys, and neither + * tolerates a null key - which is why the endpoints filter nulls out of the + * list first. + */ +class BeneficiaryOrderingTest { + + private BeneficiariesDTO beneficiary(long benMapId) { + BeneficiariesDTO dto = new BeneficiariesDTO(); + dto.setBenMapId(BigInteger.valueOf(benMapId)); + return dto; + } + + private BeneficiariesPartialDTO partial(long benRegId) { + BeneficiariesPartialDTO dto = new BeneficiariesPartialDTO(); + dto.setBenRegId(BigInteger.valueOf(benRegId)); + return dto; + } + + @Test + @DisplayName("full results are ordered by their mapping id") + void fullResultsAreOrderedByMappingId() { + List results = new ArrayList<>( + Arrays.asList(beneficiary(30L), beneficiary(10L), beneficiary(20L))); + + Collections.sort(results); + + assertEquals(BigInteger.valueOf(10L), results.get(0).getBenMapId()); + assertEquals(BigInteger.valueOf(20L), results.get(1).getBenMapId()); + assertEquals(BigInteger.valueOf(30L), results.get(2).getBenMapId()); + } + + @Test + @DisplayName("partial results are ordered by their registration id") + void partialResultsAreOrderedByRegistrationId() { + List results = new ArrayList<>( + Arrays.asList(partial(300L), partial(100L), partial(200L))); + + Collections.sort(results); + + assertEquals(BigInteger.valueOf(100L), results.get(0).getBenRegId()); + assertEquals(BigInteger.valueOf(300L), results.get(2).getBenRegId()); + } + + @Test + @DisplayName("two results with the same key compare as equal") + void resultsWithTheSameKeyCompareAsEqual() { + assertEquals(0, beneficiary(10L).compareTo(beneficiary(10L))); + assertEquals(0, partial(100L).compareTo(partial(100L))); + } + + @Test + @DisplayName("a result with no key cannot be ordered, which is why nulls are filtered first") + void resultWithNoKeyCannotBeOrdered() { + assertThrows(NullPointerException.class, + () -> new BeneficiariesDTO().compareTo(beneficiary(10L))); + assertThrows(NullPointerException.class, + () -> new BeneficiariesPartialDTO().compareTo(partial(100L))); + } +} diff --git a/src/test/java/com/iemr/common/identity/exception/IdentityExceptionsTest.java b/src/test/java/com/iemr/common/identity/exception/IdentityExceptionsTest.java new file mode 100644 index 00000000..debff705 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/exception/IdentityExceptionsTest.java @@ -0,0 +1,85 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for the service's own exception types. + * + *

+ * These reach the caller as the {@code errorMessage} of the response envelope, + * so the message a constructor keeps is user-visible. They also have to remain + * checked exceptions, since the controllers rely on catching them specifically + * to distinguish a bad request from a server fault. + */ +class IdentityExceptionsTest { + + @Test + @DisplayName("a missing-mandatory-field failure carries the message shown to the caller") + void missingMandatoryFieldCarriesItsMessage() { + MissingMandatoryFieldsException thrown = new MissingMandatoryFieldsException( + "Either of BeneficiaryID or Beneficiary Reg Id is mandatory."); + + assertEquals("Either of BeneficiaryID or Beneficiary Reg Id is mandatory.", thrown.getMessage()); + assertTrue(thrown instanceof Exception); + } + + @Test + @DisplayName("an illegal-action failure carries the message shown to the caller") + void illegalActionCarriesItsMessage() { + IllegalActionException thrown = new IllegalActionException("Beneficiary is already registered."); + + assertEquals("Beneficiary is already registered.", thrown.getMessage()); + assertTrue(thrown instanceof Exception); + } + + @Test + @DisplayName("a service failure adopts its cause's stack trace but not the cause itself") + void serviceFailureAdoptsItsCausesStackTraceOnly() { + // The two-argument constructor copies the stack trace rather than + // calling initCause, so the original exception is not reachable from + // the one that reaches the caller - only its frames survive. + IllegalStateException cause = new IllegalStateException("connection refused"); + + IEMRException thrown = new IEMRException("Validation error", cause); + + assertEquals("Validation error", thrown.getMessage()); + assertNull(thrown.getCause()); + org.junit.jupiter.api.Assertions.assertArrayEquals(cause.getStackTrace(), thrown.getStackTrace()); + } + + @Test + @DisplayName("a service failure without a cause carries only its message") + void serviceFailureWithoutACauseCarriesOnlyItsMessage() { + IEMRException thrown = new IEMRException("Invalid family ID"); + + assertEquals("Invalid family ID", thrown.getMessage()); + assertNull(thrown.getCause()); + } +} diff --git a/src/test/java/com/iemr/common/identity/fixture/PojoFixture.java b/src/test/java/com/iemr/common/identity/fixture/PojoFixture.java new file mode 100644 index 00000000..48c024e4 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/fixture/PojoFixture.java @@ -0,0 +1,342 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.fixture; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Reflection-based test fixture builder. + * + *

+ * Identity-API leans heavily on generated MapStruct mappers and on wide JPA + * entities whose only logic is a long chain of {@code if (value != null)} + * guards. Hand-writing a fully populated {@code IdentityEditDTO} graph for + * every mapper test would run to thousands of lines and rot on the first + * schema change, so tests build their inputs here instead. + * + *

+ */ +public final class PojoFixture { + + /** Depth cap: entities reference each other, so the graph must be bounded. */ + private static final int DEFAULT_DEPTH = 4; + + private PojoFixture() { + } + + /** Creates an instance with no properties set. */ + public static T blank(Class type) { + return instantiate(type); + } + + /** Creates an instance with every writable property set to a non-null value. */ + public static T populate(Class type) { + return populate(type, DEFAULT_DEPTH); + } + + /** Creates a populated instance, recursing at most {@code depth} levels. */ + public static T populate(Class type, int depth) { + T instance = instantiate(type); + fill(instance, depth); + return instance; + } + + /** Sets every writable property on an existing instance. */ + public static void fill(Object instance, int depth) { + for (Method setter : instance.getClass().getMethods()) { + if (!isSetter(setter)) { + continue; + } + Object value = valueFor(setter.getParameterTypes()[0], setter.getGenericParameterTypes()[0], depth); + if (value == null) { + continue; + } + try { + setter.invoke(instance, value); + } catch (ReflectiveOperationException | IllegalArgumentException e) { + // A property we cannot supply is simply left at its default. + } + } + } + + /** + * Invokes every no-argument getter and returns how many were read. Used to + * cover accessor-only classes without asserting on each field by hand. + */ + public static int exerciseAccessors(Object instance) { + int read = 0; + for (Method getter : instance.getClass().getMethods()) { + if (!isGetter(getter)) { + continue; + } + try { + getter.invoke(instance); + read++; + } catch (ReflectiveOperationException | RuntimeException e) { + // Derived getters may reject the fixture's values; skip them. + } + } + return read; + } + + /** + * Populates an instance, reads every property back and also exercises + * {@code toString}/{@code hashCode}/{@code equals} where Lombok generated + * them. Returns the populated instance so callers can assert on it. + */ + public static T roundTrip(Class type) { + T populated = populate(type); + exerciseAccessors(populated); + exerciseObjectMethods(populated, populate(type), blank(type)); + return populated; + } + + private static void exerciseObjectMethods(Object first, Object second, Object third) { + try { + first.toString(); + first.hashCode(); + first.equals(first); + first.equals(second); + first.equals(third); + first.equals(null); + first.equals("not the same type"); + } catch (RuntimeException e) { + // Generated equals/hashCode can trip over lazy fields; not the subject + // of the test. + } + } + + private static boolean isSetter(Method method) { + return method.getName().startsWith("set") && method.getParameterCount() == 1 + && !Modifier.isStatic(method.getModifiers()) && method.getDeclaringClass() != Object.class; + } + + private static boolean isGetter(Method method) { + if (method.getParameterCount() != 0 || Modifier.isStatic(method.getModifiers()) + || method.getDeclaringClass() == Object.class || method.getReturnType() == void.class) { + return false; + } + String name = method.getName(); + return name.startsWith("get") || name.startsWith("is"); + } + + @SuppressWarnings("unchecked") + private static T instantiate(Class type) { + try { + Constructor constructor = type.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (ReflectiveOperationException e) { + // Fall back to the widest constructor, supplying values for its + // parameters. + Constructor[] constructors = type.getDeclaredConstructors(); + Constructor widest = null; + for (Constructor candidate : constructors) { + if (widest == null || candidate.getParameterCount() > widest.getParameterCount()) { + widest = candidate; + } + } + if (widest == null) { + throw new IllegalArgumentException("No usable constructor for " + type.getName(), e); + } + Object[] args = new Object[widest.getParameterCount()]; + for (int i = 0; i < args.length; i++) { + args[i] = valueFor(widest.getParameterTypes()[i], widest.getGenericParameterTypes()[i], 1); + } + try { + widest.setAccessible(true); + return (T) widest.newInstance(args); + } catch (ReflectiveOperationException nested) { + throw new IllegalArgumentException("Cannot instantiate " + type.getName(), nested); + } + } + } + + private static Object valueFor(Class type, Type genericType, int depth) { + if (type == String.class) { + return "test"; + } + if (type == Integer.class || type == int.class) { + return Integer.valueOf(1); + } + if (type == Long.class || type == long.class) { + return Long.valueOf(1L); + } + if (type == Short.class || type == short.class) { + return Short.valueOf((short) 1); + } + if (type == Byte.class || type == byte.class) { + return Byte.valueOf((byte) 1); + } + if (type == Double.class || type == double.class) { + return Double.valueOf(1d); + } + if (type == Float.class || type == float.class) { + return Float.valueOf(1f); + } + if (type == Boolean.class || type == boolean.class) { + return Boolean.TRUE; + } + if (type == Character.class || type == char.class) { + return Character.valueOf('Y'); + } + if (type == BigInteger.class) { + return BigInteger.ONE; + } + if (type == BigDecimal.class) { + return BigDecimal.ONE; + } + if (type == Timestamp.class) { + return new Timestamp(FIXED_MILLIS); + } + if (type == Date.class) { + return new Date(FIXED_MILLIS); + } + if (type == java.util.Date.class) { + return new java.util.Date(FIXED_MILLIS); + } + if (type == LocalDate.class) { + return LocalDate.of(1990, 1, 1); + } + if (type == LocalDateTime.class) { + return LocalDateTime.of(1990, 1, 1, 0, 0); + } + if (type == byte[].class) { + return new byte[] { 1, 2, 3 }; + } + if (type.isEnum()) { + Object[] constants = type.getEnumConstants(); + return constants.length > 0 ? constants[0] : null; + } + if (type == Object.class) { + return "test"; + } + if (Collection.class.isAssignableFrom(type)) { + return collectionFor(type, genericType, depth); + } + if (Map.class.isAssignableFrom(type)) { + return mapFor(type, genericType, depth); + } + if (depth <= 1 || type.isInterface() || Modifier.isAbstract(type.getModifiers()) || type.isArray() + || !type.getName().startsWith("com.iemr")) { + return null; + } + try { + return populate(type, depth - 1); + } catch (RuntimeException e) { + return null; + } + } + + private static final long FIXED_MILLIS = 631152000000L; // 1990-01-01T00:00:00Z + + private static Object collectionFor(Class type, Type genericType, int depth) { + Collection collection = Set.class.isAssignableFrom(type) + ? (type == LinkedHashSet.class ? new LinkedHashSet<>() : new HashSet<>()) + : new ArrayList<>(); + Class elementType = typeArgument(genericType, 0); + if (elementType != null && depth > 1) { + Object element = valueFor(elementType, elementType, depth - 1); + if (element != null) { + collection.add(element); + } + } + return collection; + } + + private static Object mapFor(Class type, Type genericType, int depth) { + Map map = type == LinkedHashMap.class ? new LinkedHashMap<>() : new HashMap<>(); + Class keyType = typeArgument(genericType, 0); + Class valueType = typeArgument(genericType, 1); + if (keyType != null && valueType != null && depth > 1) { + Object key = valueFor(keyType, keyType, depth - 1); + Object value = valueFor(valueType, valueType, depth - 1); + if (key != null && value != null) { + map.put(key, value); + } + } + return map; + } + + private static Class typeArgument(Type genericType, int index) { + if (!(genericType instanceof ParameterizedType)) { + return null; + } + Type[] arguments = ((ParameterizedType) genericType).getActualTypeArguments(); + if (index >= arguments.length) { + return null; + } + Type argument = arguments[index]; + if (argument instanceof Class) { + return (Class) argument; + } + if (argument instanceof ParameterizedType + && ((ParameterizedType) argument).getRawType() instanceof Class) { + return (Class) ((ParameterizedType) argument).getRawType(); + } + return null; + } + + /** + * Builds a value suitable for a method parameter of the given declared type, + * resolving element types for collection parameters. Returns {@code null} when + * the type is one the fixture cannot supply. + */ + public static Object argument(Class type, Type genericType) { + return valueFor(type, genericType, DEFAULT_DEPTH); + } + + /** Convenience for tests that need a single-element list of populated items. */ + public static List populatedList(Class type) { + List list = new ArrayList<>(); + list.add(populate(type)); + return list; + } +} diff --git a/src/test/java/com/iemr/common/identity/mapper/BeneficiaryESMapperTest.java b/src/test/java/com/iemr/common/identity/mapper/BeneficiaryESMapperTest.java new file mode 100644 index 00000000..781328d4 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/mapper/BeneficiaryESMapperTest.java @@ -0,0 +1,319 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for the Elasticsearch-to-API response shape. + * + *

+ * The 1097 call-centre UI reads a nested structure that predates the search + * index - demographics wrapped in {@code i_bendemographics}, with separate + * {@code m_state}/{@code m_district}/{@code m_districtblock} objects, and phone + * numbers as {@code benPhoneMaps}. Index documents are flat, so this mapper + * rebuilds that nesting. A field dropped here is a blank in the agent's screen + * with nothing in the logs, which is what these tests are for. + */ +class BeneficiaryESMapperTest { + + private final BeneficiaryESMapper mapper = new BeneficiaryESMapper(); + + private Map indexDocument() { + Map document = new HashMap<>(); + document.put("beneficiaryRegID", 100200300L); + document.put("beneficiaryID", "4001"); + document.put("firstName", "Asha"); + document.put("lastName", "Devi"); + document.put("genderID", 2); + document.put("genderName", "Female"); + document.put("dOB", "1996-01-15"); + document.put("age", 30); + document.put("createdBy", "field.worker"); + document.put("createdDate", "2026-01-01"); + document.put("lastModDate", 1767225600000L); + document.put("benAccountID", 77L); + return document; + } + + private Map demographics() { + Map demographics = new HashMap<>(); + demographics.put("stateID", 101); + demographics.put("stateName", "Karnataka"); + demographics.put("stateCode", "KA"); + demographics.put("districtID", 201); + demographics.put("districtName", "Bengaluru"); + demographics.put("blockID", 301); + demographics.put("blockName", "North"); + demographics.put("districtBranchID", 401); + demographics.put("districtBranchName", "Yelahanka"); + demographics.put("pinCode", "560064"); + return demographics; + } + + @SuppressWarnings("unchecked") + private Map firstBeneficiary(Map response) { + return ((List>) response.get("data")).get(0); + } + + @Test + @DisplayName("the envelope reports success around the transformed data") + void envelopeReportsSuccess() { + Map response = mapper + .transformESResponse(Collections.singletonList(indexDocument())); + + assertEquals(200, response.get("statusCode")); + assertEquals("Success", response.get("status")); + assertEquals("Success", response.get("errorMessage")); + assertEquals(1, ((List) response.get("data")).size()); + } + + @Test + @DisplayName("no hits yields an empty data list rather than a missing key") + void noHitsYieldsEmptyDataList() { + Map response = mapper.transformESResponse(Collections.emptyList()); + + assertNotNull(response.get("data")); + assertTrue(((List) response.get("data")).isEmpty()); + } + + @Test + @DisplayName("every hit is transformed, not just the first") + void everyHitIsTransformed() { + Map second = indexDocument(); + second.put("firstName", "Sunita"); + + Map response = mapper + .transformESResponse(Arrays.asList(indexDocument(), second)); + + assertEquals(2, ((List) response.get("data")).size()); + } + + @Test + @DisplayName("the identity fields the agent's screen reads are all carried across") + void identityFieldsAreCarriedAcross() { + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(indexDocument()))); + + assertEquals(100200300L, beneficiary.get("beneficiaryRegID")); + assertEquals("4001", beneficiary.get("beneficiaryID")); + assertEquals("Asha", beneficiary.get("firstName")); + assertEquals("Devi", beneficiary.get("lastName")); + assertEquals(2, beneficiary.get("genderID")); + assertEquals("Female", beneficiary.get("genderName")); + assertEquals("field.worker", beneficiary.get("createdBy")); + assertEquals(1767225600000L, beneficiary.get("lastModDate")); + assertEquals(77L, beneficiary.get("benAccountID")); + } + + @Test + @DisplayName("the date of birth and age are published under both names the UI uses") + void dateOfBirthAndAgeArePublishedUnderBothNames() { + // The screen reads dOB in one place and dob in another; dropping either + // blanks a field. + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(indexDocument()))); + + assertEquals("1996-01-15", beneficiary.get("dOB")); + assertEquals("1996-01-15", beneficiary.get("dob")); + assertEquals(30, beneficiary.get("age")); + assertEquals(30, beneficiary.get("actualAge")); + assertEquals("Years", beneficiary.get("ageUnits")); + } + + @Test + @DisplayName("absent optional text fields become blanks rather than nulls") + void absentOptionalTextFieldsBecomeBlanks() { + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(indexDocument()))); + + assertEquals("", beneficiary.get("fatherName")); + assertEquals("", beneficiary.get("spouseName")); + assertEquals("", beneficiary.get("isHIVPos")); + } + + @Test + @DisplayName("present optional text fields are carried across unchanged") + void presentOptionalTextFieldsAreCarriedAcross() { + Map document = indexDocument(); + document.put("fatherName", "Ram"); + document.put("spouseName", "Suresh"); + document.put("isHIVPos", "no"); + + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(document))); + + assertEquals("Ram", beneficiary.get("fatherName")); + assertEquals("Suresh", beneficiary.get("spouseName")); + assertEquals("no", beneficiary.get("isHIVPos")); + } + + @Test + @DisplayName("gender is published both flat and as the nested object the UI binds to") + void genderIsPublishedFlatAndNested() { + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(indexDocument()))); + + @SuppressWarnings("unchecked") + Map gender = (Map) beneficiary.get("m_gender"); + assertEquals(2, gender.get("genderID")); + assertEquals("Female", gender.get("genderName")); + } + + @Test + @DisplayName("demographics are rebuilt into the nested location objects the UI expects") + void demographicsAreRebuiltIntoNestedObjects() { + Map document = indexDocument(); + document.put("demographics", demographics()); + + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(document))); + + @SuppressWarnings("unchecked") + Map nested = (Map) beneficiary.get("i_bendemographics"); + assertEquals(100200300L, nested.get("beneficiaryRegID")); + assertEquals(101, nested.get("stateID")); + + @SuppressWarnings("unchecked") + Map state = (Map) nested.get("m_state"); + assertEquals(101, state.get("stateID")); + assertEquals("Karnataka", state.get("stateName")); + assertEquals("KA", state.get("stateCode")); + assertEquals(1, state.get("countryID")); + + @SuppressWarnings("unchecked") + Map district = (Map) nested.get("m_district"); + assertEquals(201, district.get("districtID")); + assertEquals("Bengaluru", district.get("districtName")); + assertEquals(101, district.get("stateID")); + + @SuppressWarnings("unchecked") + Map block = (Map) nested.get("m_districtblock"); + assertEquals(301, block.get("blockID")); + assertEquals("North", block.get("blockName")); + assertEquals(201, block.get("districtID")); + + @SuppressWarnings("unchecked") + Map branch = (Map) nested.get("m_districtbranchmapping"); + assertEquals(401, branch.get("districtBranchID")); + assertEquals("Yelahanka", branch.get("villageName")); + assertEquals("560064", branch.get("pinCode")); + } + + @Test + @DisplayName("a hit with no demographics omits the nested block rather than publishing an empty one") + void hitWithNoDemographicsOmitsTheNestedBlock() { + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(indexDocument()))); + + assertFalse(beneficiary.containsKey("i_bendemographics")); + } + + @Test + @DisplayName("phone numbers are rebuilt as phone maps with a nested relationship object") + void phoneNumbersAreRebuiltAsPhoneMaps() { + Map phone = new HashMap<>(); + phone.put("phoneNo", "9000000000"); + phone.put("benRelationshipID", 1); + phone.put("benRelationshipType", "Self"); + Map document = indexDocument(); + document.put("phoneNumbers", Collections.singletonList(phone)); + + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(document))); + + @SuppressWarnings("unchecked") + List> phoneMaps = (List>) beneficiary.get("benPhoneMaps"); + assertEquals(1, phoneMaps.size()); + assertEquals("9000000000", phoneMaps.get(0).get("phoneNo")); + assertEquals(100200300L, phoneMaps.get(0).get("benificiaryRegID")); + + @SuppressWarnings("unchecked") + Map relation = (Map) phoneMaps.get(0).get("benRelationshipType"); + assertEquals(1, relation.get("benRelationshipID")); + assertEquals("Self", relation.get("benRelationshipType")); + } + + @Test + @DisplayName("a hit with no phone numbers gets an empty phone-map list") + void hitWithNoPhoneNumbersGetsAnEmptyList() { + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(indexDocument()))); + + assertTrue(((List) beneficiary.get("benPhoneMaps")).isEmpty()); + } + + @Test + @DisplayName("an empty phone-number list also yields an empty phone-map list") + void emptyPhoneNumberListYieldsAnEmptyList() { + Map document = indexDocument(); + document.put("phoneNumbers", new ArrayList<>()); + + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(document))); + + assertTrue(((List) beneficiary.get("benPhoneMaps")).isEmpty()); + } + + @Test + @DisplayName("the change-tracking flags the edit screen posts back all start false") + void changeTrackingFlagsAllStartFalse() { + // The screen posts this object back on save; a flag left true would + // trigger a spurious update of that section. + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(indexDocument()))); + + for (String flag : new String[] { "isConsent", "changeInSelfDetails", "changeInAddress", "changeInContacts", + "changeInIdentities", "changeInOtherDetails", "changeInFamilyDetails", "changeInAssociations", + "changeInBankDetails", "changeInBenImage", "is1097", "emergencyRegistration", "passToNurse" }) { + assertEquals(false, beneficiary.get(flag), flag + " should default to false"); + } + assertTrue(((Map) beneficiary.get("m_title")).isEmpty()); + assertTrue(((Map) beneficiary.get("maritalStatus")).isEmpty()); + assertTrue(((List) beneficiary.get("beneficiaryIdentities")).isEmpty()); + } + + @Test + @DisplayName("a hit missing every field still produces a usable envelope") + void hitMissingEveryFieldStillProducesAUsableEnvelope() { + Map beneficiary = firstBeneficiary( + mapper.transformESResponse(Collections.singletonList(new HashMap<>()))); + + assertNull(beneficiary.get("firstName")); + assertEquals("Years", beneficiary.get("ageUnits")); + assertEquals("", beneficiary.get("fatherName")); + } +} diff --git a/src/test/java/com/iemr/common/identity/mapper/IdentityEditMapperTest.java b/src/test/java/com/iemr/common/identity/mapper/IdentityEditMapperTest.java new file mode 100644 index 00000000..2847df38 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/mapper/IdentityEditMapperTest.java @@ -0,0 +1,254 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigInteger; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.iemr.common.identity.domain.Address; +import com.iemr.common.identity.domain.Contact; +import com.iemr.common.identity.domain.Identity; +import com.iemr.common.identity.domain.MBeneficiaryaddress; +import com.iemr.common.identity.domain.MBeneficiaryconsent; +import com.iemr.common.identity.domain.MBeneficiarycontact; +import com.iemr.common.identity.domain.MBeneficiaryfamilymapping; +import com.iemr.common.identity.domain.MBeneficiaryidentity; +import com.iemr.common.identity.domain.MBeneficiarymapping; +import com.iemr.common.identity.dto.BenFamilyDTO; +import com.iemr.common.identity.dto.IdentityEditDTO; +import com.iemr.common.identity.fixture.PojoFixture; + +/** + * Field-level tests for the beneficiary-edit mapper. + * + *

+ * The edit flow flattens a nested {@link IdentityEditDTO} - three addresses, a + * contact block, identity and family lists - onto the wide {@code m_beneficiary*} + * tables, and the column each field lands in is not obvious from the names. These + * tests assert the mappings that a rename or a reordered {@code @Mapping} would + * silently break; the null-guard sweep lives in {@link MapperContractTest}. + */ +class IdentityEditMapperTest { + + private final IdentityEditMapper mapper = IdentityEditMapper.INSTANCE; + + private Address address(String line1, String city, Integer stateId) { + Address address = new Address(); + address.setAddrLine1(line1); + address.setAddrLine2(city); + address.setStateId(stateId); + address.setState("Karnataka"); + address.setDistrictId(200); + address.setDistrict("Bengaluru"); + address.setSubDistrictId(300); + address.setSubDistrict("North"); + address.setVillageId(400); + address.setVillage("Yelahanka"); + address.setPinCode("560064"); + address.setHabitation("Main"); + address.setAddressValue(line1 + ", " + city); + return address; + } + + @Test + @DisplayName("agent name and event date become the audit columns on the mapping row") + void mappingRowTakesAuditColumnsFromAgentFields() { + IdentityEditDTO dto = new IdentityEditDTO(); + dto.setAgentName("field.worker"); + dto.setEventTypeDate(Timestamp.valueOf("2026-03-01 10:15:00")); + dto.setBenAccountID(BigInteger.valueOf(77L)); + dto.setBenImageId(42L); + dto.setVanID(9); + + MBeneficiarymapping mapping = mapper.identityEditDTOToMBeneficiarymapping(dto); + + assertEquals("field.worker", mapping.getCreatedBy()); + assertEquals(Timestamp.valueOf("2026-03-01 10:15:00"), mapping.getLastModDate()); + assertEquals(BigInteger.valueOf(77L), mapping.getBenAccountID()); + assertEquals(BigInteger.valueOf(42L), mapping.getBenImageId()); + assertEquals(9, mapping.getVanID()); + } + + @Test + @DisplayName("a null benImageId leaves the image column unset instead of failing to convert") + void nullBenImageIdIsNotConverted() { + IdentityEditDTO dto = new IdentityEditDTO(); + dto.setAgentName("field.worker"); + + MBeneficiarymapping mapping = mapper.identityEditDTOToMBeneficiarymapping(dto); + + assertNull(mapping.getBenImageId()); + } + + @Nested + @DisplayName("address flattening") + class AddressFlattening { + + @Test + @DisplayName("current, permanent and emergency addresses land in their own column prefixes") + void eachAddressLandsInItsOwnPrefix() { + IdentityEditDTO dto = new IdentityEditDTO(); + dto.setCurrentAddress(address("1 Current St", "Bengaluru", 101)); + dto.setPermanentAddress(address("2 Permanent Rd", "Mysuru", 102)); + dto.setEmergencyAddress(address("3 Emergency Ln", "Hubballi", 103)); + + MBeneficiaryaddress mapped = mapper.identityEditDTOToMBeneficiaryaddress(dto); + + assertEquals("1 Current St", mapped.getCurrAddrLine1()); + assertEquals("2 Permanent Rd", mapped.getPermAddrLine1()); + assertEquals("3 Emergency Ln", mapped.getEmerAddrLine1()); + assertEquals(101, mapped.getCurrStateId()); + assertEquals(102, mapped.getPermStateId()); + assertEquals(103, mapped.getEmerStateId()); + assertEquals("560064", mapped.getCurrPinCode()); + assertEquals("Yelahanka", mapped.getEmerVillage()); + } + + @Test + @DisplayName("the mapper requires all three addresses to be present") + void missingNestedAddressIsRejected() { + // MBeneficiaryaddress.setCurrentAddress dereferences its argument + // without a null check and the generated mapper calls it + // unconditionally, so an edit payload that omits an address block + // fails here rather than persisting a half-filled row. Callers in + // IdentityService populate all three before mapping. + IdentityEditDTO dto = new IdentityEditDTO(); + dto.setFirstName("Asha"); + + assertThrows(NullPointerException.class, () -> mapper.identityEditDTOToMBeneficiaryaddress(dto)); + } + } + + @Test + @DisplayName("consent defaults are applied per share-scope rather than copied wholesale") + void consentDefaultsAreAppliedPerScope() { + IdentityEditDTO dto = new IdentityEditDTO(); + dto.setAgentName("field.worker"); + + MBeneficiaryconsent consent = mapper.identityEditDTOToDefaultMBeneficiaryconsent(dto, Boolean.TRUE, + Boolean.FALSE); + + assertNotNull(consent); + assertTrue(consent.getShareMedicalDetailsWithDoctor()); + assertTrue(consent.getSharePersonalDetailsWithSpouse()); + assertFalse(consent.getShareAnonymousWithGovt()); + assertFalse(consent.getSharePersonalDetailsForMedicalStudy()); + } + + @Test + @DisplayName("contact numbers are copied into the numbered phone columns") + void contactNumbersPopulateNumberedColumns() { + IdentityEditDTO dto = new IdentityEditDTO(); + Contact contact = new Contact(); + contact.setPreferredPhoneNum("9000000001"); + contact.setPhoneNum1("9000000002"); + contact.setPhoneNum2("9000000003"); + contact.setEmergencyContactNum("9000000004"); + dto.setContact(contact); + dto.setPreferredEmailId("asha@example.org"); + + MBeneficiarycontact mapped = mapper.identityEdiDTOToMBeneficiarycontact(dto); + + assertEquals("9000000001", mapped.getPreferredPhoneNum()); + assertEquals("9000000002", mapped.getPhoneNum1()); + assertEquals("9000000003", mapped.getPhoneNum2()); + assertEquals("9000000004", mapped.getEmergencyContactNum()); + assertEquals("asha@example.org", mapped.getEmailId()); + } + + @Test + @DisplayName("family mapping carries the supplied audit fields, not the DTO's own") + void familyMappingUsesSuppliedAuditFields() { + BenFamilyDTO family = new BenFamilyDTO(); + family.setAssociatedBenRegId(BigInteger.valueOf(555L)); + Timestamp created = Timestamp.valueOf("2026-01-02 03:04:05"); + + MBeneficiaryfamilymapping mapped = mapper.identityEditDTOToMBeneficiaryfamilymapping(family, "supervisor", + created); + + assertEquals(BigInteger.valueOf(555L), mapped.getAssociatedBenRegId()); + assertEquals("supervisor", mapped.getCreatedBy()); + assertEquals(created, mapped.getCreatedDate()); + } + + @Test + @DisplayName("identity rows carry the supplied audit fields") + void identityRowUsesSuppliedAuditFields() { + Identity identity = new Identity(); + identity.setIdentityNo("ABHA-1234"); + identity.setIdentityName("ABHA"); + Timestamp created = Timestamp.valueOf("2026-01-02 03:04:05"); + + MBeneficiaryidentity mapped = mapper.identityToMBeneficiaryidentity(identity, "supervisor", created); + + assertEquals("ABHA-1234", mapped.getIdentityNo()); + assertEquals("supervisor", mapped.getCreatedBy()); + assertEquals(created, mapped.getCreatedDate()); + } + + @Test + @DisplayName("list mappings preserve order and size") + void listMappingsPreserveOrderAndSize() { + BenFamilyDTO first = new BenFamilyDTO(); + first.setAssociatedBenRegId(BigInteger.ONE); + BenFamilyDTO second = new BenFamilyDTO(); + second.setAssociatedBenRegId(BigInteger.TEN); + + List mapped = mapper + .identityEditDTOListToMBeneficiaryfamilymappingList(Arrays.asList(first, second)); + + assertEquals(2, mapped.size()); + assertEquals(BigInteger.ONE, mapped.get(0).getAssociatedBenRegId()); + assertEquals(BigInteger.TEN, mapped.get(1).getAssociatedBenRegId()); + } + + @Test + @DisplayName("an empty list maps to an empty list, not null") + void emptyListMapsToEmptyList() { + assertTrue(mapper.identityEditDTOListToMBeneficiaryfamilymappingList(Collections.emptyList()).isEmpty()); + assertTrue(mapper.identityEditDTOListToMBeneficiaryidentityList(Collections.emptyList()).isEmpty()); + assertTrue(mapper.mBeneficiaryfamilymappingListToBenFamilyDTOList(Collections.emptyList()).isEmpty()); + } + + @Test + @DisplayName("account and image mappings accept a list of edit payloads") + void accountAndImageListMappings() { + IdentityEditDTO dto = PojoFixture.populate(IdentityEditDTO.class); + + assertEquals(1, mapper.identityEditDTOToMBeneficiaryAccount(Collections.singletonList(dto)).size()); + assertEquals(1, mapper.identityEditDTOToMBeneficiaryImage(Collections.singletonList(dto)).size()); + } +} diff --git a/src/test/java/com/iemr/common/identity/mapper/MapperContractTest.java b/src/test/java/com/iemr/common/identity/mapper/MapperContractTest.java new file mode 100644 index 00000000..8ce80db4 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/mapper/MapperContractTest.java @@ -0,0 +1,220 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.mapper; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.TestFactory; + +import com.iemr.common.identity.fixture.PojoFixture; + +/** + * Contract test for the generated MapStruct mappers. + * + *

+ * The mapper implementations are code-generated from the {@code @Mapping} + * declarations on the interfaces, so the risk they carry is not "is this line + * right" but "does a mapping declaration still resolve after the DTO or entity + * changed". Every generated method follows the same shape: + * + *

+ * if (all sources are null) return null;
+ * target = new Target();
+ * if (source.getX() != null) target.setX(...);
+ * 
+ * + * This test drives each mapper method three ways - fully populated sources, + * empty sources, and all-null sources - which walks both sides of every null + * guard and asserts the null contract holds. A mapping that stops resolving, + * or a nested source object the generator dereferences unguarded, shows up + * here as a failure rather than as a production NPE. + * + *

+ * Field-level mapping assertions live in the per-mapper tests alongside this + * one. + */ +class MapperContractTest { + + private static final List MAPPERS = Arrays.asList(IdentityMapper.INSTANCE, IdentityEditMapper.INSTANCE, + IdentitySearchMapper.INSTANCE, IdentityPartialMapper.INSTANCE, BenIdImportMapper.INSTANCE); + + /** + * Mapper methods that require their nested sources to be present and throw + * when they are not, because the generated code hands a possibly-null nested + * object to a setter that dereferences it without a guard. Their actual + * behaviour is pinned by the {@code assertThrows} tests in + * {@link IdentityEditMapperTest}, {@link IdentitySearchMapperTest} and + * {@link IdentityPartialMapperTest}, so the empty-source sweep skips them + * rather than restating it here. + */ + private static final Set REQUIRE_POPULATED_NESTED_SOURCES = new HashSet<>( + Arrays.asList("identityEditDTOToMBeneficiaryaddress", "identitySearchDTOToMBeneficiaryaddress", + "mBeneficiarymappingToBeneficiariesPartialDTO")); + + @TestFactory + @SuppressWarnings("unchecked") + List everyMapperMethodHonoursTheNullContract() { + List tests = new ArrayList<>(); + for (Object mapper : MAPPERS) { + for (Method method : mappingMethods(mapper)) { + tests.add(dynamicTest(mapper.getClass().getSimpleName() + "." + method.getName(), + () -> assertMappingContract(mapper, method))); + } + } + assertNotNull(tests); + return tests; + } + + private void assertMappingContract(Object mapper, Method method) throws Exception { + Object populatedResult = invoke(mapper, method, arguments(method, ArgumentStyle.POPULATED)); + assertNotNull(populatedResult, method.getName() + " returned null for fully populated sources"); + + if (!REQUIRE_POPULATED_NESTED_SOURCES.contains(method.getName())) { + Object blankResult = invoke(mapper, method, arguments(method, ArgumentStyle.BLANK)); + assertNotNull(blankResult, method.getName() + " returned null for empty (but non-null) sources"); + } + + Object nullResult = invoke(mapper, method, arguments(method, ArgumentStyle.NULL)); + assertNull(nullResult, method.getName() + " should return null when every source is null"); + } + + /** + * A populated source must produce a target that actually carries values, not + * just a non-null shell - this is what catches a mapping that silently stopped + * resolving. + */ + @TestFactory + List populatedSourcesProduceNonEmptyTargets() { + List tests = new ArrayList<>(); + for (Object mapper : MAPPERS) { + for (Method method : mappingMethods(mapper)) { + tests.add(dynamicTest(mapper.getClass().getSimpleName() + "." + method.getName(), () -> { + Object result = invoke(mapper, method, arguments(method, ArgumentStyle.POPULATED)); + assertNotNull(result); + assertCarriesValues(method, result); + })); + } + } + return tests; + } + + private void assertCarriesValues(Method method, Object result) { + if (result instanceof Collection) { + Collection collection = (Collection) result; + org.junit.jupiter.api.Assertions.assertFalse(collection.isEmpty(), + method.getName() + " mapped a populated list to an empty list"); + collection.forEach(element -> assertNotNull(element, method.getName() + " mapped an element to null")); + return; + } + if (result instanceof Map) { + return; + } + int populatedProperties = 0; + for (Method getter : result.getClass().getMethods()) { + if (getter.getParameterCount() != 0 || Modifier.isStatic(getter.getModifiers()) + || getter.getDeclaringClass() == Object.class + || !(getter.getName().startsWith("get") || getter.getName().startsWith("is"))) { + continue; + } + try { + if (getter.invoke(result) != null) { + populatedProperties++; + } + } catch (ReflectiveOperationException | RuntimeException e) { + // Derived getter; not part of the mapping surface. + } + } + org.junit.jupiter.api.Assertions.assertTrue(populatedProperties > 0, + method.getName() + " produced a target with no populated properties"); + } + + private Object invoke(Object mapper, Method method, Object[] args) throws Exception { + try { + return method.invoke(mapper, args); + } catch (InvocationTargetException e) { + throw e.getCause() instanceof Exception ? (Exception) e.getCause() : e; + } + } + + private enum ArgumentStyle { + POPULATED, BLANK, NULL + } + + private Object[] arguments(Method method, ArgumentStyle style) { + Class[] types = method.getParameterTypes(); + Object[] args = new Object[types.length]; + for (int i = 0; i < types.length; i++) { + if (style == ArgumentStyle.POPULATED) { + args[i] = PojoFixture.argument(types[i], method.getGenericParameterTypes()[i]); + } else if (style == ArgumentStyle.BLANK) { + args[i] = blankArgument(types[i]); + } else { + args[i] = null; + } + } + if (style == ArgumentStyle.BLANK && Arrays.stream(args).allMatch(java.util.Objects::isNull)) { + // Nothing non-null to pass, so the null contract already covers this + // method; reuse the populated arguments rather than asserting twice. + return arguments(method, ArgumentStyle.POPULATED); + } + return args; + } + + private Object blankArgument(Class type) { + if (Collection.class.isAssignableFrom(type)) { + return new ArrayList<>(); + } + if (type.getName().startsWith("com.iemr") && !type.isInterface() && !type.isEnum() + && !Modifier.isAbstract(type.getModifiers())) { + return PojoFixture.blank(type); + } + return null; + } + + private List mappingMethods(Object mapper) { + List methods = new ArrayList<>(); + for (Method method : mapper.getClass().getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers()) && !Modifier.isStatic(method.getModifiers()) + && method.getParameterCount() > 0 && method.getReturnType() != void.class + && !method.isSynthetic()) { + methods.add(method); + } + } + methods.sort(Comparator.comparing(Method::getName).thenComparing(m -> m.getParameterTypes()[0].getName())); + return methods; + } +} diff --git a/src/test/java/com/iemr/common/identity/repo/BenDetailRepoImplTest.java b/src/test/java/com/iemr/common/identity/repo/BenDetailRepoImplTest.java new file mode 100644 index 00000000..98f97df5 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/repo/BenDetailRepoImplTest.java @@ -0,0 +1,161 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.repo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.identity.domain.MBeneficiarydetail; +import com.iemr.common.identity.dto.IdentitySearchDTO; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; + +/** + * Tests for the advance-search query over the beneficiary-detail table. + * + *

+ * As with the mapping search, the risk is a supplied criterion that never + * reaches the WHERE clause and quietly widens the result set, so the tests + * assert the predicate count for each combination. The Criteria API is mocked, + * so attribute names are not validated against the entity metamodel. + */ +class BenDetailRepoImplTest { + + private EntityManager entityManager; + private CriteriaBuilder criteriaBuilder; + private BenDetailRepoImpl repo; + + @BeforeEach + void setUp() { + entityManager = mock(EntityManager.class, RETURNS_DEEP_STUBS); + criteriaBuilder = entityManager.getCriteriaBuilder(); + repo = new BenDetailRepoImpl(); + ReflectionTestUtils.setField(repo, "entityManager", entityManager); + } + + @SuppressWarnings("unchecked") + private Predicate[] capturedPredicates() { + CriteriaQuery query = criteriaBuilder.createQuery(MBeneficiarydetail.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(Predicate[].class); + verify(query.select(any())).where(captor.capture()); + return captor.getValue(); + } + + @Test + @DisplayName("a search with no criteria produces no predicates") + void searchWithNoCriteriaProducesNoPredicates() { + repo.advanceFilterSearch(new IdentitySearchDTO()); + + assertEquals(0, capturedPredicates().length); + } + + @Test + @DisplayName("each name criterion contributes one predicate") + void eachNameCriterionContributesOnePredicate() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setFirstName("Asha"); + searchDTO.setMiddleName("Rani"); + searchDTO.setLastName("Devi"); + searchDTO.setSpouseName("Suresh"); + searchDTO.setFatherName("Ram"); + + repo.advanceFilterSearch(searchDTO); + + assertEquals(5, capturedPredicates().length); + } + + @Test + @DisplayName("each age and gender criterion contributes one predicate") + void eachAgeAndGenderCriterionContributesOnePredicate() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setAgeId(3); + searchDTO.setAge(30); + searchDTO.setGenderId(2); + searchDTO.setGenderName("Female"); + + repo.advanceFilterSearch(searchDTO); + + assertEquals(4, capturedPredicates().length); + } + + @Test + @DisplayName("a pin code contributes one predicate") + void pinCodeContributesOnePredicate() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setPinCode("560064"); + + repo.advanceFilterSearch(searchDTO); + + assertEquals(1, capturedPredicates().length); + } + + @Test + @DisplayName("every criterion together produces one predicate each") + void everyCriterionTogetherProducesOnePredicateEach() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setFirstName("Asha"); + searchDTO.setMiddleName("Rani"); + searchDTO.setLastName("Devi"); + searchDTO.setAgeId(3); + searchDTO.setAge(30); + searchDTO.setGenderId(2); + searchDTO.setGenderName("Female"); + searchDTO.setSpouseName("Suresh"); + searchDTO.setFatherName("Ram"); + searchDTO.setPinCode("560064"); + + repo.advanceFilterSearch(searchDTO); + + assertEquals(10, capturedPredicates().length); + } + + @Test + @DisplayName("the matching rows are returned from the executed query") + void matchingRowsAreReturned() { + List rows = Collections.singletonList(new MBeneficiarydetail()); + CriteriaQuery query = criteriaBuilder.createQuery(MBeneficiarydetail.class); + @SuppressWarnings("unchecked") + TypedQuery typedQuery = mock(TypedQuery.class); + when(entityManager.createQuery(query)).thenReturn(typedQuery); + when(typedQuery.getResultList()).thenReturn(rows); + + assertSame(rows, repo.advanceFilterSearch(new IdentitySearchDTO())); + } +} diff --git a/src/test/java/com/iemr/common/identity/repo/BenMappingRepoImplTest.java b/src/test/java/com/iemr/common/identity/repo/BenMappingRepoImplTest.java new file mode 100644 index 00000000..e00b0457 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/repo/BenMappingRepoImplTest.java @@ -0,0 +1,402 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.repo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Timestamp; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.identity.domain.Address; +import com.iemr.common.identity.domain.Contact; +import com.iemr.common.identity.domain.MBeneficiarymapping; +import com.iemr.common.identity.domain.VBenAdvanceSearch; +import com.iemr.common.identity.dto.IdentityDTO; +import com.iemr.common.identity.dto.IdentitySearchDTO; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +/** + * Tests for the dynamic beneficiary-search queries. + * + *

+ * These build a Criteria query one optional predicate at a time. The failure + * mode is not a crash but a missing predicate: a criterion the caller supplied + * that never makes it into the WHERE clause silently widens the search, which + * on this dataset means returning other people's records to a call-centre + * agent. So the tests assert the number of predicates each combination + * of criteria produces, and that the joins the WHERE clause depends on are + * added. + * + *

+ * The Criteria API is driven through mocks, so these tests do not verify that + * the attribute names passed to {@code get(...)} exist on the entities - only + * an integration test against a real metamodel can do that. + */ +class BenMappingRepoImplTest { + + private EntityManager entityManager; + private CriteriaBuilder criteriaBuilder; + private BenMappingRepoImpl repo; + + @BeforeEach + void setUp() { + entityManager = mock(EntityManager.class, RETURNS_DEEP_STUBS); + criteriaBuilder = entityManager.getCriteriaBuilder(); + repo = new BenMappingRepoImpl(); + ReflectionTestUtils.setField(repo, "entityManager", entityManager); + } + + /** Captures the predicates handed to the WHERE clause of a mapping query. */ + @SuppressWarnings("unchecked") + private Predicate[] mappingPredicates() { + CriteriaQuery query = criteriaBuilder.createQuery(MBeneficiarymapping.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(Predicate[].class); + verify(query.select(any())).where(captor.capture()); + return captor.getValue(); + } + + /** Captures the predicates handed to the WHERE clause of an advance-search query. */ + @SuppressWarnings("unchecked") + private Predicate[] advanceSearchPredicates() { + CriteriaQuery query = criteriaBuilder.createQuery(VBenAdvanceSearch.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(Predicate[].class); + verify(query.select(any())).where(captor.capture()); + return captor.getValue(); + } + + private Address address() { + Address address = new Address(); + address.setPinCode("560064"); + address.setStateId(101); + address.setState("Karnataka"); + address.setDistrictId(201); + address.setDistrict("Bengaluru"); + address.setSubDistrictId(301); + address.setSubDistrict("North"); + address.setVillageId(401); + address.setVillage("Yelahanka"); + address.setAddrLine1("1 Main Rd"); + return address; + } + + @Nested + @DisplayName("advance search over the search view") + class AdvanceSearch { + + @Test + @DisplayName("a search with no criteria produces no predicates") + void searchWithNoCriteriaProducesNoPredicates() { + repo.dynamicFilterSearchNew(new IdentitySearchDTO()); + + assertEquals(0, advanceSearchPredicates().length); + } + + @Test + @DisplayName("each supplied name and gender criterion contributes one predicate") + void eachNameAndGenderCriterionContributesOnePredicate() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setFirstName("Asha"); + searchDTO.setMiddleName("Rani"); + searchDTO.setLastName("Devi"); + searchDTO.setGenderId(2); + searchDTO.setFatherName("Ram"); + + repo.dynamicFilterSearchNew(searchDTO); + + assertEquals(5, advanceSearchPredicates().length); + } + + @Test + @DisplayName("each address level supplied contributes one predicate") + void eachAddressLevelContributesOnePredicate() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setCurrentAddress(address()); + + repo.dynamicFilterSearchNew(searchDTO); + + // state, district, sub-district and village + assertEquals(4, advanceSearchPredicates().length); + } + + @Test + @DisplayName("an address with no location set contributes no predicates") + void addressWithNoLocationContributesNoPredicates() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setCurrentAddress(new Address()); + + repo.dynamicFilterSearchNew(searchDTO); + + assertEquals(0, advanceSearchPredicates().length); + } + + @Test + @DisplayName("a date of birth is matched as a whole day, not an instant") + void dateOfBirthIsMatchedAsAWholeDay() { + // The column is a timestamp, so an equality match would only find + // beneficiaries recorded at exactly midnight. + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setDob(Timestamp.valueOf("1996-01-15 00:00:00")); + + repo.dynamicFilterSearchNew(searchDTO); + + assertEquals(2, advanceSearchPredicates().length); + verify(criteriaBuilder).greaterThanOrEqualTo(any(), any(Timestamp.class)); + ArgumentCaptor upperBound = ArgumentCaptor.forClass(Timestamp.class); + verify(criteriaBuilder).lessThan(any(), upperBound.capture()); + assertEquals(Timestamp.valueOf("1996-01-16 00:00:00"), upperBound.getValue()); + } + + @Test + @DisplayName("a household id contributes one predicate") + void householdIdContributesOnePredicate() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setHouseHoldID(555L); + + repo.dynamicFilterSearchNew(searchDTO); + + assertEquals(1, advanceSearchPredicates().length); + } + + @Test + @DisplayName("every criterion together produces one predicate each") + void everyCriterionTogetherProducesOnePredicateEach() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setFirstName("Asha"); + searchDTO.setMiddleName("Rani"); + searchDTO.setLastName("Devi"); + searchDTO.setGenderId(2); + searchDTO.setFatherName("Ram"); + searchDTO.setDob(Timestamp.valueOf("1996-01-15 00:00:00")); + searchDTO.setHouseHoldID(555L); + searchDTO.setCurrentAddress(address()); + + repo.dynamicFilterSearchNew(searchDTO); + + // 5 name/gender/father + 4 address levels + 2 date bounds + household + assertEquals(12, advanceSearchPredicates().length); + } + + @Test + @DisplayName("the matching rows are returned from the executed query") + void matchingRowsAreReturned() { + List rows = Collections.singletonList(new VBenAdvanceSearch()); + CriteriaQuery query = criteriaBuilder.createQuery(VBenAdvanceSearch.class); + TypedQuery typedQuery = mock(TypedQuery.class); + when(entityManager.createQuery(query)).thenReturn(typedQuery); + when(typedQuery.getResultList()).thenReturn(rows); + + assertSame(rows, repo.dynamicFilterSearchNew(new IdentitySearchDTO())); + } + } + + @Nested + @DisplayName("dynamic filter search over the mapping table") + class DynamicFilterSearch { + + @Test + @DisplayName("the detail and address tables are joined so their columns can be filtered") + void detailAndAddressTablesAreJoined() { + CriteriaQuery query = criteriaBuilder.createQuery(MBeneficiarymapping.class); + Root root = query.from(MBeneficiarymapping.class); + + repo.dynamicFilterSearch(new IdentitySearchDTO()); + + verify(root).join("mBeneficiarydetail", JoinType.INNER); + verify(root).join("mBeneficiaryaddress", JoinType.INNER); + } + + @Test + @DisplayName("a search with no criteria produces no predicates") + void searchWithNoCriteriaProducesNoPredicates() { + repo.dynamicFilterSearch(new IdentitySearchDTO()); + + assertEquals(0, mappingPredicates().length); + } + + @Test + @DisplayName("each beneficiary-detail criterion contributes one predicate") + void eachDetailCriterionContributesOnePredicate() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setFirstName("Asha"); + searchDTO.setMiddleName("Rani"); + searchDTO.setLastName("Devi"); + searchDTO.setGenderId(2); + searchDTO.setGenderName("Female"); + searchDTO.setSpouseName("Suresh"); + searchDTO.setFatherName("Ram"); + + repo.dynamicFilterSearch(searchDTO); + + assertEquals(7, mappingPredicates().length); + } + + @Test + @DisplayName("each address criterion contributes one predicate") + void eachAddressCriterionContributesOnePredicate() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setPinCode("560064"); + searchDTO.setCurrentAddress(address()); + + repo.dynamicFilterSearch(searchDTO); + + // pin code plus district id, district, state id and state + assertEquals(5, mappingPredicates().length); + } + + @Test + @DisplayName("results are ordered by mapping id so paging is stable") + void resultsAreOrderedByMappingId() { + CriteriaQuery query = criteriaBuilder.createQuery(MBeneficiarymapping.class); + + repo.dynamicFilterSearch(new IdentitySearchDTO()); + + verify(query.select(any()).where(new Predicate[] {})).orderBy(any(jakarta.persistence.criteria.Order.class)); + } + + @Test + @DisplayName("the matching rows are returned from the executed query") + void matchingRowsAreReturned() { + List rows = Collections.singletonList(new MBeneficiarymapping()); + CriteriaQuery query = criteriaBuilder.createQuery(MBeneficiarymapping.class); + TypedQuery typedQuery = mock(TypedQuery.class); + when(entityManager.createQuery(query)).thenReturn(typedQuery); + when(typedQuery.getResultList()).thenReturn(rows); + + assertSame(rows, repo.dynamicFilterSearch(new IdentitySearchDTO())); + } + } + + @Nested + @DisplayName("finite search for identifier generation") + class FiniteSearch { + + @Test + @DisplayName("a search with no criteria produces no predicates") + void searchWithNoCriteriaProducesNoPredicates() { + repo.finiteSearch(new IdentityDTO()); + + assertEquals(0, mappingPredicates().length); + } + + @Test + @DisplayName("each beneficiary-detail criterion contributes one predicate") + void eachDetailCriterionContributesOnePredicate() { + IdentityDTO identityDTO = new IdentityDTO(); + identityDTO.setFirstName("Asha"); + identityDTO.setMiddleName("Rani"); + identityDTO.setLastName("Devi"); + identityDTO.setGenderId(2); + identityDTO.setGender("Female"); + identityDTO.setSpouseName("Suresh"); + identityDTO.setFatherName("Ram"); + identityDTO.setCommunity("General"); + + repo.finiteSearch(identityDTO); + + assertEquals(8, mappingPredicates().length); + } + + @Test + @DisplayName("every level of the current address contributes one predicate") + void everyAddressLevelContributesOnePredicate() { + IdentityDTO identityDTO = new IdentityDTO(); + identityDTO.setCurrentAddress(address()); + + repo.finiteSearch(identityDTO); + + // pin code, district id, district, state id, state, address line 1, + // sub-district id, sub-district, village id and village + assertEquals(10, mappingPredicates().length); + } + + @Test + @DisplayName("an absent address contributes no predicates") + void absentAddressContributesNoPredicates() { + IdentityDTO identityDTO = new IdentityDTO(); + identityDTO.setFirstName("Asha"); + + repo.finiteSearch(identityDTO); + + assertEquals(1, mappingPredicates().length); + } + + @Test + @DisplayName("a preferred phone number contributes one predicate") + void preferredPhoneNumberContributesOnePredicate() { + IdentityDTO identityDTO = new IdentityDTO(); + Contact contact = new Contact(); + contact.setPreferredPhoneNum("9000000000"); + identityDTO.setContact(contact); + + repo.finiteSearch(identityDTO); + + assertEquals(1, mappingPredicates().length); + } + + @Test + @DisplayName("a contact with no preferred number contributes no predicates") + void contactWithNoPreferredNumberContributesNoPredicates() { + IdentityDTO identityDTO = new IdentityDTO(); + identityDTO.setContact(new Contact()); + + repo.finiteSearch(identityDTO); + + assertEquals(0, mappingPredicates().length); + } + + @Test + @DisplayName("the matching rows are returned from the executed query") + void matchingRowsAreReturned() { + List rows = Collections.singletonList(new MBeneficiarymapping()); + CriteriaQuery query = criteriaBuilder.createQuery(MBeneficiarymapping.class); + TypedQuery typedQuery = mock(TypedQuery.class); + when(entityManager.createQuery(query)).thenReturn(typedQuery); + when(typedQuery.getResultList()).thenReturn(rows); + + assertSame(rows, repo.finiteSearch(new IdentityDTO())); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/service/IdentityServiceTest.java b/src/test/java/com/iemr/common/identity/service/IdentityServiceTest.java new file mode 100644 index 00000000..271dec3e --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/IdentityServiceTest.java @@ -0,0 +1,1603 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.identity.data.rmnch.RMNCHBeneficiaryDetailsRmnch; +import com.iemr.common.identity.domain.Address; +import com.iemr.common.identity.domain.Contact; +import com.iemr.common.identity.domain.MBeneficiaryAccount; +import com.iemr.common.identity.domain.MBeneficiaryImage; +import com.iemr.common.identity.domain.MBeneficiaryaddress; +import com.iemr.common.identity.domain.MBeneficiaryconsent; +import com.iemr.common.identity.domain.MBeneficiarycontact; +import com.iemr.common.identity.domain.MBeneficiarydetail; +import com.iemr.common.identity.domain.MBeneficiaryfamilymapping; +import com.iemr.common.identity.domain.MBeneficiaryidentity; +import com.iemr.common.identity.domain.MBeneficiarymapping; +import com.iemr.common.identity.domain.MBeneficiaryregidmapping; +import com.iemr.common.identity.domain.MBeneficiaryservicemapping; +import com.iemr.common.identity.domain.VBenAdvanceSearch; +import com.iemr.common.identity.dto.BenFamilyDTO; +import com.iemr.common.identity.dto.BenIdImportDTO; +import com.iemr.common.identity.dto.BeneficiariesDTO; +import com.iemr.common.identity.dto.BeneficiaryCreateResp; +import com.iemr.common.identity.dto.IdentityDTO; +import com.iemr.common.identity.dto.IdentityEditDTO; +import com.iemr.common.identity.dto.IdentitySearchDTO; +import com.iemr.common.identity.dto.ReserveIdentityDTO; +import com.iemr.common.identity.exception.MissingMandatoryFieldsException; +import com.iemr.common.identity.mapper.BenIdImportMapper; +import com.iemr.common.identity.mapper.IdentityEditMapper; +import com.iemr.common.identity.mapper.IdentityMapper; +import com.iemr.common.identity.mapper.IdentityPartialMapper; +import com.iemr.common.identity.mapper.IdentitySearchMapper; +import com.iemr.common.identity.repo.BenAddressRepo; +import com.iemr.common.identity.repo.BenConsentRepo; +import com.iemr.common.identity.repo.BenContactRepo; +import com.iemr.common.identity.repo.BenDataAccessRepo; +import com.iemr.common.identity.repo.BenDetailRepo; +import com.iemr.common.identity.repo.BenFamilyMappingRepo; +import com.iemr.common.identity.repo.BenIdentityRepo; +import com.iemr.common.identity.repo.BenMappingRepo; +import com.iemr.common.identity.repo.BenRegIdMappingRepo; +import com.iemr.common.identity.repo.BenServiceMappingRepo; +import com.iemr.common.identity.repo.MBeneficiaryAccountRepo; +import com.iemr.common.identity.repo.MBeneficiaryImageRepo; +import com.iemr.common.identity.repo.V_BenAdvanceSearchRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHBeneficiaryDetailsRmnchRepo; +import com.iemr.common.identity.service.elasticsearch.BeneficiaryElasticsearchIndexUpdater; +import com.iemr.common.identity.service.elasticsearch.ElasticsearchService; + +/** + * Tests for the core beneficiary identity service. + * + *

+ * The service sits on a schema split across nine {@code m_beneficiary*} tables + * that are stitched together by a (vanSerialNo, vanID) pair rather than by + * foreign keys, and every read path assembles a beneficiary from a positional + * {@code Object[]} projection. The behaviour worth protecting is therefore the + * assembly and the branching around it: which search key wins, when a row is + * skipped, when Elasticsearch is consulted, and when a sync is triggered. + * + *

+ * The real MapStruct mappers are injected rather than mocked - they are + * generated, deterministic, and stubbing their several hundred field copies + * would leave the assembly logic untested. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class IdentityServiceTest { + + @Mock + private javax.sql.DataSource dataSource; + @Mock + private RMNCHBeneficiaryDetailsRmnchRepo rmnchRepo; + @Mock + private ElasticsearchService elasticsearchService; + @Mock + private BeneficiaryElasticsearchIndexUpdater syncService; + @Mock + private BenAddressRepo addressRepo; + @Mock + private BenConsentRepo consentRepo; + @Mock + private BenContactRepo contactRepo; + @Mock + private BenDataAccessRepo accessRepo; + @Mock + private BenDetailRepo detailRepo; + @Mock + private BenFamilyMappingRepo familyMapRepo; + @Mock + private BenIdentityRepo identityRepo; + @Mock + private BenMappingRepo mappingRepo; + @Mock + private BenRegIdMappingRepo regIdRepo; + @Mock + private BenRegIdClaimService benRegIdClaimService; + @Mock + private BenServiceMappingRepo serviceMapRepo; + @Mock + private MBeneficiaryAccountRepo accountRepo; + @Mock + private MBeneficiaryImageRepo imageRepo; + @Mock + private V_BenAdvanceSearchRepo advanceSearchRepo; + + @InjectMocks + private IdentityService service; + + /** The (vanSerialNo, vanID) pair every row lookup is keyed on. */ + private static final Integer VAN_ID = 7; + private static final BigInteger BEN_REG_ID = BigInteger.valueOf(100200300L); + private static final BigInteger BEN_ID = BigInteger.valueOf(4001L); + + @BeforeEach + void injectRealMappers() { + ReflectionTestUtils.setField(service, "mapper", IdentityMapper.INSTANCE); + ReflectionTestUtils.setField(service, "editMapper", IdentityEditMapper.INSTANCE); + ReflectionTestUtils.setField(service, "searchMapper", IdentitySearchMapper.INSTANCE); + ReflectionTestUtils.setField(service, "partialMapper", IdentityPartialMapper.INSTANCE); + ReflectionTestUtils.setField(service, "benIdImportMapper", BenIdImportMapper.INSTANCE); + ReflectionTestUtils.setField(service, "rMNCHBeneficiaryDetailsRmnchRepo", rmnchRepo); + ReflectionTestUtils.setField(service, "v_BenAdvanceSearchRepo", advanceSearchRepo); + ReflectionTestUtils.setField(service, "esEnabled", false); + } + + /** + * The 12-column projection {@code getBenMappingByRegID} and friends return. + * Positions 8 (vanID) and 9 (benMapId) are the ones the assembly checks. + */ + private Object[] projectionRow() { + Object[] row = new Object[12]; + row[0] = BigInteger.valueOf(1L); // benMapId + row[1] = BigInteger.valueOf(2L); // benAddressId + row[2] = BigInteger.valueOf(3L); // benConsentId + row[3] = BigInteger.valueOf(4L); // benContactsId + row[4] = BigInteger.valueOf(5L); // benDetailsId + row[5] = BEN_REG_ID; // benRegId + row[6] = BigInteger.valueOf(6L); // benImageId + row[7] = BigInteger.valueOf(7L); // benAccountId + row[8] = VAN_ID; + row[9] = BigInteger.valueOf(9L); // vanSerialNo + row[10] = "field.worker"; // createdBy + row[11] = Timestamp.valueOf("2026-01-01 00:00:00"); + return row; + } + + /** Stubs the nine per-table lookups the assembly performs for one row. */ + private MBeneficiarymapping stubRowAssembly() { + MBeneficiarymapping mapping = new MBeneficiarymapping(); + mapping.setBenMapId(BigInteger.ONE); + mapping.setBenRegId(BEN_REG_ID); + mapping.setVanID(VAN_ID); + + MBeneficiarydetail detail = new MBeneficiarydetail(); + detail.setFirstName("Asha"); + detail.setLastName("Devi"); + detail.setGenderId(2); + detail.setBeneficiaryDetailsId(BigInteger.valueOf(5L)); + detail.setVanID(VAN_ID); + + MBeneficiaryregidmapping regId = new MBeneficiaryregidmapping(); + regId.setBenRegId(BEN_REG_ID); + regId.setBeneficiaryID(BEN_ID); + + when(mappingRepo.getWithVanSerialNoVanID(any(), anyInt())).thenReturn(mapping); + when(addressRepo.getWithVanSerialNoVanID(any(), anyInt())).thenReturn(new MBeneficiaryaddress()); + when(consentRepo.getWithVanSerialNoVanID(any(), anyInt())).thenReturn(new MBeneficiaryconsent()); + when(contactRepo.getWithVanSerialNoVanID(any(), anyInt())).thenReturn(new MBeneficiarycontact()); + when(detailRepo.getWith_vanSerialNo_vanID(any(), anyInt())).thenReturn(detail); + when(regIdRepo.getWithVanSerialNoVanID(any(), anyInt())).thenReturn(regId); + when(accountRepo.getWithVanSerialNoVanID(any(), anyInt())).thenReturn(new MBeneficiaryAccount()); + when(imageRepo.getWithVanSerialNoVanID(any(), anyInt())).thenReturn(new MBeneficiaryImage()); + return mapping; + } + + @Nested + @DisplayName("search key precedence") + class SearchKeyPrecedence { + + @Test + @DisplayName("a beneficiary ID short-circuits every other criterion") + void beneficiaryIdWinsOverEverythingElse() throws Exception { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setBeneficiaryId(BEN_ID); + searchDTO.setBeneficiaryRegId(BEN_REG_ID); + searchDTO.setContactNumber("9000000000"); + MBeneficiaryregidmapping regId = new MBeneficiaryregidmapping(); + regId.setBenRegId(BEN_REG_ID); + when(regIdRepo.findByBeneficiaryID(BEN_ID)).thenReturn(regId); + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + + service.getBeneficiaries(searchDTO); + + verify(regIdRepo).findByBeneficiaryID(BEN_ID); + verify(contactRepo, never()).findByAnyPhoneNum(any()); + verify(mappingRepo, never()).dynamicFilterSearchNew(any()); + } + + @Test + @DisplayName("a registration ID is used when no beneficiary ID is supplied") + void registrationIdIsSecondChoice() throws Exception { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setBeneficiaryRegId(BEN_REG_ID); + searchDTO.setContactNumber("9000000000"); + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + + service.getBeneficiaries(searchDTO); + + verify(mappingRepo).getBenMappingByRegID(BEN_REG_ID); + verify(contactRepo, never()).findByAnyPhoneNum(any()); + } + + @Test + @DisplayName("a phone number is used when no identifier is supplied") + void phoneNumberIsThirdChoice() throws Exception { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setContactNumber("9000000000"); + when(contactRepo.findByAnyPhoneNum(any())).thenReturn(Collections.emptyList()); + + service.getBeneficiaries(searchDTO); + + verify(contactRepo).findByAnyPhoneNum(any()); + verify(mappingRepo, never()).dynamicFilterSearchNew(any()); + } + + @Test + @DisplayName("with no identifier and no phone number the search falls through to the advance-search view") + void fallsThroughToAdvanceSearchView() throws Exception { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setFirstName("Asha"); + when(mappingRepo.dynamicFilterSearchNew(searchDTO)).thenReturn(Collections.emptyList()); + + assertTrue(service.getBeneficiaries(searchDTO).isEmpty()); + + verify(mappingRepo).dynamicFilterSearchNew(searchDTO); + } + } + + @Nested + @DisplayName("phone-number normalisation") + class PhoneNumberNormalisation { + + @ParameterizedTest + @CsvSource({ "+919000000000, 9000000000", "919000000000, 9000000000", "09000000000, 9000000000", + "9000000000, 9000000000", "' 9000000000 ', 9000000000" }) + @DisplayName("a stored number is looked up under every prefix form it might have been saved with") + void lookupCoversEveryPrefixForm(String supplied, String expectedBase) { + when(contactRepo.findByAnyPhoneNum(any())).thenReturn(Collections.emptyList()); + + service.getBeneficiariesByPhoneNum(supplied); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(contactRepo).findByAnyPhoneNum(captor.capture()); + assertEquals(Arrays.asList(expectedBase, "0" + expectedBase, "91" + expectedBase, "+91" + expectedBase), + captor.getValue()); + } + + @Test + @DisplayName("a 91-prefixed number that is not 12 digits long is left alone") + void shortNumberStartingWith91IsNotTruncated() { + when(contactRepo.findByAnyPhoneNum(any())).thenReturn(Collections.emptyList()); + + service.getBeneficiariesByPhoneNum("919000"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(contactRepo).findByAnyPhoneNum(captor.capture()); + assertEquals("919000", captor.getValue().get(0)); + } + + @Test + @DisplayName("a lookup failure yields no results rather than propagating") + void lookupFailureYieldsNoResults() { + // A search is a read-only convenience for the agent on the call; a + // dead contact table should not surface as a 500. + when(contactRepo.findByAnyPhoneNum(any())).thenThrow(new IllegalStateException("connection reset")); + + assertTrue(service.getBeneficiariesByPhoneNum("9000000000").isEmpty()); + } + } + + @Nested + @DisplayName("door-to-door filtering") + class DoorToDoorFiltering { + + private BeneficiariesDTO beneficiary(String firstName, String lastName, Integer genderId, Integer stateId) { + BeneficiariesDTO dto = new BeneficiariesDTO(); + com.iemr.common.identity.dto.BenDetailDTO details = new com.iemr.common.identity.dto.BenDetailDTO(); + details.setFirstName(firstName); + details.setLastName(lastName); + details.setGenderId(genderId); + dto.setBeneficiaryDetails(details); + Address address = new Address(); + address.setStateId(stateId); + dto.setCurrentAddress(address); + return dto; + } + + private IdentitySearchDTO d2dSearch() { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setContactNumber("9000000000"); + searchDTO.setIsD2D(Boolean.TRUE); + return searchDTO; + } + + private void stubPhoneHits(BeneficiariesDTO... hits) { + MBeneficiarycontact contact = new MBeneficiarycontact(); + contact.setVanSerialNo(BigInteger.valueOf(9L)); + contact.setVanID(VAN_ID); + when(contactRepo.findByAnyPhoneNum(any())).thenReturn(Collections.singletonList(contact)); + List rows = new java.util.ArrayList<>(); + for (int i = 0; i < hits.length; i++) { + rows.add(projectionRow()); + } + when(mappingRepo.getBenMappingByBenContactIdListNew(any(), anyInt())).thenReturn(rows); + } + + @Test + @DisplayName("a mismatched first name drops the beneficiary from the results") + void mismatchedFirstNameIsDropped() throws Exception { + IdentitySearchDTO searchDTO = d2dSearch(); + searchDTO.setFirstName("Sunita"); + stubPhoneHits(beneficiary("Asha", "Devi", 2, 101)); + stubRowAssembly(); + + assertTrue(service.getBeneficiarieswithES(searchDTO).isEmpty()); + } + + @Test + @DisplayName("a matching first name is case-insensitive and keeps the beneficiary") + void matchingFirstNameIsCaseInsensitive() throws Exception { + IdentitySearchDTO searchDTO = d2dSearch(); + searchDTO.setFirstName("aSHa"); + stubPhoneHits(beneficiary("Asha", "Devi", 2, 101)); + stubRowAssembly(); + + assertEquals(1, service.getBeneficiarieswithES(searchDTO).size()); + } + + @Test + @DisplayName("filters are not applied at all when the search is not door-to-door") + void filtersAreSkippedForNonD2DSearches() throws Exception { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setContactNumber("9000000000"); + searchDTO.setFirstName("Sunita"); + searchDTO.setIsD2D(Boolean.FALSE); + stubPhoneHits(beneficiary("Asha", "Devi", 2, 101)); + stubRowAssembly(); + + assertEquals(1, service.getBeneficiarieswithES(searchDTO).size()); + } + } + + @Nested + @DisplayName("row assembly from the positional projection") + class RowAssembly { + + @Test + @DisplayName("a full row is assembled into a beneficiary with its nested tables") + void fullRowIsAssembled() throws Exception { + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + + List result = service.getBeneficiariesByBenRegId(BEN_REG_ID); + + assertEquals(1, result.size()); + assertEquals("Asha", result.get(0).getBeneficiaryDetails().getFirstName()); + assertEquals(BEN_ID, result.get(0).getBenId()); + } + + @Test + @DisplayName("a row missing its vanID or vanSerialNo is not assembled") + void rowMissingSyncKeysIsNotAssembled() throws Exception { + Object[] row = projectionRow(); + row[8] = null; + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(row)); + + List result = service.getBeneficiariesByBenRegId(BEN_REG_ID); + + assertEquals(1, result.size()); + verifyNoInteractions(addressRepo); + } + + @Test + @DisplayName("RMNCH household, guideline and RCH identifiers are folded in when present") + void rmnchIdentifiersAreFoldedIn() throws Exception { + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + RMNCHBeneficiaryDetailsRmnch rmnch = new RMNCHBeneficiaryDetailsRmnch(); + rmnch.setHouseoldId(555L); + rmnch.setGuidelineId("GL-1"); + rmnch.setRchid("RCH-1"); + rmnch.setReproductiveStatus("Pregnant"); + rmnch.setReproductiveStatusId(3); + when(rmnchRepo.getByRegID(any())).thenReturn(Collections.singletonList(rmnch)); + + List result = service.getBeneficiariesByBenRegId(BEN_REG_ID); + + assertEquals(1, result.size()); + assertEquals("Pregnant", result.get(0).getReproductiveStatus()); + assertEquals(3, result.get(0).getReproductiveStatusId()); + } + + @Test + @DisplayName("ABHA rows found for the beneficiary are attached to the response") + void abhaRowsAreAttached() throws Exception { + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + Object[] abhaRow = new Object[] { BEN_REG_ID, "asha@abdm", "12-3456-7890-1234", "AADHAAR_OTP", + Timestamp.valueOf("2026-02-02 00:00:00") }; + when(advanceSearchRepo.getBenAbhaDetailsByBenRegID(any())) + .thenReturn(Collections.singletonList(abhaRow)); + + List result = service.getBeneficiariesByBenRegId(BEN_REG_ID); + + assertEquals(1, result.get(0).getAbhaDetails().size()); + assertEquals("asha@abdm", result.get(0).getAbhaDetails().get(0).getHealthID()); + assertEquals("12-3456-7890-1234", result.get(0).getAbhaDetails().get(0).getHealthIDNumber()); + assertEquals("AADHAAR_OTP", result.get(0).getAbhaDetails().get(0).getAuthenticationMode()); + } + + @Test + @DisplayName("a stored face embedding is parsed into a float vector") + void faceEmbeddingIsParsedIntoVector() throws Exception { + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(projectionRow())); + MBeneficiarymapping mapping = stubRowAssembly(); + mapping.getMBeneficiarydetail(); + MBeneficiarydetail detail = new MBeneficiarydetail(); + detail.setFirstName("Asha"); + detail.setFaceEmbedding("[0.25, -0.5, 0.75]"); + when(detailRepo.getWith_vanSerialNo_vanID(any(), anyInt())).thenReturn(detail); + + List result = service.getBeneficiariesByBenRegId(BEN_REG_ID); + + assertEquals(Arrays.asList(0.25f, -0.5f, 0.75f), result.get(0).getFaceEmbedding()); + } + + @Test + @DisplayName("an empty face embedding yields an empty vector rather than a parse failure") + void emptyFaceEmbeddingYieldsEmptyVector() throws Exception { + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + MBeneficiarydetail detail = new MBeneficiarydetail(); + detail.setFaceEmbedding("[]"); + when(detailRepo.getWith_vanSerialNo_vanID(any(), anyInt())).thenReturn(detail); + + List result = service.getBeneficiariesByBenRegId(BEN_REG_ID); + + assertTrue(result.get(0).getFaceEmbedding().isEmpty()); + } + + @Test + @DisplayName("a lookup failure during assembly yields no results rather than propagating") + void assemblyFailureYieldsNoResults() throws Exception { + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenThrow(new IllegalStateException("query timeout")); + + assertTrue(service.getBeneficiariesByBenRegId(BEN_REG_ID).isEmpty()); + } + + @Test + @DisplayName("an unknown beneficiary ID yields no results") + void unknownBeneficiaryIdYieldsNoResults() throws Exception { + when(regIdRepo.findByBeneficiaryID(BEN_ID)).thenReturn(null); + + assertTrue(service.getBeneficiariesByBenId(BEN_ID).isEmpty()); + verify(mappingRepo, never()).getBenMappingByRegID(any()); + } + } + + @Nested + @DisplayName("identifier coercion from the projection") + class IdentifierCoercion { + + @Test + @DisplayName("the numeric types JPA may hand back are all coerced to BigInteger") + void everyNumericTypeIsCoerced() throws Exception { + for (Object supplied : Arrays.asList(BigInteger.valueOf(5L), BigDecimal.valueOf(5L), Integer.valueOf(5), + Long.valueOf(5L), "5")) { + Object[] row = projectionRow(); + row[4] = supplied; + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(row)); + stubRowAssembly(); + + assertEquals(1, service.getBeneficiariesByBenRegId(BEN_REG_ID).size(), + "failed to coerce " + supplied.getClass().getSimpleName()); + } + } + + @Test + @DisplayName("a value of an unexpected type is reported rather than silently dropped") + void unexpectedTypeIsReported() throws Exception { + Object[] row = projectionRow(); + row[4] = Boolean.TRUE; + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(row)); + + // getBeneficiariesByBenRegId swallows the failure, so the beneficiary + // simply does not appear - the error is logged for the sync job. + assertTrue(service.getBeneficiariesByBenRegId(BEN_REG_ID).isEmpty()); + } + } + + @Nested + @DisplayName("advanced search routing") + class AdvancedSearchRouting { + + @Test + @DisplayName("with Elasticsearch enabled the query goes to the index") + void elasticsearchIsUsedWhenEnabled() throws Exception { + ReflectionTestUtils.setField(service, "esEnabled", true); + when(elasticsearchService.advancedSearch(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(Collections.singletonList(Map.of("benRegId", BEN_REG_ID))); + + Map response = service.advancedSearchBeneficiariesES("Asha", null, "Devi", 2, null, 101, + null, null, null, null, null, null, null, null, null, null, 1, "auth", Boolean.FALSE); + + assertEquals("elasticsearch", response.get("source")); + assertEquals(1, response.get("count")); + verify(mappingRepo, never()).dynamicFilterSearchNew(any()); + } + + @Test + @DisplayName("with Elasticsearch disabled the query falls back to the database") + void databaseIsUsedWhenElasticsearchDisabled() throws Exception { + when(mappingRepo.dynamicFilterSearchNew(any())).thenReturn(Collections.emptyList()); + + Map response = service.advancedSearchBeneficiariesES("Asha", null, "Devi", 2, + new java.util.Date(), 101, 201, 301, 401, "Ram", "Suresh", "Married", null, null, null, null, 1, + "auth", Boolean.FALSE); + + assertEquals("database", response.get("source")); + assertEquals(0, response.get("count")); + verifyNoInteractions(elasticsearchService); + } + + @Test + @DisplayName("a non-numeric beneficiary ID is ignored rather than failing the search") + void nonNumericBeneficiaryIdIsIgnored() throws Exception { + when(mappingRepo.dynamicFilterSearchNew(any())).thenReturn(Collections.emptyList()); + + Map response = service.advancedSearchBeneficiariesES(null, null, null, null, null, null, + null, null, null, null, null, null, null, "not-a-number", null, null, 1, "auth", Boolean.FALSE); + + assertEquals(0, response.get("count")); + verify(mappingRepo).dynamicFilterSearchNew(any()); + } + + @Test + @DisplayName("an index failure is surfaced to the caller rather than returning empty results") + void indexFailureIsSurfaced() { + ReflectionTestUtils.setField(service, "esEnabled", true); + when(elasticsearchService.advancedSearch(any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new IllegalStateException("index unavailable")); + + Exception thrown = assertThrows(Exception.class, () -> service.advancedSearchBeneficiariesES(null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, 1, "auth", + Boolean.FALSE)); + + assertTrue(thrown.getMessage().contains("Error in advanced search")); + } + } + + @Nested + @DisplayName("search by ABHA and government identifiers") + class IdentifierSearches { + + @Test + @DisplayName("every registration ID behind an ABHA address is resolved") + void abhaAddressResolvesEveryRegistrationId() throws Exception { + when(advanceSearchRepo.getBenRegIDByHealthIDAbhaAddress("asha@abdm")) + .thenReturn(Arrays.asList(BEN_REG_ID, BigInteger.valueOf(999L), null)); + when(mappingRepo.getBenMappingByRegID(any())).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + + assertEquals(2, service.getBeneficiaryByHealthIDAbhaAddress("asha@abdm").size()); + } + + @Test + @DisplayName("an unknown ABHA address yields no results") + void unknownAbhaAddressYieldsNoResults() throws Exception { + when(advanceSearchRepo.getBenRegIDByHealthIDAbhaAddress("nobody@abdm")) + .thenReturn(Collections.emptyList()); + + assertTrue(service.getBeneficiaryByHealthIDAbhaAddress("nobody@abdm").isEmpty()); + } + + @Test + @DisplayName("an ABHA number lookup failure yields no results rather than propagating") + void abhaNumberFailureYieldsNoResults() throws Exception { + when(advanceSearchRepo.getBenRegIDByHealthIDNoAbhaIdNo(any())) + .thenThrow(new IllegalStateException("view unavailable")); + + assertTrue(service.getBeneficiaryByHealthIDNoAbhaIdNo("12-3456-7890-1234").isEmpty()); + } + + @Test + @DisplayName("an ABHA number resolves to its beneficiary") + void abhaNumberResolvesToBeneficiary() throws Exception { + when(advanceSearchRepo.getBenRegIDByHealthIDNoAbhaIdNo("12-3456-7890-1234")) + .thenReturn(Collections.singletonList(BEN_REG_ID)); + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + + assertEquals(1, service.getBeneficiaryByHealthIDNoAbhaIdNo("12-3456-7890-1234").size()); + } + + @Test + @DisplayName("a family ID resolves every member recorded against it") + void familyIdResolvesEveryMember() { + MBeneficiarydetail first = new MBeneficiarydetail(); + first.setVanID(VAN_ID); + first.setVanSerialNo(BigInteger.valueOf(9L)); + MBeneficiarydetail second = new MBeneficiarydetail(); + second.setVanID(VAN_ID); + second.setVanSerialNo(BigInteger.valueOf(10L)); + when(detailRepo.searchByFamilyId("FAM-1")).thenReturn(Arrays.asList(first, second)); + when(mappingRepo.getBenMappingByBenDetailsIds(any(), anyInt())) + .thenReturn(Arrays.asList(projectionRow(), projectionRow())); + stubRowAssembly(); + + assertEquals(2, service.searhBeneficiaryByFamilyId("FAM-1").size()); + } + + @Test + @DisplayName("an unknown family ID yields no results and no mapping lookup") + void unknownFamilyIdYieldsNoResults() { + when(detailRepo.searchByFamilyId("FAM-NONE")).thenReturn(Collections.emptyList()); + + assertTrue(service.searhBeneficiaryByFamilyId("FAM-NONE").isEmpty()); + verify(mappingRepo, never()).getBenMappingByBenDetailsIds(any(), anyInt()); + } + + @Test + @DisplayName("a government identity number resolves its beneficiaries") + void governmentIdentityResolvesBeneficiaries() { + MBeneficiaryidentity identity = new MBeneficiaryidentity(); + identity.setBenMapId(BigInteger.valueOf(9L)); + identity.setVanID(VAN_ID); + when(identityRepo.searchByIdentityNo("AADHAAR-1")).thenReturn(Collections.singletonList(identity)); + when(mappingRepo.getBenMappingByVanSerialNo(any(), anyInt())) + .thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + + assertEquals(1, service.searhBeneficiaryByGovIdentity("AADHAAR-1").size()); + } + + @Test + @DisplayName("an unknown government identity number yields no results") + void unknownGovernmentIdentityYieldsNoResults() { + when(identityRepo.searchByIdentityNo("AADHAAR-NONE")).thenReturn(Collections.emptyList()); + + assertTrue(service.searhBeneficiaryByGovIdentity("AADHAAR-NONE").isEmpty()); + } + } + + @Nested + @DisplayName("village sync queries") + class VillageSyncQueries { + + @Test + @DisplayName("beneficiaries changed in the given villages since the watermark are returned") + void changedBeneficiariesAreReturned() { + MBeneficiarymapping mapping = new MBeneficiarymapping(); + mapping.setBenRegId(BEN_REG_ID); + when(mappingRepo.findByBeneficiaryDetailsByVillageIDAndLastModifyDate(any(), any())) + .thenReturn(Collections.singletonList(mapping)); + + List result = service.searchBeneficiaryByVillageIdAndLastModifyDate(List.of(401), + Timestamp.valueOf("2026-01-01 00:00:00")); + + assertEquals(1, result.size()); + } + + @Test + @DisplayName("a query failure yields no results so the CHO app sync can retry") + void queryFailureYieldsNoResults() { + when(mappingRepo.findByBeneficiaryDetailsByVillageIDAndLastModifyDate(any(), any())) + .thenThrow(new IllegalStateException("timeout")); + + assertTrue(service + .searchBeneficiaryByVillageIdAndLastModifyDate(List.of(401), Timestamp.valueOf("2026-01-01 00:00:00")) + .isEmpty()); + } + + @Test + @DisplayName("the count query is passed straight through") + void countIsPassedThrough() { + when(mappingRepo.getBeneficiaryCountsByVillageIDAndLastModifyDate(any(), any())).thenReturn(42L); + + assertEquals(42L, service.countBeneficiaryByVillageIdAndLastModifyDate(List.of(401), + Timestamp.valueOf("2026-01-01 00:00:00"))); + } + + @Test + @DisplayName("a failing count reports zero rather than propagating") + void failingCountReportsZero() { + when(mappingRepo.getBeneficiaryCountsByVillageIDAndLastModifyDate(any(), any())) + .thenThrow(new IllegalStateException("timeout")); + + assertEquals(0L, service.countBeneficiaryByVillageIdAndLastModifyDate(List.of(401), + Timestamp.valueOf("2026-01-01 00:00:00"))); + } + } + + @Nested + @DisplayName("RMNCH lookup") + class RmnchLookup { + + @Test + @DisplayName("the stored RMNCH row is returned when one exists") + void storedRowIsReturned() { + RMNCHBeneficiaryDetailsRmnch stored = new RMNCHBeneficiaryDetailsRmnch(); + stored.setRchid("RCH-1"); + when(rmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(stored)); + + assertSame(stored, service.getRmnchDataByBenID(BEN_REG_ID)); + } + + @Test + @DisplayName("a beneficiary with no RMNCH row gets an empty one rather than null") + void missingRowYieldsEmptyObject() { + when(rmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + + RMNCHBeneficiaryDetailsRmnch result = service.getRmnchDataByBenID(BEN_REG_ID); + + assertNotNull(result); + assertEquals(null, result.getRchid()); + } + } + + @Nested + @DisplayName("editing an existing beneficiary") + class EditingBeneficiary { + + private MBeneficiarymapping storedMapping() { + MBeneficiarymapping mapping = new MBeneficiarymapping(); + mapping.setBenMapId(BigInteger.ONE); + mapping.setVanID(VAN_ID); + mapping.setVanSerialNo(BigInteger.valueOf(9L)); + mapping.setParkingPlaceID(3); + + MBeneficiarydetail detail = new MBeneficiarydetail(); + detail.setBeneficiaryDetailsId(BigInteger.valueOf(5L)); + mapping.setMBeneficiarydetail(detail); + + MBeneficiaryaddress address = new MBeneficiaryaddress(); + address.setBenAddressID(BigInteger.valueOf(2L)); + mapping.setMBeneficiaryaddress(address); + + MBeneficiarycontact contact = new MBeneficiarycontact(); + contact.setBenContactsID(BigInteger.valueOf(4L)); + mapping.setMBeneficiarycontact(contact); + + MBeneficiaryAccount account = new MBeneficiaryAccount(); + account.setBenAccountID(BigInteger.valueOf(7L)); + mapping.setMBeneficiaryAccount(account); + + MBeneficiaryImage image = new MBeneficiaryImage(); + image.setBenImageId(BigInteger.valueOf(6L)); + mapping.setMBeneficiaryImage(image); + + return mapping; + } + + private IdentityEditDTO editRequest() { + IdentityEditDTO dto = new IdentityEditDTO(); + dto.setBeneficiaryRegId(BEN_REG_ID); + dto.setAgentName("field.worker"); + return dto; + } + + @Test + @DisplayName("an edit with neither identifier is rejected before any write") + void editWithoutIdentifierIsRejected() { + IdentityEditDTO dto = new IdentityEditDTO(); + + assertThrows(MissingMandatoryFieldsException.class, () -> service.editIdentity(dto)); + verifyNoInteractions(detailRepo); + } + + @Test + @DisplayName("a self-details change saves the detail row against its existing primary key") + void selfDetailsChangeSavesDetailRow() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInSelfDetails(Boolean.TRUE); + dto.setFirstName("Asha"); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + MBeneficiarydetail existing = new MBeneficiarydetail(); + existing.setBeneficiaryDetailsId(BigInteger.valueOf(5L)); + existing.setFamilyId("FAM-1"); + existing.setOccupationId(11); + existing.setEducationId(22); + when(detailRepo.findBenDetailsByVanSerialNoAndVanID(any(), anyInt())).thenReturn(existing); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiarydetail.class); + verify(detailRepo).save(captor.capture()); + assertEquals(BigInteger.valueOf(5L), captor.getValue().getBeneficiaryDetailsId()); + assertEquals("FAM-1", captor.getValue().getFamilyId()); + assertEquals("Asha", captor.getValue().getFirstName()); + } + + @Test + @DisplayName("occupation and education already on the row are preserved when the edit omits them") + void existingOccupationAndEducationArePreserved() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInSelfDetails(Boolean.TRUE); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + MBeneficiarydetail existing = new MBeneficiarydetail(); + existing.setBeneficiaryDetailsId(BigInteger.valueOf(5L)); + existing.setOccupationId(11); + existing.setOccupation("Farmer"); + existing.setEducationId(22); + existing.setEducation("Primary"); + when(detailRepo.findBenDetailsByVanSerialNoAndVanID(any(), anyInt())).thenReturn(existing); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiarydetail.class); + verify(detailRepo).save(captor.capture()); + assertEquals(11, captor.getValue().getOccupationId()); + assertEquals("Farmer", captor.getValue().getOccupation()); + assertEquals(22, captor.getValue().getEducationId()); + assertEquals("Primary", captor.getValue().getEducation()); + } + + @Test + @DisplayName("an emergency registration flag on the stored row survives the edit") + void emergencyRegistrationFlagSurvives() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInSelfDetails(Boolean.TRUE); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + MBeneficiarydetail existing = new MBeneficiarydetail(); + existing.setBeneficiaryDetailsId(BigInteger.valueOf(5L)); + existing.setEmergencyRegistration(Boolean.TRUE); + when(detailRepo.findBenDetailsByVanSerialNoAndVanID(any(), anyInt())).thenReturn(existing); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiarydetail.class); + verify(detailRepo).save(captor.capture()); + assertTrue(captor.getValue().getEmergencyRegistration()); + } + + @Test + @DisplayName("an address change resolves the address primary key before saving") + void addressChangeResolvesPrimaryKey() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInAddress(Boolean.TRUE); + dto.setCurrentAddress(new Address()); + dto.setPermanentAddress(new Address()); + dto.setEmergencyAddress(new Address()); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + when(addressRepo.findIdByVanSerialNoAndVanID(any(), anyInt())).thenReturn(BigInteger.valueOf(20L)); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryaddress.class); + verify(addressRepo).save(captor.capture()); + assertEquals(BigInteger.valueOf(20L), captor.getValue().getBenAddressID()); + } + + @Test + @DisplayName("an address change is rejected when the sync key does not resolve") + void addressChangeWithoutSyncKeyIsRejected() { + IdentityEditDTO dto = editRequest(); + dto.setChangeInAddress(Boolean.TRUE); + dto.setCurrentAddress(new Address()); + dto.setPermanentAddress(new Address()); + dto.setEmergencyAddress(new Address()); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + when(addressRepo.findIdByVanSerialNoAndVanID(any(), anyInt())).thenReturn(null); + + assertThrows(MissingMandatoryFieldsException.class, () -> service.editIdentity(dto)); + verify(addressRepo, never()).save(any()); + } + + @Test + @DisplayName("a contact change resolves the contact primary key before saving") + void contactChangeResolvesPrimaryKey() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInContacts(Boolean.TRUE); + Contact contact = new Contact(); + contact.setPreferredPhoneNum("9000000000"); + dto.setContact(contact); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + when(contactRepo.findIdByVanSerialNoAndVanID(any(), anyInt())).thenReturn(BigInteger.valueOf(40L)); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiarycontact.class); + verify(contactRepo).save(captor.capture()); + assertEquals(BigInteger.valueOf(40L), captor.getValue().getBenContactsID()); + } + + @Test + @DisplayName("a contact change is rejected when the sync key does not resolve") + void contactChangeWithoutSyncKeyIsRejected() { + IdentityEditDTO dto = editRequest(); + dto.setChangeInContacts(Boolean.TRUE); + dto.setContact(new Contact()); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + when(contactRepo.findIdByVanSerialNoAndVanID(any(), anyInt())).thenReturn(null); + + assertThrows(MissingMandatoryFieldsException.class, () -> service.editIdentity(dto)); + } + + @Test + @DisplayName("an edited identity reuses the primary key of the row in the same position") + void editedIdentityReusesExistingKey() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInIdentities(Boolean.TRUE); + com.iemr.common.identity.domain.Identity identity = new com.iemr.common.identity.domain.Identity(); + identity.setIdentityNo("ABHA-1"); + dto.setIdentities(Collections.singletonList(identity)); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + MBeneficiaryidentity stored = new MBeneficiaryidentity(); + stored.setBenIdentityId(BigInteger.valueOf(70L)); + when(identityRepo.findByBenMapId(any())).thenReturn(Collections.singletonList(stored)); + when(identityRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryidentity.class); + verify(identityRepo).save(captor.capture()); + assertEquals(BigInteger.valueOf(70L), captor.getValue().getBenIdentityId()); + assertEquals(BigInteger.valueOf(9L), captor.getValue().getBenMapId()); + } + + @Test + @DisplayName("an identity beyond the stored rows is created with the van and parking place of its mapping") + void newIdentityInheritsVanAndParkingPlace() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInIdentities(Boolean.TRUE); + com.iemr.common.identity.domain.Identity identity = new com.iemr.common.identity.domain.Identity(); + identity.setIdentityNo("ABHA-1"); + dto.setIdentities(Collections.singletonList(identity)); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + when(identityRepo.findByBenMapId(any())).thenReturn(Collections.emptyList()); + MBeneficiaryidentity saved = new MBeneficiaryidentity(); + saved.setBenIdentityId(BigInteger.valueOf(71L)); + when(identityRepo.save(any())).thenReturn(saved); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryidentity.class); + verify(identityRepo).save(captor.capture()); + assertEquals(VAN_ID, captor.getValue().getVanID()); + assertEquals(3, captor.getValue().getParkingPlaceID()); + verify(identityRepo).updateVanSerialNo(BigInteger.valueOf(71L)); + } + + @Test + @DisplayName("a family-details change upserts each member against its stored row") + void familyDetailsChangeUpsertsEachMember() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInFamilyDetails(Boolean.TRUE); + BenFamilyDTO member = new BenFamilyDTO(); + member.setAssociatedBenRegId(BigInteger.valueOf(888L)); + dto.setBenFamilyDTOs(Collections.singletonList(member)); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + MBeneficiaryfamilymapping stored = new MBeneficiaryfamilymapping(); + stored.setBenFamilyMapId(BigInteger.valueOf(80L)); + when(familyMapRepo.findByBenMapIdOrderByBenFamilyMapIdAsc(any())) + .thenReturn(Collections.singletonList(stored)); + when(familyMapRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(MBeneficiaryfamilymapping.class); + verify(familyMapRepo).save(captor.capture()); + assertEquals(BigInteger.valueOf(80L), captor.getValue().getBenFamilyMapId()); + } + + @Test + @DisplayName("a bank-details change resolves the account primary key before saving") + void bankDetailsChangeResolvesPrimaryKey() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInBankDetails(Boolean.TRUE); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + when(accountRepo.findIdByVanSerialNoAndVanID(any(), anyInt())).thenReturn(BigInteger.valueOf(90L)); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryAccount.class); + verify(accountRepo).save(captor.capture()); + assertEquals(BigInteger.valueOf(90L), captor.getValue().getBenAccountID()); + } + + @Test + @DisplayName("an image change is stored unprocessed so the sync job picks it up") + void imageChangeIsStoredUnprocessed() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInBenImage(Boolean.TRUE); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + when(imageRepo.findIdByVanSerialNoAndVanID(any(), anyInt())).thenReturn(BigInteger.valueOf(60L)); + + service.editIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryImage.class); + verify(imageRepo).save(captor.capture()); + assertEquals("N", captor.getValue().getProcessed()); + assertEquals(BigInteger.valueOf(60L), captor.getValue().getBenImageId()); + } + + @Test + @DisplayName("any persisted change triggers an Elasticsearch re-index") + void persistedChangeTriggersReindex() throws Exception { + IdentityEditDTO dto = editRequest(); + dto.setChangeInSelfDetails(Boolean.TRUE); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + MBeneficiarydetail existing = new MBeneficiarydetail(); + existing.setBeneficiaryDetailsId(BigInteger.valueOf(5L)); + when(detailRepo.findBenDetailsByVanSerialNoAndVanID(any(), anyInt())).thenReturn(existing); + + service.editIdentity(dto); + + verify(syncService).syncBeneficiaryAsync(BEN_REG_ID); + } + + @Test + @DisplayName("an edit that changes nothing does not trigger a re-index") + void noChangeDoesNotTriggerReindex() throws Exception { + IdentityEditDTO dto = editRequest(); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(storedMapping()); + + service.editIdentity(dto); + + verifyNoInteractions(syncService); + } + } + + @Nested + @DisplayName("education and community edits") + class EducationAndCommunityEdits { + + @Test + @DisplayName("an edit with neither identifier is rejected") + void editWithoutIdentifierIsRejected() { + assertThrows(MissingMandatoryFieldsException.class, + () -> service.editIdentityEducationOrCommunity(new IdentityEditDTO())); + } + + @Test + @DisplayName("community and education are updated independently") + void communityAndEducationAreUpdatedIndependently() throws Exception { + IdentityEditDTO dto = new IdentityEditDTO(); + dto.setBeneficiaryRegId(BEN_REG_ID); + dto.setCommunityId(5); + MBeneficiarymapping mapping = new MBeneficiarymapping(); + mapping.setBenDetailsId(BigInteger.valueOf(5L)); + mapping.setVanID(VAN_ID); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(mapping); + + service.editIdentityEducationOrCommunity(dto); + + verify(detailRepo).updateCommunity(BigInteger.valueOf(5L), VAN_ID, 5); + verify(detailRepo, never()).updateEducation(any(), anyInt(), anyInt()); + } + + @Test + @DisplayName("an unknown registration ID updates nothing") + void unknownRegistrationIdUpdatesNothing() throws Exception { + IdentityEditDTO dto = new IdentityEditDTO(); + dto.setBeneficiaryRegId(BEN_REG_ID); + dto.setEducationId(9); + when(mappingRepo.findByBenRegIdOrderByBenMapIdAsc(BEN_REG_ID)).thenReturn(null); + + service.editIdentityEducationOrCommunity(dto); + + verify(detailRepo, never()).updateEducation(any(), anyInt(), anyInt()); + } + } + + @Nested + @DisplayName("creating a beneficiary") + class CreatingBeneficiary { + + private IdentityDTO createRequest() { + IdentityDTO dto = new IdentityDTO(); + dto.setFirstName("Asha"); + dto.setLastName("Devi"); + dto.setAgentName("field.worker"); + dto.setVanID(VAN_ID); + dto.setParkingPlaceId(3); + dto.setProviderServiceMapId(11); + dto.setCurrentAddress(new Address()); + dto.setPermanentAddress(new Address()); + dto.setEmergencyAddress(new Address()); + BenFamilyDTO family = new BenFamilyDTO(); + family.setVanID(VAN_ID); + dto.setBenFamilyDTOs(Collections.singletonList(family)); + return dto; + } + + @BeforeEach + void stubPersistence() { + MBeneficiaryregidmapping claimed = new MBeneficiaryregidmapping(); + claimed.setBenRegId(BEN_REG_ID); + claimed.setBeneficiaryID(BEN_ID); + when(benRegIdClaimService.claimNextAvailableRegId()).thenReturn(claimed); + when(addressRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(consentRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(contactRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(detailRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(accountRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(imageRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(mappingRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(serviceMapRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(identityRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(familyMapRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = (List) invocation.getArgument(0); + return new java.util.ArrayList<>(supplied); + }); + } + + @Test + @DisplayName("the claimed registration ID is marked provisioned and returned") + void claimedRegistrationIdIsProvisioned() { + BeneficiaryCreateResp response = service.createIdentity(createRequest()); + + assertNotNull(response); + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryregidmapping.class); + verify(regIdRepo).save(captor.capture()); + assertTrue(captor.getValue().getProvisioned()); + assertEquals(11, captor.getValue().getProviderServiceMapID()); + assertEquals(BEN_REG_ID, captor.getValue().getVanSerialNo()); + } + + @Test + @DisplayName("every table gets its van serial number back-filled for the data sync") + void vanSerialNumbersAreBackFilled() { + service.createIdentity(createRequest()); + + verify(addressRepo).updateVanSerialNo(any()); + verify(consentRepo).updateVanSerialNo(any()); + verify(contactRepo).updateVanSerialNo(any()); + verify(detailRepo).updateVanSerialNo(any()); + verify(accountRepo).updateVanSerialNo(any()); + verify(imageRepo).updateVanSerialNo(any()); + verify(mappingRepo).updateVanSerialNo(any()); + verify(serviceMapRepo).updateVanSerialNo(any()); + } + + @Test + @DisplayName("a permanent address flagged as same-as-current is copied from the current address") + void permanentAddressIsCopiedFromCurrent() { + IdentityDTO dto = createRequest(); + Address current = new Address(); + current.setAddrLine1("1 Current St"); + current.setPinCode("560064"); + dto.setCurrentAddress(current); + dto.setIsPermAddrSameAsCurrAddr(Boolean.TRUE); + + service.createIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryaddress.class); + verify(addressRepo).save(captor.capture()); + assertEquals("1 Current St", captor.getValue().getPermAddrLine1()); + } + + @Test + @DisplayName("an emergency address flagged as same-as-permanent is copied from the permanent address") + void emergencyAddressIsCopiedFromPermanent() { + IdentityDTO dto = createRequest(); + Address permanent = new Address(); + permanent.setAddrLine1("2 Permanent Rd"); + dto.setPermanentAddress(permanent); + dto.setIsEmerAddrSameAsPermAddr(Boolean.TRUE); + + service.createIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryaddress.class); + verify(addressRepo).save(captor.capture()); + assertEquals("2 Permanent Rd", captor.getValue().getEmerAddrLine1()); + } + + @ParameterizedTest + @CsvSource({ "+919000000000, 9000000000", "919000000000, 9000000000", "09000000000, 9000000000", + "9000000000, 9000000000" }) + @DisplayName("every stored phone number is normalised to its bare ten digits") + void phoneNumbersAreNormalisedOnCreate(String supplied, String expected) { + IdentityDTO dto = createRequest(); + Contact contact = new Contact(); + contact.setPreferredPhoneNum(supplied); + contact.setPhoneNum1(supplied); + contact.setPhoneNum2(supplied); + contact.setPhoneNum3(supplied); + contact.setPhoneNum4(supplied); + contact.setPhoneNum5(supplied); + contact.setPreferredSMSPhoneNum(supplied); + contact.setEmergencyContactNum(supplied); + dto.setContact(contact); + + service.createIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiarycontact.class); + verify(contactRepo).save(captor.capture()); + MBeneficiarycontact saved = captor.getValue(); + assertEquals(expected, saved.getPreferredPhoneNum()); + assertEquals(expected, saved.getPhoneNum1()); + assertEquals(expected, saved.getPhoneNum5()); + assertEquals(expected, saved.getPreferredSMSPhoneNum()); + assertEquals(expected, saved.getEmergencyContactNum()); + } + + @Test + @DisplayName("a created date is stamped on every row that arrives without one") + void createdDateIsStampedWhenAbsent() { + service.createIdentity(createRequest()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryaddress.class); + verify(addressRepo).save(captor.capture()); + assertNotNull(captor.getValue().getCreatedDate()); + } + + @Test + @DisplayName("family members are linked to the new mapping and inherit its registration ID") + void familyMembersAreLinkedToTheNewMapping() { + service.createIdentity(createRequest()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(familyMapRepo).saveAll(captor.capture()); + MBeneficiaryfamilymapping member = captor.getValue().get(0); + assertEquals(VAN_ID, member.getVanID()); + assertEquals(3, member.getParkingPlaceID()); + } + + @Test + @DisplayName("supplied identities are saved against the new mapping") + void suppliedIdentitiesAreSaved() { + IdentityDTO dto = createRequest(); + com.iemr.common.identity.domain.Identity identity = new com.iemr.common.identity.domain.Identity(); + identity.setIdentityNo("ABHA-1"); + dto.setIdentities(Collections.singletonList(identity)); + + service.createIdentity(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MBeneficiaryidentity.class); + verify(identityRepo).save(captor.capture()); + assertEquals("ABHA-1", captor.getValue().getIdentityNo()); + assertEquals("field.worker", captor.getValue().getCreatedBy()); + assertEquals(VAN_ID, captor.getValue().getVanID()); + } + + @Test + @DisplayName("the service mapping row is linked to the new beneficiary mapping") + void serviceMappingIsLinked() { + service.createIdentity(createRequest()); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(MBeneficiaryservicemapping.class); + verify(serviceMapRepo).save(captor.capture()); + assertNotNull(captor.getValue().getCreatedDate()); + } + + @Test + @DisplayName("a new beneficiary is queued for indexing") + void newBeneficiaryIsQueuedForIndexing() { + service.createIdentity(createRequest()); + + verify(syncService).syncBeneficiaryAsync(BEN_REG_ID); + } + } + + @Nested + @DisplayName("reserving and releasing identifiers") + class ReservingIdentifiers { + + @Test + @DisplayName("identifiers are reserved only up to the shortfall") + void identifiersAreReservedUpToTheShortfall() { + ReserveIdentityDTO request = new ReserveIdentityDTO(); + request.setProviderServiceMapID(11); + request.setVehicalNo("KA-01-1234"); + request.setReserveCount(3L); + when(regIdRepo.countByProviderServiceMapIDAndVehicalNoOrderByBenRegIdAsc(11, "KA-01-1234")).thenReturn(5L); + when(regIdRepo.findFirstByProviderServiceMapIDAndVehicalNoOrderByBenRegIdAsc(null, null)) + .thenReturn(new MBeneficiaryregidmapping()); + + assertEquals("Successfully Completed", service.reserveIdentity(request)); + + // reserveCount (3) - available (5) + 1 == -1, so the loop body never + // runs and nothing is reserved. + verify(regIdRepo, never()).save(any()); + } + + @Test + @DisplayName("nothing is reserved when the request already exceeds what is available") + void nothingIsReservedWhenRequestExceedsAvailability() { + ReserveIdentityDTO request = new ReserveIdentityDTO(); + request.setProviderServiceMapID(11); + request.setVehicalNo("KA-01-1234"); + request.setReserveCount(10L); + when(regIdRepo.countByProviderServiceMapIDAndVehicalNoOrderByBenRegIdAsc(11, "KA-01-1234")).thenReturn(5L); + + assertEquals("Successfully Completed", service.reserveIdentity(request)); + + verify(regIdRepo, never()).save(any()); + } + + @Test + @DisplayName("releasing identifiers delegates straight to the repository") + void releasingDelegatesToRepository() { + ReserveIdentityDTO request = new ReserveIdentityDTO(); + request.setProviderServiceMapID(11); + request.setVehicalNo("KA-01-1234"); + + assertEquals("Successfully Completed", service.unReserveIdentity(request)); + + verify(regIdRepo).unreserveBeneficiaryIds(11, "KA-01-1234"); + } + + @Test + @DisplayName("the reserved-id listing is a fixed acknowledgement") + void reservedIdListingIsFixed() { + assertEquals("success", service.getReservedIdList()); + } + + @Test + @DisplayName("unprovisioned identifiers are counted for the local pool") + void unprovisionedIdentifiersAreCounted() { + when(regIdRepo.countByProvisioned(false)).thenReturn(120L); + + assertEquals(120L, service.checkBenIDAvailabilityLocal()); + } + } + + @Nested + @DisplayName("bulk lookups by registration ID list") + class BulkLookups { + + @Test + @DisplayName("an empty or absent list is not sent to the database") + void emptyListIsNotQueried() { + assertTrue(service.getBeneficiariesPartialDeatilsByBenRegIdList(Collections.emptyList()).isEmpty()); + assertTrue(service.getBeneficiariesPartialDeatilsByBenRegIdList(null).isEmpty()); + assertTrue(service.getBeneficiariesDeatilsByBenRegIdList(Collections.emptyList()).isEmpty()); + assertTrue(service.getBeneficiariesDeatilsByBenRegIdList(null).isEmpty()); + + verify(mappingRepo, never()).getBenMappingByRegIDList(any()); + } + + @Test + @DisplayName("partial details are limited to the name and identifier columns") + void partialDetailsAreLimitedToNameColumns() { + when(mappingRepo.getBenMappingByRegIDList(any())).thenReturn(Collections.singletonList(projectionRow())); + MBeneficiarydetail detail = new MBeneficiarydetail(); + detail.setFirstName("Asha"); + detail.setLastName("Devi"); + when(detailRepo.getWith_vanSerialNo_vanID(any(), anyInt())).thenReturn(detail); + MBeneficiaryregidmapping regId = new MBeneficiaryregidmapping(); + regId.setBeneficiaryID(BEN_ID); + regId.setBenRegId(BEN_REG_ID); + when(regIdRepo.getWithVanSerialNoVanID(any(), anyInt())).thenReturn(regId); + + List result = service + .getBeneficiariesPartialDeatilsByBenRegIdList(List.of(BEN_REG_ID)); + + assertEquals(1, result.size()); + assertEquals("Asha", result.get(0).getFirstName()); + assertEquals(BEN_ID, result.get(0).getBenId()); + verifyNoInteractions(addressRepo); + } + + @Test + @DisplayName("full details assemble every nested table for each registration ID") + void fullDetailsAssembleEveryTable() { + when(mappingRepo.getBenMappingByRegIDList(any())).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + + List result = service.getBeneficiariesDeatilsByBenRegIdList(List.of(BEN_REG_ID)); + + assertEquals(1, result.size()); + verify(addressRepo).getWithVanSerialNoVanID(any(), anyInt()); + } + } + + @Nested + @DisplayName("finite search") + class FiniteSearch { + + @Test + @DisplayName("each mapping returned by the finite search is expanded into a beneficiary") + void eachMappingIsExpanded() { + MBeneficiarymapping mapping = new MBeneficiarymapping(); + mapping.setBenMapId(BigInteger.ONE); + mapping.setBenRegId(BEN_REG_ID); + MBeneficiaryregidmapping regId = new MBeneficiaryregidmapping(); + regId.setBeneficiaryID(BEN_ID); + mapping.setMBeneficiaryregidmapping(regId); + IdentityDTO query = new IdentityDTO(); + when(mappingRepo.finiteSearch(query)).thenReturn(Collections.singletonList(mapping)); + + assertEquals(1, service.getBeneficiaries(query).size()); + } + } + + @Nested + @DisplayName("beneficiary image retrieval") + class BeneficiaryImageRetrieval { + + @Test + @DisplayName("a stored image is returned with its capture date") + void storedImageIsReturnedWithCaptureDate() { + MBeneficiarymapping mapping = new MBeneficiarymapping(); + mapping.setBenImageId(BigInteger.valueOf(60L)); + mapping.setVanID(VAN_ID); + when(mappingRepo.getBenImageIdByBenRegID(BEN_REG_ID)).thenReturn(mapping); + MBeneficiaryImage image = new MBeneficiaryImage(); + image.setBenImage("base64-image-data"); + image.setCreatedDate(Timestamp.valueOf("2026-01-01 00:00:00")); + when(imageRepo.getBenImageByBenImageID(BigInteger.valueOf(60L), VAN_ID)).thenReturn(image); + + String response = service.getBeneficiaryImage("{\"beneficiaryRegID\":" + BEN_REG_ID + "}"); + + assertTrue(response.contains("base64-image-data")); + } + + @Test + @DisplayName("a beneficiary with no image gets a plain not-available response") + void missingImageReportsNotAvailable() { + when(mappingRepo.getBenImageIdByBenRegID(BEN_REG_ID)).thenReturn(null); + + String response = service.getBeneficiaryImage("{\"beneficiaryRegID\":" + BEN_REG_ID + "}"); + + assertTrue(response.contains("Image not available")); + } + + @Test + @DisplayName("a request without a registration ID is rejected as invalid") + void requestWithoutRegistrationIdIsRejected() { + String response = service.getBeneficiaryImage("{\"somethingElse\":1}"); + + assertTrue(response.contains("Invalid request")); + } + + @Test + @DisplayName("malformed JSON is reported as an error rather than thrown") + void malformedJsonIsReportedAsError() { + String response = service.getBeneficiaryImage("not json at all {"); + + assertNotNull(response); + } + } + + @Nested + @DisplayName("importing pre-allocated identifiers") + class ImportingIdentifiers { + + @Test + @DisplayName("an empty import is a no-op") + void emptyImportIsANoOp() { + assertEquals(0, service.importBenIdToLocalServer(Collections.emptyList())); + verifyNoInteractions(dataSource); + } + + @Test + @DisplayName("a failing batch insert is surfaced rather than reported as a partial import") + void failingBatchIsSurfaced() throws Exception { + // The caller retries the whole batch, so a half-reported import would + // silently drop identifiers. + when(dataSource.getConnection()).thenThrow(new java.sql.SQLException("no connection")); + BenIdImportDTO row = new BenIdImportDTO(); + row.setBenRegId(BEN_REG_ID); + row.setBeneficiaryId(BEN_ID); + + assertThrows(RuntimeException.class, () -> service.importBenIdToLocalServer(List.of(row))); + } + } + + @Test + @DisplayName("the diagnostic address dump touches every repository it reports on") + void diagnosticDumpTouchesEveryRepository() { + service.getBenAdress(); + + verify(addressRepo).count(); + verify(consentRepo).count(); + verify(contactRepo).count(); + verify(accessRepo).count(); + verify(detailRepo).count(); + verify(familyMapRepo).count(); + verify(identityRepo).count(); + verify(mappingRepo).count(); + } + + @Test + @DisplayName("an Elasticsearch-backed search still resolves a beneficiary ID directly") + void elasticsearchSearchStillResolvesBeneficiaryIdDirectly() throws Exception { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setBeneficiaryId(BEN_ID); + MBeneficiaryregidmapping regId = new MBeneficiaryregidmapping(); + regId.setBenRegId(BEN_REG_ID); + when(regIdRepo.findByBeneficiaryID(BEN_ID)).thenReturn(regId); + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + + assertEquals(1, service.getBeneficiarieswithES(searchDTO).size()); + verifyNoInteractions(elasticsearchService); + } + + @Test + @DisplayName("a registration ID search reaches the mapping projection through the ES entry point") + void elasticsearchSearchResolvesRegistrationId() throws Exception { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setBeneficiaryRegId(BEN_REG_ID); + when(mappingRepo.getBenMappingByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(projectionRow())); + stubRowAssembly(); + + assertEquals(1, service.getBeneficiarieswithES(searchDTO).size()); + } + + @Test + @DisplayName("the advance-search view drives the fallback path of the ES entry point") + void advanceSearchViewDrivesFallbackPath() throws Exception { + IdentitySearchDTO searchDTO = new IdentitySearchDTO(); + searchDTO.setFirstName("Asha"); + VBenAdvanceSearch row = new VBenAdvanceSearch(); + row.setBenMapID(BigInteger.ONE); + row.setVanID(VAN_ID); + row.setBenDetailsID(BigInteger.valueOf(5L)); + row.setBenRegID(BEN_REG_ID); + row.setVanSerialNo(BigInteger.valueOf(9L)); + row.setHouseHoldID(555L); + row.setGuideLineID("GL-1"); + row.setRchID("RCH-1"); + when(mappingRepo.dynamicFilterSearchNew(searchDTO)).thenReturn(Collections.singletonList(row)); + MBeneficiarydetail detail = new MBeneficiarydetail(); + detail.setCreatedBy("field.worker"); + detail.setCreatedDate(Timestamp.valueOf("2026-01-01 00:00:00")); + detail.setFirstName("Asha"); + when(detailRepo.getWith_vanSerialNo_vanID(any(), anyInt())).thenReturn(detail); + + List result = service.getBeneficiarieswithES(searchDTO); + + assertEquals(1, result.size()); + assertEquals("Asha", result.get(0).getBeneficiaryDetails().getFirstName()); + verify(familyMapRepo).findByBenMapIdOrderByBenFamilyMapIdAsc(BigInteger.valueOf(9L)); + } +} diff --git a/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryDocumentDataServiceTest.java b/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryDocumentDataServiceTest.java new file mode 100644 index 00000000..0c9f74c0 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryDocumentDataServiceTest.java @@ -0,0 +1,426 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigInteger; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.identity.data.elasticsearch.BeneficiaryDocument; +import com.iemr.common.identity.repo.BenMappingRepo; +import com.iemr.common.identity.repo.V_BenAdvanceSearchRepo; + +/** + * Tests for the batch loader that builds Elasticsearch documents. + * + *

+ * This is the query that makes a full re-index feasible: one 40-column + * projection for a page of beneficiaries, plus one ABHA query for the same page, + * joined in memory. Two things make it fragile. The projection is read + * positionally, so a column added or reordered in the repository query silently + * shifts every field after it; and the ABHA view is optional - it may not exist + * on every deployment - so a failure there must degrade to documents without + * ABHA rather than losing the page. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BeneficiaryDocumentDataServiceTest { + + @Mock + private BenMappingRepo mappingRepo; + @Mock + private V_BenAdvanceSearchRepo advanceSearchRepo; + + @InjectMocks + private BeneficiaryDocumentDataService service; + + private static final BigInteger BEN_REG_ID = BigInteger.valueOf(100200300L); + + /** The 40-column projection, in the order the repository query selects. */ + private Object[] projectionRow() { + return new Object[] { 100200300L, "4001", "Asha", "Rani", "Devi", 2, "Female", + Timestamp.valueOf("1996-01-15 00:00:00"), 30, "Ram", "Suresh", 1, "Married", "no", "field.worker", + Timestamp.valueOf("2026-01-01 00:00:00"), 1767225600000L, 77L, "9000000000", "FAM-1", 101, + "Karnataka", 201, "Bengaluru", 301, "North", 401, "Yelahanka", "560064", 501, "PHC Yelahanka", 3, + 102, "Kerala", 202, "Kochi", 302, "South", 402, "Kakkanad" }; + } + + private void useReflectionForAdvanceSearchRepo() { + ReflectionTestUtils.setField(service, "v_BenAdvanceSearchRepo", advanceSearchRepo); + } + + @Nested + @DisplayName("batch loading") + class BatchLoading { + + @Test + @DisplayName("an empty or absent identifier list is not sent to the database") + void emptyIdentifierListIsNotQueried() { + assertTrue(service.getBeneficiariesBatch(Collections.emptyList()).isEmpty()); + assertTrue(service.getBeneficiariesBatch(null).isEmpty()); + + verify(mappingRepo, never()).findCompleteDataByBenRegIds(anyList()); + } + + @Test + @DisplayName("the projection is read into the document field by field") + void projectionIsReadFieldByField() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + + BeneficiaryDocument document = service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0); + + assertEquals(100200300L, document.getBenRegId()); + assertEquals("4001", document.getBenId()); + assertEquals("4001", document.getBeneficiaryID()); + assertEquals("Asha", document.getFirstName()); + assertEquals("Rani", document.getMiddleName()); + assertEquals("Devi", document.getLastName()); + assertEquals(2, document.getGenderID()); + assertEquals("Female", document.getGenderName()); + assertEquals("Female", document.getGender()); + assertEquals(30, document.getAge()); + assertEquals("Ram", document.getFatherName()); + assertEquals("Suresh", document.getSpouseName()); + assertEquals(1, document.getMaritalStatusID()); + assertEquals("Married", document.getMaritalStatusName()); + assertEquals("no", document.getIsHIVPos()); + assertEquals("field.worker", document.getCreatedBy()); + assertEquals(1767225600000L, document.getLastModDate()); + assertEquals(77L, document.getBenAccountID()); + assertEquals("9000000000", document.getPhoneNum()); + assertEquals("FAM-1", document.getFamilyID()); + assertNotNull(document.getDOB()); + assertNotNull(document.getCreatedDate()); + } + + @Test + @DisplayName("the current and permanent address columns land in their own fields") + void currentAndPermanentAddressesLandSeparately() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + + BeneficiaryDocument document = service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0); + + assertEquals(101, document.getStateID()); + assertEquals("Karnataka", document.getStateName()); + assertEquals(401, document.getVillageID()); + assertEquals("560064", document.getPinCode()); + assertEquals(501, document.getServicePointID()); + assertEquals(3, document.getParkingPlaceID()); + assertEquals(102, document.getPermStateID()); + assertEquals("Kerala", document.getPermStateName()); + assertEquals(402, document.getPermVillageID()); + assertEquals("Kakkanad", document.getPermVillageName()); + } + + @Test + @DisplayName("a beneficiary with no beneficiary id is left out of the batch") + void beneficiaryWithNoIdIsLeftOut() { + // The beneficiary id becomes the Elasticsearch document id, so a row + // without one cannot be indexed. + useReflectionForAdvanceSearchRepo(); + Object[] row = projectionRow(); + row[1] = null; + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Arrays.asList(row, projectionRow())); + + assertEquals(1, service.getBeneficiariesBatch(List.of(BEN_REG_ID)).size()); + } + + @Test + @DisplayName("a beneficiary with a blank beneficiary id is left out of the batch") + void beneficiaryWithBlankIdIsLeftOut() { + useReflectionForAdvanceSearchRepo(); + Object[] row = projectionRow(); + row[1] = ""; + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(row)); + + assertTrue(service.getBeneficiariesBatch(List.of(BEN_REG_ID)).isEmpty()); + } + + @Test + @DisplayName("one malformed row does not lose the rest of the page") + void oneMalformedRowDoesNotLoseThePage() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Arrays.asList(new Object[] { 1L }, projectionRow())); + + assertEquals(1, service.getBeneficiariesBatch(List.of(BEN_REG_ID)).size()); + } + + @Test + @DisplayName("a failing projection query yields no documents rather than propagating") + void failingProjectionQueryYieldsNoDocuments() { + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenThrow(new IllegalStateException("query timeout")); + + assertTrue(service.getBeneficiariesBatch(List.of(BEN_REG_ID)).isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { "long", "integer", "big-integer", "text", "unparseable" }) + @DisplayName("the numeric column types the native query can return are all handled") + void everyNumericColumnTypeIsHandled(String kind) { + useReflectionForAdvanceSearchRepo(); + Object value; + switch (kind) { + case "long": + value = Long.valueOf(101L); + break; + case "integer": + value = Integer.valueOf(101); + break; + case "big-integer": + value = BigInteger.valueOf(101L); + break; + case "text": + value = "101"; + break; + default: + value = "not-a-number"; + } + Object[] row = projectionRow(); + row[20] = value; // stateID + row[16] = value; // lastModDate + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(row)); + + BeneficiaryDocument document = service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0); + + if ("unparseable".equals(kind)) { + assertNull(document.getStateID()); + assertNull(document.getLastModDate()); + } else { + assertEquals(101, document.getStateID()); + assertEquals(101L, document.getLastModDate()); + } + } + + @Test + @DisplayName("a date column arriving as a plain date or a SQL date is accepted") + void dateColumnIsAcceptedInEitherForm() { + useReflectionForAdvanceSearchRepo(); + Object[] withUtilDate = projectionRow(); + withUtilDate[7] = new java.util.Date(); + Object[] withSqlDate = projectionRow(); + withSqlDate[7] = new java.sql.Date(System.currentTimeMillis()); + Object[] withText = projectionRow(); + withText[7] = "1996-01-15"; + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Arrays.asList(withUtilDate, withSqlDate, withText)); + + List documents = service.getBeneficiariesBatch(List.of(BEN_REG_ID)); + + assertNotNull(documents.get(0).getDOB()); + assertNotNull(documents.get(1).getDOB()); + assertNull(documents.get(2).getDOB()); + } + } + + @Nested + @DisplayName("ABHA enrichment") + class AbhaEnrichment { + + @Test + @DisplayName("a beneficiary's ABHA address and number are attached to its document") + void abhaDetailsAreAttached() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegIDs(anyList())) + .thenReturn(Collections.singletonList(new Object[] { BigInteger.valueOf(100200300L), + "asha@abdm", "12-3456-7890-1234", "AADHAAR_OTP", + Timestamp.valueOf("2026-01-15 10:30:00") })); + + BeneficiaryDocument document = service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0); + + assertEquals("asha@abdm", document.getHealthID()); + assertEquals("12-3456-7890-1234", document.getAbhaID()); + assertNotNull(document.getAbhaCreatedDate()); + } + + @Test + @DisplayName("only the first ABHA record for a beneficiary is used") + void onlyTheFirstAbhaRecordIsUsed() { + // The view returns newest first, and a beneficiary can have more than + // one ABHA linkage recorded. + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegIDs(anyList())).thenReturn(Arrays.asList( + new Object[] { BigInteger.valueOf(100200300L), "newest@abdm", "11", "OTP", null }, + new Object[] { BigInteger.valueOf(100200300L), "older@abdm", "22", "OTP", null })); + + assertEquals("newest@abdm", service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0).getHealthID()); + } + + @ParameterizedTest + @ValueSource(strings = { "big-integer", "long", "integer" }) + @DisplayName("the ABHA view's identifier column is matched whichever numeric type it returns") + void abhaIdentifierColumnIsMatchedWhicheverType(String kind) { + useReflectionForAdvanceSearchRepo(); + Object id; + switch (kind) { + case "long": + id = Long.valueOf(100200300L); + break; + case "integer": + id = Integer.valueOf(100200300); + break; + default: + id = BigInteger.valueOf(100200300L); + } + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegIDs(anyList())) + .thenReturn(Collections.singletonList(new Object[] { id, "asha@abdm", "12", "OTP", null })); + + assertEquals("asha@abdm", service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0).getHealthID()); + } + + @Test + @DisplayName("an ABHA row with an unrecognised identifier type is skipped") + void abhaRowWithUnrecognisedIdentifierIsSkipped() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegIDs(anyList())).thenReturn( + Collections.singletonList(new Object[] { "100200300", "asha@abdm", "12", "OTP", null })); + + assertNull(service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0).getHealthID()); + } + + @Test + @DisplayName("a failing ABHA query still yields documents, just without ABHA") + void failingAbhaQueryStillYieldsDocuments() { + // The ABHA view is not present on every deployment, so its absence + // must not stop a re-index. + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegIDs(anyList())) + .thenThrow(new IllegalStateException("view does not exist")); + + BeneficiaryDocument document = service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0); + + assertEquals("4001", document.getBenId()); + assertNull(document.getHealthID()); + } + + @Test + @DisplayName("no ABHA records for the page leaves the documents unenriched") + void noAbhaRecordsLeavesDocumentsUnenriched() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegIDs(anyList())).thenReturn(Collections.emptyList()); + + assertNull(service.getBeneficiariesBatch(List.of(BEN_REG_ID)).get(0).getHealthID()); + } + } + + @Nested + @DisplayName("single beneficiary loading") + class SingleBeneficiaryLoading { + + @Test + @DisplayName("a single beneficiary is loaded through the same batch query") + void singleBeneficiaryUsesTheBatchQuery() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + + assertEquals("4001", service.getBeneficiaryFromDatabase(BEN_REG_ID).getBenId()); + } + + @Test + @DisplayName("an unknown beneficiary yields no document") + void unknownBeneficiaryYieldsNoDocument() { + when(mappingRepo.findCompleteDataByBenRegIds(anyList())).thenReturn(Collections.emptyList()); + + assertNull(service.getBeneficiaryFromDatabase(BEN_REG_ID)); + assertNull(service.getBeneficiaryWithAbhaDetails(BEN_REG_ID)); + } + + @Test + @DisplayName("a null identifier is rejected without a query") + void nullIdentifierIsRejected() { + assertNull(service.getBeneficiaryWithAbhaDetails(null)); + + verify(mappingRepo, never()).findCompleteDataByBenRegIds(anyList()); + } + + @Test + @DisplayName("the real-time sync path returns the beneficiary with its ABHA details") + void realTimeSyncPathReturnsAbhaDetails() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegIDs(anyList())).thenReturn(Collections + .singletonList(new Object[] { BigInteger.valueOf(100200300L), "asha@abdm", "12", "OTP", null })); + + BeneficiaryDocument document = service.getBeneficiaryWithAbhaDetails(BEN_REG_ID); + + assertEquals("asha@abdm", document.getHealthID()); + } + + @Test + @DisplayName("a beneficiary with no ABHA is still returned by the real-time sync path") + void beneficiaryWithNoAbhaIsStillReturned() { + useReflectionForAdvanceSearchRepo(); + when(mappingRepo.findCompleteDataByBenRegIds(anyList())) + .thenReturn(Collections.singletonList(projectionRow())); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegIDs(anyList())).thenReturn(Collections.emptyList()); + + assertNotNull(service.getBeneficiaryWithAbhaDetails(BEN_REG_ID)); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryElasticsearchIndexServiceTest.java b/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryElasticsearchIndexServiceTest.java new file mode 100644 index 00000000..3b6d5080 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryElasticsearchIndexServiceTest.java @@ -0,0 +1,423 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.math.BigInteger; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.BulkRequest; +import co.elastic.clients.elasticsearch.core.BulkResponse; + +import com.iemr.common.identity.data.elasticsearch.BeneficiaryDocument; +import com.iemr.common.identity.data.elasticsearch.ElasticsearchSyncJob; +import com.iemr.common.identity.repo.elasticsearch.SyncJobRepo; + +/** + * Tests for the resumable, job-tracked variant of the index rebuild. + * + *

+ * Unlike the fire-and-forget sync, this one records its progress on a job row + * so an interrupted rebuild of ~800k beneficiaries can pick up where it left + * off instead of starting over. The behaviour worth protecting is that + * bookkeeping: a resume must continue from the stored offset and keep the + * counts already accumulated, a run of consecutive batch failures must park the + * job as STALLED rather than spin, and any terminal state must be written back + * so the status endpoint stops reporting the job as running. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BeneficiaryElasticsearchIndexServiceTest { + + @Mock + private ElasticsearchClient esClient; + @Mock + private BeneficiaryTransactionHelper transactionalWrapper; + @Mock + private BeneficiaryDocumentDataService dataService; + @Mock + private SyncJobRepo syncJobRepository; + + @InjectMocks + private BeneficiaryElasticsearchIndexService service; + + private static final Long JOB_ID = 55L; + /** Matches BATCH_SIZE in the service. */ + private static final int BATCH_SIZE = 2000; + + @BeforeEach + void configureIndexName() throws IOException { + ReflectionTestUtils.setField(service, "beneficiaryIndex", "beneficiary"); + when(syncJobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(esClient.bulk(any(BulkRequest.class))) + .thenReturn(BulkResponse.of(b -> b.took(1).errors(false).items(Collections.emptyList()))); + } + + private ElasticsearchSyncJob job(String status) { + ElasticsearchSyncJob job = new ElasticsearchSyncJob(); + job.setJobId(JOB_ID); + job.setJobType("FULL_SYNC"); + job.setStatus(status); + job.setProcessedRecords(0L); + job.setSuccessCount(0L); + job.setFailureCount(0L); + job.setCurrentOffset(0); + return job; + } + + private BeneficiaryDocument document(String benId) { + BeneficiaryDocument document = new BeneficiaryDocument(); + document.setBenId(benId); + return document; + } + + private List idPage(int count) { + return IntStream.range(0, count).mapToObj(index -> new Object[] { BigInteger.valueOf(1000L + index) }) + .collect(Collectors.toList()); + } + + /** Returns one page of ids at offset 0, then nothing. */ + private void stubSinglePage(int count) { + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())).thenAnswer(invocation -> { + int offset = invocation.getArgument(0); + return offset == 0 ? idPage(count) : Collections.emptyList(); + }); + } + + @Test + @DisplayName("an unknown job id is rejected before any work starts") + void unknownJobIdIsRejected() { + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.empty()); + + assertThrows(RuntimeException.class, () -> service.syncAllBeneficiariesAsync(JOB_ID, "admin")); + verify(transactionalWrapper, never()).countActiveBeneficiaries(); + } + + @Test + @DisplayName("a fresh job is marked running and stamped with a start time") + void freshJobIsMarkedRunning() { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + stubSinglePage(1); + when(dataService.getBeneficiariesBatch(any())).thenReturn(Collections.singletonList(document("1"))); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertNotNull(job.getStartedAt()); + assertEquals("COMPLETED", job.getStatus()); + assertEquals(1L, job.getSuccessCount()); + assertEquals(0L, job.getFailureCount()); + } + + @Test + @DisplayName("an empty database completes the job with an explanatory message") + void emptyDatabaseCompletesTheJobWithAMessage() { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(0L); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals("COMPLETED", job.getStatus()); + assertEquals("No beneficiaries found to sync", job.getErrorMessage()); + assertNotNull(job.getCompletedAt()); + verify(transactionalWrapper, never()).getBeneficiaryIdsBatch(anyInt(), anyInt()); + } + + @Test + @DisplayName("a total already recorded on the job is reused instead of re-counting") + void recordedTotalIsReused() { + // The count is a full table scan; a resumed job must not pay for it + // again. + ElasticsearchSyncJob job = job("PENDING"); + job.setTotalRecords(1L); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + stubSinglePage(1); + when(dataService.getBeneficiariesBatch(any())).thenReturn(Collections.singletonList(document("1"))); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + verify(transactionalWrapper, never()).countActiveBeneficiaries(); + } + + @Test + @DisplayName("a running job with a stored offset resumes from it and keeps its counts") + void runningJobResumesFromStoredOffset() { + ElasticsearchSyncJob job = job("RUNNING"); + job.setCurrentOffset(BATCH_SIZE); + job.setTotalRecords((long) BATCH_SIZE * 2); + job.setProcessedRecords(1500L); + job.setSuccessCount(1400L); + job.setFailureCount(100L); + job.setStartedAt(new Timestamp(System.currentTimeMillis() - 60_000L)); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())).thenAnswer(invocation -> { + int offset = invocation.getArgument(0); + return offset == BATCH_SIZE ? idPage(1) : Collections.emptyList(); + }); + when(dataService.getBeneficiariesBatch(any())).thenReturn(Collections.singletonList(document("1"))); + + service.syncAllBeneficiariesAsync(JOB_ID, "AUTO_RESUME"); + + assertEquals("COMPLETED", job.getStatus()); + assertEquals(1401L, job.getSuccessCount()); + assertEquals(100L, job.getFailureCount()); + assertEquals(1501L, job.getProcessedRecords()); + verify(transactionalWrapper, never()).getBeneficiaryIdsBatch(org.mockito.ArgumentMatchers.eq(0), anyInt()); + } + + @Test + @DisplayName("a document with no beneficiary id is counted as a failure") + void documentWithNoIdIsCountedAsFailure() { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(2L); + stubSinglePage(2); + when(dataService.getBeneficiariesBatch(any())) + .thenReturn(Arrays.asList(document("1"), document(null), null)); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals(1L, job.getSuccessCount()); + assertEquals(2L, job.getFailureCount()); + } + + @Test + @DisplayName("beneficiaries the batch query did not return are counted as failures") + void beneficiariesNotReturnedAreCountedAsFailures() { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(3L); + stubSinglePage(3); + when(dataService.getBeneficiariesBatch(any())).thenReturn(Collections.singletonList(document("1"))); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals(1L, job.getSuccessCount()); + assertEquals(2L, job.getFailureCount()); + } + + @Test + @DisplayName("a batch whose ids all fail conversion is skipped without loading documents") + void batchWithNoConvertibleIdsIsSkipped() { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())).thenAnswer(invocation -> { + int offset = invocation.getArgument(0); + return offset == 0 ? Collections.singletonList(new Object[] { null }) : Collections.emptyList(); + }); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals("COMPLETED", job.getStatus()); + verify(dataService, never()).getBeneficiariesBatch(any()); + } + + @ParameterizedTest + @ValueSource(strings = { "big-integer", "long", "integer", "short", "text", "unparseable" }) + @DisplayName("every id type the native query can return is converted or skipped") + void everyIdTypeIsConvertedOrSkipped(String kind) { + Object id; + switch (kind) { + case "big-integer": + id = BigInteger.valueOf(1L); + break; + case "long": + id = Long.valueOf(1L); + break; + case "integer": + id = Integer.valueOf(1); + break; + case "short": + id = Short.valueOf((short) 1); + break; + case "text": + id = "1"; + break; + default: + id = "not-a-number"; + } + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + final Object idValue = id; + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())).thenAnswer(invocation -> { + int offset = invocation.getArgument(0); + return offset == 0 ? Collections.singletonList(new Object[] { idValue }) : Collections.emptyList(); + }); + when(dataService.getBeneficiariesBatch(any())).thenReturn(Collections.singletonList(document("1"))); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals("COMPLETED", job.getStatus()); + } + + @Test + @DisplayName("a single failing batch is skipped and the job still completes") + void singleFailingBatchIsSkipped() { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn((long) BATCH_SIZE * 2); + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())).thenAnswer(invocation -> { + int offset = invocation.getArgument(0); + if (offset == 0) { + throw new IllegalStateException("connection reset"); + } + return offset == BATCH_SIZE ? idPage(1) : Collections.emptyList(); + }); + when(dataService.getBeneficiariesBatch(any())).thenReturn(Collections.singletonList(document("1"))); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals("COMPLETED", job.getStatus()); + assertEquals(1L, job.getSuccessCount()); + } + + @Test + @DisplayName("a run of consecutive batch failures parks the job as stalled") + void consecutiveBatchFailuresParkTheJobAsStalled() { + // Backoff caps at 10s per error, so a permanently broken query would + // otherwise hold the executor thread indefinitely. + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn((long) BATCH_SIZE * 20); + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())) + .thenThrow(new IllegalStateException("connection reset")); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals("STALLED", job.getStatus()); + assertTrue(job.getErrorMessage().contains("Too many consecutive errors"), job.getErrorMessage()); + } + + @Test + @DisplayName("a failure outside the batch loop marks the job failed with its reason") + void failureOutsideTheBatchLoopMarksTheJobFailed() { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()) + .thenThrow(new IllegalStateException("table locked")); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals("FAILED", job.getStatus()); + assertEquals("table locked", job.getErrorMessage()); + assertNotNull(job.getCompletedAt()); + } + + @Test + @DisplayName("a completed job records the throughput it achieved") + void completedJobRecordsItsThroughput() { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + stubSinglePage(1); + when(dataService.getBeneficiariesBatch(any())).thenReturn(Collections.singletonList(document("1"))); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertNotNull(job.getProcessingSpeed()); + assertEquals(1, job.getCurrentOffset()); + } + + @Test + @DisplayName("a stalled job can be resumed") + void stalledJobCanBeResumed() { + ElasticsearchSyncJob job = job("STALLED"); + job.setCurrentOffset(BATCH_SIZE); + job.setTotalRecords((long) BATCH_SIZE); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + + service.resumeStalledJob(JOB_ID); + + assertEquals("COMPLETED", job.getStatus()); + } + + @ParameterizedTest + @ValueSource(strings = { "COMPLETED", "FAILED", "PENDING", "CANCELLED" }) + @DisplayName("a job that is not stalled or running cannot be resumed") + void jobThatIsNotStalledOrRunningCannotBeResumed(String status) { + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job(status))); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> service.resumeStalledJob(JOB_ID)); + + assertTrue(thrown.getMessage().contains("Cannot resume job"), thrown.getMessage()); + } + + @Test + @DisplayName("resuming an unknown job id is rejected") + void resumingUnknownJobIdIsRejected() { + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.empty()); + + assertThrows(RuntimeException.class, () -> service.resumeStalledJob(JOB_ID)); + } + + @Test + @DisplayName("a bulk request that cannot be sent counts the batch as failed and still completes the job") + void unsendableBulkStillCompletesTheJob() throws Exception { + ElasticsearchSyncJob job = job("PENDING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job)); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + stubSinglePage(1); + when(dataService.getBeneficiariesBatch(any())).thenReturn(Collections.singletonList(document("1"))); + when(esClient.bulk(any(BulkRequest.class))).thenThrow(new IOException("index unavailable")); + + service.syncAllBeneficiariesAsync(JOB_ID, "admin"); + + assertEquals("COMPLETED", job.getStatus()); + assertEquals(0L, job.getSuccessCount()); + assertEquals(1L, job.getFailureCount()); + } +} diff --git a/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryElasticsearchIndexUpdaterTest.java b/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryElasticsearchIndexUpdaterTest.java new file mode 100644 index 00000000..2fe661f9 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryElasticsearchIndexUpdaterTest.java @@ -0,0 +1,197 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.math.BigInteger; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.Result; +import co.elastic.clients.elasticsearch.core.DeleteRequest; +import co.elastic.clients.elasticsearch.core.DeleteResponse; +import co.elastic.clients.elasticsearch.core.IndexRequest; +import co.elastic.clients.elasticsearch.core.IndexResponse; +import co.elastic.clients.util.ObjectBuilder; + +import com.iemr.common.identity.data.elasticsearch.BeneficiaryDocument; + +/** + * Tests for the per-beneficiary index update triggered after a create or edit. + * + *

+ * This runs asynchronously off the request thread, so a failure here can never + * be allowed to surface to the caller - a beneficiary must stay registered even + * if the index is down. The other thing worth pinning is the document id: it + * has to be the beneficiary id, matching the bulk sync and the delete path. + * Keying it on the registration id instead wrote a second document per + * beneficiary, so edits never appeared in search. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BeneficiaryElasticsearchIndexUpdaterTest { + + @Mock + private ElasticsearchClient esClient; + @Mock + private BeneficiaryDocumentDataService dataService; + + @InjectMocks + private BeneficiaryElasticsearchIndexUpdater updater; + + private static final BigInteger BEN_REG_ID = BigInteger.valueOf(100200300L); + + private IndexRequest capturedIndexRequest; + + @BeforeEach + @SuppressWarnings({ "unchecked", "rawtypes" }) + void configureUpdater() throws IOException { + ReflectionTestUtils.setField(updater, "beneficiaryIndex", "beneficiary"); + ReflectionTestUtils.setField(updater, "esEnabled", true); + capturedIndexRequest = null; + when(esClient.index(any(Function.class))).thenAnswer(invocation -> { + Function> builder = invocation.getArgument(0); + capturedIndexRequest = builder.apply(new IndexRequest.Builder<>()).build(); + return IndexResponse.of(r -> r.index("beneficiary").id("4001").version(1L).result(Result.Updated) + .seqNo(1L).primaryTerm(1L).shards(s -> s.total(1).successful(1).failed(0))); + }); + } + + private BeneficiaryDocument document(String benId) { + BeneficiaryDocument document = new BeneficiaryDocument(); + document.setBenId(benId); + document.setBenRegId(100200300L); + document.setFirstName("Asha"); + return document; + } + + @Test + @DisplayName("an edited beneficiary is re-indexed under its beneficiary id") + void editedBeneficiaryIsReindexedUnderItsBeneficiaryId() throws Exception { + when(dataService.getBeneficiaryWithAbhaDetails(BEN_REG_ID)).thenReturn(document("4001")); + + assertTrue(updater.syncBeneficiaryAsync(BEN_REG_ID).isDone()); + + assertNotNull(capturedIndexRequest); + assertEquals("beneficiary", capturedIndexRequest.index()); + assertEquals("4001", capturedIndexRequest.id()); + } + + @Test + @DisplayName("the re-index refreshes immediately so the edit is searchable at once") + void reindexRefreshesImmediately() throws Exception { + // The agent who just saved the edit searches again straight away. + when(dataService.getBeneficiaryWithAbhaDetails(BEN_REG_ID)).thenReturn(document("4001")); + + updater.syncBeneficiaryAsync(BEN_REG_ID).get(); + + assertEquals(co.elastic.clients.elasticsearch._types.Refresh.True, capturedIndexRequest.refresh()); + } + + @Test + @DisplayName("a beneficiary with no document is skipped") + void beneficiaryWithNoDocumentIsSkipped() throws Exception { + when(dataService.getBeneficiaryWithAbhaDetails(BEN_REG_ID)).thenReturn(null); + + updater.syncBeneficiaryAsync(BEN_REG_ID).get(); + + verifyNoInteractions(esClient); + } + + @Test + @DisplayName("a document with no beneficiary id is skipped rather than indexed under a guessed id") + void documentWithNoBeneficiaryIdIsSkipped() throws Exception { + when(dataService.getBeneficiaryWithAbhaDetails(BEN_REG_ID)).thenReturn(document(null)); + + updater.syncBeneficiaryAsync(BEN_REG_ID).get(); + + verifyNoInteractions(esClient); + } + + @Test + @DisplayName("an index failure does not fail the edit that triggered it") + void indexFailureDoesNotFailTheEdit() throws Exception { + when(dataService.getBeneficiaryWithAbhaDetails(BEN_REG_ID)).thenReturn(document("4001")); + when(esClient.index(any(Function.class))).thenThrow(new IOException("index unavailable")); + + assertTrue(updater.syncBeneficiaryAsync(BEN_REG_ID).isDone()); + } + + @Test + @DisplayName("a deleted beneficiary is removed from the index under its beneficiary id") + void deletedBeneficiaryIsRemovedFromTheIndex() throws Exception { + when(esClient.delete(any(DeleteRequest.class))).thenReturn(DeleteResponse.of(r -> r.index("beneficiary") + .id("4001").version(1L).result(Result.Deleted).seqNo(1L).primaryTerm(1L) + .shards(s -> s.total(1).successful(1).failed(0)))); + + updater.deleteBeneficiaryAsync("4001"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteRequest.class); + verify(esClient).delete(captor.capture()); + assertEquals("beneficiary", captor.getValue().index()); + assertEquals("4001", captor.getValue().id()); + } + + @Test + @DisplayName("nothing is sent to the index when the integration is switched off") + void nothingIsSentWhenIntegrationIsOff() throws Exception { + ReflectionTestUtils.setField(updater, "esEnabled", false); + + updater.deleteBeneficiaryAsync("4001"); + + verify(esClient, never()).delete(any(DeleteRequest.class)); + } + + @Test + @DisplayName("a delete failure is swallowed rather than surfaced to the caller") + void deleteFailureIsSwallowed() throws Exception { + when(esClient.delete(any(DeleteRequest.class))).thenThrow(new IOException("index unavailable")); + + updater.deleteBeneficiaryAsync("4001"); + + verify(esClient).delete(any(DeleteRequest.class)); + } + + private static void assertEquals(Object expected, Object actual) { + org.junit.jupiter.api.Assertions.assertEquals(expected, actual); + } +} diff --git a/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryTransactionHelperTest.java b/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryTransactionHelperTest.java new file mode 100644 index 00000000..6870e77d --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/elasticsearch/BeneficiaryTransactionHelperTest.java @@ -0,0 +1,119 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.math.BigInteger; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.iemr.common.identity.repo.BenMappingRepo; + +/** + * Tests for the transaction boundary the re-index paging runs inside. + * + *

+ * Each page runs in its own short transaction so a rebuild does not hold one + * connection open for the whole run. That makes this class thin, but it is the + * one place where a query failure has to propagate rather than be swallowed: + * the callers count on an exception to trigger their retry and backoff. + */ +@ExtendWith(MockitoExtension.class) +class BeneficiaryTransactionHelperTest { + + @Mock + private BenMappingRepo mappingRepo; + + @InjectMocks + private BeneficiaryTransactionHelper helper; + + @Test + @DisplayName("a page of beneficiary ids is fetched with the requested window") + void pageOfIdsIsFetchedWithTheRequestedWindow() { + List page = Collections.singletonList(new Object[] { BigInteger.ONE }); + when(mappingRepo.getBeneficiaryIdsBatch(2000, 500)).thenReturn(page); + + assertEquals(page, helper.getBeneficiaryIdsBatch(2000, 500)); + } + + @Test + @DisplayName("a failing page query propagates so the caller can retry it") + void failingPageQueryPropagates() { + when(mappingRepo.getBeneficiaryIdsBatch(0, 500)).thenThrow(new IllegalStateException("connection reset")); + + assertThrows(IllegalStateException.class, () -> helper.getBeneficiaryIdsBatch(0, 500)); + } + + @Test + @DisplayName("the active beneficiary count is passed through") + void activeCountIsPassedThrough() { + when(mappingRepo.countActiveBeneficiaries()).thenReturn(784_000L); + + assertEquals(784_000L, helper.countActiveBeneficiaries()); + } + + @Test + @DisplayName("a failing count propagates rather than reporting zero") + void failingCountPropagates() { + // Zero would make the caller skip the whole rebuild. + when(mappingRepo.countActiveBeneficiaries()).thenThrow(new IllegalStateException("table locked")); + + assertThrows(IllegalStateException.class, () -> helper.countActiveBeneficiaries()); + } + + @Test + @DisplayName("a beneficiary with at least one active row exists") + void beneficiaryWithAnActiveRowExists() { + when(mappingRepo.countActiveByBenRegId(BigInteger.TEN)).thenReturn(1L); + + assertTrue(helper.existsByBenRegId(BigInteger.TEN)); + } + + @Test + @DisplayName("a beneficiary with no active row does not exist") + void beneficiaryWithNoActiveRowDoesNotExist() { + when(mappingRepo.countActiveByBenRegId(BigInteger.TEN)).thenReturn(0L); + + assertFalse(helper.existsByBenRegId(BigInteger.TEN)); + } + + @Test + @DisplayName("a failing existence check propagates rather than reporting absence") + void failingExistenceCheckPropagates() { + when(mappingRepo.countActiveByBenRegId(BigInteger.TEN)) + .thenThrow(new IllegalStateException("connection reset")); + + assertThrows(IllegalStateException.class, () -> helper.existsByBenRegId(BigInteger.TEN)); + } +} diff --git a/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchIndexingServiceTest.java b/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchIndexingServiceTest.java new file mode 100644 index 00000000..86f322d5 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchIndexingServiceTest.java @@ -0,0 +1,291 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.indices.ElasticsearchIndicesClient; +import co.elastic.clients.elasticsearch.indices.CreateIndexRequest; +import co.elastic.clients.elasticsearch.indices.CreateIndexResponse; +import co.elastic.clients.elasticsearch.indices.DeleteIndexRequest; +import co.elastic.clients.elasticsearch.indices.ExistsRequest; +import co.elastic.clients.elasticsearch.indices.ForcemergeRequest; +import co.elastic.clients.elasticsearch.indices.PutIndicesSettingsRequest; +import co.elastic.clients.elasticsearch.indices.RefreshRequest; +import co.elastic.clients.transport.endpoints.BooleanResponse; +import co.elastic.clients.util.ObjectBuilder; + +/** + * Tests for the index lifecycle used around a full re-index. + * + *

+ * A rebuild runs in two phases: create the index with write-optimised settings + * (refresh disabled, no replicas, async translog) so ~800k documents land + * quickly, then switch it to read-optimised settings once the data is in. Get + * that ordering or those settings wrong and either the rebuild takes hours or + * the index is left permanently un-refreshed and invisible to search - neither + * of which shows up until production. The tests build the real requests the + * client would send and assert on the settings they carry. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ElasticsearchIndexingServiceTest { + + @Mock + private ElasticsearchClient esClient; + @Mock + private ElasticsearchIndicesClient indicesClient; + @Mock + private ElasticsearchSyncService syncService; + + @InjectMocks + private ElasticsearchIndexingService service; + + private CreateIndexRequest createRequest; + private PutIndicesSettingsRequest putSettingsRequest; + private ForcemergeRequest forcemergeRequest; + + @BeforeEach + @SuppressWarnings("unchecked") + void configureClient() throws IOException { + ReflectionTestUtils.setField(service, "beneficiaryIndex", "beneficiary"); + when(esClient.indices()).thenReturn(indicesClient); + + when(indicesClient.exists(any(Function.class))).thenAnswer(invocation -> { + Function> builder = invocation.getArgument(0); + builder.apply(new ExistsRequest.Builder()).build(); + return new BooleanResponse(false); + }); + when(indicesClient.create(any(Function.class))).thenAnswer(invocation -> { + Function> builder = invocation + .getArgument(0); + createRequest = builder.apply(new CreateIndexRequest.Builder()).build(); + return CreateIndexResponse.of(r -> r.acknowledged(true).shardsAcknowledged(true).index("beneficiary")); + }); + when(indicesClient.refresh(any(Function.class))).thenAnswer(invocation -> { + Function> builder = invocation.getArgument(0); + builder.apply(new RefreshRequest.Builder()).build(); + return co.elastic.clients.elasticsearch.indices.RefreshResponse + .of(r -> r.shards(s -> s.total(1.0).successful(1.0).failed(0.0))); + }); + when(indicesClient.putSettings(any(Function.class))).thenAnswer(invocation -> { + Function> builder = invocation + .getArgument(0); + putSettingsRequest = builder.apply(new PutIndicesSettingsRequest.Builder()).build(); + return co.elastic.clients.elasticsearch.indices.PutIndicesSettingsResponse + .of(r -> r.acknowledged(true)); + }); + when(indicesClient.forcemerge(any(Function.class))).thenAnswer(invocation -> { + Function> builder = invocation.getArgument(0); + forcemergeRequest = builder.apply(new ForcemergeRequest.Builder()).build(); + return co.elastic.clients.elasticsearch.indices.ForcemergeResponse + .of(r -> r.shards(s -> s.total(1.0).successful(1.0).failed(0.0))); + }); + } + + @Test + @DisplayName("the index is created write-optimised so a full rebuild can keep up") + void indexIsCreatedWriteOptimised() throws Exception { + service.createIndexWithMapping(); + + assertNotNull(createRequest); + assertEquals("beneficiary", createRequest.index()); + assertEquals("-1", createRequest.settings().refreshInterval().time()); + assertEquals("1", createRequest.settings().numberOfShards()); + assertEquals("0", createRequest.settings().numberOfReplicas()); + assertEquals(co.elastic.clients.elasticsearch.indices.TranslogDurability.Async, + createRequest.settings().translog().durability()); + } + + @Test + @DisplayName("the searchable name fields are mapped for exact, prefix and fuzzy matching") + void nameFieldsAreMappedForEveryMatchStyle() throws Exception { + // The universal search issues term, prefix and fuzzy clauses against + // these fields; a missing sub-field silently returns no hits for one of + // them. + service.createIndexWithMapping(); + + Map properties = createRequest.mappings() + .properties(); + for (String field : new String[] { "firstName", "middleName", "lastName" }) { + assertNotNull(properties.get(field), field + " is not mapped"); + assertTrue(properties.get(field).text().fields().containsKey("keyword"), + field + " has no keyword sub-field for exact matching"); + assertTrue(properties.get(field).text().fields().containsKey("prefix"), + field + " has no prefix sub-field"); + } + assertNotNull(properties.get("benRegId")); + assertNotNull(properties.get("beneficiaryID")); + assertNotNull(properties.get("permVillageName")); + assertNotNull(properties.get("aadharNo")); + } + + @Test + @DisplayName("an existing index is dropped before it is recreated") + void existingIndexIsDroppedFirst() throws Exception { + when(indicesClient.exists(any(Function.class))).thenReturn(new BooleanResponse(true)); + + service.createIndexWithMapping(); + + verify(indicesClient).delete(any(Function.class)); + assertNotNull(createRequest); + } + + @Test + @DisplayName("an index that does not exist yet is not deleted") + void absentIndexIsNotDeleted() throws Exception { + service.createIndexWithMapping(); + + verify(indicesClient, never()).delete(any(Function.class)); + } + + @Test + @DisplayName("a failure while creating the index is surfaced to the caller") + void createFailureIsSurfaced() throws Exception { + when(indicesClient.create(any(Function.class))).thenThrow(new IOException("cluster unavailable")); + + assertThrows(IOException.class, () -> service.createIndexWithMapping()); + } + + @Test + @DisplayName("optimising for search refreshes, re-settles the settings and merges segments, in that order") + void optimiseRefreshesThenResettlesThenMerges() throws Exception { + service.optimizeForSearch(); + + org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(indicesClient); + inOrder.verify(indicesClient).refresh(any(Function.class)); + inOrder.verify(indicesClient).putSettings(any(Function.class)); + inOrder.verify(indicesClient).forcemerge(any(Function.class)); + } + + @Test + @DisplayName("optimising for search turns refresh and replication back on") + void optimiseTurnsRefreshAndReplicationBackOn() throws Exception { + // Leaving refresh at -1 after a rebuild is the failure mode this guards: + // the documents are in the index but never become searchable. + service.optimizeForSearch(); + + assertEquals("1s", putSettingsRequest.settings().refreshInterval().time()); + assertEquals("1", putSettingsRequest.settings().numberOfReplicas()); + assertEquals(co.elastic.clients.elasticsearch.indices.TranslogDurability.Request, + putSettingsRequest.settings().translog().durability()); + assertTrue(putSettingsRequest.settings().queries().cache().enabled()); + } + + @Test + @DisplayName("segments are merged down to one per shard and flushed") + void segmentsAreMergedToOnePerShard() throws Exception { + service.optimizeForSearch(); + + assertEquals(1L, forcemergeRequest.maxNumSegments()); + assertTrue(forcemergeRequest.flush()); + } + + @Test + @DisplayName("a failure while optimising is surfaced to the caller") + void optimiseFailureIsSurfaced() throws Exception { + when(indicesClient.refresh(any(Function.class))).thenThrow(new IOException("cluster unavailable")); + + assertThrows(IOException.class, () -> service.optimizeForSearch()); + } + + @Test + @DisplayName("the full workflow reports the sync counts it achieved") + void fullWorkflowReportsSyncCounts() { + ElasticsearchSyncService.SyncResult result = new ElasticsearchSyncService.SyncResult(); + result.addSuccess(700); + result.addFailure(3); + when(syncService.syncAllBeneficiaries()).thenReturn(result); + + Map response = service.indexAllBeneficiaries(); + + assertEquals(700, response.get("success")); + assertEquals(3, response.get("failed")); + } + + @Test + @DisplayName("the full workflow optimises for search once the data is in") + void fullWorkflowOptimisesAfterSyncing() throws Exception { + when(syncService.syncAllBeneficiaries()).thenReturn(new ElasticsearchSyncService.SyncResult()); + + service.indexAllBeneficiaries(); + + org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(syncService, indicesClient); + inOrder.verify(syncService).syncAllBeneficiaries(); + inOrder.verify(indicesClient).refresh(any(Function.class)); + } + + @Test + @DisplayName("a workflow that fails reports zero counts rather than throwing") + void failingWorkflowReportsZeroCounts() { + when(syncService.syncAllBeneficiaries()).thenThrow(new IllegalStateException("database unavailable")); + + Map response = service.indexAllBeneficiaries(); + + assertEquals(0, response.get("success")); + assertEquals(0, response.get("failed")); + } + + @Test + @DisplayName("a workflow whose optimisation fails still reports zero rather than partial counts") + void workflowWithFailingOptimisationReportsZero() throws Exception { + ElasticsearchSyncService.SyncResult result = new ElasticsearchSyncService.SyncResult(); + result.addSuccess(700); + when(syncService.syncAllBeneficiaries()).thenReturn(result); + when(indicesClient.refresh(any(Function.class))).thenThrow(new IOException("cluster unavailable")); + + Map response = service.indexAllBeneficiaries(); + + assertEquals(0, response.get("success")); + } + + @Test + @DisplayName("index statistics failures are surfaced rather than reported as empty stats") + void indexStatisticsFailuresAreSurfaced() throws Exception { + when(indicesClient.stats(any(Function.class))).thenThrow(new IOException("cluster unavailable")); + + assertThrows(IOException.class, () -> service.getIndexStats()); + } +} diff --git a/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchServiceTest.java b/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchServiceTest.java new file mode 100644 index 00000000..b5567580 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchServiceTest.java @@ -0,0 +1,772 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.search.TotalHitsRelation; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.util.ObjectBuilder; + +import com.iemr.common.identity.dto.BeneficiariesESDTO; +import com.iemr.common.identity.repo.BenAddressRepo; +import com.iemr.common.identity.repo.BenDetailRepo; +import com.iemr.common.identity.repo.V_BenAdvanceSearchRepo; + +/** + * Tests for the Elasticsearch-backed beneficiary search. + * + *

+ * Nearly all of this service is a query built out of nested lambdas and a + * projection of the hit back into the wide map shape the 1097 call-centre UI + * expects. Two things can go wrong there and neither shows up at compile time: + * the query DSL can be assembled into something the client rejects (a boost on + * a clause that cannot carry one, a term on a field that is not a keyword), and + * the projection can quietly drop or rename a field the UI reads. + * + *

+ * The client mock therefore applies the builder lambda it is handed + * against a real {@link SearchRequest.Builder} instead of ignoring it, so an + * unbuildable query fails the test. The service swallows search failures and + * falls back to the database, so tests assert the fallback happened rather than + * expecting an exception. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ElasticsearchServiceTest { + + @Mock + private ElasticsearchClient esClient; + @Mock + private BenDetailRepo benDetailRepo; + @Mock + private BenAddressRepo benAddressRepo; + @Mock + private V_BenAdvanceSearchRepo advanceSearchRepo; + + @InjectMocks + private ElasticsearchService service; + + /** The request the client mock actually built, for assertions on the DSL. */ + private SearchRequest builtRequest; + + @BeforeEach + void configureService() { + ReflectionTestUtils.setField(service, "beneficiaryIndex", "beneficiary"); + ReflectionTestUtils.setField(service, "esEnabled", true); + ReflectionTestUtils.setField(service, "v_BenAdvanceSearchRepo", advanceSearchRepo); + builtRequest = null; + } + + /** + * Stubs the client so the builder lambda is applied for real and the given + * documents come back as hits. + */ + @SuppressWarnings("unchecked") + private void stubSearchReturning(BeneficiariesESDTO... documents) throws IOException { + when(esClient.search(any(Function.class), any(Class.class))).thenAnswer(invocation -> { + Function> builder = invocation.getArgument(0); + builtRequest = builder.apply(new SearchRequest.Builder()).build(); + return searchResponse(documents); + }); + } + + /** Stubs the client so the query is still built, but the search then fails. */ + @SuppressWarnings("unchecked") + private void stubSearchFailing() throws IOException { + when(esClient.search(any(Function.class), any(Class.class))).thenAnswer(invocation -> { + Function> builder = invocation.getArgument(0); + builtRequest = builder.apply(new SearchRequest.Builder()).build(); + throw new IOException("index unavailable"); + }); + } + + private SearchResponse searchResponse(BeneficiariesESDTO... documents) { + List> hits = new ArrayList<>(); + for (int i = 0; i < documents.length; i++) { + final BeneficiariesESDTO document = documents[i]; + final String id = String.valueOf(i); + hits.add(Hit.of(h -> h.index("beneficiary").id(id).score(9.5).source(document))); + } + return SearchResponse.of(r -> r.took(5).timedOut(false) + .shards(s -> s.total(1).successful(1).failed(0)) + .hits(h -> h.total(t -> t.value(hits.size()).relation(TotalHitsRelation.Eq)).hits(hits))); + } + + private BeneficiariesESDTO document() { + BeneficiariesESDTO dto = new BeneficiariesESDTO(); + dto.setBenRegId(100200300L); + dto.setBeneficiaryID("4001"); + dto.setFirstName("Asha"); + dto.setLastName("Devi"); + dto.setGenderID(2); + dto.setGenderName("Female"); + dto.setAge(30); + dto.setPhoneNum("9000000000"); + dto.setStateID(101); + dto.setStateName("Karnataka"); + dto.setDistrictID(201); + dto.setDistrictName("Bengaluru"); + dto.setBlockID(301); + dto.setBlockName("North"); + dto.setVillageID(401); + dto.setVillageName("Yelahanka"); + dto.setPinCode("560064"); + dto.setFamilyID("FAM-1"); + return dto; + } + + /** The 31-column projection the database fallback query returns. */ + private Object[] databaseRow() { + Object[] row = new Object[31]; + row[0] = 100200300L; // beneficiaryRegID + row[1] = "4001"; // beneficiaryID + row[2] = "Asha"; + row[3] = "Rani"; + row[4] = "Devi"; + row[5] = 2; // genderID + row[6] = "Female"; + row[7] = Timestamp.valueOf("1996-01-01 00:00:00"); // dob + row[8] = 30; // age + row[9] = "Ram"; // fatherName + row[10] = "Suresh"; // spouseName + row[11] = "1"; // maritalStatusID + row[12] = "Married"; + row[13] = "no"; // isHIVPos + row[14] = "field.worker"; + row[15] = Timestamp.valueOf("2026-01-01 00:00:00"); + row[16] = 1767225600000L; // lastModDate + row[17] = 77L; // benAccountID + row[18] = 101; // stateID + row[19] = "Karnataka"; + row[20] = 201; + row[21] = "Bengaluru"; + row[22] = 301; + row[23] = "North"; + row[24] = "560064"; + row[25] = 501; // servicePointID + row[26] = "PHC Yelahanka"; + row[27] = 3; // parkingPlaceID + row[28] = "9000000000"; + row[29] = 401; // villageID + row[30] = "Yelahanka"; + return row; + } + + @Nested + @DisplayName("universal search query construction") + class UniversalSearchQueryConstruction { + + @Test + @DisplayName("a single-word name query builds against the configured index") + void singleWordQueryBuildsAgainstConfiguredIndex() throws Exception { + stubSearchReturning(document()); + + service.universalSearch("Asha", null); + + assertNotNull(builtRequest); + assertEquals(Collections.singletonList("beneficiary"), builtRequest.index()); + assertEquals(100, builtRequest.size()); + } + + /** healthID, abhaID, beneficiaryID, benId and aadharNo, on every query. */ + private static final int IDENTIFIER_CLAUSES = 5; + + @Test + @DisplayName("a multi-word query builds one clause group per word alongside the identifier clauses") + void multiWordQueryBuildsAClauseGroupPerWord() throws Exception { + stubSearchReturning(document()); + + service.universalSearch("Asha Rani Devi", null); + + assertNotNull(builtRequest); + assertEquals(3 + IDENTIFIER_CLAUSES, + builtRequest.query().functionScore().query().bool().should().size()); + } + + @Test + @DisplayName("a numeric query adds the phone and identifier prefix clauses a name query does not") + void numericQueryAddsPrefixClauses() throws Exception { + stubSearchReturning(document()); + + service.universalSearch("100200300", null); + + // Five identifier terms, four identifier prefixes, one phone + // wildcard, and the benRegId and benAccountID numeric terms. + assertEquals(12, builtRequest.query().functionScore().query().bool().should().size()); + } + + @Test + @DisplayName("a numeric query matching the user's own village and block boosts those clauses") + void numericQueryMatchingUsersLocationBoostsThoseClauses() throws Exception { + stubSearchReturning(document()); + when(benAddressRepo.getUserLocation(42)) + .thenReturn(Collections.singletonList(new Object[] { 11, 401, 401, 501 })); + + service.universalSearch("401", 42); + + // The village and block clauses are added on top of the five + // identifier terms, four prefixes and two numeric ID terms; the + // phone wildcard needs four digits, so it is absent. + assertEquals(13, builtRequest.query().functionScore().query().bool().should().size()); + } + + @Test + @DisplayName("a short numeric query omits the phone-contains wildcard") + void shortNumericQueryOmitsPhoneWildcard() throws Exception { + stubSearchReturning(document()); + + service.universalSearch("401", null); + + assertEquals(11, builtRequest.query().functionScore().query().bool().should().size()); + } + + @ParameterizedTest + @ValueSource(strings = { "A", "As", "Asha", "Asha Rani", " Asha " }) + @DisplayName("queries of any length build without tripping the fuzzy and wildcard thresholds") + void queriesOfAnyLengthBuild(String query) throws Exception { + stubSearchReturning(document()); + + assertEquals(1, service.universalSearch(query, null).size()); + assertNotNull(builtRequest); + } + + @Test + @DisplayName("a numeric query is given the lower relevance floor an ID lookup needs") + void numericQueryUsesLowerRelevanceFloor() throws Exception { + stubSearchReturning(document()); + + service.universalSearch("100200300", null); + + assertEquals(1.0d, builtRequest.minScore()); + } + + @Test + @DisplayName("a name query is given a higher relevance floor to keep noise out") + void nameQueryUsesHigherRelevanceFloor() throws Exception { + stubSearchReturning(document()); + + service.universalSearch("Asha", null); + + assertEquals(1.5d, builtRequest.minScore()); + } + + @Test + @DisplayName("results are ranked toward the searching user's own village and block") + void resultsAreRankedTowardTheUsersLocation() throws Exception { + stubSearchReturning(document()); + when(benAddressRepo.getUserLocation(42)) + .thenReturn(Collections.singletonList(new Object[] { 11, 301, 401, 501 })); + + service.universalSearch("Asha", 42); + + assertEquals(2, builtRequest.query().functionScore().functions().size()); + } + + @Test + @DisplayName("an unknown user contributes no location ranking") + void unknownUserContributesNoLocationRanking() throws Exception { + stubSearchReturning(document()); + when(benAddressRepo.getUserLocation(42)).thenReturn(Collections.emptyList()); + + service.universalSearch("Asha", 42); + + assertTrue(builtRequest.query().functionScore().functions().isEmpty()); + } + + @Test + @DisplayName("a failing user-location lookup does not stop the search") + void failingUserLocationLookupDoesNotStopTheSearch() throws Exception { + stubSearchReturning(document()); + when(benAddressRepo.getUserLocation(42)).thenThrow(new IllegalStateException("view unavailable")); + + assertEquals(1, service.universalSearch("Asha", 42).size()); + } + + @Test + @DisplayName("the single-argument overload searches without location ranking") + void singleArgumentOverloadSearchesWithoutLocationRanking() throws Exception { + stubSearchReturning(document()); + + assertEquals(1, service.universalSearch("Asha").size()); + + verify(benAddressRepo, never()).getUserLocation(any()); + } + } + + @Nested + @DisplayName("universal search result projection") + class UniversalSearchResultProjection { + + @Test + @DisplayName("a hit is projected into the wide shape the call-centre UI reads") + void hitIsProjectedIntoTheWideShape() throws Exception { + stubSearchReturning(document()); + + Map result = service.universalSearch("Asha", null).get(0); + + assertEquals(100200300L, result.get("beneficiaryRegID")); + assertEquals("Asha", result.get("firstName")); + assertEquals("Years", result.get("ageUnits")); + assertEquals(30, result.get("age")); + assertEquals(30, result.get("actualAge")); + assertEquals("FAM-1", result.get("familyID")); + assertEquals("FAM-1", result.get("familyId")); + assertEquals(9.5, result.get("_score")); + } + + @Test + @DisplayName("absent optional names are projected as blanks rather than nulls") + void absentOptionalNamesAreProjectedAsBlanks() throws Exception { + BeneficiariesESDTO document = document(); + document.setMiddleName(null); + document.setLastName(null); + document.setFatherName(null); + document.setSpouseName(null); + document.setMaritalStatusID(null); + document.setMaritalStatusName(null); + document.setIsHIVPos(null); + stubSearchReturning(document); + + Map result = service.universalSearch("Asha", null).get(0); + + assertEquals("", result.get("middleName")); + assertEquals("", result.get("lastName")); + assertEquals("", result.get("fatherName")); + assertEquals("", result.get("spouseName")); + assertEquals("", result.get("maritalStatusID")); + assertEquals("", result.get("isHIVPos")); + } + + @Test + @DisplayName("the address hierarchy is projected as the nested objects the UI expects") + void addressHierarchyIsProjectedAsNestedObjects() throws Exception { + stubSearchReturning(document()); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + Map demographics = (Map) result.get("i_bendemographics"); + assertEquals(101, demographics.get("stateID")); + assertEquals(401, demographics.get("villageID")); + assertEquals(401, demographics.get("districtBranchID")); + @SuppressWarnings("unchecked") + Map state = (Map) demographics.get("m_state"); + assertEquals("Karnataka", state.get("stateName")); + assertEquals(1, state.get("countryID")); + @SuppressWarnings("unchecked") + Map branch = (Map) demographics.get("m_districtbranchmapping"); + assertEquals("560064", branch.get("pinCode")); + } + + @Test + @DisplayName("a phone number on the document becomes a self-relationship phone map") + void phoneNumberBecomesSelfRelationshipPhoneMap() throws Exception { + stubSearchReturning(document()); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + List> phoneMaps = (List>) result.get("benPhoneMaps"); + assertEquals(1, phoneMaps.size()); + assertEquals("9000000000", phoneMaps.get(0).get("phoneNo")); + @SuppressWarnings("unchecked") + Map relation = (Map) phoneMaps.get(0).get("benRelationshipType"); + assertEquals("Self", relation.get("benRelationshipType")); + } + + @Test + @DisplayName("a document with no phone number yields no phone maps") + void documentWithNoPhoneNumberYieldsNoPhoneMaps() throws Exception { + BeneficiariesESDTO document = document(); + document.setPhoneNum(""); + stubSearchReturning(document); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + List> phoneMaps = (List>) result.get("benPhoneMaps"); + assertTrue(phoneMaps.isEmpty()); + } + + @Test + @DisplayName("an ABHA number on the document is projected without a database round trip") + void abhaOnDocumentAvoidsDatabaseRoundTrip() throws Exception { + BeneficiariesESDTO document = document(); + document.setAbhaID("12-3456-7890-1234"); + document.setHealthID("asha@abdm"); + document.setAbhaCreatedDate("2026-01-15"); + stubSearchReturning(document); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + List> abhaDetails = (List>) result.get("abhaDetails"); + assertEquals(1, abhaDetails.size()); + assertEquals("12-3456-7890-1234", abhaDetails.get(0).get("healthIDNumber")); + assertNotNull(abhaDetails.get(0).get("createdDate")); + verify(advanceSearchRepo, never()).getBenAbhaDetailsByBenRegID(any()); + } + + @ParameterizedTest + @ValueSource(strings = { "2026-01-15", "2026-01-15 10:30:00", "2026-01-15 10:30:00.5" }) + @DisplayName("every ABHA date format the index stores is parsed to epoch millis") + void everyAbhaDateFormatIsParsed(String storedDate) throws Exception { + BeneficiariesESDTO document = document(); + document.setAbhaID("12-3456-7890-1234"); + document.setAbhaCreatedDate(storedDate); + stubSearchReturning(document); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + List> abhaDetails = (List>) result.get("abhaDetails"); + assertNotNull(abhaDetails.get(0).get("createdDate")); + } + + @Test + @DisplayName("an unparseable ABHA date drops the whole projection rather than half of it") + void unparseableAbhaDateDropsTheProjection() throws Exception { + // The mapper aborts and returns null, and the caller filters it out - + // a beneficiary is dropped from the results rather than returned with + // a corrupt ABHA block. + BeneficiariesESDTO document = document(); + document.setAbhaID("12-3456-7890-1234"); + document.setAbhaCreatedDate("15/01/2026"); + stubSearchReturning(document); + + assertTrue(service.universalSearch("Asha", null).isEmpty()); + } + + @Test + @DisplayName("a document without ABHA falls back to the ABHA view") + void documentWithoutAbhaFallsBackToTheView() throws Exception { + stubSearchReturning(document()); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegID(any())).thenReturn(Collections + .singletonList(new Object[] { "asha@abdm", Timestamp.valueOf("2026-01-15 10:30:00") })); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + List> abhaDetails = (List>) result.get("abhaDetails"); + assertEquals(1, abhaDetails.size()); + assertEquals("asha@abdm", abhaDetails.get(0).get("healthID")); + assertEquals(Timestamp.valueOf("2026-01-15 10:30:00").getTime(), abhaDetails.get(0).get("createdDate")); + } + + @Test + @DisplayName("a failing ABHA view leaves the beneficiary without ABHA details rather than dropping it") + void failingAbhaViewLeavesBeneficiaryWithoutAbhaDetails() throws Exception { + stubSearchReturning(document()); + when(advanceSearchRepo.getBenAbhaDetailsByBenRegID(any())) + .thenThrow(new IllegalStateException("view unavailable")); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + List> abhaDetails = (List>) result.get("abhaDetails"); + assertTrue(abhaDetails.isEmpty()); + } + } + + @Nested + @DisplayName("database fallback") + class DatabaseFallback { + + @Test + @DisplayName("an index with no hits falls back to the database") + void noHitsFallsBackToTheDatabase() throws Exception { + stubSearchReturning(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenReturn(Collections.singletonList(databaseRow())); + + List> results = service.universalSearch("Asha", null); + + assertEquals(1, results.size()); + assertEquals("Asha", results.get(0).get("firstName")); + } + + @Test + @DisplayName("an unreachable index falls back to the database") + void unreachableIndexFallsBackToTheDatabase() throws Exception { + stubSearchFailing(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenReturn(Collections.singletonList(databaseRow())); + + assertEquals(1, service.universalSearch("Asha", null).size()); + } + + @Test + @DisplayName("a failing fallback yields no results rather than an error") + void failingFallbackYieldsNoResults() throws Exception { + stubSearchFailing(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenThrow(new IllegalStateException("db down")); + + assertTrue(service.universalSearch("Asha", null).isEmpty()); + } + + @Test + @DisplayName("a database row is projected into the same shape as an index hit") + void databaseRowIsProjectedIntoTheSameShape() throws Exception { + stubSearchReturning(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenReturn(Collections.singletonList(databaseRow())); + + Map result = service.universalSearch("Asha", null).get(0); + + assertEquals(100200300L, result.get("beneficiaryRegID")); + assertEquals("4001", result.get("beneficiaryID")); + assertEquals("Rani", result.get("middleName")); + assertEquals("Married", result.get("maritalStatusName")); + assertEquals("Years", result.get("ageUnits")); + assertNotNull(result.get("dOB")); + @SuppressWarnings("unchecked") + Map demographics = (Map) result.get("i_bendemographics"); + assertEquals("PHC Yelahanka", demographics.get("servicePointName")); + assertEquals(3, demographics.get("parkingPlaceID")); + } + + @Test + @DisplayName("phone numbers for a fallback row are fetched and numbered in order") + void phoneNumbersForFallbackRowAreNumberedInOrder() throws Exception { + stubSearchReturning(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenReturn(Collections.singletonList(databaseRow())); + when(benDetailRepo.findPhoneNumbersByBeneficiaryId(100200300L)).thenReturn( + Arrays.asList(new Object[] { "9000000001", "Mobile" }, new Object[] { "9000000002", null }, + new Object[] { "", "Mobile" }, new Object[] { null, "Mobile" })); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + List> phoneMaps = (List>) result.get("benPhoneMaps"); + assertEquals(2, phoneMaps.size()); + assertEquals(1L, phoneMaps.get(0).get("benPhMapID")); + assertEquals(2L, phoneMaps.get(1).get("benPhMapID")); + @SuppressWarnings("unchecked") + Map relation = (Map) phoneMaps.get(1).get("benRelationshipType"); + assertEquals("Self", relation.get("benRelationshipType")); + } + + @Test + @DisplayName("a failing phone lookup leaves the row without phone maps") + void failingPhoneLookupLeavesRowWithoutPhoneMaps() throws Exception { + stubSearchReturning(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenReturn(Collections.singletonList(databaseRow())); + when(benDetailRepo.findPhoneNumbersByBeneficiaryId(anyLong())) + .thenThrow(new IllegalStateException("db down")); + + Map result = service.universalSearch("Asha", null).get(0); + + @SuppressWarnings("unchecked") + List> phoneMaps = (List>) result.get("benPhoneMaps"); + assertTrue(phoneMaps.isEmpty()); + } + + @Test + @DisplayName("the numeric column types the driver may hand back are all coerced") + void everyNumericColumnTypeIsCoerced() throws Exception { + Object[] row = databaseRow(); + row[0] = BigDecimal.valueOf(100200300L); // regId as BigDecimal + row[5] = "2"; // genderID as text + row[8] = 30L; // age as Long + row[16] = "1767225600000"; // lastModDate as text + row[17] = BigDecimal.valueOf(77L); + row[7] = new java.sql.Date(System.currentTimeMillis()); + stubSearchReturning(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenReturn(Collections.singletonList(row)); + + Map result = service.universalSearch("Asha", null).get(0); + + assertEquals(100200300L, result.get("beneficiaryRegID")); + assertEquals(2, result.get("genderID")); + assertEquals(30, result.get("age")); + assertEquals(1767225600000L, result.get("lastModDate")); + assertNotNull(result.get("dOB")); + } + + @Test + @DisplayName("an unparseable numeric column becomes null rather than failing the row") + void unparseableNumericColumnBecomesNull() throws Exception { + Object[] row = databaseRow(); + row[5] = "not-a-number"; + row[16] = "not-a-number"; + row[7] = "not-a-date"; + stubSearchReturning(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenReturn(Collections.singletonList(row)); + + Map result = service.universalSearch("Asha", null).get(0); + + assertNull(result.get("genderID")); + assertNull(result.get("lastModDate")); + assertNull(result.get("dOB")); + } + + @Test + @DisplayName("a row that is too short is reported as a partial result rather than failing the search") + void shortRowIsReportedAsPartialResult() throws Exception { + stubSearchReturning(); + when(benDetailRepo.searchBeneficiaries("Asha")).thenReturn(Collections.singletonList(new Object[] { 1L })); + + List> results = service.universalSearch("Asha", null); + + assertEquals(1, results.size()); + assertTrue(results.get(0).isEmpty()); + } + } + + @Nested + @DisplayName("advanced search") + class AdvancedSearch { + + @Test + @DisplayName("exact-match criteria are placed in filter context so the index can cache them") + void exactMatchCriteriaGoInFilterContext() throws Exception { + stubSearchReturning(document()); + + service.advancedSearch(null, null, null, 2, null, 101, 201, 301, 401, null, null, null, null, "4001", + null, null, null); + + assertNotNull(builtRequest); + // genderID, stateID, districtID, blockID, villageID and beneficiaryID + assertEquals(6, builtRequest.query().bool().filter().size()); + assertTrue(builtRequest.query().bool().must().isEmpty()); + } + + @Test + @DisplayName("name criteria are placed in must context so they contribute to the score") + void nameCriteriaGoInMustContext() throws Exception { + stubSearchReturning(document()); + + service.advancedSearch("Asha", "Rani", "Devi", null, null, null, null, null, null, null, null, null, + null, null, null, null, null); + + assertEquals(3, builtRequest.query().bool().must().size()); + assertTrue(builtRequest.query().bool().filter().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { "", " " }) + @DisplayName("a blank name is not treated as a criterion") + void blankNameIsNotACriterion(String blank) throws Exception { + stubSearchReturning(document()); + + service.advancedSearch(blank, blank, blank, null, null, null, null, null, null, null, null, null, null, + blank, null, null, null); + + assertTrue(builtRequest.query().bool().must().isEmpty()); + assertTrue(builtRequest.query().bool().filter().isEmpty()); + } + + @Test + @DisplayName("every optional criterion together still builds one valid query") + void everyOptionalCriterionTogetherStillBuilds() throws Exception { + stubSearchReturning(document()); + + List> results = service.advancedSearch("Asha", "Rani", "Devi", 2, new Date(), 101, + 201, 301, 401, "Ram", "Suresh", "Married", "9000000000", "4001", "asha@abdm", "123456789012", + 42); + + assertEquals(1, results.size()); + assertNotNull(builtRequest); + } + + @Test + @DisplayName("a search with no criteria at all still builds a valid match-everything query") + void searchWithNoCriteriaStillBuilds() throws Exception { + stubSearchReturning(document()); + + assertEquals(1, service.advancedSearch(null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null).size()); + } + + @Test + @DisplayName("an unreachable index falls back to the database advanced search") + void unreachableIndexFallsBackToDatabaseAdvancedSearch() throws Exception { + stubSearchFailing(); + when(benDetailRepo.advancedSearchBeneficiaries(any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any())).thenReturn(Collections.singletonList(databaseRow())); + + List> results = service.advancedSearch("Asha", null, "Devi", 2, null, 101, null, + null, null, null, null, null, null, null, null, null, null); + + assertEquals(1, results.size()); + assertEquals("Asha", results.get(0).get("firstName")); + } + + @Test + @DisplayName("a failing database advanced search yields no results") + void failingDatabaseAdvancedSearchYieldsNoResults() throws Exception { + stubSearchFailing(); + when(benDetailRepo.advancedSearchBeneficiaries(any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any())).thenThrow(new IllegalStateException("db down")); + + assertTrue(service.advancedSearch("Asha", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null).isEmpty()); + } + + @Test + @DisplayName("results are ranked toward the searching user's location when a user is supplied") + void resultsAreRankedTowardTheUsersLocation() throws Exception { + stubSearchReturning(document()); + when(benAddressRepo.getUserLocation(42)) + .thenReturn(Collections.singletonList(new Object[] { 11, 301, 401, 501 })); + + service.advancedSearch("Asha", null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, 42); + + verify(benAddressRepo).getUserLocation(42); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchSyncServiceTest.java b/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchSyncServiceTest.java new file mode 100644 index 00000000..5bffe0fc --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/elasticsearch/ElasticsearchSyncServiceTest.java @@ -0,0 +1,524 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.BulkRequest; +import co.elastic.clients.elasticsearch.core.BulkResponse; +import co.elastic.clients.elasticsearch.core.CountRequest; +import co.elastic.clients.elasticsearch.core.CountResponse; +import co.elastic.clients.elasticsearch.core.IndexRequest; +import co.elastic.clients.elasticsearch.core.IndexResponse; +import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem; +import co.elastic.clients.elasticsearch._types.ErrorCause; +import co.elastic.clients.elasticsearch._types.Result; +import co.elastic.clients.util.ObjectBuilder; + +import com.iemr.common.identity.data.elasticsearch.BeneficiaryDocument; + +/** + * Tests for the full-index rebuild that backs the beneficiary search. + * + *

+ * The rebuild walks ~800k beneficiaries in database pages, batch-loads each + * page, and bulk-indexes in fixed-size chunks. What the tests protect is the + * paging and accounting around that loop: the run must stop when a page comes + * back empty rather than looping to the declared total, a bulk request that + * partially fails must be counted per document rather than all-or-nothing, and + * a mid-run failure must be reported in the result instead of thrown, because + * the sync is triggered from an endpoint that reports progress. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ElasticsearchSyncServiceTest { + + @Mock + private ElasticsearchClient esClient; + @Mock + private BeneficiaryTransactionHelper transactionalWrapper; + @Mock + private BeneficiaryDocumentDataService documentDataService; + + @InjectMocks + private ElasticsearchSyncService service; + + /** The request the bulk mock received, for assertions on what was indexed. */ + private BulkRequest capturedBulkRequest; + + @BeforeEach + void configureIndexName() { + ReflectionTestUtils.setField(service, "beneficiaryIndex", "beneficiary"); + capturedBulkRequest = null; + } + + private BeneficiaryDocument document(String benId) { + BeneficiaryDocument document = new BeneficiaryDocument(); + document.setBenId(benId); + document.setFirstName("Asha"); + return document; + } + + private BeneficiaryDocument documentWithAbha(String benId) { + BeneficiaryDocument document = document(benId); + document.setHealthID("asha@abdm"); + document.setAbhaID("12-3456-7890-1234"); + return document; + } + + /** Stubs a bulk call that succeeds for every operation in the request. */ + private void stubBulkSuccess() throws IOException { + when(esClient.bulk(any(BulkRequest.class))).thenAnswer(invocation -> { + capturedBulkRequest = invocation.getArgument(0); + return BulkResponse.of(b -> b.took(1).errors(false).items(Collections.emptyList())); + }); + } + + /** + * Stubs a bulk call where the first {@code failures} operations are rejected + * and the rest succeed. + */ + private void stubBulkPartialFailure(int failures) throws IOException { + when(esClient.bulk(any(BulkRequest.class))).thenAnswer(invocation -> { + BulkRequest request = invocation.getArgument(0); + capturedBulkRequest = request; + List items = new ArrayList<>(); + for (int i = 0; i < request.operations().size(); i++) { + final boolean failed = i < failures; + final String id = String.valueOf(i); + items.add(BulkResponseItem.of(item -> { + item.index("beneficiary").id(id).status(failed ? 400 : 201) + .operationType(co.elastic.clients.elasticsearch.core.bulk.OperationType.Index); + if (failed) { + item.error(ErrorCause.of(e -> e.type("mapper_parsing_exception").reason("bad field"))); + } + return item; + })); + } + return BulkResponse.of(b -> b.took(1).errors(failures > 0).items(items)); + }); + } + + /** Stubs one page of beneficiary IDs followed by an empty page. */ + private void stubOnePageOfIds(int count) { + List page = IntStream.range(0, count) + .mapToObj(index -> new Object[] { BigInteger.valueOf(1000L + index) }) + .collect(java.util.stream.Collectors.toList()); + when(transactionalWrapper.getBeneficiaryIdsBatch(eq(0), anyInt())).thenReturn(page); + when(transactionalWrapper.getBeneficiaryIdsBatch(eq(10000), anyInt())).thenReturn(Collections.emptyList()); + } + + @Nested + @DisplayName("full rebuild") + class FullRebuild { + + @Test + @DisplayName("an empty database is not indexed at all") + void emptyDatabaseIsNotIndexed() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(0L); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals(0, result.getSuccessCount()); + assertEquals(0, result.getFailureCount()); + verify(transactionalWrapper, never()).getBeneficiaryIdsBatch(anyInt(), anyInt()); + } + + @Test + @DisplayName("every loaded beneficiary is indexed and counted") + void everyLoadedBeneficiaryIsIndexedAndCounted() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(3L); + stubOnePageOfIds(3); + when(documentDataService.getBeneficiariesBatch(any())) + .thenReturn(Arrays.asList(document("1"), document("2"), document("3"))); + stubBulkSuccess(); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals(3, result.getSuccessCount()); + assertEquals(0, result.getFailureCount()); + assertEquals(3, capturedBulkRequest.operations().size()); + } + + @Test + @DisplayName("documents are indexed into the configured index under their beneficiary id") + void documentsAreIndexedUnderTheirBeneficiaryId() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + stubOnePageOfIds(1); + when(documentDataService.getBeneficiariesBatch(any())) + .thenReturn(Collections.singletonList(document("4001"))); + stubBulkSuccess(); + + service.syncAllBeneficiaries(); + + assertEquals("beneficiary", capturedBulkRequest.operations().get(0).index().index()); + assertEquals("4001", capturedBulkRequest.operations().get(0).index().id()); + } + + @Test + @DisplayName("a document with no beneficiary id is counted as a failure, not indexed") + void documentWithNoIdIsCountedAsFailure() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(2L); + stubOnePageOfIds(2); + when(documentDataService.getBeneficiariesBatch(any())) + .thenReturn(Arrays.asList(document("1"), document(null), null)); + stubBulkSuccess(); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals(1, result.getSuccessCount()); + assertEquals(2, result.getFailureCount()); + } + + @Test + @DisplayName("a partially rejected bulk request is accounted for per document") + void partiallyRejectedBulkIsAccountedPerDocument() throws Exception { + // Counting the whole batch as failed would hide that most of it + // landed, and re-running the sync is the only remedy. + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(3L); + stubOnePageOfIds(3); + when(documentDataService.getBeneficiariesBatch(any())) + .thenReturn(Arrays.asList(document("1"), document("2"), document("3"))); + stubBulkPartialFailure(1); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals(2, result.getSuccessCount()); + assertEquals(1, result.getFailureCount()); + } + + @Test + @DisplayName("a bulk request that cannot be sent counts the whole batch as failed") + void unsendableBulkCountsTheWholeBatchAsFailed() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(2L); + stubOnePageOfIds(2); + when(documentDataService.getBeneficiariesBatch(any())) + .thenReturn(Arrays.asList(document("1"), document("2"))); + when(esClient.bulk(any(BulkRequest.class))).thenThrow(new IOException("index unavailable")); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals(0, result.getSuccessCount()); + assertEquals(2, result.getFailureCount()); + } + + @Test + @DisplayName("the run stops at the first empty page rather than paging to the declared total") + void runStopsAtTheFirstEmptyPage() throws Exception { + // The count and the paging query can disagree - rows are deleted + // while a rebuild runs - so the empty page is the real terminator. + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1_000_000L); + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())) + .thenReturn(Collections.emptyList()); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals(0, result.getSuccessCount()); + verify(transactionalWrapper, org.mockito.Mockito.times(1)).getBeneficiaryIdsBatch(anyInt(), anyInt()); + } + + @Test + @DisplayName("beneficiaries carrying an ABHA identifier are loaded through the enriching batch query") + void abhaCarryingBeneficiariesAreLoadedEnriched() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + stubOnePageOfIds(1); + when(documentDataService.getBeneficiariesBatch(any())) + .thenReturn(Collections.singletonList(documentWithAbha("4001"))); + stubBulkSuccess(); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals(1, result.getSuccessCount()); + verify(documentDataService).getBeneficiariesBatch(any()); + } + + @Test + @DisplayName("the id types the paging query can return are all accepted") + void everyIdTypeIsAccepted() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(4L); + when(transactionalWrapper.getBeneficiaryIdsBatch(eq(0), anyInt())).thenReturn( + Arrays.asList(new Object[] { BigInteger.valueOf(1L) }, new Object[] { Long.valueOf(2L) }, + new Object[] { Integer.valueOf(3) }, new Object[] { "not-an-id" }, + new Object[] { null })); + when(transactionalWrapper.getBeneficiaryIdsBatch(eq(10000), anyInt())) + .thenReturn(Collections.emptyList()); + when(documentDataService.getBeneficiariesBatch(any())) + .thenReturn(Collections.singletonList(document("1"))); + stubBulkSuccess(); + + service.syncAllBeneficiaries(); + + ArgumentCaptor> captor = captor(); + verify(documentDataService).getBeneficiariesBatch(captor.capture()); + assertEquals(Arrays.asList(BigInteger.ONE, BigInteger.TWO, BigInteger.valueOf(3L)), captor.getValue()); + } + + @Test + @DisplayName("a page query is retried before the run is abandoned") + void pageQueryIsRetriedBeforeAbandoningTheRun() throws Exception { + java.util.concurrent.atomic.AtomicInteger attempts = new java.util.concurrent.atomic.AtomicInteger(); + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())).thenAnswer(invocation -> { + int offset = invocation.getArgument(0); + if (offset > 0) { + return Collections.emptyList(); + } + if (attempts.getAndIncrement() == 0) { + throw new IllegalStateException("connection reset"); + } + return Collections.singletonList(new Object[] { BigInteger.ONE }); + }); + when(documentDataService.getBeneficiariesBatch(any())) + .thenReturn(Collections.singletonList(document("1"))); + stubBulkSuccess(); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals(1, result.getSuccessCount()); + assertEquals(2, attempts.get(), "the failed page query should have been retried once"); + } + + @Test + @DisplayName("a page query that keeps failing reports the error in the result rather than throwing") + void persistentlyFailingPageQueryIsReportedInTheResult() throws Exception { + // The caller is an HTTP endpoint that reports sync progress; an + // exception here would surface as a 500 with no partial counts. + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(1L); + when(transactionalWrapper.getBeneficiaryIdsBatch(anyInt(), anyInt())) + .thenThrow(new IllegalStateException("connection reset")); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertNotNull(result.getError()); + assertTrue(result.toString().contains("error"), result.toString()); + } + + @Test + @DisplayName("a failing count is reported in the result") + void failingCountIsReportedInTheResult() { + when(transactionalWrapper.countActiveBeneficiaries()) + .thenThrow(new IllegalStateException("table locked")); + + ElasticsearchSyncService.SyncResult result = service.syncAllBeneficiaries(); + + assertEquals("table locked", result.getError()); + } + } + + @Nested + @DisplayName("single beneficiary sync") + class SingleBeneficiarySync { + + @BeforeEach + void stubIndexCall() throws IOException { + when(esClient.index(any(Function.class))).thenAnswer(invocation -> { + @SuppressWarnings({ "unchecked", "rawtypes" }) + Function> builder = invocation.getArgument(0); + builder.apply(new IndexRequest.Builder<>()).build(); + return IndexResponse.of(r -> r.index("beneficiary").id("4001").version(1L) + .result(Result.Created).seqNo(1L).primaryTerm(1L) + .shards(s -> s.total(1).successful(1).failed(0))); + }); + } + + @Test + @DisplayName("an existing beneficiary is fetched and indexed") + void existingBeneficiaryIsFetchedAndIndexed() { + when(transactionalWrapper.existsByBenRegId(BigInteger.valueOf(100200300L))).thenReturn(true); + when(documentDataService.getBeneficiaryFromDatabase(BigInteger.valueOf(100200300L))) + .thenReturn(documentWithAbha("4001")); + + assertTrue(service.syncSingleBeneficiary("100200300")); + } + + @Test + @DisplayName("a beneficiary that is not in the database is not indexed") + void beneficiaryNotInTheDatabaseIsNotIndexed() { + when(transactionalWrapper.existsByBenRegId(any())).thenReturn(false); + + assertFalse(service.syncSingleBeneficiary("100200300")); + verify(documentDataService, never()).getBeneficiaryFromDatabase(any()); + } + + @Test + @DisplayName("a beneficiary whose document cannot be built is not indexed") + void beneficiaryWithNoDocumentIsNotIndexed() throws Exception { + when(transactionalWrapper.existsByBenRegId(any())).thenReturn(true); + when(documentDataService.getBeneficiaryFromDatabase(any())).thenReturn(null); + + assertFalse(service.syncSingleBeneficiary("100200300")); + verify(esClient, never()).index(any(Function.class)); + } + + @Test + @DisplayName("a document with no beneficiary id is not indexed") + void documentWithNoIdIsNotIndexed() throws Exception { + when(transactionalWrapper.existsByBenRegId(any())).thenReturn(true); + when(documentDataService.getBeneficiaryFromDatabase(any())).thenReturn(document(null)); + + assertFalse(service.syncSingleBeneficiary("100200300")); + verify(esClient, never()).index(any(Function.class)); + } + + @Test + @DisplayName("a non-numeric identifier is rejected") + void nonNumericIdentifierIsRejected() { + assertFalse(service.syncSingleBeneficiary("not-a-number")); + } + + @Test + @DisplayName("an index failure is reported rather than thrown") + void indexFailureIsReported() throws Exception { + when(transactionalWrapper.existsByBenRegId(any())).thenReturn(true); + when(documentDataService.getBeneficiaryFromDatabase(any())).thenReturn(document("4001")); + when(esClient.index(any(Function.class))).thenThrow(new IOException("index unavailable")); + + assertFalse(service.syncSingleBeneficiary("100200300")); + } + } + + @Nested + @DisplayName("sync status") + class SyncStatusReport { + + private void stubCount(long esCount) throws IOException { + when(esClient.count(any(Function.class))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + Function> builder = invocation.getArgument(0); + builder.apply(new CountRequest.Builder()).build(); + return CountResponse.of(c -> c.count(esCount).shards(s -> s.total(1).successful(1).failed(0))); + }); + } + + @Test + @DisplayName("matching counts report the index as in sync") + void matchingCountsReportInSync() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(500L); + stubCount(500L); + + ElasticsearchSyncService.SyncStatus status = service.checkSyncStatus(); + + assertTrue(status.isSynced()); + assertEquals(0L, status.getMissingCount()); + assertEquals(500L, status.getDatabaseCount()); + assertEquals(500L, status.getElasticsearchCount()); + assertTrue(status.toString().contains("500")); + } + + @Test + @DisplayName("a shortfall in the index is reported as the number of missing documents") + void shortfallIsReportedAsMissingDocuments() throws Exception { + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(500L); + stubCount(480L); + + ElasticsearchSyncService.SyncStatus status = service.checkSyncStatus(); + + assertFalse(status.isSynced()); + assertEquals(20L, status.getMissingCount()); + } + + @Test + @DisplayName("an unreachable index reports the error rather than a false zero") + void unreachableIndexReportsTheError() throws Exception { + // A zero elasticsearchCount would read as "everything is missing" and + // could trigger an unnecessary full rebuild. + when(transactionalWrapper.countActiveBeneficiaries()).thenReturn(500L); + when(esClient.count(any(Function.class))).thenThrow(new IOException("index unavailable")); + + ElasticsearchSyncService.SyncStatus status = service.checkSyncStatus(); + + assertNotNull(status.getError()); + assertTrue(status.toString().contains("error"), status.toString()); + } + } + + @Test + @DisplayName("the result accounting exposes both counts and any error") + void resultAccountingExposesCountsAndError() { + ElasticsearchSyncService.SyncResult result = new ElasticsearchSyncService.SyncResult(); + + result.addSuccess(5); + result.addFailure(); + result.addFailure(2); + result.setError("partial run"); + + assertEquals(5, result.getSuccessCount()); + assertEquals(3, result.getFailureCount()); + assertEquals("partial run", result.getError()); + assertTrue(result.toString().contains("5")); + } + + @Test + @DisplayName("the status report exposes its counts through its accessors") + void statusReportExposesItsCounts() { + ElasticsearchSyncService.SyncStatus status = new ElasticsearchSyncService.SyncStatus(); + + status.setDatabaseCount(10L); + status.setElasticsearchCount(8L); + status.setMissingCount(2L); + status.setSynced(false); + status.setError(null); + + assertEquals(10L, status.getDatabaseCount()); + assertEquals(8L, status.getElasticsearchCount()); + assertEquals(2L, status.getMissingCount()); + assertFalse(status.isSynced()); + assertNotNull(status.toString()); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> captor() { + return ArgumentCaptor.forClass(List.class); + } +} diff --git a/src/test/java/com/iemr/common/identity/service/elasticsearch/SyncJobServiceTest.java b/src/test/java/com/iemr/common/identity/service/elasticsearch/SyncJobServiceTest.java new file mode 100644 index 00000000..13d92a99 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/elasticsearch/SyncJobServiceTest.java @@ -0,0 +1,255 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.elasticsearch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.iemr.common.identity.data.elasticsearch.ElasticsearchSyncJob; +import com.iemr.common.identity.repo.elasticsearch.SyncJobRepo; + +/** + * Tests for the sync-job bookkeeping behind the admin sync endpoints. + * + *

+ * A full re-index takes tens of minutes and saturates the database, so the + * important guarantees are that only one can be running at a time, that a job + * can only be resumed or cancelled from a state where that makes sense, and + * that the async worker is only handed a job that has actually been persisted. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SyncJobServiceTest { + + @Mock + private SyncJobRepo syncJobRepository; + @Mock + private BeneficiaryElasticsearchIndexService syncService; + + @InjectMocks + private SyncJobService service; + + private static final Long JOB_ID = 55L; + + @BeforeEach + void stubSave() { + when(syncJobRepository.save(any())).thenAnswer(invocation -> { + ElasticsearchSyncJob job = invocation.getArgument(0); + if (job.getJobId() == null) { + job.setJobId(JOB_ID); + } + return job; + }); + } + + private ElasticsearchSyncJob job(String status) { + ElasticsearchSyncJob job = new ElasticsearchSyncJob(); + job.setJobId(JOB_ID); + job.setJobType("FULL_SYNC"); + job.setStatus(status); + job.setCurrentOffset(2000); + return job; + } + + @Test + @DisplayName("a new full sync is persisted with zeroed counters before the worker starts") + void newFullSyncIsPersistedBeforeTheWorkerStarts() { + // The worker looks the job up by id, so it has to exist first. + when(syncJobRepository.hasActiveFullSyncJob()).thenReturn(false); + + ElasticsearchSyncJob job = service.startFullSyncJob("admin"); + + assertEquals("FULL_SYNC", job.getJobType()); + assertEquals("PENDING", job.getStatus()); + assertEquals("admin", job.getTriggeredBy()); + assertEquals(0L, job.getProcessedRecords()); + assertEquals(0L, job.getSuccessCount()); + assertEquals(0L, job.getFailureCount()); + assertEquals(0, job.getCurrentOffset()); + org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(syncJobRepository, syncService); + inOrder.verify(syncJobRepository).save(any()); + inOrder.verify(syncService).syncAllBeneficiariesAsync(JOB_ID, "admin"); + } + + @Test + @DisplayName("a second full sync is refused while one is already running") + void secondFullSyncIsRefusedWhileOneIsRunning() { + when(syncJobRepository.hasActiveFullSyncJob()).thenReturn(true); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> service.startFullSyncJob("admin")); + + assertTrue(thrown.getMessage().contains("already running"), thrown.getMessage()); + verify(syncService, never()).syncAllBeneficiariesAsync(anyLong(), anyString()); + verify(syncJobRepository, never()).save(any()); + } + + @Test + @DisplayName("a failed job is resumed from its stored offset") + void failedJobIsResumedFromItsStoredOffset() { + ElasticsearchSyncJob failed = job("FAILED"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(failed)); + + ElasticsearchSyncJob resumed = service.resumeJob(JOB_ID, "admin"); + + assertEquals("PENDING", resumed.getStatus()); + assertEquals("admin", resumed.getTriggeredBy()); + assertEquals(2000, resumed.getCurrentOffset()); + verify(syncService).syncAllBeneficiariesAsync(JOB_ID, "admin"); + } + + @ParameterizedTest + @ValueSource(strings = { "RUNNING", "PENDING", "COMPLETED", "CANCELLED", "STALLED" }) + @DisplayName("only a failed job can be resumed") + void onlyFailedJobsCanBeResumed(String status) { + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(job(status))); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> service.resumeJob(JOB_ID, "admin")); + + assertTrue(thrown.getMessage().contains("Can only resume FAILED jobs"), thrown.getMessage()); + verify(syncService, never()).syncAllBeneficiariesAsync(anyLong(), anyString()); + } + + @Test + @DisplayName("resuming an unknown job id is rejected") + void resumingUnknownJobIdIsRejected() { + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.empty()); + + assertThrows(RuntimeException.class, () -> service.resumeJob(JOB_ID, "admin")); + } + + @ParameterizedTest + @ValueSource(strings = { "RUNNING", "PENDING" }) + @DisplayName("an active job is cancelled and stamped with a completion time") + void activeJobIsCancelled(String status) { + ElasticsearchSyncJob active = job(status); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(active)); + + assertTrue(service.cancelJob(JOB_ID)); + + assertEquals("CANCELLED", active.getStatus()); + assertNotNull(active.getCompletedAt()); + } + + @ParameterizedTest + @ValueSource(strings = { "COMPLETED", "FAILED", "CANCELLED" }) + @DisplayName("a job that is no longer active cannot be cancelled") + void inactiveJobCannotBeCancelled(String status) { + ElasticsearchSyncJob finished = job(status); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(finished)); + + assertFalse(service.cancelJob(JOB_ID)); + + assertEquals(status, finished.getStatus()); + verify(syncJobRepository, never()).save(any()); + } + + @Test + @DisplayName("cancelling an unknown job id reports failure rather than throwing") + void cancellingUnknownJobIdReportsFailure() { + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.empty()); + + assertFalse(service.cancelJob(JOB_ID)); + } + + @Test + @DisplayName("a job's status is looked up by its id") + void jobStatusIsLookedUpById() { + ElasticsearchSyncJob running = job("RUNNING"); + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.of(running)); + + assertEquals(running, service.getJobStatus(JOB_ID)); + } + + @Test + @DisplayName("asking for an unknown job's status is rejected") + void unknownJobStatusIsRejected() { + when(syncJobRepository.findByJobId(JOB_ID)).thenReturn(Optional.empty()); + + assertThrows(RuntimeException.class, () -> service.getJobStatus(JOB_ID)); + } + + @Test + @DisplayName("active and recent job listings are passed straight through") + void jobListingsArePassedStraightThrough() { + List active = Collections.singletonList(job("RUNNING")); + List recent = Arrays.asList(job("COMPLETED"), job("FAILED")); + when(syncJobRepository.findActiveJobs()).thenReturn(active); + when(syncJobRepository.findRecentJobs()).thenReturn(recent); + + assertEquals(active, service.getActiveJobs()); + assertEquals(recent, service.getRecentJobs()); + } + + @Test + @DisplayName("whether a full sync is running is answered from the repository") + void fullSyncRunningIsAnsweredFromTheRepository() { + when(syncJobRepository.hasActiveFullSyncJob()).thenReturn(true); + + assertTrue(service.isFullSyncRunning()); + } + + @Test + @DisplayName("the latest job of a type is the first the repository returns") + void latestJobOfATypeIsTheFirstReturned() { + // The query orders newest first, so position matters. + ElasticsearchSyncJob newest = job("COMPLETED"); + when(syncJobRepository.findLatestJobsByType("FULL_SYNC")) + .thenReturn(Arrays.asList(newest, job("FAILED"))); + + assertEquals(newest, service.getLatestJobByType("FULL_SYNC")); + } + + @Test + @DisplayName("a type with no jobs yet yields nothing rather than an error") + void typeWithNoJobsYieldsNothing() { + when(syncJobRepository.findLatestJobsByType("INCREMENTAL_SYNC")).thenReturn(Collections.emptyList()); + + assertNull(service.getLatestJobByType("INCREMENTAL_SYNC")); + } +} diff --git a/src/test/java/com/iemr/common/identity/service/familyTagging/FamilyTagServiceImplTest.java b/src/test/java/com/iemr/common/identity/service/familyTagging/FamilyTagServiceImplTest.java new file mode 100644 index 00000000..ce270265 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/familyTagging/FamilyTagServiceImplTest.java @@ -0,0 +1,638 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.familyTagging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.iemr.common.identity.data.familyTagging.BenFamilyMapping; +import com.iemr.common.identity.domain.MBeneficiarydetail; +import com.iemr.common.identity.domain.MBeneficiarymapping; +import com.iemr.common.identity.exception.IEMRException; +import com.iemr.common.identity.repo.BenDetailRepo; +import com.iemr.common.identity.repo.BenMappingRepo; +import com.iemr.common.identity.repo.familyTag.FamilyTagRepo; + +/** + * Tests for family tagging. + * + *

+ * A family is a row in {@code i_benfamilymapping} carrying a member count and a + * head-of-family name, plus a {@code familyId} stamped onto each member's + * beneficiary-detail row. Nothing in the schema keeps those two in step, so the + * service does it by hand: tagging increments the count, untagging decrements + * it, and either can clear the head name. These tests pin that bookkeeping, and + * the fact that every failure comes back as an {@link IEMRException} rather + * than a half-applied change. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FamilyTagServiceImplTest { + + @Mock + private FamilyTagRepo familyTagRepo; + @Mock + private BenDetailRepo benDetailRepo; + @Mock + private BenMappingRepo benMappingRepo; + + @InjectMocks + private FamilyTagServiceImpl service; + + private static final String FAMILY_ID = "1767225600000123"; + private static final Integer VAN_ID = 7; + + private MBeneficiarymapping mapping() { + MBeneficiarymapping mapping = new MBeneficiarymapping(); + mapping.setBenDetailsId(BigInteger.valueOf(5L)); + mapping.setVanID(VAN_ID); + return mapping; + } + + private BenFamilyMapping family(Integer members, String headName) { + BenFamilyMapping family = new BenFamilyMapping(); + family.setBenFamilyTagId(9L); + family.setFamilyId(FAMILY_ID); + family.setFamilyName("Devi"); + family.setNoOfmembers(members); + family.setFamilyHeadName(headName); + return family; + } + + @Nested + @DisplayName("tagging a beneficiary to a family") + class Tagging { + + private static final String REQUEST = "{\"familyId\":\"1767225600000123\",\"beneficiaryRegId\":100200300," + + "\"headofFamily_RelationID\":2,\"headofFamily_Relation\":\"Daughter\"," + + "\"modifiedBy\":\"field.worker\"}"; + + @Test + @DisplayName("the family id is stamped on the beneficiary and the member count goes up") + void familyIdIsStampedAndMemberCountIncrements() throws Exception { + when(benMappingRepo.getBenDetailsId(BigInteger.valueOf(100200300L))).thenReturn(mapping()); + BenFamilyMapping family = family(3, "Asha Devi"); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + + String response = service.addTag(REQUEST); + + assertEquals("Family tagging completed successfully", response); + verify(benDetailRepo).updateFamilyDetails(FAMILY_ID, 2, "Daughter", null, BigInteger.valueOf(5L), + VAN_ID); + assertEquals(4, family.getNoOfmembers()); + assertEquals("field.worker", family.getModifiedBy()); + verify(familyTagRepo).save(family); + } + + @Test + @DisplayName("the first member of a family with no recorded count sets it to one") + void firstMemberSetsCountToOne() throws Exception { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + BenFamilyMapping family = family(null, null); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + + service.addTag(REQUEST); + + assertEquals(1, family.getNoOfmembers()); + } + + @Test + @DisplayName("a member tagged as head of the family becomes the recorded head") + void memberTaggedAsHeadBecomesTheRecordedHead() throws Exception { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + BenFamilyMapping family = family(1, "Asha Devi"); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + + service.addTag("{\"familyId\":\"" + FAMILY_ID + "\",\"beneficiaryRegId\":100200300," + + "\"isHeadOfTheFamily\":true,\"memberName\":\"Ram Devi\"}"); + + assertEquals("Ram Devi", family.getFamilyHeadName()); + } + + @Test + @DisplayName("a beneficiary the platform does not know is rejected without touching the family") + void unknownBeneficiaryIsRejected() { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(null); + + IEMRException thrown = assertThrows(IEMRException.class, () -> service.addTag(REQUEST)); + + assertTrue(thrown.getMessage().contains("Beneficiary is not found"), thrown.getMessage()); + verify(familyTagRepo, never()).save(any()); + } + + @Test + @DisplayName("a beneficiary with no sync key is rejected") + void beneficiaryWithNoSyncKeyIsRejected() { + MBeneficiarymapping incomplete = new MBeneficiarymapping(); + incomplete.setBenDetailsId(BigInteger.valueOf(5L)); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(incomplete); + + assertThrows(IEMRException.class, () -> service.addTag(REQUEST)); + verify(benDetailRepo, never()).updateFamilyDetails(anyString(), anyInt(), anyString(), anyString(), + any(), anyInt()); + } + + @Test + @DisplayName("an unknown family id is rejected after the beneficiary has been stamped") + void unknownFamilyIdIsRejected() { + // The stamp has already been written at this point, so the caller + // has to retry with a valid family rather than assume nothing + // happened. + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(null); + + IEMRException thrown = assertThrows(IEMRException.class, () -> service.addTag(REQUEST)); + + assertTrue(thrown.getMessage().contains("Invalid family ID"), thrown.getMessage()); + } + + @Test + @DisplayName("an unparseable request is rejected") + void unparseableRequestIsRejected() { + assertThrows(IEMRException.class, () -> service.addTag("not json at all {")); + } + } + + @Nested + @DisplayName("untagging beneficiaries from a family") + class Untagging { + + private static final String REQUEST = "{\"memberList\":[{\"familyId\":\"1767225600000123\"," + + "\"beneficiaryRegId\":100200300,\"modifiedBy\":\"field.worker\"}]}"; + + @Test + @DisplayName("the beneficiary is untagged and the member count goes down") + void beneficiaryIsUntaggedAndMemberCountDecrements() throws Exception { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + BenFamilyMapping family = family(3, "Asha Devi"); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + + String response = service.doFamilyUntag(REQUEST); + + assertEquals("Beneficiary untagged successfully", response); + verify(benDetailRepo).untagFamily("field.worker", BigInteger.valueOf(5L), VAN_ID); + assertEquals(2, family.getNoOfmembers()); + } + + @Test + @DisplayName("the member count is not driven below zero") + void memberCountIsNotDrivenBelowZero() throws Exception { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + BenFamilyMapping family = family(0, "Asha Devi"); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + + service.doFamilyUntag(REQUEST); + + assertEquals(0, family.getNoOfmembers()); + } + + @Test + @DisplayName("untagging the head of the family leaves the family without a recorded head") + void untaggingTheHeadClearsTheRecordedHead() throws Exception { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + BenFamilyMapping family = family(2, "Asha Devi"); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + + service.doFamilyUntag("{\"memberList\":[{\"familyId\":\"" + FAMILY_ID + + "\",\"beneficiaryRegId\":100200300,\"isHeadOfTheFamily\":true}]}"); + + assertEquals("", family.getFamilyHeadName()); + } + + @Test + @DisplayName("every member in the request is untagged") + void everyMemberInTheRequestIsUntagged() throws Exception { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family(5, "Asha Devi")); + + service.doFamilyUntag("{\"memberList\":[{\"familyId\":\"" + FAMILY_ID + + "\",\"beneficiaryRegId\":100200300},{\"familyId\":\"" + FAMILY_ID + + "\",\"beneficiaryRegId\":100200301}]}"); + + verify(benDetailRepo, org.mockito.Mockito.times(2)).untagFamily(any(), any(), anyInt()); + } + + @Test + @DisplayName("a request with no member list is rejected") + void requestWithNoMemberListIsRejected() { + assertThrows(IEMRException.class, () -> service.doFamilyUntag("{}")); + } + + @Test + @DisplayName("a request with an empty member list is rejected") + void requestWithEmptyMemberListIsRejected() { + assertThrows(IEMRException.class, () -> service.doFamilyUntag("{\"memberList\":[]}")); + } + + @Test + @DisplayName("a member with no family id is rejected") + void memberWithNoFamilyIdIsRejected() { + IEMRException thrown = assertThrows(IEMRException.class, + () -> service.doFamilyUntag("{\"memberList\":[{\"beneficiaryRegId\":100200300}]}")); + + assertTrue(thrown.getMessage().contains("Invalid family ID"), thrown.getMessage()); + } + + @Test + @DisplayName("an unknown family id is rejected") + void unknownFamilyIdIsRejected() { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(null); + + assertThrows(IEMRException.class, () -> service.doFamilyUntag(REQUEST)); + } + } + + @Nested + @DisplayName("editing a family") + class Editing { + + private static final String REQUEST = "{\"familyId\":\"1767225600000123\",\"beneficiaryRegId\":100200300," + + "\"headofFamily_RelationID\":3,\"headofFamily_Relation\":\"Son\"}"; + + @Test + @DisplayName("the beneficiary's relationship to the head is updated") + void relationshipToTheHeadIsUpdated() throws Exception { + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family(2, "Asha Devi")); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + + String response = service.editFamilyDetails(REQUEST); + + assertEquals("Beneficiary family tagging updateed successfully", response); + verify(benDetailRepo).editFamilyDetails(3, "Son", null, BigInteger.valueOf(5L), VAN_ID, FAMILY_ID); + } + + @Test + @DisplayName("promoting a member to head records their name") + void promotingAMemberToHeadRecordsTheirName() throws Exception { + BenFamilyMapping family = family(2, "Asha Devi"); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + + service.editFamilyDetails("{\"familyId\":\"" + FAMILY_ID + "\",\"beneficiaryRegId\":100200300," + + "\"isHeadOfTheFamily\":true,\"memberName\":\"Ram Devi\"}"); + + assertEquals("Ram Devi", family.getFamilyHeadName()); + } + + @Test + @DisplayName("demoting the current head leaves the family without a recorded head") + void demotingTheCurrentHeadClearsTheRecordedHead() throws Exception { + BenFamilyMapping family = family(2, "Asha Devi"); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + + service.editFamilyDetails("{\"familyId\":\"" + FAMILY_ID + "\",\"beneficiaryRegId\":100200300," + + "\"isHeadOfTheFamily\":false,\"memberName\":\"Asha Devi\"}"); + + assertEquals("", family.getFamilyHeadName()); + } + + @Test + @DisplayName("demoting someone who is not the head records them instead of clearing the head") + void demotingSomeoneWhoIsNotTheHeadRecordsThem() throws Exception { + BenFamilyMapping family = family(2, "Asha Devi"); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + + service.editFamilyDetails("{\"familyId\":\"" + FAMILY_ID + "\",\"beneficiaryRegId\":100200300," + + "\"isHeadOfTheFamily\":false,\"memberName\":\"Ram Devi\"}"); + + assertEquals("Ram Devi", family.getFamilyHeadName()); + } + + @Test + @DisplayName("an unknown family id is rejected") + void unknownFamilyIdIsRejected() { + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(null); + + IEMRException thrown = assertThrows(IEMRException.class, () -> service.editFamilyDetails(REQUEST)); + + assertTrue(thrown.getMessage().contains("Invalid Family ID"), thrown.getMessage()); + } + + @Test + @DisplayName("an unknown beneficiary is rejected") + void unknownBeneficiaryIsRejected() { + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family(2, "Asha Devi")); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(null); + + assertThrows(IEMRException.class, () -> service.editFamilyDetails(REQUEST)); + } + } + + @Nested + @DisplayName("searching for families") + class Searching { + + @Test + @DisplayName("a search without a family id matches on name and village") + void searchWithoutFamilyIdMatchesOnNameAndVillage() throws Exception { + when(familyTagRepo.searchFamily("Devi", 401)) + .thenReturn(Collections.singletonList(family(3, "Asha Devi"))); + + String response = service.searchFamily("{\"familyName\":\"Devi\",\"villageId\":401}"); + + assertTrue(response.contains("Devi"), response); + verify(familyTagRepo, never()).searchFamilyWithFamilyId(any(), any(), any()); + } + + @Test + @DisplayName("a search with a family id narrows to that family") + void searchWithFamilyIdNarrowsToThatFamily() throws Exception { + when(familyTagRepo.searchFamilyWithFamilyId("Devi", 401, FAMILY_ID)) + .thenReturn(Collections.singletonList(family(3, "Asha Devi"))); + + String response = service + .searchFamily("{\"familyName\":\"Devi\",\"villageId\":401,\"familyId\":\"" + FAMILY_ID + "\"}"); + + assertTrue(response.contains(FAMILY_ID), response); + verify(familyTagRepo, never()).searchFamily(any(), any()); + } + + @Test + @DisplayName("a search with no matches says so rather than returning an empty list") + void searchWithNoMatchesSaysSo() throws Exception { + when(familyTagRepo.searchFamily(any(), any())).thenReturn(Collections.emptyList()); + + assertEquals("No records found", service.searchFamily("{\"familyName\":\"Nobody\"}")); + } + + @Test + @DisplayName("a failing search is reported") + void failingSearchIsReported() { + when(familyTagRepo.searchFamily(any(), any())).thenThrow(new IllegalStateException("table locked")); + + assertThrows(IEMRException.class, () -> service.searchFamily("{\"familyName\":\"Devi\"}")); + } + } + + @Nested + @DisplayName("creating a family") + class Creating { + + private static final String REQUEST = "{\"familyName\":\"Devi\",\"villageId\":401," + + "\"beneficiaryRegId\":100200300,\"createdBy\":\"field.worker\"}"; + + @Test + @DisplayName("a new family gets a generated id, one member, and stamps its first beneficiary") + void newFamilyGetsGeneratedIdAndStampsItsFirstBeneficiary() throws Exception { + when(familyTagRepo.getUserId("field.worker")).thenReturn(123); + when(familyTagRepo.save(any())).thenAnswer(invocation -> { + BenFamilyMapping saved = invocation.getArgument(0); + saved.setBenFamilyTagId(9L); + return saved; + }); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + + String response = service.createFamily(REQUEST); + + assertTrue(response.contains("\"noOfmembers\":1"), response); + ArgumentCaptor captor = ArgumentCaptor.forClass(BenFamilyMapping.class); + verify(familyTagRepo).save(captor.capture()); + assertEquals(16, captor.getValue().getFamilyId().length(), + "a family id is 13 timestamp digits plus 3 user digits"); + assertTrue(captor.getValue().getFamilyId().endsWith("123")); + verify(benDetailRepo).updateFamilyDetails(eq(captor.getValue().getFamilyId()), any(), any(), any(), + eq(BigInteger.valueOf(5L)), eq(VAN_ID)); + } + + @Test + @DisplayName("a short user id is padded so every family id is the same length") + void shortUserIdIsPadded() throws Exception { + when(familyTagRepo.getUserId("field.worker")).thenReturn(7); + when(familyTagRepo.save(any())).thenAnswer(invocation -> { + BenFamilyMapping saved = invocation.getArgument(0); + saved.setBenFamilyTagId(9L); + return saved; + }); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + + service.createFamily(REQUEST); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BenFamilyMapping.class); + verify(familyTagRepo).save(captor.capture()); + assertEquals(16, captor.getValue().getFamilyId().length()); + assertTrue(captor.getValue().getFamilyId().endsWith("700")); + } + + @Test + @DisplayName("an unrecognised creating user is rejected") + void unrecognisedCreatingUserIsRejected() { + when(familyTagRepo.getUserId("field.worker")).thenReturn(null); + + assertThrows(IEMRException.class, () -> service.createFamily(REQUEST)); + verify(familyTagRepo, never()).save(any()); + } + + @Test + @DisplayName("a family the database did not assign an id to is reported as a failure") + void familyWithNoAssignedIdIsReportedAsFailure() { + when(familyTagRepo.getUserId("field.worker")).thenReturn(123); + when(familyTagRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + + assertThrows(IEMRException.class, () -> service.createFamily(REQUEST)); + } + + @Test + @DisplayName("a beneficiary that cannot be resolved leaves the family created but unstamped") + void unresolvableBeneficiaryLeavesTheFamilyUnstamped() throws Exception { + when(familyTagRepo.getUserId("field.worker")).thenReturn(123); + when(familyTagRepo.save(any())).thenAnswer(invocation -> { + BenFamilyMapping saved = invocation.getArgument(0); + saved.setBenFamilyTagId(9L); + return saved; + }); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(null); + + assertNotNull(service.createFamily(REQUEST)); + + verify(benDetailRepo, never()).updateFamilyDetails(anyString(), anyInt(), anyString(), anyString(), + any(), anyInt()); + } + } + + @Nested + @DisplayName("listing family members") + class ListingMembers { + + private MBeneficiarydetail member(String title, String firstName, String lastName, String relation, + String other) { + MBeneficiarydetail detail = new MBeneficiarydetail(); + detail.setBeneficiaryDetailsId(BigInteger.valueOf(5L)); + detail.setVanID(VAN_ID); + detail.setTitle(title); + detail.setFirstName(firstName); + detail.setLastName(lastName); + detail.setHeadOfFamily_Relation(relation); + detail.setOther(other); + detail.setFamilyId(FAMILY_ID); + return detail; + } + + @Test + @DisplayName("each member is listed with its registration id and assembled name") + void eachMemberIsListedWithIdAndName() throws Exception { + when(benDetailRepo.getFamilyDetails(FAMILY_ID)) + .thenReturn(Collections.singletonList(member("Ms", "Asha", "Devi", "Daughter", null))); + when(benMappingRepo.getBenRegId(BigInteger.valueOf(5L), VAN_ID)) + .thenReturn(BigInteger.valueOf(100200300L)); + + String response = service.getFamilyDetails("{\"familyId\":\"" + FAMILY_ID + "\"}"); + + assertTrue(response.contains("Ms Asha Devi"), response); + assertTrue(response.contains("100200300"), response); + assertTrue(response.contains("Daughter"), response); + } + + @Test + @DisplayName("a member with missing name parts is still listed") + void memberWithMissingNamePartsIsStillListed() throws Exception { + when(benDetailRepo.getFamilyDetails(FAMILY_ID)) + .thenReturn(Collections.singletonList(member(null, null, null, null, null))); + when(benMappingRepo.getBenRegId(any(), anyInt())).thenReturn(null); + + assertNotNull(service.getFamilyDetails("{\"familyId\":\"" + FAMILY_ID + "\"}")); + } + + @Test + @DisplayName("a free-text relationship is appended to the relationship label") + void freeTextRelationshipIsAppended() throws Exception { + when(benDetailRepo.getFamilyDetails(FAMILY_ID)) + .thenReturn(Collections.singletonList(member("Mr", "Ram", "Devi", "Other", "Nephew"))); + when(benMappingRepo.getBenRegId(any(), anyInt())).thenReturn(BigInteger.valueOf(100200301L)); + + assertTrue(service.getFamilyDetails("{\"familyId\":\"" + FAMILY_ID + "\"}") + .contains("Other-Nephew")); + } + + @Test + @DisplayName("a request with no family id is rejected") + void requestWithNoFamilyIdIsRejected() { + assertThrows(IEMRException.class, () -> service.getFamilyDetails("{}")); + } + + @Test + @DisplayName("a beneficiary's own family is returned with the family summary and its members") + void beneficiarysOwnFamilyIsReturnedWithSummaryAndMembers() throws Exception { + when(benMappingRepo.getBenDetailsId(BigInteger.valueOf(100200300L))).thenReturn(mapping()); + when(benDetailRepo.findByBeneficiaryDetailsIdOrderByBeneficiaryDetailsIdAsc(BigInteger.valueOf(5L))) + .thenReturn(Collections.singletonList(member("Ms", "Asha", "Devi", "Daughter", null))); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(family(2, "Ram Devi")); + when(benDetailRepo.getFamilyDetails(FAMILY_ID)) + .thenReturn(Arrays.asList(member("Ms", "Asha", "Devi", "Daughter", null), + member("Mr", "Ram", "Devi", "Self", null))); + when(benMappingRepo.getBenRegId(any(), anyInt())).thenReturn(BigInteger.valueOf(100200300L)); + + String response = service.getFamilyDetailsByBeneficiaryId("{\"beneficiaryRegId\":100200300}"); + + assertTrue(response.contains(FAMILY_ID), response); + assertTrue(response.contains("Ram Devi"), response); + assertTrue(response.contains("Asha Devi"), response); + assertTrue(response.contains("\"noOfMembers\":2"), response); + } + + @Test + @DisplayName("a beneficiary with no family tagged says so rather than returning an empty family") + void beneficiaryWithNoFamilySaysSo() throws Exception { + MBeneficiarydetail untagged = member("Ms", "Asha", "Devi", null, null); + untagged.setFamilyId(null); + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + when(benDetailRepo.findByBeneficiaryDetailsIdOrderByBeneficiaryDetailsIdAsc(any())) + .thenReturn(Collections.singletonList(untagged)); + + assertEquals("No family tagged to this beneficiary", + service.getFamilyDetailsByBeneficiaryId("{\"beneficiaryRegId\":100200300}")); + } + + @Test + @DisplayName("a beneficiary with no detail row says no family is tagged") + void beneficiaryWithNoDetailRowSaysNoFamily() throws Exception { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + when(benDetailRepo.findByBeneficiaryDetailsIdOrderByBeneficiaryDetailsIdAsc(any())) + .thenReturn(Collections.emptyList()); + + assertEquals("No family tagged to this beneficiary", + service.getFamilyDetailsByBeneficiaryId("{\"beneficiaryRegId\":100200300}")); + } + + @Test + @DisplayName("a request with no registration id is rejected") + void requestWithNoRegistrationIdIsRejected() { + IEMRException thrown = assertThrows(IEMRException.class, + () -> service.getFamilyDetailsByBeneficiaryId("{}")); + + assertTrue(thrown.getMessage().contains("beneficiaryRegId is required"), thrown.getMessage()); + } + + @Test + @DisplayName("an unknown beneficiary is rejected") + void unknownBeneficiaryIsRejected() { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(null); + + IEMRException thrown = assertThrows(IEMRException.class, + () -> service.getFamilyDetailsByBeneficiaryId("{\"beneficiaryRegId\":100200300}")); + + assertTrue(thrown.getMessage().contains("Beneficiary not found"), thrown.getMessage()); + } + + @Test + @DisplayName("a family whose master row is missing still lists the members") + void familyWithMissingMasterRowStillListsMembers() throws Exception { + when(benMappingRepo.getBenDetailsId(any())).thenReturn(mapping()); + when(benDetailRepo.findByBeneficiaryDetailsIdOrderByBeneficiaryDetailsIdAsc(any())) + .thenReturn(Collections.singletonList(member("Ms", "Asha", "Devi", "Daughter", null))); + when(familyTagRepo.searchFamilyByFamilyId(FAMILY_ID)).thenReturn(null); + when(benDetailRepo.getFamilyDetails(FAMILY_ID)) + .thenReturn(Collections.singletonList(member("Ms", "Asha", "Devi", "Daughter", null))); + + assertTrue(service.getFamilyDetailsByBeneficiaryId("{\"beneficiaryRegId\":100200300}") + .contains("Asha Devi")); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/service/health/HealthServiceTest.java b/src/test/java/com/iemr/common/identity/service/health/HealthServiceTest.java new file mode 100644 index 00000000..4e05aa90 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/health/HealthServiceTest.java @@ -0,0 +1,661 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.health; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Map; + +import javax.sql.DataSource; + +import org.apache.http.HttpEntity; +import org.apache.http.ProtocolVersion; +import org.apache.http.StatusLine; +import org.apache.http.message.BasicStatusLine; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.RestClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Tests for the dependency health check behind {@code /health}. + * + *

+ * This endpoint is what the load balancer and the on-call dashboard read, so + * the distinctions it draws matter operationally: a slow-but-working dependency + * has to report DEGRADED rather than DOWN so instances are not pulled out of + * rotation, an unavailable dependency has to report DOWN, and neither may leak + * cluster topology or raw exception text into a response that is served + * unauthenticated. + * + *

+ * Every probe is driven through mocked JDBC, Redis and Elasticsearch clients; + * nothing here contacts a real dependency. + */ +class HealthServiceTest { + + private DataSource dataSource; + private RedisTemplate redisTemplate; + private RestClient restClient; + + @BeforeEach + void setUp() { + dataSource = mock(DataSource.class); + redisTemplate = mock(RedisTemplate.class); + restClient = mock(RestClient.class); + } + + /** Builds the service with Elasticsearch switched off. */ + private HealthService serviceWithoutElasticsearch(RedisTemplate redis) { + return new HealthService(dataSource, redis, "localhost", 9200, false, "amrit_data", false); + } + + /** Builds the service with a mocked Elasticsearch client already in place. */ + private HealthService serviceWithElasticsearch(boolean indexingRequired) { + HealthService service = new HealthService(dataSource, null, "localhost", 9200, true, "amrit_data", + indexingRequired); + ReflectionTestUtils.setField(service, "elasticsearchRestClient", restClient); + ReflectionTestUtils.setField(service, "elasticsearchClientReady", true); + return service; + } + + /** + * Stubs a JDBC connection whose {@code SELECT 1} probe succeeds and whose + * diagnostic {@code PROCESSLIST} queries report a quiet server. + */ + private Connection stubHealthyDatabase() throws SQLException { + return stubDatabase(0); + } + + /** + * Stubs a working JDBC connection where the diagnostic queries report + * {@code diagnosticCount} waiting or slow sessions. + */ + private Connection stubDatabase(int diagnosticCount) throws SQLException { + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.isValid(anyInt())).thenReturn(true); + when(connection.prepareStatement(anyString())).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + return sql.contains("PROCESSLIST") ? countingStatement(diagnosticCount) : countingStatement(1); + }); + return connection; + } + + /** A statement whose single-column result is {@code count}. */ + private PreparedStatement countingStatement(int count) throws SQLException { + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); + when(resultSet.getInt(1)).thenReturn(count); + return statement; + } + + private void stubPong(String reply) { + when(redisTemplate.execute(any(RedisCallback.class))).thenReturn(reply); + } + + private Response response(int statusCode, String body) throws IOException { + Response response = mock(Response.class); + StatusLine statusLine = new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), statusCode, ""); + when(response.getStatusLine()).thenReturn(statusLine); + HttpEntity entity = mock(HttpEntity.class); + when(entity.getContent()).thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + when(response.getEntity()).thenReturn(entity); + return response; + } + + @SuppressWarnings("unchecked") + private Map component(Map health, String name) { + return (Map) ((Map) health.get("components")).get(name); + } + + @SuppressWarnings("unchecked") + private Map details(Map component) { + return (Map) component.get("details"); + } + + @Nested + @DisplayName("overall status") + class OverallStatus { + + @Test + @DisplayName("a working database with no other dependency configured reports UP") + void workingDatabaseReportsUp() throws Exception { + stubHealthyDatabase(); + HealthService service = serviceWithoutElasticsearch(null); + + Map health = service.checkHealth(); + + assertEquals("UP", health.get("status")); + assertNotNull(health.get("timestamp")); + assertEquals("UP", component(health, "mysql").get("status")); + } + + @Test + @DisplayName("a degraded dependency keeps the instance in rotation") + void degradedDependencyKeepsInstanceInRotation() throws Exception { + // DEGRADED must not become an overall DOWN: the load balancer would + // pull a working instance out of service. + stubDatabase(5); + HealthService service = serviceWithoutElasticsearch(null); + + Map health = service.checkHealth(); + + assertEquals("UP", health.get("status")); + assertEquals("DEGRADED", component(health, "mysql").get("status")); + assertEquals("WARNING", component(health, "mysql").get("severity")); + } + + @Test + @DisplayName("an unreachable database reports DOWN overall") + void unreachableDatabaseReportsDown() throws Exception { + when(dataSource.getConnection()).thenThrow(new SQLException("connection refused")); + HealthService service = serviceWithoutElasticsearch(null); + + Map health = service.checkHealth(); + + assertEquals("DOWN", health.get("status")); + assertEquals("DOWN", component(health, "mysql").get("status")); + assertEquals("CRITICAL", component(health, "mysql").get("severity")); + } + + @Test + @DisplayName("Redis is only reported when it is configured") + void redisIsOnlyReportedWhenConfigured() throws Exception { + stubHealthyDatabase(); + + Map health = serviceWithoutElasticsearch(null).checkHealth(); + + assertFalse(((Map) health.get("components")).containsKey("redis")); + } + + @Test + @DisplayName("Elasticsearch is only reported when it is enabled") + void elasticsearchIsOnlyReportedWhenEnabled() throws Exception { + stubHealthyDatabase(); + + Map health = serviceWithoutElasticsearch(null).checkHealth(); + + assertFalse(((Map) health.get("components")).containsKey("elasticsearch")); + } + + } + + @Nested + @DisplayName("database probe") + class DatabaseProbe { + + @Test + @DisplayName("a connection that fails validation is reported DOWN without running a query") + void invalidConnectionIsReportedDown() throws Exception { + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.isValid(anyInt())).thenReturn(false); + + Map health = serviceWithoutElasticsearch(null).checkHealth(); + + assertEquals("DOWN", component(health, "mysql").get("status")); + verify(connection, never()).prepareStatement(anyString()); + } + + @Test + @DisplayName("a probe that returns nothing is reported DOWN") + void probeReturningNothingIsReportedDown() throws Exception { + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.isValid(anyInt())).thenReturn(true); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + + Map health = serviceWithoutElasticsearch(null).checkHealth(); + + assertEquals("DOWN", component(health, "mysql").get("status")); + assertEquals("Unexpected query result", details(component(health, "mysql")).get("errorCategory")); + } + + @Test + @DisplayName("the probe is given a query timeout so a stuck server cannot hang the endpoint") + void probeIsGivenAQueryTimeout() throws Exception { + Connection connection = mock(Connection.class); + PreparedStatement statement = countingStatement(1); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.isValid(anyInt())).thenReturn(true); + when(connection.prepareStatement(anyString())).thenReturn(statement); + + serviceWithoutElasticsearch(null).checkHealth(); + + verify(statement, org.mockito.Mockito.atLeastOnce()).setQueryTimeout(3); + } + + @Test + @DisplayName("a raw exception message is never exposed in the response") + void rawExceptionMessageIsNeverExposed() throws Exception { + when(dataSource.getConnection()) + .thenThrow(new SQLException("Access denied for user 'amrit'@'10.1.2.3' to database 'db_iemr'")); + + Map health = serviceWithoutElasticsearch(null).checkHealth(); + + Map details = details(component(health, "mysql")); + assertEquals("Dependency unavailable", details.get("error")); + assertFalse(details.toString().contains("10.1.2.3")); + assertFalse(details.toString().contains("db_iemr")); + } + + @Test + @DisplayName("a diagnostic query that fails does not turn a working database into a degraded one") + void failingDiagnosticQueryDoesNotDegradeAWorkingDatabase() throws Exception { + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.isValid(anyInt())).thenReturn(true); + when(connection.prepareStatement(anyString())).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (sql.contains("PROCESSLIST")) { + throw new SQLException("INFORMATION_SCHEMA not permitted"); + } + return countingStatement(1); + }); + + Map health = serviceWithoutElasticsearch(null).checkHealth(); + + assertEquals("UP", component(health, "mysql").get("status")); + } + } + + @Nested + @DisplayName("Redis probe") + class RedisProbe { + + @Test + @DisplayName("a server that answers PONG is reported UP") + void pongIsReportedUp() throws Exception { + stubHealthyDatabase(); + stubPong("PONG"); + + Map health = serviceWithoutElasticsearch(redisTemplate).checkHealth(); + + assertEquals("UP", component(health, "redis").get("status")); + assertEquals("UP", health.get("status")); + } + + @ParameterizedTest + @ValueSource(strings = { "", "WRONGPASS", "pong" }) + @DisplayName("any answer other than PONG is reported DOWN") + void anyOtherAnswerIsReportedDown(String reply) throws Exception { + stubHealthyDatabase(); + stubPong(reply); + + Map health = serviceWithoutElasticsearch(redisTemplate).checkHealth(); + + assertEquals("DOWN", component(health, "redis").get("status")); + assertEquals("DOWN", health.get("status")); + } + + @Test + @DisplayName("an unreachable Redis is reported DOWN with a sanitised message") + void unreachableRedisIsReportedDown() throws Exception { + stubHealthyDatabase(); + when(redisTemplate.execute(any(RedisCallback.class))) + .thenThrow(new IllegalStateException("Unable to connect to redis-01.internal:6379")); + + Map health = serviceWithoutElasticsearch(redisTemplate).checkHealth(); + + Map details = details(component(health, "redis")); + assertEquals("DOWN", component(health, "redis").get("status")); + assertEquals("Dependency unavailable", details.get("error")); + assertFalse(details.toString().contains("redis-01.internal")); + } + } + + @Nested + @DisplayName("Elasticsearch probe") + class ElasticsearchProbe { + + @BeforeEach + void stubDatabase() throws SQLException { + stubHealthyDatabase(); + } + + /** + * The probe issues up to four requests; this routes each by method and + * path so a test can control them independently. + */ + private void stubCluster(String clusterStatus, boolean indexPresent, String settingsBody, + Integer canaryStatusCode) throws IOException { + when(restClient.performRequest(any(Request.class))).thenAnswer(invocation -> { + Request request = invocation.getArgument(0); + String endpoint = request.getEndpoint(); + if ("/_cluster/health".equals(endpoint)) { + if (clusterStatus == null) { + throw new IOException("cluster health unavailable"); + } + return response(200, "{\"status\":\"" + clusterStatus + "\"}"); + } + if ("HEAD".equals(request.getMethod())) { + if (!indexPresent) { + throw new IOException("index missing"); + } + return response(200, ""); + } + if (endpoint.startsWith("/_cluster/settings")) { + if (settingsBody == null) { + throw new IOException("settings unavailable"); + } + return response(200, settingsBody); + } + if ("PUT".equals(request.getMethod())) { + if (canaryStatusCode == null) { + throw new java.net.SocketTimeoutException("canary timeout"); + } + return response(canaryStatusCode, "{}"); + } + return response(200, "{}"); + }); + } + + private static final String NO_READ_ONLY_BLOCKS = "{\"persistent\":{},\"transient\":{},\"defaults\":{}}"; + + @Test + @DisplayName("a green cluster that accepts a canary write is reported UP") + void greenClusterAcceptingWritesIsReportedUp() throws Exception { + stubCluster("green", true, NO_READ_ONLY_BLOCKS, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("UP", component(health, "elasticsearch").get("status")); + assertEquals("UP", health.get("status")); + } + + @Test + @DisplayName("a yellow cluster is reported DEGRADED, not DOWN") + void yellowClusterIsReportedDegraded() throws Exception { + // Yellow means unassigned replicas - searches and writes still work. + stubCluster("yellow", true, NO_READ_ONLY_BLOCKS, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DEGRADED", component(health, "elasticsearch").get("status")); + assertEquals("WARNING", component(health, "elasticsearch").get("severity")); + assertEquals("UP", health.get("status")); + } + + @Test + @DisplayName("a red cluster is reported DOWN") + void redClusterIsReportedDown() throws Exception { + stubCluster("red", true, NO_READ_ONLY_BLOCKS, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DOWN", component(health, "elasticsearch").get("status")); + assertEquals("DOWN", health.get("status")); + } + + @Test + @DisplayName("an unreadable cluster health with a reachable index is DEGRADED rather than DOWN") + void unreadableClusterHealthWithReachableIndexIsDegraded() throws Exception { + stubCluster(null, true, NO_READ_ONLY_BLOCKS, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DEGRADED", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("an unreadable cluster health with an unreachable index is DOWN") + void unreadableClusterHealthWithUnreachableIndexIsDown() throws Exception { + stubCluster(null, false, NO_READ_ONLY_BLOCKS, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DOWN", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("a missing target index is reported DOWN") + void missingTargetIndexIsReportedDown() throws Exception { + stubCluster("green", false, NO_READ_ONLY_BLOCKS, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DOWN", component(health, "elasticsearch").get("status")); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "{\"persistent\":{\"cluster\":{\"blocks\":{\"read_only\":true}}}}", + "{\"transient\":{\"cluster\":{\"blocks\":{\"read_only\":\"true\"}}}}", + "{\"defaults\":{\"cluster\":{\"blocks\":{\"read_only_allow_delete\":true}}}}" }) + @DisplayName("a read-only block is reported DOWN wherever the setting is applied") + void readOnlyBlockIsReportedDown(String settingsBody) throws Exception { + // A read-only cluster still answers cluster health as green, so only + // the settings probe catches the disk-watermark case. + stubCluster("green", true, settingsBody, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DOWN", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("an unreadable settings response does not by itself fail the check") + void unreadableSettingsDoesNotFailTheCheck() throws Exception { + stubCluster("green", true, null, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("UP", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("a rejected canary write is reported DOWN when indexing is required") + void rejectedCanaryWriteIsReportedDownWhenIndexingRequired() throws Exception { + stubCluster("green", true, NO_READ_ONLY_BLOCKS, 503); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DOWN", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("a rejected canary write is tolerated when indexing is not required") + void rejectedCanaryWriteIsToleratedWhenIndexingNotRequired() throws Exception { + // Search-only deployments index from a separate job, so a write + // failure is not an outage for this service. + stubCluster("green", true, NO_READ_ONLY_BLOCKS, 503); + + Map health = serviceWithElasticsearch(false).checkHealth(); + + assertEquals("UP", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("a canary write that times out is reported DOWN when indexing is required") + void timedOutCanaryWriteIsReportedDown() throws Exception { + stubCluster("green", true, NO_READ_ONLY_BLOCKS, null); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DOWN", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("the canary document is deleted again after a successful write") + void canaryDocumentIsDeletedAgain() throws Exception { + stubCluster("green", true, NO_READ_ONLY_BLOCKS, 201); + + serviceWithElasticsearch(true).checkHealth(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + verify(restClient, org.mockito.Mockito.atLeastOnce()).performRequest(captor.capture()); + assertTrue(captor.getAllValues().stream().anyMatch(request -> "DELETE".equals(request.getMethod())), + "the canary document was left behind in the index"); + } + + @Test + @DisplayName("the failure reason is not leaked to an unauthenticated caller") + void failureReasonIsNotLeaked() throws Exception { + stubCluster("red", true, NO_READ_ONLY_BLOCKS, 201); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + Map details = details(component(health, "elasticsearch")); + assertEquals("Dependency unavailable", details.get("error")); + assertEquals("DEPENDENCY_FAILURE", details.get("errorCategory")); + assertFalse(details.toString().contains("Cluster red")); + } + + @Test + @DisplayName("a repeated check is served from cache rather than re-probing the cluster") + void repeatedCheckIsServedFromCache() throws Exception { + stubCluster("green", true, NO_READ_ONLY_BLOCKS, 201); + HealthService service = serviceWithElasticsearch(true); + + service.checkHealth(); + int requestsAfterFirstCheck = org.mockito.Mockito.mockingDetails(restClient).getInvocations().size(); + service.checkHealth(); + + assertEquals(requestsAfterFirstCheck, + org.mockito.Mockito.mockingDetails(restClient).getInvocations().size()); + } + + @Test + @DisplayName("an enabled but uninitialised client is reported DOWN rather than skipped") + void uninitialisedClientIsReportedDown() throws Exception { + HealthService service = new HealthService(dataSource, null, "localhost", 9200, true, "amrit_data", false); + ReflectionTestUtils.setField(service, "elasticsearchClientReady", true); + + Map health = service.checkHealth(); + + assertEquals("DOWN", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("a cluster health response that is not 200 counts as unreadable") + void nonOkClusterHealthCountsAsUnreadable() throws Exception { + when(restClient.performRequest(any(Request.class))).thenAnswer(invocation -> { + Request request = invocation.getArgument(0); + if ("/_cluster/health".equals(request.getEndpoint())) { + return response(503, ""); + } + return response(200, ""); + }); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DEGRADED", component(health, "elasticsearch").get("status")); + } + + @Test + @DisplayName("a cluster health response with no status field counts as unreadable") + void clusterHealthWithNoStatusCountsAsUnreadable() throws Exception { + when(restClient.performRequest(any(Request.class))).thenAnswer(invocation -> { + Request request = invocation.getArgument(0); + if ("/_cluster/health".equals(request.getEndpoint())) { + return response(200, "{\"cluster_name\":\"amrit\"}"); + } + return response(200, ""); + }); + + Map health = serviceWithElasticsearch(true).checkHealth(); + + assertEquals("DEGRADED", component(health, "elasticsearch").get("status")); + } + } + + @Nested + @DisplayName("lifecycle") + class Lifecycle { + + @Test + @DisplayName("start-up builds an Elasticsearch client only when the integration is enabled") + void startUpBuildsClientOnlyWhenEnabled() { + HealthService disabled = serviceWithoutElasticsearch(null); + disabled.init(); + assertFalse((Boolean) ReflectionTestUtils.getField(disabled, "elasticsearchClientReady")); + + HealthService enabled = new HealthService(dataSource, null, "localhost", 9200, true, "amrit_data", false); + enabled.init(); + assertTrue((Boolean) ReflectionTestUtils.getField(enabled, "elasticsearchClientReady")); + enabled.cleanup(); + } + + @Test + @DisplayName("shutdown releases the checker thread and the Elasticsearch client") + void shutdownReleasesResources() throws Exception { + HealthService service = serviceWithElasticsearch(false); + + service.cleanup(); + + verify(restClient).close(); + } + + @Test + @DisplayName("a client that fails to close does not break shutdown") + void clientThatFailsToCloseDoesNotBreakShutdown() throws Exception { + HealthService service = serviceWithElasticsearch(false); + org.mockito.Mockito.doThrow(new IOException("already closed")).when(restClient).close(); + + service.cleanup(); + + verify(restClient).close(); + } + + @Test + @DisplayName("an absent target index name falls back to the default index") + void absentTargetIndexFallsBackToDefault() { + HealthService service = new HealthService(dataSource, null, "localhost", 9200, false, null, false); + + assertEquals("amrit_data", ReflectionTestUtils.getField(service, "elasticsearchTargetIndex")); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/service/rmnch/RmnchDataSyncServiceImplTest.java b/src/test/java/com/iemr/common/identity/service/rmnch/RmnchDataSyncServiceImplTest.java new file mode 100644 index 00000000..ebb94ed8 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/service/rmnch/RmnchDataSyncServiceImplTest.java @@ -0,0 +1,1047 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service.rmnch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigInteger; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.iemr.common.identity.data.rmnch.NcdTbHrpData; +import com.iemr.common.identity.data.rmnch.RMNCHBeneficiaryDetailsRmnch; +import com.iemr.common.identity.data.rmnch.RMNCHBornBirthDetails; +import com.iemr.common.identity.data.rmnch.RMNCHCBACdetails; +import com.iemr.common.identity.data.rmnch.RMNCHHouseHoldDetails; +import com.iemr.common.identity.data.rmnch.RMNCHMBeneficiaryAccount; +import com.iemr.common.identity.data.rmnch.RMNCHMBeneficiaryImage; +import com.iemr.common.identity.data.rmnch.RMNCHMBeneficiaryaddress; +import com.iemr.common.identity.data.rmnch.RMNCHMBeneficiarycontact; +import com.iemr.common.identity.data.rmnch.RMNCHMBeneficiarydetail; +import com.iemr.common.identity.data.rmnch.RMNCHMBeneficiarymapping; +import com.iemr.common.identity.repo.rmnch.RMNCHBenAccountRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHBenAddressRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHBenContactRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHBenDetailsRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHBenImageRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHBeneficiaryDetailsRmnchRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHBornBirthDetailsRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHCBACDetailsRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHHouseHoldDetailsRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHMBenMappingRepo; +import com.iemr.common.identity.repo.rmnch.RMNCHMBenRegIdMapRepo; +import com.iemr.common.identity.utils.exception.IEMRException; + +/** + * Tests for the RMNCH field-app synchronisation service. + * + *

+ * Field workers sync offline-collected data in bulk, and the app addresses + * beneficiaries by the ID it was given at registration rather than by the + * platform's registration ID. Every sync therefore has to translate IDs, decide + * insert-versus-update per record by looking for an existing row, and keep going + * when one beneficiary in a batch is unusable. Those three concerns are what + * these tests pin down. + * + *

+ * The outbound HTTP calls to the teleconsultation and FHIR services are made + * through a locally constructed client, so tests exercise the paths that do not + * depend on those services being up, plus the failure handling for the ones + * that do. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RmnchDataSyncServiceImplTest { + + @Mock + private RMNCHBeneficiaryDetailsRmnchRepo benDetailsRmnchRepo; + @Mock + private RMNCHBornBirthDetailsRepo bornBirthRepo; + @Mock + private RMNCHCBACDetailsRepo cbacRepo; + @Mock + private RMNCHHouseHoldDetailsRepo houseHoldRepo; + @Mock + private RMNCHBenAddressRepo addressRepo; + @Mock + private RMNCHMBenMappingRepo mappingRepo; + @Mock + private RMNCHBenDetailsRepo detailsRepo; + @Mock + private RMNCHBenAccountRepo accountRepo; + @Mock + private RMNCHBenImageRepo imageRepo; + @Mock + private RMNCHBenContactRepo contactRepo; + @Mock + private RMNCHMBenRegIdMapRepo regIdMapRepo; + + @InjectMocks + private RmnchDataSyncServiceImpl service; + + private static final BigInteger BEN_ID = BigInteger.valueOf(4001L); + private static final BigInteger BEN_REG_ID = BigInteger.valueOf(100200300L); + private static final Integer VAN_ID = 7; + + @BeforeEach + void configurePageSize() { + ReflectionTestUtils.setField(service, "door_to_door_page_size", "2"); + } + + @Nested + @DisplayName("bulk sync from the field app") + class BulkSync { + + @Test + @DisplayName("a beneficiary-details record is translated to its registration ID and saved") + void beneficiaryDetailsAreTranslatedAndSaved() throws Exception { + when(regIdMapRepo.getRegID(BEN_ID)).thenReturn(BEN_REG_ID); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(detailsRepo.getByBenRegID(any())).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + supplied.forEach(record -> record.setId(11L)); + return new ArrayList<>(supplied); + }); + + String response = service.syncDataToAmrit( + "{\"beneficiaryDetails\":[{\"benficieryid\":4001,\"firstName\":\"Asha\"}]}"); + + ArgumentCaptor> captor = captor(); + verify(benDetailsRmnchRepo).saveAll(captor.capture()); + assertEquals(BEN_REG_ID, captor.getValue().get(0).getBenRegId()); + assertTrue(response.contains("11")); + } + + @Test + @DisplayName("an existing RMNCH row is updated in place rather than duplicated") + void existingRowIsUpdatedInPlace() throws Exception { + when(regIdMapRepo.getRegID(BEN_ID)).thenReturn(BEN_REG_ID); + RMNCHBeneficiaryDetailsRmnch existing = new RMNCHBeneficiaryDetailsRmnch(); + existing.setBeneficiaryDetails_RmnchId(BigInteger.valueOf(55L)); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(existing)); + when(detailsRepo.getByBenRegID(any())).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + return new ArrayList<>(supplied); + }); + + service.syncDataToAmrit("{\"beneficiaryDetails\":[{\"benficieryid\":4001,\"firstName\":\"Asha\"}]}"); + + ArgumentCaptor> captor = captor(); + verify(benDetailsRmnchRepo).saveAll(captor.capture()); + assertEquals(BigInteger.valueOf(55L), captor.getValue().get(0).getBeneficiaryDetails_RmnchId()); + } + + @Test + @DisplayName("names from the sync are written back onto the platform's own detail row") + void namesAreWrittenBackToThePlatformRow() throws Exception { + when(regIdMapRepo.getRegID(BEN_ID)).thenReturn(BEN_REG_ID); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + RMNCHMBeneficiarydetail platformRow = new RMNCHMBeneficiarydetail(); + when(detailsRepo.getByBenRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(platformRow)); + when(benDetailsRmnchRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + return new ArrayList<>(supplied); + }); + + service.syncDataToAmrit("{\"beneficiaryDetails\":[{\"benficieryid\":4001,\"firstName\":\"Asha\"," + + "\"lastName\":\"Devi\",\"genderId\":2,\"gender\":\"Female\"}]}"); + + verify(detailsRepo).saveAll(any()); + assertEquals("Asha", platformRow.getFirstName()); + assertEquals("Devi", platformRow.getLastName()); + assertEquals("Female", platformRow.getGender()); + } + + @Test + @DisplayName("related beneficiary IDs are flattened to the comma-separated column the schema stores") + void relatedBeneficiaryIdsAreFlattened() throws Exception { + // The separator loop never advances its pointer, so its + // last-element check never fires and the stored value carries a + // trailing comma. Reading it back still yields the right IDs because + // String.split discards the trailing empty field. + when(regIdMapRepo.getRegID(BEN_ID)).thenReturn(BEN_REG_ID); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(detailsRepo.getByBenRegID(any())).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + return new ArrayList<>(supplied); + }); + + service.syncDataToAmrit("{\"beneficiaryDetails\":[{\"benficieryid\":4001," + + "\"relatedBeneficiaryIds\":[11,22,33]}]}"); + + ArgumentCaptor> captor = captor(); + verify(benDetailsRmnchRepo).saveAll(captor.capture()); + assertEquals("11,22,33,", captor.getValue().get(0).getRelatedBeneficiaryIdsDB()); + } + + @Test + @DisplayName("birth details in the same payload are synced alongside the beneficiary") + void birthDetailsAreSyncedAlongside() throws Exception { + stubBeneficiaryDetailsSync(); + when(bornBirthRepo.getByRegID(BEN_REG_ID)).thenReturn(null); + when(bornBirthRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + supplied.forEach(record -> record.setId(22L)); + return new ArrayList<>(supplied); + }); + + String response = service.syncDataToAmrit("{\"beneficiaryDetails\":[{\"benficieryid\":4001}]," + + "\"bornBirthDeatils\":[{\"benficieryid\":4001}]}"); + + verify(bornBirthRepo).saveAll(any()); + assertTrue(response.contains("22")); + } + + @Test + @DisplayName("an existing birth-details row is updated in place") + void existingBirthDetailsRowIsUpdatedInPlace() throws Exception { + stubBeneficiaryDetailsSync(); + RMNCHBornBirthDetails existing = new RMNCHBornBirthDetails(); + existing.setBornBirthDeatilsId(66L); + when(bornBirthRepo.getByRegID(BEN_REG_ID)).thenReturn(existing); + when(bornBirthRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + return new ArrayList<>(supplied); + }); + + service.syncDataToAmrit("{\"beneficiaryDetails\":[{\"benficieryid\":4001}]," + + "\"bornBirthDeatils\":[{\"benficieryid\":4001}]}"); + + ArgumentCaptor> captor = captor(); + verify(bornBirthRepo).saveAll(captor.capture()); + assertEquals(66L, captor.getValue().get(0).getBornBirthDeatilsId()); + } + + @Test + @DisplayName("a synced CBAC screening starts with its diagnosis outcomes unchecked") + void syncedCbacScreeningStartsUnchecked() throws Exception { + stubBeneficiaryDetailsSync(); + when(cbacRepo.getByRegID(BEN_REG_ID)).thenReturn(null); + when(cbacRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + supplied.forEach(record -> record.setId(33L)); + return new ArrayList<>(supplied); + }); + + service.syncDataToAmrit("{\"beneficiaryDetails\":[{\"benficieryid\":4001}]," + + "\"cBACDetails\":[{\"benficieryid\":4001}]}"); + + ArgumentCaptor> captor = captor(); + verify(cbacRepo).saveAll(captor.capture()); + RMNCHCBACdetails saved = captor.getValue().get(0); + assertEquals("Not checked", saved.getConfirmed_hrp()); + assertEquals("Not checked", saved.getConfirmed_ncd()); + assertEquals("Not checked", saved.getConfirmed_tb()); + assertEquals("Not checked", saved.getConfirmed_ncd_diseases()); + assertEquals("pending", saved.getDiagnosis_status()); + } + + @Test + @DisplayName("household details are keyed on the household ID, not the beneficiary") + void householdDetailsAreKeyedOnHouseholdId() throws Exception { + stubBeneficiaryDetailsSync(); + RMNCHHouseHoldDetails existing = new RMNCHHouseHoldDetails(); + existing.setHouseHoldDetailsId(77L); + when(houseHoldRepo.getByHouseHoldID(anyLong())).thenReturn(Collections.singletonList(existing)); + when(houseHoldRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + supplied.forEach(record -> record.setId(44L)); + return new ArrayList<>(supplied); + }); + + String response = service.syncDataToAmrit("{\"beneficiaryDetails\":[{\"benficieryid\":4001}]," + + "\"houseHoldDetails\":[{\"houseoldId\":555}]}"); + + ArgumentCaptor> captor = captor(); + verify(houseHoldRepo).saveAll(captor.capture()); + assertEquals(77L, captor.getValue().get(0).getHouseHoldDetailsId()); + assertTrue(response.contains("44")); + } + + @ParameterizedTest + @ValueSource(strings = { "", "{}", "{\"beneficiaryDetails\":[]}" }) + @DisplayName("a payload with no beneficiary is rejected rather than silently syncing nothing") + void payloadWithNoBeneficiaryIsRejected(String payload) { + assertThrows(Exception.class, () -> service.syncDataToAmrit(payload)); + verify(benDetailsRmnchRepo, never()).saveAll(any()); + } + + @Test + @DisplayName("a null payload is rejected") + void nullPayloadIsRejected() { + assertThrows(Exception.class, () -> service.syncDataToAmrit(null)); + } + + @Test + @DisplayName("a persistence failure aborts the whole sync so the app retries the batch") + void persistenceFailureAbortsTheSync() { + when(regIdMapRepo.getRegID(any())).thenReturn(BEN_REG_ID); + when(benDetailsRmnchRepo.getByRegID(any())).thenReturn(Collections.emptyList()); + when(detailsRepo.getByBenRegID(any())).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.saveAll(any())).thenThrow(new IllegalStateException("deadlock")); + + assertThrows(Exception.class, + () -> service.syncDataToAmrit("{\"beneficiaryDetails\":[{\"benficieryid\":4001}]}")); + } + + private void stubBeneficiaryDetailsSync() { + when(regIdMapRepo.getRegID(BEN_ID)).thenReturn(BEN_REG_ID); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(detailsRepo.getByBenRegID(any())).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.saveAll(any())).thenAnswer(invocation -> { + List supplied = invocation.getArgument(0); + return new ArrayList<>(supplied); + }); + } + } + + @Nested + @DisplayName("saving RMNCH details after a registration") + class SavingAfterRegistration { + + /** + * The minimum request the method can actually process: every integer + * field it reads with a {@code null} fallback has to be present. See + * {@link #requestOmittingAnIntegerFieldIsRejected()}. + */ + private String requestWith(String extraFields) { + String base = "\"vanID\":7,\"parkingPlaceID\":3,\"providerServiceMapID\":11," + + "\"genderID\":2,\"maritalStatusID\":3"; + return "{" + base + (extraFields.isEmpty() ? "" : "," + extraFields) + "}"; + } + + @Test + @DisplayName("a request that omits an optional integer field saves nothing") + void requestOmittingAnIntegerFieldIsRejected() { + // getInt(obj, key, null) unboxes its fallback, so an absent vanID - + // which the registration UI does not always send - throws before the + // row is written. The failure is swallowed into the return value, so + // the caller sees registration succeed with no RMNCH row behind it. + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + + String response = service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, + "{\"createdBy\":\"field.worker\"}"); + + assertTrue(response.startsWith("Error save beneficiary in rmnch")); + verify(benDetailsRmnchRepo, never()).save(any()); + } + + @Test + @DisplayName("a beneficiary with no RMNCH row gets one stamped with the creating user") + void newRowIsStampedWithTheCreatingUser() { + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + String response = service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, + requestWith("\"createdBy\":\"field.worker\",\"firstName\":\"Asha\"")); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(RMNCHBeneficiaryDetailsRmnch.class); + verify(benDetailsRmnchRepo).save(captor.capture()); + assertEquals("field.worker", captor.getValue().getCreatedBy()); + assertNotNull(captor.getValue().getCreatedDate()); + assertNull(captor.getValue().getUpdatedBy()); + assertEquals(BEN_ID, captor.getValue().getBenficieryid()); + assertEquals(BEN_REG_ID, captor.getValue().getBenRegId()); + assertTrue(response.contains("4001")); + } + + @Test + @DisplayName("an existing row records the modifying user instead of the creating one") + void existingRowRecordsTheModifyingUser() { + RMNCHBeneficiaryDetailsRmnch existing = new RMNCHBeneficiaryDetailsRmnch(); + existing.setCreatedBy("original.worker"); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(existing)); + when(benDetailsRmnchRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, + requestWith("\"createdBy\":\"second.worker\"")); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(RMNCHBeneficiaryDetailsRmnch.class); + verify(benDetailsRmnchRepo).save(captor.capture()); + assertEquals("original.worker", captor.getValue().getCreatedBy()); + assertEquals("second.worker", captor.getValue().getUpdatedBy()); + assertNotNull(captor.getValue().getUpdatedDate()); + } + + @Test + @DisplayName("an omitted creating user is recorded as the system") + void omittedCreatingUserIsRecordedAsSystem() { + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, requestWith("")); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(RMNCHBeneficiaryDetailsRmnch.class); + verify(benDetailsRmnchRepo).save(captor.capture()); + assertEquals("system", captor.getValue().getCreatedBy()); + } + + @Test + @DisplayName("the reproductive status falls back to the marital status when not sent") + void reproductiveStatusFallsBackToMaritalStatus() { + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, requestWith("")); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(RMNCHBeneficiaryDetailsRmnch.class); + verify(benDetailsRmnchRepo).save(captor.capture()); + assertEquals(3, captor.getValue().getReproductiveStatusId()); + assertEquals(3, captor.getValue().getMaritalstatusId()); + } + + @Test + @DisplayName("an explicit reproductive status wins over the marital status") + void explicitReproductiveStatusWins() { + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, + requestWith("\"reproductiveStatusId\":9,\"reproductiveStatus\":\"Pregnant\"")); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(RMNCHBeneficiaryDetailsRmnch.class); + verify(benDetailsRmnchRepo).save(captor.capture()); + assertEquals(9, captor.getValue().getReproductiveStatusId()); + assertEquals("Pregnant", captor.getValue().getReproductiveStatus()); + } + + @ParameterizedTest + @ValueSource(strings = { "1996-01-15 00:00:00", "1996-01-15T00:00:00Z", "1996-01-15T00:00:00" }) + @DisplayName("a date of birth is accepted in each form the registration UI sends it") + void dateOfBirthIsAcceptedInEachForm(String suppliedDob) { + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, + requestWith("\"dOB\":\"" + suppliedDob + "\"")); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(RMNCHBeneficiaryDetailsRmnch.class); + verify(benDetailsRmnchRepo).save(captor.capture()); + assertEquals(Timestamp.valueOf("1996-01-15 00:00:00"), captor.getValue().getDob()); + } + + @ParameterizedTest + @ValueSource(strings = { "15/01/1996", "", " " }) + @DisplayName("an unusable date of birth is skipped rather than failing the registration") + void unusableDateOfBirthIsSkipped(String suppliedDob) { + // The beneficiary is already registered by this point, so losing the + // DOB is preferable to failing the call. + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(benDetailsRmnchRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, + requestWith("\"dOB\":\"" + suppliedDob + "\"")); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(RMNCHBeneficiaryDetailsRmnch.class); + verify(benDetailsRmnchRepo).save(captor.capture()); + assertNull(captor.getValue().getDob()); + } + + @Test + @DisplayName("a failure is reported in the response rather than thrown at the registration flow") + void failureIsReportedInTheResponse() { + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenThrow(new IllegalStateException("table locked")); + + String response = service.saveBeneficiaryDetailsAfterRegistration(4001L, 100200300L, "{}"); + + assertTrue(response.startsWith("Error save beneficiary in rmnch")); + } + } + + @Nested + @DisplayName("door-to-door fetch by village") + class FetchByVillage { + + @Test + @DisplayName("a village page is fetched with the configured page size") + void villagePageIsFetchedWithConfiguredPageSize() throws Exception { + when(addressRepo.getBenData(eq(401), any())).thenReturn(pageOf(address())); + when(mappingRepo.getByAddressIDAndVanID(any(), anyInt())).thenReturn(null); + + String response = service.getBenData("{\"villageID\":401,\"pageNo\":0}", "Bearer token"); + + assertNotNull(response); + ArgumentCaptor captor = ArgumentCaptor.forClass(PageRequest.class); + verify(addressRepo).getBenData(eq(401), captor.capture()); + assertEquals(2, captor.getValue().getPageSize()); + assertEquals(0, captor.getValue().getPageNumber()); + } + + @Test + @DisplayName("a date range narrows the query to the range-filtered variant") + void dateRangeUsesTheFilteredQuery() throws Exception { + when(addressRepo.getBenDataFilteredWithDateRange(eq(401), any(), any(), any())) + .thenReturn(pageOf(address())); + + service.getBenData("{\"villageID\":401,\"pageNo\":0,\"fromDate\":\"2026-01-01T00:00:00Z\"," + + "\"toDate\":\"2026-02-01T00:00:00Z\"}", "Bearer token"); + + verify(addressRepo).getBenDataFilteredWithDateRange(eq(401), any(), any(), any()); + verify(addressRepo, never()).getBenData(anyInt(), any()); + } + + @Test + @DisplayName("a request without a village is rejected") + void requestWithoutVillageIsRejected() { + Exception thrown = assertThrows(Exception.class, () -> service.getBenData("{\"pageNo\":0}", "token")); + + assertTrue(thrown.getMessage().contains("village")); + } + + @Test + @DisplayName("a request without a page number is rejected") + void requestWithoutPageNumberIsRejected() { + Exception thrown = assertThrows(Exception.class, + () -> service.getBenData("{\"villageID\":401}", "token")); + + assertTrue(thrown.getMessage().contains("page no")); + } + + @Test + @DisplayName("an empty village page yields no payload at all") + void emptyVillagePageYieldsNoPayload() throws Exception { + when(addressRepo.getBenData(eq(401), any())).thenReturn(new PageImpl<>(Collections.emptyList())); + + assertNull(service.getBenData("{\"villageID\":401,\"pageNo\":0}", "token")); + } + } + + @Nested + @DisplayName("door-to-door fetch by ASHA worker") + class FetchByAsha { + + @Test + @DisplayName("the ASHA's ID is resolved to the username the address rows are stamped with") + void ashaIdIsResolvedToUsername() throws Exception { + when(addressRepo.getUserNameForAsha(9)).thenReturn("asha.worker"); + when(addressRepo.getBenDataByAsha(eq("asha.worker"), any())).thenReturn(pageOf(address())); + + assertNotNull(service.getBenDataByAsha("{\"AshaId\":9,\"pageNo\":0}", "token")); + + verify(addressRepo).getBenDataByAsha(eq("asha.worker"), any()); + } + + @Test + @DisplayName("a date range narrows the ASHA query to the range-filtered variant") + void dateRangeUsesTheFilteredQuery() throws Exception { + when(addressRepo.getUserNameForAsha(9)).thenReturn("asha.worker"); + when(addressRepo.getBenDataByAshaFilteredWithDateRange(eq("asha.worker"), any(), any(), any())) + .thenReturn(pageOf(address())); + + service.getBenDataByAsha("{\"AshaId\":9,\"pageNo\":0,\"fromDate\":\"2026-01-01T00:00:00Z\"," + + "\"toDate\":\"2026-02-01T00:00:00Z\"}", "token"); + + verify(addressRepo).getBenDataByAshaFilteredWithDateRange(eq("asha.worker"), any(), any(), any()); + } + + @ParameterizedTest + @CsvSource(nullValues = "null", value = { "null", "''" }) + @DisplayName("an unrecognised ASHA ID is reported as a configuration problem") + void unrecognisedAshaIdIsReported(String resolvedUsername) { + when(addressRepo.getUserNameForAsha(9)).thenReturn(resolvedUsername); + + Exception thrown = assertThrows(Exception.class, + () -> service.getBenDataByAsha("{\"AshaId\":9,\"pageNo\":0}", "token")); + + assertTrue(thrown.getMessage().contains("Asha details not found")); + } + + @Test + @DisplayName("a request without an ASHA ID is rejected") + void requestWithoutAshaIdIsRejected() { + assertThrows(Exception.class, () -> service.getBenDataByAsha("{\"pageNo\":0}", "token")); + } + + @Test + @DisplayName("a request without a page number is rejected") + void requestWithoutPageNumberIsRejected() { + when(addressRepo.getUserNameForAsha(9)).thenReturn("asha.worker"); + + Exception thrown = assertThrows(Exception.class, + () -> service.getBenDataByAsha("{\"AshaId\":9}", "token")); + + assertTrue(thrown.getMessage().contains("page no")); + } + } + + @Nested + @DisplayName("assembling the door-to-door payload") + class AssemblingPayload { + + @BeforeEach + void stubAddressPage() { + when(addressRepo.getBenData(eq(401), any())).thenReturn(pageOf(address())); + } + + private RMNCHMBeneficiarymapping mapping() { + RMNCHMBeneficiarymapping mapping = new RMNCHMBeneficiarymapping(); + mapping.setBenRegId(BEN_REG_ID); + mapping.setVanID(VAN_ID); + mapping.setBenDetailsId(BigInteger.valueOf(5L)); + mapping.setBenAccountID(BigInteger.valueOf(7L)); + mapping.setBenImageId(BigInteger.valueOf(6L)); + mapping.setBenAddressId(BigInteger.valueOf(2L)); + mapping.setBenContactsId(BigInteger.valueOf(4L)); + return mapping; + } + + private String fetch() throws Exception { + return service.getBenData("{\"villageID\":401,\"pageNo\":0}", "Bearer token"); + } + + @Test + @DisplayName("an address with no mapping row contributes nothing to the payload") + void addressWithNoMappingContributesNothing() throws Exception { + when(mappingRepo.getByAddressIDAndVanID(any(), anyInt())).thenReturn(null); + + String response = fetch(); + + assertTrue(response.contains("\"data\":[]")); + assertTrue(response.contains("\"totalPage\":1")); + assertTrue(response.contains("\"pageSize\":2")); + } + + @Test + @DisplayName("the platform's bank, address and contact rows are folded into the RMNCH record") + void platformRowsAreFoldedIn() throws Exception { + when(mappingRepo.getByAddressIDAndVanID(any(), anyInt())).thenReturn(mapping()); + RMNCHMBeneficiarydetail detail = new RMNCHMBeneficiarydetail(); + detail.setFirstName("Asha"); + detail.setLastName("Devi"); + detail.setMotherName("Sita"); + detail.setCreatedBy("field.worker"); + detail.setDob(Timestamp.valueOf("1996-01-15 00:00:00")); + when(detailsRepo.getByIdAndVanID(any(), anyInt())).thenReturn(detail); + RMNCHMBeneficiaryAccount account = new RMNCHMBeneficiaryAccount(); + account.setNameOfBank("SBI"); + account.setBranchName("Yelahanka"); + account.setIfscCode("SBIN0001"); + account.setBankAccount("1234567890"); + when(accountRepo.getByIdAndVanID(any(), anyInt())).thenReturn(account); + RMNCHMBeneficiaryaddress benAddress = new RMNCHMBeneficiaryaddress(); + benAddress.setPermState("Karnataka"); + benAddress.setPermAddrLine1("1 Main Rd"); + benAddress.setCreatedBy("field.worker"); + when(addressRepo.getByIdAndVanID(any(), anyInt())).thenReturn(benAddress); + RMNCHMBeneficiarycontact contact = new RMNCHMBeneficiarycontact(); + contact.setPreferredPhoneNum("9000000000"); + when(contactRepo.getByIdAndVanID(any(), anyInt())).thenReturn(contact); + when(imageRepo.getByIdAndVanID(anyLong(), anyInt())).thenReturn(new RMNCHMBeneficiaryImage()); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + when(mappingRepo.getUserIDByUserName("field.worker")).thenReturn(31); + + String response = fetch(); + + assertTrue(response.contains("SBI")); + assertTrue(response.contains("Karnataka")); + assertTrue(response.contains("9000000000")); + assertTrue(response.contains("Sita")); + assertTrue(response.contains("\"ashaId\":31")); + } + + @Test + @DisplayName("the stored comma-separated related IDs are expanded back into a list") + void relatedIdsAreExpandedBackIntoAList() throws Exception { + when(mappingRepo.getByAddressIDAndVanID(any(), anyInt())).thenReturn(mapping()); + stubEmptyPlatformRows(); + RMNCHBeneficiaryDetailsRmnch rmnch = new RMNCHBeneficiaryDetailsRmnch(); + rmnch.setRelatedBeneficiaryIdsDB("11,22,33"); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.singletonList(rmnch)); + + String response = fetch(); + + assertTrue(response.contains("11")); + assertTrue(response.contains("33")); + } + + @ParameterizedTest + @CsvSource({ "30, Years", "1, Year" }) + @DisplayName("an age in whole years is rendered with the matching unit") + void ageInYearsIsRenderedWithMatchingUnit(int yearsAgo, String expectedUnit) throws Exception { + when(mappingRepo.getByAddressIDAndVanID(any(), anyInt())).thenReturn(mapping()); + RMNCHMBeneficiarydetail detail = new RMNCHMBeneficiarydetail(); + detail.setDob(Timestamp.valueOf( + java.time.LocalDate.now().minusYears(yearsAgo).minusDays(1).atStartOfDay())); + when(detailsRepo.getByIdAndVanID(any(), anyInt())).thenReturn(detail); + stubEmptyPlatformRowsExceptDetail(); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + + String response = fetch(); + + assertTrue(response.contains(expectedUnit), response); + } + + @Test + @DisplayName("an infant's age is rendered in months") + void infantAgeIsRenderedInMonths() throws Exception { + when(mappingRepo.getByAddressIDAndVanID(any(), anyInt())).thenReturn(mapping()); + RMNCHMBeneficiarydetail detail = new RMNCHMBeneficiarydetail(); + detail.setDob(Timestamp.valueOf(java.time.LocalDate.now().minusMonths(3).atStartOfDay())); + when(detailsRepo.getByIdAndVanID(any(), anyInt())).thenReturn(detail); + stubEmptyPlatformRowsExceptDetail(); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + + String response = fetch(); + + assertTrue(response.contains("Months"), response); + } + + @Test + @DisplayName("a newborn's age is rendered in days") + void newbornAgeIsRenderedInDays() throws Exception { + when(mappingRepo.getByAddressIDAndVanID(any(), anyInt())).thenReturn(mapping()); + RMNCHMBeneficiarydetail detail = new RMNCHMBeneficiarydetail(); + detail.setDob(Timestamp.valueOf(java.time.LocalDate.now().minusDays(5).atStartOfDay())); + when(detailsRepo.getByIdAndVanID(any(), anyInt())).thenReturn(detail); + stubEmptyPlatformRowsExceptDetail(); + when(benDetailsRmnchRepo.getByRegID(BEN_REG_ID)).thenReturn(Collections.emptyList()); + + String response = fetch(); + + assertTrue(response.contains("Days"), response); + } + + @Test + @DisplayName("one unusable beneficiary does not lose the rest of the page") + void oneUnusableBeneficiaryDoesNotLoseThePage() throws Exception { + when(addressRepo.getBenData(eq(401), any())).thenReturn(pageOf(address(), address())); + when(mappingRepo.getByAddressIDAndVanID(any(), anyInt())).thenReturn(mapping()); + when(detailsRepo.getByIdAndVanID(any(), anyInt())).thenThrow(new IllegalStateException("row corrupt")); + + String response = fetch(); + + assertTrue(response.contains("\"data\":[]")); + } + + private void stubEmptyPlatformRows() { + when(detailsRepo.getByIdAndVanID(any(), anyInt())).thenReturn(new RMNCHMBeneficiarydetail()); + stubEmptyPlatformRowsExceptDetail(); + } + + private void stubEmptyPlatformRowsExceptDetail() { + when(accountRepo.getByIdAndVanID(any(), anyInt())).thenReturn(new RMNCHMBeneficiaryAccount()); + when(imageRepo.getByIdAndVanID(anyLong(), anyInt())).thenReturn(new RMNCHMBeneficiaryImage()); + when(addressRepo.getByIdAndVanID(any(), anyInt())).thenReturn(new RMNCHMBeneficiaryaddress()); + when(contactRepo.getByIdAndVanID(any(), anyInt())).thenReturn(new RMNCHMBeneficiarycontact()); + } + } + + @Nested + @DisplayName("suspected-condition status") + class SuspectedConditionStatus { + + @Test + @DisplayName("a beneficiary with no recorded visit has no suspected-condition status") + void noVisitYieldsNoStatus() throws Exception { + when(cbacRepo.getVisitDetailsbyRegID(100200300L)).thenReturn(Collections.emptyList()); + + assertNull(service.getHRP_NCD_TB_SuspectedStatus(100200300L, "token", maleDetail())); + } + + @Test + @DisplayName("no beneficiary means no status rather than a lookup") + void noBeneficiaryMeansNoStatus() throws Exception { + assertNull(service.getHRP_NCD_TB_SuspectedStatus(null, "token", maleDetail())); + + verify(cbacRepo, never()).getVisitDetailsbyRegID(any()); + } + + @ParameterizedTest + @ValueSource(strings = { "General OPD", "General OPD (QC)", "NCD screening", "COVID-19 Screening" }) + @DisplayName("the common visit categories read the general diagnosis column") + void commonVisitCategoriesReadTheGeneralColumn(String visitCategory) throws Exception { + stubVisit(visitCategory); + when(cbacRepo.getDiagnosisProvidedCommon(100200300L, 900L)) + .thenReturn(Collections.singletonList("Tuberculosis")); + + NcdTbHrpData result = service.getHRP_NCD_TB_SuspectedStatus(100200300L, "token", maleDetail()); + + assertEquals("Yes", result.getConfirmed_tb()); + assertEquals("No", result.getConfirmed_hrp()); + } + + @Test + @DisplayName("a post-natal visit reads the PNC diagnosis column") + void postNatalVisitReadsThePncColumn() throws Exception { + stubVisit("PNC"); + when(cbacRepo.getDiagnosisProvidedPNC(100200300L, 900L)) + .thenReturn(Collections.singletonList("Hypertension")); + + NcdTbHrpData result = service.getHRP_NCD_TB_SuspectedStatus(100200300L, "token", maleDetail()); + + assertEquals("Yes", result.getConfirmed_ncd()); + assertEquals("Hypertension", result.getConfirmed_ncd_diseases()); + } + + @Test + @DisplayName("an NCD-care visit reads the NCD-care diagnosis column") + void ncdCareVisitReadsTheNcdCareColumn() throws Exception { + stubVisit("NCD care"); + when(cbacRepo.getDiagnosisProvidedNCDCare(100200300L, 900L)) + .thenReturn(Collections.singletonList("Diabetes mellitus")); + + NcdTbHrpData result = service.getHRP_NCD_TB_SuspectedStatus(100200300L, "token", maleDetail()); + + assertEquals("Yes", result.getConfirmed_ncd()); + // The "No" is only written once a TB verdict has already been + // recorded, so a single non-TB diagnosis leaves it unset rather than + // asserting the beneficiary is TB-free. + assertNull(result.getConfirmed_tb()); + } + + @Test + @DisplayName("an antenatal visit only reports the high-risk-pregnancy status") + void antenatalVisitOnlyReportsHrp() throws Exception { + stubVisit("ANC"); + + NcdTbHrpData result = service.getHRP_NCD_TB_SuspectedStatus(100200300L, "token", maleDetail()); + + assertEquals("No", result.getConfirmed_hrp()); + verify(cbacRepo, never()).getDiagnosisProvidedCommon(any(), any()); + } + + @Test + @DisplayName("an unrecognised visit category yields an empty status") + void unrecognisedVisitCategoryYieldsEmptyStatus() throws Exception { + stubVisit("Dental"); + + NcdTbHrpData result = service.getHRP_NCD_TB_SuspectedStatus(100200300L, "token", maleDetail()); + + assertNotNull(result); + assertNull(result.getConfirmed_tb()); + } + + @Test + @DisplayName("a failing visit lookup is reported as a service error") + void failingVisitLookupIsReported() { + when(cbacRepo.getVisitDetailsbyRegID(100200300L)).thenThrow(new IllegalStateException("view down")); + + assertThrows(IEMRException.class, + () -> service.getHRP_NCD_TB_SuspectedStatus(100200300L, "token", maleDetail())); + } + + private void stubVisit(String visitCategory) { + when(cbacRepo.getVisitDetailsbyRegID(100200300L)) + .thenReturn(Collections.singletonList(new Object[] { BigInteger.valueOf(900L), visitCategory })); + } + } + + @Nested + @DisplayName("diagnosis interpretation") + class DiagnosisInterpretation { + + @Test + @DisplayName("a beneficiary with no diagnosis is left pending") + void noDiagnosisIsLeftPending() throws Exception { + when(cbacRepo.getDiagnosisProvidedCommon(100200300L, 900L)).thenReturn(Collections.emptyList()); + + NcdTbHrpData result = service.getConfirmedNCD_TB_Common(100200300L, 900L); + + assertEquals("Pending", result.getDiagnosis_status()); + assertNull(result.getConfirmed_tb()); + } + + @Test + @DisplayName("a missing visit or beneficiary leaves the status pending without a lookup") + void missingVisitLeavesStatusPending() throws Exception { + assertEquals("Pending", service.getConfirmedNCD_TB_Common(null, 900L).getDiagnosis_status()); + assertEquals("Pending", service.getConfirmedNCD_TB_Common(100200300L, null).getDiagnosis_status()); + assertEquals("Pending", service.getConfirmedNCD_TB_PNC(null, null).getDiagnosis_status()); + assertEquals("Pending", service.getConfirmedNCD_TB_NCD_CARE(null, null).getDiagnosis_status()); + + verify(cbacRepo, never()).getDiagnosisProvidedCommon(any(), any()); + } + + @ParameterizedTest + @ValueSource(strings = { "Diabetes mellitus", "Hypertension", "Breast cancer", "Mental health disorder", + "Oral cancer" }) + @DisplayName("each tracked non-communicable disease is recognised") + void eachTrackedNcdIsRecognised(String diagnosis) throws Exception { + when(cbacRepo.getDiagnosisProvidedCommon(100200300L, 900L)) + .thenReturn(Collections.singletonList(diagnosis)); + + NcdTbHrpData result = service.getConfirmedNCD_TB_Common(100200300L, 900L); + + assertEquals("Yes", result.getConfirmed_ncd()); + assertEquals(diagnosis, result.getConfirmed_ncd_diseases()); + assertEquals("Yes", result.getDiagnosis_status()); + } + + @Test + @DisplayName("multiple diagnoses in one visit are all listed") + void multipleDiagnosesAreAllListed() throws Exception { + when(cbacRepo.getDiagnosisProvidedCommon(100200300L, 900L)) + .thenReturn(Collections.singletonList("Hypertension||Diabetes mellitus||Tuberculosis")); + + NcdTbHrpData result = service.getConfirmedNCD_TB_Common(100200300L, 900L); + + assertEquals("Yes", result.getConfirmed_ncd()); + assertEquals("Yes", result.getConfirmed_tb()); + assertEquals("Hypertension,Diabetes mellitus", result.getConfirmed_ncd_diseases()); + } + + @Test + @DisplayName("an untracked diagnosis is recorded as neither TB nor NCD") + void untrackedDiagnosisIsRecordedAsNeither() throws Exception { + when(cbacRepo.getDiagnosisProvidedCommon(100200300L, 900L)) + .thenReturn(Collections.singletonList("Common cold")); + + NcdTbHrpData result = service.getConfirmedNCD_TB_Common(100200300L, 900L); + + assertEquals("Yes", result.getDiagnosis_status()); + assertNull(result.getConfirmed_ncd_diseases()); + } + + @Test + @DisplayName("the PNC and NCD-care columns are interpreted the same way as the general one") + void pncAndNcdCareAreInterpretedTheSameWay() throws Exception { + when(cbacRepo.getDiagnosisProvidedPNC(100200300L, 900L)) + .thenReturn(Collections.singletonList("Tuberculosis||Oral cancer")); + when(cbacRepo.getDiagnosisProvidedNCDCare(100200300L, 900L)) + .thenReturn(Collections.singletonList("Tuberculosis||Oral cancer")); + + NcdTbHrpData pnc = service.getConfirmedNCD_TB_PNC(100200300L, 900L); + NcdTbHrpData ncdCare = service.getConfirmedNCD_TB_NCD_CARE(100200300L, 900L); + + assertEquals("Yes", pnc.getConfirmed_tb()); + assertEquals("Oral cancer", pnc.getConfirmed_ncd_diseases()); + assertEquals("Yes", ncdCare.getConfirmed_tb()); + assertEquals("Oral cancer", ncdCare.getConfirmed_ncd_diseases()); + } + + @Test + @DisplayName("a failing diagnosis lookup is reported as a service error on every column") + void failingDiagnosisLookupIsReported() { + when(cbacRepo.getDiagnosisProvidedCommon(any(), any())).thenThrow(new IllegalStateException("view down")); + when(cbacRepo.getDiagnosisProvidedPNC(any(), any())).thenThrow(new IllegalStateException("view down")); + when(cbacRepo.getDiagnosisProvidedNCDCare(any(), any())) + .thenThrow(new IllegalStateException("view down")); + + assertThrows(IEMRException.class, () -> service.getConfirmedNCD_TB_Common(100200300L, 900L)); + assertThrows(IEMRException.class, () -> service.getConfirmedNCD_TB_PNC(100200300L, 900L)); + assertThrows(IEMRException.class, () -> service.getConfirmedNCD_TB_NCD_CARE(100200300L, 900L)); + } + } + + @Nested + @DisplayName("high-risk-pregnancy status") + class HighRiskPregnancyStatus { + + @Test + @DisplayName("a male beneficiary is never high-risk and is not looked up remotely") + void maleBeneficiaryIsNeverHighRisk() throws Exception { + assertEquals("No", service.getConfirmedHRP(100200300L, 900L, "token", maleDetail())); + } + + @Test + @DisplayName("a beneficiary with no recorded gender is not looked up remotely") + void unknownGenderIsNotLookedUpRemotely() throws Exception { + assertEquals("No", service.getConfirmedHRP(100200300L, 900L, "token", new RMNCHMBeneficiarydetail())); + assertEquals("No", service.getConfirmedHRP(100200300L, 900L, "token", null)); + } + + @Test + @DisplayName("an unreachable teleconsultation service is reported as a service error") + void unreachableTeleconsultationServiceIsReported() { + // The status drives clinical follow-up, so an unknown answer must not + // be reported as "not high risk". + RMNCHMBeneficiarydetail female = new RMNCHMBeneficiarydetail(); + female.setGender("Female"); + + assertThrows(IEMRException.class, () -> service.getConfirmedHRP(100200300L, 900L, "token", female)); + } + } + + @Test + @DisplayName("an unreachable FHIR service leaves the ABHA list unknown rather than empty") + void unreachableFhirServiceLeavesAbhaListUnknown() { + assertNull(service.fetchHealthIdByBenRegID(100200300L, "token")); + } + + private RMNCHMBeneficiarydetail maleDetail() { + RMNCHMBeneficiarydetail detail = new RMNCHMBeneficiarydetail(); + detail.setGender("Male"); + return detail; + } + + private RMNCHMBeneficiaryaddress address() { + RMNCHMBeneficiaryaddress address = new RMNCHMBeneficiaryaddress(); + address.setId(BigInteger.valueOf(2L)); + address.setVanID(VAN_ID); + return address; + } + + private Page pageOf(RMNCHMBeneficiaryaddress... addresses) { + return new PageImpl<>(Arrays.asList(addresses)); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> captor() { + return ArgumentCaptor.forClass(List.class); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/CommonMainTest.java b/src/test/java/com/iemr/common/identity/utils/CommonMainTest.java new file mode 100644 index 00000000..0bb4f518 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/CommonMainTest.java @@ -0,0 +1,54 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for the Redis session infrastructure beans. + * + *

+ * These back the HTTP session store; the only thing worth asserting without a + * context is that each bean method produces a usable instance rather than + * sharing one, since Spring manages their lifecycle. + */ +class CommonMainTest { + + private final CommonMain config = new CommonMain(); + + @Test + @DisplayName("the Redis HTTP session configuration is provided") + void redisHttpSessionConfigurationIsProvided() { + assertNotNull(config.redisSession()); + } + + @Test + @DisplayName("the session store is provided as a fresh instance per bean definition") + void sessionStoreIsProvidedAsAFreshInstance() { + assertNotNull(config.redisStorage()); + assertNotSame(config.redisStorage(), config.redisStorage()); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/FilterConfigTest.java b/src/test/java/com/iemr/common/identity/utils/FilterConfigTest.java new file mode 100644 index 00000000..dcf39559 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/FilterConfigTest.java @@ -0,0 +1,76 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.Collection; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.core.Ordered; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Tests for the authentication filter's servlet registration. + * + *

+ * The filter has to run before anything else and cover every path, or an + * endpoint could be served without authentication. Both properties are set + * here, so they are what this test pins. + */ +class FilterConfigTest { + + @Test + @DisplayName("the authentication filter runs first and covers every path") + void authenticationFilterRunsFirstAndCoversEveryPath() { + // Registering it at anything other than highest precedence would let + // another filter serve a request before authentication runs. + FilterConfig config = new FilterConfig(); + ReflectionTestUtils.setField(config, "allowedOrigins", "https://amrit.piramalswasthya.org"); + + FilterRegistrationBean registration = config + .jwtUserIdValidationFilter(mock(JwtAuthenticationUtil.class), new CookieUtil()); + + assertNotNull(registration.getFilter()); + assertEquals(Ordered.HIGHEST_PRECEDENCE, registration.getOrder()); + Collection patterns = registration.getUrlPatterns(); + assertTrue(patterns.contains("/*"), patterns.toString()); + } + + @Test + @DisplayName("the configured allow-list is handed to the filter") + void configuredAllowListIsHandedToTheFilter() { + FilterConfig config = new FilterConfig(); + ReflectionTestUtils.setField(config, "allowedOrigins", "https://amrit.piramalswasthya.org"); + + JwtUserIdValidationFilter filter = config + .jwtUserIdValidationFilter(mock(JwtAuthenticationUtil.class), new CookieUtil()).getFilter(); + + assertEquals("https://amrit.piramalswasthya.org", + ReflectionTestUtils.getField(filter, "allowedOrigins")); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/JsonHelpersTest.java b/src/test/java/com/iemr/common/identity/utils/JsonHelpersTest.java new file mode 100644 index 00000000..93403ec7 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/JsonHelpersTest.java @@ -0,0 +1,137 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.iemr.common.identity.dto.BenIdImportDTO; +import com.iemr.common.identity.filter.QuerySelector; +import com.iemr.common.identity.dto.IdentityFilterDTO; + +import java.math.BigInteger; + +/** + * Tests for the small JSON and filter helpers. + * + *

+ * {@link Utilities#getJsonAsString} is used to log request payloads, so it has + * to return something rather than throw on an object Jackson cannot handle - + * losing a log line is acceptable, failing the request that produced it is not. + * {@link IdentityFilterDTO} wraps its fields in {@link java.util.Optional}, and + * {@link QuerySelector} reads them, so both are exercised together. + */ +class JsonHelpersTest { + + @Test + @DisplayName("an object is serialised to JSON for logging") + void objectIsSerialisedToJsonForLogging() { + BenIdImportDTO dto = new BenIdImportDTO(); + dto.setBenRegId(BigInteger.valueOf(100200300L)); + dto.setBeneficiaryId(BigInteger.valueOf(4001L)); + + String json = new Utilities().getJsonAsString(dto); + + assertTrue(json.contains("100200300"), json); + assertTrue(json.contains("4001"), json); + } + + @Test + @DisplayName("an object Jackson cannot serialise yields an empty string rather than throwing") + void unserialisableObjectYieldsAnEmptyString() { + assertEquals("", new Utilities().getJsonAsString(new Object() { + @SuppressWarnings("unused") + public String getBoom() { + throw new IllegalStateException("cannot serialise"); + } + })); + } + + @Test + @DisplayName("the null-stripping helper is a no-op placeholder") + void nullStrippingHelperIsANoOpPlaceholder() { + // Retained for callers that expect the hook; it currently only logs. + new JsonUtilities().removeNullsFromJson("{\"firstName\":null}"); + } + + @Test + @DisplayName("an unset filter field reads as null, not as an empty optional") + void unsetFilterFieldReadsAsNull() { + // The Optional-typed fields are never initialised, so callers still have + // to null-check them - which is what QuerySelector does not do. + IdentityFilterDTO filter = new IdentityFilterDTO(); + + org.junit.jupiter.api.Assertions.assertNull(filter.getBeneficiaryId()); + org.junit.jupiter.api.Assertions.assertNull(filter.getFirstName()); + } + + @Test + @DisplayName("a set filter field is readable through its optional") + void setFilterFieldIsReadableThroughItsOptional() { + IdentityFilterDTO filter = new IdentityFilterDTO(); + + filter.setBeneficiaryId(BigInteger.valueOf(4001L)); + filter.setBeneficiaryRegId(BigInteger.valueOf(100200300L)); + filter.setFirstName("Asha"); + filter.setMiddleName("Rani"); + filter.setLastName("Devi"); + filter.setAgeId(3); + filter.setAge(30); + filter.setGenderId(2); + filter.setGenderName("Female"); + filter.setSpouseName("Suresh"); + filter.setFatherName("Ram"); + filter.setPinCode("560064"); + filter.setContactNumber("9000000000"); + + assertEquals(BigInteger.valueOf(4001L), filter.getBeneficiaryId().get()); + assertEquals("Asha", filter.getFirstName().get()); + assertEquals("560064", filter.getPinCode().get()); + assertEquals("9000000000", filter.getContactNumber().get()); + } + + @Test + @DisplayName("the query selector reads every criterion the filter can carry") + void querySelectorReadsEveryCriterion() { + IdentityFilterDTO filter = new IdentityFilterDTO(); + filter.setBeneficiaryId(BigInteger.valueOf(4001L)); + filter.setBeneficiaryRegId(BigInteger.valueOf(100200300L)); + filter.setFirstName("Asha"); + filter.setPinCode("560064"); + filter.setContactNumber("9000000000"); + + new QuerySelector().getQuery(filter); + } + + @Test + @DisplayName("the query selector requires every criterion to have been set") + void querySelectorRequiresEveryCriterionToHaveBeenSet() { + // It reads each field's optional without a null check, so a filter with + // an unset field fails rather than being treated as unconstrained. + org.junit.jupiter.api.Assertions.assertThrows(NullPointerException.class, + () -> new QuerySelector().getQuery(new IdentityFilterDTO())); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/JwtAuthenticationUtilTest.java b/src/test/java/com/iemr/common/identity/utils/JwtAuthenticationUtilTest.java new file mode 100644 index 00000000..7a85ae2e --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/JwtAuthenticationUtilTest.java @@ -0,0 +1,224 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; + +import com.iemr.common.identity.domain.User; +import com.iemr.common.identity.exception.IEMRException; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.Cookie; + +/** + * Tests for the token-to-user check the filter delegates to. + * + *

+ * A signature-valid token is not sufficient: the user it names has to still + * exist and not be soft-deleted, or a token issued before an account was + * removed would keep working. The lookup is cached in Redis for 30 minutes, so + * these tests also pin that the cache is consulted before the database and + * populated after a miss. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class JwtAuthenticationUtilTest { + + @Mock + private JwtUtil jwtUtil; + @Mock + private RedisTemplate redisTemplate; + @Mock + private ValueOperations valueOperations; + @Mock + private JdbcTemplate jdbcTemplate; + @Mock + private Claims claims; + + private JwtAuthenticationUtil authUtil; + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + authUtil = new JwtAuthenticationUtil(new CookieUtil(), jwtUtil, redisTemplate, jdbcTemplate); + request = new MockHttpServletRequest(); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + private void stubValidTokenFor(String userId, String username) { + when(jwtUtil.validateToken(anyString())).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(userId); + when(claims.getSubject()).thenReturn(username); + } + + @Test + @DisplayName("a request with a valid token cookie yields the username it names") + void validTokenCookieYieldsItsUsername() { + request.setCookies(new Cookie("Jwttoken", "valid-token")); + stubValidTokenFor("31", "field.worker"); + + ResponseEntity response = authUtil.validateJwtToken(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("field.worker", response.getBody()); + } + + @Test + @DisplayName("a request with no token cookie is unauthorized") + void requestWithNoTokenCookieIsUnauthorized() { + ResponseEntity response = authUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + assertTrue(response.getBody().contains("JWT Token is not set")); + } + + @Test + @DisplayName("a request with an invalid token is unauthorized") + void requestWithAnInvalidTokenIsUnauthorized() { + request.setCookies(new Cookie("Jwttoken", "invalid-token")); + when(jwtUtil.validateToken(anyString())).thenReturn(null); + + ResponseEntity response = authUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + assertTrue(response.getBody().contains("Invalid JWT Token")); + } + + @Test + @DisplayName("a token naming no user is unauthorized rather than treated as anonymous") + void tokenNamingNoUserIsUnauthorized() { + request.setCookies(new Cookie("Jwttoken", "valid-token")); + when(jwtUtil.validateToken(anyString())).thenReturn(claims); + when(claims.getSubject()).thenReturn(null); + + ResponseEntity response = authUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + assertTrue(response.getBody().contains("Username is missing")); + } + + @Test + @DisplayName("a token naming an empty user is unauthorized") + void tokenNamingAnEmptyUserIsUnauthorized() { + request.setCookies(new Cookie("Jwttoken", "valid-token")); + when(jwtUtil.validateToken(anyString())).thenReturn(claims); + when(claims.getSubject()).thenReturn(""); + + assertEquals(HttpStatus.UNAUTHORIZED, authUtil.validateJwtToken(request).getStatusCode()); + } + + @Test + @DisplayName("a cached user satisfies the check without a database query") + void cachedUserSatisfiesTheCheckWithoutAQuery() throws Exception { + stubValidTokenFor("31", "field.worker"); + when(valueOperations.get("user_31")).thenReturn(new User()); + + assertTrue(authUtil.validateUserIdAndJwtToken("valid-token")); + + verify(jdbcTemplate, never()).query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), + any(Object[].class)); + } + + @Test + @DisplayName("a cache miss falls back to the database and caches the result") + void cacheMissFallsBackToTheDatabaseAndCaches() throws Exception { + stubValidTokenFor("31", "field.worker"); + when(valueOperations.get("user_31")).thenReturn(null); + when(jdbcTemplate.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), eq("31"))) + .thenReturn(Collections.singletonList(new User())); + + assertTrue(authUtil.validateUserIdAndJwtToken("valid-token")); + + verify(valueOperations).set(eq("user_31"), any(), eq(30L), eq(TimeUnit.MINUTES)); + } + + @Test + @DisplayName("a token naming a user who no longer exists is rejected") + void tokenNamingADeletedUserIsRejected() { + // Otherwise a token issued before the account was removed keeps working + // until it expires. + stubValidTokenFor("31", "field.worker"); + when(valueOperations.get("user_31")).thenReturn(null); + when(jdbcTemplate.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), eq("31"))) + .thenReturn(Collections.emptyList()); + + assertThrows(IEMRException.class, () -> authUtil.validateUserIdAndJwtToken("valid-token")); + } + + @Test + @DisplayName("an invalid token is rejected before any user lookup") + void invalidTokenIsRejectedBeforeAnyUserLookup() { + when(jwtUtil.validateToken(anyString())).thenReturn(null); + + assertThrows(IEMRException.class, () -> authUtil.validateUserIdAndJwtToken("invalid-token")); + verify(valueOperations, never()).get(anyString()); + } + + @Test + @DisplayName("an unreachable cache is reported rather than passed as authenticated") + void unreachableCacheIsReported() { + stubValidTokenFor("31", "field.worker"); + when(valueOperations.get(anyString())).thenThrow(new IllegalStateException("connection refused")); + + assertThrows(IEMRException.class, () -> authUtil.validateUserIdAndJwtToken("valid-token")); + } + + @Test + @DisplayName("an unreachable database is reported rather than passed as authenticated") + void unreachableDatabaseIsReported() { + stubValidTokenFor("31", "field.worker"); + when(valueOperations.get("user_31")).thenReturn(null); + when(jdbcTemplate.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), eq("31"))) + .thenThrow(new IllegalStateException("connection refused")); + + assertThrows(IEMRException.class, () -> authUtil.validateUserIdAndJwtToken("valid-token")); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/JwtUserIdValidationFilterTest.java b/src/test/java/com/iemr/common/identity/utils/JwtUserIdValidationFilterTest.java new file mode 100644 index 00000000..ad6c33a3 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/JwtUserIdValidationFilterTest.java @@ -0,0 +1,332 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import com.iemr.common.identity.exception.IEMRException; +import com.iemr.common.identity.utils.http.AuthorizationHeaderRequestWrapper; + +/** + * Tests for the authentication filter in front of every identity endpoint. + * + *

+ * This filter is the whole authentication boundary, so the tests are written + * around what must not get through: a request with no token, a request whose + * token fails validation, and a request that merely claims to be a mobile + * client. It also has to keep letting the health and version probes through + * unauthenticated - the load balancer depends on that - and must reflect a CORS + * origin only when it matches the configured allow-list, since the responses + * carry credentials. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class JwtUserIdValidationFilterTest { + + @Mock + private JwtAuthenticationUtil jwtAuthenticationUtil; + @Mock + private FilterChain filterChain; + + private JwtUserIdValidationFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + private static final String ALLOWED_ORIGINS = "https://*.piramalswasthya.org,https://amrit.example.org"; + + @BeforeEach + void setUp() { + filter = new JwtUserIdValidationFilter(jwtAuthenticationUtil, ALLOWED_ORIGINS, new CookieUtil()); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + request.setMethod("POST"); + request.setRequestURI("/id/getByBenRegId"); + request.setServletPath("/id/getByBenRegId"); + } + + @Nested + @DisplayName("authenticating a request") + class AuthenticatingARequest { + + @Test + @DisplayName("a request carrying a valid token in a cookie is let through") + void validTokenInACookieIsLetThrough() throws Exception { + request.setCookies(new Cookie("Jwttoken", "valid-token")); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("valid-token")).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(AuthorizationHeaderRequestWrapper.class), any()); + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + } + + @Test + @DisplayName("a request carrying a valid token in a header is let through") + void validTokenInAHeaderIsLetThrough() throws Exception { + request.addHeader("JwtToken", "valid-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("valid-token")).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(AuthorizationHeaderRequestWrapper.class), any()); + } + + @Test + @DisplayName("a cookie token wins over a header token") + void cookieTokenWinsOverHeaderToken() throws Exception { + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + request.addHeader("JwtToken", "header-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken(anyString())).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + verify(jwtAuthenticationUtil).validateUserIdAndJwtToken("cookie-token"); + } + + @Test + @DisplayName("a request with no token at all is refused") + void requestWithNoTokenIsRefused() throws Exception { + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(filterChain, never()).doFilter(any(), any()); + } + + @Test + @DisplayName("a request whose token fails validation is refused") + void requestWithAnInvalidTokenIsRefused() throws Exception { + request.addHeader("JwtToken", "invalid-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("invalid-token")).thenReturn(false); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(filterChain, never()).doFilter(any(), any()); + } + + @Test + @DisplayName("a validation error is refused rather than treated as authenticated") + void validationErrorIsRefused() throws Exception { + request.addHeader("JwtToken", "some-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken(anyString())) + .thenThrow(new IEMRException("Invalid User ID.")); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(filterChain, never()).doFilter(any(), any()); + } + + @Test + @DisplayName("a userId cookie sent by the client is cleared instead of trusted") + void userIdCookieSentByTheClientIsCleared() throws Exception { + // The user id is taken from the signed token; an attacker-supplied + // cookie of the same name must not survive the request. + request.setCookies(new Cookie("userId", "31"), new Cookie("Jwttoken", "valid-token")); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("valid-token")).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + Cookie cleared = response.getCookie("userId"); + assertEquals(0, cleared.getMaxAge()); + assertNull(cleared.getValue()); + assertTrue(cleared.isHttpOnly()); + assertTrue(cleared.getSecure()); + } + } + + @Nested + @DisplayName("mobile clients") + class MobileClients { + + @ParameterizedTest + @ValueSource(strings = { "okhttp/4.9.0", "Java/17.0.2", "okhttp" }) + @DisplayName("a mobile client presenting an authorization header is let through for downstream checks") + void mobileClientWithAnAuthorizationHeaderIsLetThrough(String userAgent) throws Exception { + request.addHeader("User-Agent", userAgent); + request.addHeader("Authorization", "Bearer session-token"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("a mobile client with no authorization header is refused") + void mobileClientWithNoAuthorizationHeaderIsRefused() throws Exception { + request.addHeader("User-Agent", "okhttp/4.9.0"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + } + + @ParameterizedTest + @ValueSource(strings = { "Mozilla/5.0", "curl/8.0.1", "PostmanRuntime/7.29.0" }) + @DisplayName("a browser or generic client cannot use the mobile bypass") + void browserClientCannotUseTheMobileBypass(String userAgent) throws Exception { + // Otherwise any caller could skip JWT validation by sending a bearer + // header and a plausible user agent. + request.addHeader("User-Agent", userAgent); + request.addHeader("Authorization", "Bearer session-token"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(filterChain, never()).doFilter(any(), any()); + } + } + + @Nested + @DisplayName("public probes") + class PublicProbes { + + @ParameterizedTest + @ValueSource(strings = { "/health", "/version" }) + @DisplayName("the load balancer's probes are served without a token") + void probesAreServedWithoutAToken(String path) throws Exception { + request.setServletPath(path); + request.setRequestURI(path); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @ParameterizedTest + @ValueSource(strings = { "/identity/health", "/identity/version" }) + @DisplayName("the probes are also recognised behind a context path") + void probesAreRecognisedBehindAContextPath(String uri) throws Exception { + request.setRequestURI(uri); + request.setServletPath("/other"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("a preflight request is answered without authentication") + void preflightRequestIsAnsweredWithoutAuthentication() throws Exception { + request.setMethod("OPTIONS"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + verify(filterChain, never()).doFilter(any(), any()); + } + } + + @Nested + @DisplayName("CORS reflection") + class CorsReflection { + + @ParameterizedTest + @ValueSource(strings = { "https://amrit.piramalswasthya.org", "https://uat.piramalswasthya.org", + "https://amrit.example.org" }) + @DisplayName("an allowed origin is reflected back with credentials permitted") + void allowedOriginIsReflected(String origin) throws Exception { + request.addHeader("Origin", origin); + request.setCookies(new Cookie("Jwttoken", "valid-token")); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken(anyString())).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + assertEquals(origin, response.getHeader("Access-Control-Allow-Origin")); + assertEquals("true", response.getHeader("Access-Control-Allow-Credentials")); + assertEquals("Origin", response.getHeader("Vary")); + } + + @ParameterizedTest + @ValueSource(strings = { "https://evil.example.com", "http://amrit.piramalswasthya.org.evil.com", + "null" }) + @DisplayName("an origin outside the allow-list is not reflected") + void originOutsideTheAllowListIsNotReflected(String origin) throws Exception { + // These responses carry credentials, so reflecting an arbitrary + // origin would hand a beneficiary's record to any site. + request.addHeader("Origin", origin); + + filter.doFilter(request, response, filterChain); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + } + + @Test + @DisplayName("a request with no origin gets no CORS headers") + void requestWithNoOriginGetsNoCorsHeaders() throws Exception { + filter.doFilter(request, response, filterChain); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + } + + @Test + @DisplayName("with no allow-list configured, no origin is reflected") + void withNoAllowListConfiguredNoOriginIsReflected() throws Exception { + JwtUserIdValidationFilter unconfigured = new JwtUserIdValidationFilter(jwtAuthenticationUtil, "", + new CookieUtil()); + request.addHeader("Origin", "https://amrit.piramalswasthya.org"); + + unconfigured.doFilter(request, response, filterChain); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + } + + @Test + @DisplayName("an allowed preflight request is answered with the permitted methods and headers") + void allowedPreflightIsAnsweredWithPermittedMethodsAndHeaders() throws Exception { + request.setMethod("OPTIONS"); + request.addHeader("Origin", "https://amrit.piramalswasthya.org"); + + filter.doFilter(request, response, filterChain); + + assertTrue(response.getHeader("Access-Control-Allow-Methods").contains("POST")); + assertTrue(response.getHeader("Access-Control-Allow-Headers").contains("JwtToken")); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/JwtUtilTest.java b/src/test/java/com/iemr/common/identity/utils/JwtUtilTest.java new file mode 100644 index 00000000..54c9ca0c --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/JwtUtilTest.java @@ -0,0 +1,292 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.UUID; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +/** + * Tests for JWT issuing and verification. + * + *

+ * This is the only thing standing between an unauthenticated caller and every + * beneficiary record, so the tests are written around what must be rejected: a + * token signed with a different key, a tampered payload, an expired token, and + * a token whose id has been revoked. {@code validateToken} answers all of those + * with {@code null} rather than an exception, which makes an accidental + * "invalid means valid" inversion easy to introduce and invisible without these + * cases. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class JwtUtilTest { + + /** HMAC-SHA256 needs at least 256 bits of key material. */ + private static final String SECRET = "identity-api-test-signing-secret-key-0123456789"; + private static final long ACCESS_EXPIRY_MS = 60_000L; + private static final long REFRESH_EXPIRY_MS = 600_000L; + + @Mock + private TokenDenylist tokenDenylist; + + private JwtUtil jwtUtil; + + @BeforeEach + void setUp() { + jwtUtil = new JwtUtil(); + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", SECRET); + ReflectionTestUtils.setField(jwtUtil, "ACCESS_EXPIRATION_TIME", ACCESS_EXPIRY_MS); + ReflectionTestUtils.setField(jwtUtil, "REFRESH_EXPIRATION_TIME", REFRESH_EXPIRY_MS); + ReflectionTestUtils.setField(jwtUtil, "tokenDenylist", tokenDenylist); + when(tokenDenylist.isTokenDenylisted(anyString())).thenReturn(false); + } + + private SecretKey key(String secret) { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + @Nested + @DisplayName("issuing tokens") + class IssuingTokens { + + @Test + @DisplayName("an access token carries the username, user id and its type") + void accessTokenCarriesItsClaims() { + String token = jwtUtil.generateToken("field.worker", "31"); + + Claims claims = jwtUtil.validateToken(token); + assertEquals("field.worker", claims.getSubject()); + assertEquals("31", claims.get("userId", String.class)); + assertEquals("access", claims.get("token_type", String.class)); + assertNotNull(claims.getId()); + assertNotNull(claims.getIssuedAt()); + } + + @Test + @DisplayName("a refresh token is distinguishable from an access token") + void refreshTokenIsDistinguishable() { + // Accepting a refresh token where an access token is expected would + // extend a session far past its intended lifetime. + Claims claims = jwtUtil.validateToken(jwtUtil.generateRefreshToken("field.worker", "31")); + + assertEquals("refresh", claims.get("token_type", String.class)); + } + + @Test + @DisplayName("a refresh token outlives an access token") + void refreshTokenOutlivesAccessToken() { + Claims access = jwtUtil.validateToken(jwtUtil.generateToken("field.worker", "31")); + Claims refresh = jwtUtil.validateToken(jwtUtil.generateRefreshToken("field.worker", "31")); + + assertTrue(refresh.getExpiration().after(access.getExpiration())); + assertEquals(REFRESH_EXPIRY_MS, jwtUtil.getRefreshTokenExpiration()); + } + + @Test + @DisplayName("every token gets its own id so it can be revoked individually") + void everyTokenGetsItsOwnId() { + String first = jwtUtil.getJtiFromToken(jwtUtil.generateToken("field.worker", "31")); + String second = jwtUtil.getJtiFromToken(jwtUtil.generateToken("field.worker", "31")); + + assertNotEquals(first, second); + } + + @ParameterizedTest + @ValueSource(strings = { "", " " }) + @DisplayName("a token cannot be issued without a username") + void tokenCannotBeIssuedWithoutAUsername(String username) { + assertThrows(IllegalArgumentException.class, () -> jwtUtil.generateToken(username, "31")); + } + + @Test + @DisplayName("a token cannot be issued for a null username") + void tokenCannotBeIssuedForNullUsername() { + assertThrows(IllegalArgumentException.class, () -> jwtUtil.generateToken(null, "31")); + } + + @ParameterizedTest + @ValueSource(strings = { "", " " }) + @DisplayName("a token cannot be issued without a user id") + void tokenCannotBeIssuedWithoutAUserId(String userId) { + assertThrows(IllegalArgumentException.class, () -> jwtUtil.generateToken("field.worker", userId)); + } + + @Test + @DisplayName("a token cannot be issued for a null user id") + void tokenCannotBeIssuedForNullUserId() { + assertThrows(IllegalArgumentException.class, () -> jwtUtil.generateRefreshToken("field.worker", null)); + } + + @ParameterizedTest + @ValueSource(strings = { "" }) + @DisplayName("a deployment with no configured signing secret cannot issue tokens") + void deploymentWithNoSecretCannotIssueTokens(String secret) { + // Falling back to a default key would make every deployment forge + // tokens for every other one. + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", secret); + + assertThrows(IllegalStateException.class, () -> jwtUtil.generateToken("field.worker", "31")); + } + + @Test + @DisplayName("a deployment with a null signing secret cannot issue tokens") + void deploymentWithNullSecretCannotIssueTokens() { + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", null); + + assertThrows(IllegalStateException.class, () -> jwtUtil.generateToken("field.worker", "31")); + } + } + + @Nested + @DisplayName("verifying tokens") + class VerifyingTokens { + + @Test + @DisplayName("a token signed with a different key is rejected") + void tokenSignedWithADifferentKeyIsRejected() { + String forged = Jwts.builder().subject("field.worker").claim("userId", "31") + .id(UUID.randomUUID().toString()) + // Same length as the real secret, so the same HMAC algorithm is + // selected and only the key material differs. + .signWith(key(SECRET.replace('i', 'x'))).compact(); + + assertNull(jwtUtil.validateToken(forged)); + } + + @Test + @DisplayName("an expired token is rejected") + void expiredTokenIsRejected() { + String expired = Jwts.builder().subject("field.worker").claim("userId", "31") + .id(UUID.randomUUID().toString()).issuedAt(new Date(System.currentTimeMillis() - 120_000L)) + .expiration(new Date(System.currentTimeMillis() - 60_000L)).signWith(key(SECRET)).compact(); + + assertNull(jwtUtil.validateToken(expired)); + } + + @Test + @DisplayName("a revoked token is rejected even though its signature is valid") + void revokedTokenIsRejected() { + String token = jwtUtil.generateToken("field.worker", "31"); + when(tokenDenylist.isTokenDenylisted(anyString())).thenReturn(true); + + assertNull(jwtUtil.validateToken(token)); + } + + @ParameterizedTest + @ValueSource(strings = { "not-a-token", "a.b.c", "", "eyJhbGciOiJIUzI1NiJ9.tampered.signature" }) + @DisplayName("a malformed token is rejected") + void malformedTokenIsRejected(String token) { + assertNull(jwtUtil.validateToken(token)); + } + + @Test + @DisplayName("a null token is rejected") + void nullTokenIsRejected() { + assertNull(jwtUtil.validateToken(null)); + } + + @Test + @DisplayName("an unsigned token is rejected") + void unsignedTokenIsRejected() { + // A "none"-algorithm token would otherwise let a caller mint their + // own claims. + String unsigned = Jwts.builder().subject("field.worker").claim("userId", "31").compact(); + + assertNull(jwtUtil.validateToken(unsigned)); + } + + @Test + @DisplayName("a token with no id is accepted without consulting the revocation list") + void tokenWithNoIdSkipsTheRevocationCheck() { + String noJti = Jwts.builder().subject("field.worker").claim("userId", "31") + .expiration(new Date(System.currentTimeMillis() + 60_000L)).signWith(key(SECRET)).compact(); + + assertNotNull(jwtUtil.validateToken(noJti)); + org.mockito.Mockito.verify(tokenDenylist, org.mockito.Mockito.never()).isTokenDenylisted(anyString()); + } + } + + @Nested + @DisplayName("reading claims") + class ReadingClaims { + + @Test + @DisplayName("the username, user id and token id are read back from a valid token") + void claimsAreReadBackFromAValidToken() { + String token = jwtUtil.generateToken("field.worker", "31"); + + assertEquals("field.worker", jwtUtil.getUsernameFromToken(token)); + assertEquals("31", jwtUtil.getUserIdFromToken(token)); + assertNotNull(jwtUtil.getJtiFromToken(token)); + assertEquals("access", jwtUtil.getClaimFromToken(token, claims -> claims.get("token_type"))); + } + + @Test + @DisplayName("reading claims from an invalid token is rejected rather than returning nulls") + void readingClaimsFromAnInvalidTokenIsRejected() { + // A silent null here would read as "no username", which callers + // could mistake for an anonymous but valid session. + assertThrows(IllegalArgumentException.class, () -> jwtUtil.getAllClaimsFromToken("not-a-token")); + assertThrows(IllegalArgumentException.class, () -> jwtUtil.getUsernameFromToken("not-a-token")); + assertThrows(IllegalArgumentException.class, () -> jwtUtil.getUserIdFromToken("not-a-token")); + assertThrows(IllegalArgumentException.class, () -> jwtUtil.getJtiFromToken("not-a-token")); + } + + @Test + @DisplayName("reading claims from a revoked token is rejected") + void readingClaimsFromARevokedTokenIsRejected() { + String token = jwtUtil.generateToken("field.worker", "31"); + when(tokenDenylist.isTokenDenylisted(anyString())).thenReturn(true); + + assertThrows(IllegalArgumentException.class, () -> jwtUtil.getAllClaimsFromToken(token)); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/OutputResponseBuilderTest.java b/src/test/java/com/iemr/common/identity/utils/OutputResponseBuilderTest.java new file mode 100644 index 00000000..e01f7d32 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/OutputResponseBuilderTest.java @@ -0,0 +1,93 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for the builder the identity endpoints use to assemble their response + * envelope. + * + *

+ * The envelope carries the calling method and object type alongside the data so + * that a client-reported failure can be traced back to an endpoint from the + * response body alone. These tests pin the field names, because renaming one is + * a silent break for every consumer. + */ +class OutputResponseBuilderTest { + + @Test + @DisplayName("a built response carries the data, the status and the calling method") + void builtResponseCarriesDataStatusAndCallingMethod() throws JSONException { + OutputResponse response = new OutputResponse.Builder().setDataJsonType("JsonObject.class").setStatusCode(200) + .setStatusMessage("success").setDataObjectType("IdentityController") + .setMethodName("getBeneficiariesByBenRegId").setData("[{\"benRegId\":100200300}]").build(); + + JSONObject envelope = new JSONObject(response.toString()).getJSONObject("response"); + assertEquals(200, envelope.getInt("statusCode")); + assertEquals("success", envelope.getString("statusMessage")); + assertEquals("IdentityController", envelope.getString("dataObjectType")); + assertEquals("getBeneficiariesByBenRegId", envelope.getString("methodName")); + assertEquals("JsonObject.class", envelope.getString("dataJsonType")); + assertTrue(envelope.getString("data").contains("100200300")); + } + + @Test + @DisplayName("a failure envelope carries the failure code the client branches on") + void failureEnvelopeCarriesTheFailureCode() throws JSONException { + OutputResponse response = new OutputResponse.Builder().setStatusCode(5000).setStatusMessage("failure") + .setData("\"error in beneficiary search\"").setMethodName("").setDataObjectType("IdentityController") + .setDataJsonType("JsonObject.class").build(); + + JSONObject envelope = new JSONObject(response.toString()).getJSONObject("response"); + assertEquals(5000, envelope.getInt("statusCode")); + assertEquals("failure", envelope.getString("statusMessage")); + } + + @Test + @DisplayName("a long status message is carried without truncation") + void longStatusMessageIsCarriedWithoutTruncation() throws JSONException { + String message = "error in beneficiary advance search : could not extract ResultSet"; + + OutputResponse response = new OutputResponse.Builder().setStatusCode(5000).setStatusMessage("failure") + .setStatusMessageLong(message).setData("{}").setMethodName("").setDataObjectType("") + .setDataJsonType("").build(); + + assertEquals(message, + new JSONObject(response.toString()).getJSONObject("response").getString("statusMessageLong")); + } + + @Test + @DisplayName("an envelope built with nothing set still serialises to valid JSON") + void envelopeBuiltWithNothingSetStillSerialises() throws JSONException { + OutputResponse response = new OutputResponse.Builder().build(); + + assertTrue(new JSONObject(response.toString()).has("response")); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/TokenDenylistTest.java b/src/test/java/com/iemr/common/identity/utils/TokenDenylistTest.java new file mode 100644 index 00000000..c49a8477 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/TokenDenylistTest.java @@ -0,0 +1,186 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.identity.utils.exception.TokenDenylistException; + +/** + * Tests for token revocation. + * + *

+ * Revocation is stored in Redis with a TTL matching the token's own lifetime, so + * entries expire on their own. The behaviour that matters for security is the + * failure mode: if Redis is unreachable, a revocation check must fail loudly + * rather than return "not revoked", because the latter would silently reinstate + * every logged-out session. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TokenDenylistTest { + + @Mock + private RedisTemplate redisTemplate; + @Mock + private ValueOperations valueOperations; + + private TokenDenylist denylist; + + private static final String JTI = "0f5c4c1e-9e46-4f66-9b3a-8ad6f0f0d0a1"; + /** The key prefix the entries are namespaced under. */ + private static final String KEY = "denied_" + JTI; + + @BeforeEach + void setUp() { + denylist = new TokenDenylist(); + ReflectionTestUtils.setField(denylist, "redisTemplate", redisTemplate); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + @DisplayName("a revoked token is stored with a TTL matching its remaining lifetime") + void revokedTokenIsStoredWithATtl() { + denylist.addTokenToDenylist(JTI, 60_000L); + + verify(valueOperations).set(eq(KEY), any(), eq(60_000L), eq(TimeUnit.MILLISECONDS)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " " }) + @DisplayName("a blank token id is ignored rather than stored under a bare prefix") + void blankTokenIdIsIgnored(String jti) { + // Storing under the bare prefix would revoke nothing while looking like + // it worked. + denylist.addTokenToDenylist(jti, 60_000L); + + verify(valueOperations, never()).set(anyString(), any(), anyLong(), any()); + } + + @Test + @DisplayName("a non-positive expiry is rejected so an entry cannot outlive its token") + void nonPositiveExpiryIsRejected() { + assertThrows(IllegalArgumentException.class, () -> denylist.addTokenToDenylist(JTI, 0L)); + assertThrows(IllegalArgumentException.class, () -> denylist.addTokenToDenylist(JTI, -1L)); + assertThrows(IllegalArgumentException.class, () -> denylist.addTokenToDenylist(JTI, null)); + } + + @Test + @DisplayName("a failure to store a revocation is surfaced rather than silently dropped") + void failureToStoreIsSurfaced() { + // The caller is a logout; reporting success while the token stays valid + // is the failure this prevents. + org.mockito.Mockito.doThrow(new IllegalStateException("connection refused")).when(valueOperations) + .set(anyString(), any(), anyLong(), any()); + + assertThrows(TokenDenylistException.class, () -> denylist.addTokenToDenylist(JTI, 60_000L)); + } + + @Test + @DisplayName("a token present in the store is reported as revoked") + void tokenPresentInTheStoreIsRevoked() { + when(redisTemplate.hasKey(KEY)).thenReturn(true); + + assertTrue(denylist.isTokenDenylisted(JTI)); + } + + @Test + @DisplayName("a token absent from the store is not revoked") + void tokenAbsentFromTheStoreIsNotRevoked() { + when(redisTemplate.hasKey(KEY)).thenReturn(false); + + assertFalse(denylist.isTokenDenylisted(JTI)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " " }) + @DisplayName("a blank token id is treated as not revoked without a lookup") + void blankTokenIdIsTreatedAsNotRevoked(String jti) { + assertFalse(denylist.isTokenDenylisted(jti)); + + verify(redisTemplate, never()).hasKey(anyString()); + } + + @Test + @DisplayName("an unreachable store fails the check rather than reporting the token as valid") + void unreachableStoreFailsTheCheck() { + // Answering "not revoked" here would reinstate every logged-out session + // for as long as Redis is down. + when(redisTemplate.hasKey(anyString())).thenThrow(new IllegalStateException("connection refused")); + + assertThrows(TokenDenylistException.class, () -> denylist.isTokenDenylisted(JTI)); + } + + @Test + @DisplayName("a revocation can be lifted") + void revocationCanBeLifted() { + denylist.removeTokenFromDenylist(JTI); + + verify(redisTemplate).delete(KEY); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " " }) + @DisplayName("lifting a revocation for a blank token id does nothing") + void liftingForABlankTokenIdDoesNothing(String jti) { + denylist.removeTokenFromDenylist(jti); + + verify(redisTemplate, never()).delete(anyString()); + } + + @Test + @DisplayName("a failure to lift a revocation is surfaced") + void failureToLiftIsSurfaced() { + when(redisTemplate.delete(anyString())).thenThrow(new IllegalStateException("connection refused")); + + assertThrows(TokenDenylistException.class, () -> denylist.removeTokenFromDenylist(JTI)); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/config/ConfigPropertiesTest.java b/src/test/java/com/iemr/common/identity/utils/config/ConfigPropertiesTest.java new file mode 100644 index 00000000..24026920 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/config/ConfigPropertiesTest.java @@ -0,0 +1,175 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Base64; +import java.util.Properties; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Tests for the static property reader used by the code that predates + * constructor injection. + * + *

+ * Every typed accessor swallows a parse failure and falls back to a zero-ish + * default, so a mistyped or missing property degrades silently rather than + * failing start-up. These tests pin those defaults so a caller can tell what an + * absent property will produce, and cover the Base64 obfuscation the datasource + * password uses. + */ +class ConfigPropertiesTest { + + @BeforeEach + void loadTestProperties() { + Properties properties = new Properties(); + properties.setProperty("iemr.redis.url", "redis.internal"); + properties.setProperty("iemr.redis.port", "6379"); + properties.setProperty("iemr.session.expiry.time", "1800"); + properties.setProperty("iemr.extend.expiry.time", "true"); + properties.setProperty("padded.value", " spaced "); + properties.setProperty("a.long", "9007199254740993"); + properties.setProperty("a.float", "1.5"); + properties.setProperty("not.a.number", "abc"); + properties.setProperty("plain.password", "s3cret"); + properties.setProperty("obfuscated.password", "0X10:" + Base64.getEncoder().encodeToString("s3cret".getBytes())); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", properties); + // The cached @Value fields are static, so reset them between tests. + ReflectionTestUtils.setField(ConfigProperties.class, "redisurl", null); + ReflectionTestUtils.setField(ConfigProperties.class, "redisport", null); + ReflectionTestUtils.setField(ConfigProperties.class, "sessionExpiryTime", null); + ReflectionTestUtils.setField(ConfigProperties.class, "extendExpiryTime", null); + } + + @Test + @DisplayName("a configured property is returned trimmed") + void configuredPropertyIsReturnedTrimmed() { + assertEquals("spaced", ConfigProperties.getPropertyByName("padded.value")); + } + + @Test + @DisplayName("an absent property yields null rather than an empty string") + void absentPropertyYieldsNull() { + assertNull(ConfigProperties.getPropertyByName("no.such.property")); + } + + @Test + @DisplayName("a boolean property is parsed, and anything unparseable reads as false") + void booleanPropertyIsParsed() { + assertTrue(ConfigProperties.getBoolean("iemr.extend.expiry.time")); + org.junit.jupiter.api.Assertions.assertFalse(ConfigProperties.getBoolean("not.a.number")); + org.junit.jupiter.api.Assertions.assertFalse(ConfigProperties.getBoolean("no.such.property")); + } + + @Test + @DisplayName("numeric properties are parsed, and anything unparseable reads as zero") + void numericPropertiesAreParsed() { + assertEquals(6379, ConfigProperties.getInteger("iemr.redis.port")); + assertEquals(9007199254740993L, ConfigProperties.getLong("a.long")); + assertEquals(1.5f, ConfigProperties.getFloat("a.float")); + assertEquals(0, ConfigProperties.getInteger("not.a.number")); + assertEquals(0L, ConfigProperties.getLong("not.a.number")); + assertEquals(0f, ConfigProperties.getFloat("not.a.number")); + } + + @Test + @DisplayName("an absent whole-number property reads as zero rather than failing") + void absentWholeNumberPropertyReadsAsZero() { + assertEquals(0, ConfigProperties.getInteger("no.such.property")); + assertEquals(0L, ConfigProperties.getLong("no.such.property")); + } + + @Test + @DisplayName("an absent float property throws instead of falling back to zero") + void absentFloatPropertyThrows() { + // Float.parseFloat(null) raises NullPointerException, which the + // NumberFormatException-only catch does not cover - unlike getInteger + // and getLong, which do fall back to zero. + org.junit.jupiter.api.Assertions.assertThrows(NullPointerException.class, + () -> ConfigProperties.getFloat("no.such.property")); + } + + @Test + @DisplayName("the Redis connection details are read from configuration and cached") + void redisConnectionDetailsAreReadAndCached() { + assertEquals("redis.internal", ConfigProperties.getRedisUrl()); + assertEquals(6379, ConfigProperties.getRedisPort()); + // A second read comes from the cached static field. + assertEquals("redis.internal", ConfigProperties.getRedisUrl()); + assertEquals(6379, ConfigProperties.getRedisPort()); + } + + @Test + @DisplayName("the session expiry time is read from configuration and cached") + void sessionExpiryTimeIsReadAndCached() { + assertEquals(1800, ConfigProperties.getSessionExpiryTime()); + assertEquals(1800, ConfigProperties.getSessionExpiryTime()); + } + + @Test + @DisplayName("the extend-expiry flag reads the session-expiry-time property, so it is effectively always off") + void extendExpiryFlagReadsTheWrongProperty() { + // getExtendExpiryTime() falls back to getBoolean("iemr.session.expiry.time") + // rather than "iemr.extend.expiry.time", and a duration never parses as + // a boolean - so sessions are never extended on read regardless of how + // iemr.extend.expiry.time is configured. + org.junit.jupiter.api.Assertions.assertFalse(ConfigProperties.getExtendExpiryTime()); + } + + @Test + @DisplayName("a plain password is returned as-is") + void plainPasswordIsReturnedAsIs() { + assertEquals("s3cret", ConfigProperties.getPassword("plain.password")); + } + + @Test + @DisplayName("an obfuscated password is decoded") + void obfuscatedPasswordIsDecoded() { + // The 0X10: marker means the rest is Base64; without decoding, the + // datasource would try to authenticate with the encoded text. + assertEquals("s3cret", ConfigProperties.getPassword("obfuscated.password")); + } + + @Test + @DisplayName("an absent password yields null rather than an empty string") + void absentPasswordYieldsNull() { + assertNull(ConfigProperties.getPassword("no.such.property")); + } + + @Test + @DisplayName("constructing the holder loads the packaged properties file") + void constructingTheHolderLoadsThePackagedFile() { + ReflectionTestUtils.setField(ConfigProperties.class, "properties", null); + + new ConfigProperties(); + + assertNotNull(ReflectionTestUtils.getField(ConfigProperties.class, "properties")); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/gateway/email/GenericEmailServiceImplTest.java b/src/test/java/com/iemr/common/identity/utils/gateway/email/GenericEmailServiceImplTest.java new file mode 100644 index 00000000..7fbe3c5a --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/gateway/email/GenericEmailServiceImplTest.java @@ -0,0 +1,114 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils.gateway.email; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +/** + * Tests for the outbound notification email helper. + * + *

+ * Recipients arrive as a JSON payload, and the multi-recipient overload splits + * a semicolon-separated {@code to} field. Getting that split wrong sends one + * message addressed to a single malformed address, so nobody is notified and + * nothing fails loudly. + */ +@ExtendWith(MockitoExtension.class) +class GenericEmailServiceImplTest { + + @Mock + private JavaMailSender javaMailSender; + + private GenericEmailServiceImpl service; + + @BeforeEach + void setUp() { + service = new GenericEmailServiceImpl(); + service.setJavaMailSender(javaMailSender); + } + + private static final String PAYLOAD = "{\"to\":\"nurse@example.org\",\"from\":\"amrit@example.org\"," + + "\"subject\":\"Beneficiary registered\",\"message\":\"A new beneficiary was registered.\"}"; + + private SimpleMailMessage sentMessage() { + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(javaMailSender).send(captor.capture()); + return captor.getValue(); + } + + @Test + @DisplayName("a templated message is sent with the addresses and text from the payload") + void templatedMessageIsSentWithPayloadFields() throws JSONException { + service.sendEmail(PAYLOAD, "beneficiary-registered"); + + SimpleMailMessage message = sentMessage(); + assertArrayEquals(new String[] { "nurse@example.org" }, message.getTo()); + assertEquals("amrit@example.org", message.getFrom()); + assertEquals("Beneficiary registered", message.getSubject()); + assertEquals("A new beneficiary was registered.", message.getText()); + } + + @Test + @DisplayName("a single recipient is sent to as-is") + void singleRecipientIsSentToAsIs() throws JSONException { + service.sendEmail(PAYLOAD); + + assertArrayEquals(new String[] { "nurse@example.org" }, sentMessage().getTo()); + } + + @Test + @DisplayName("semicolon-separated recipients each become their own address") + void semicolonSeparatedRecipientsEachBecomeAnAddress() throws JSONException { + service.sendEmail(PAYLOAD.replace("nurse@example.org", + "nurse@example.org;supervisor@example.org;admin@example.org")); + + assertArrayEquals(new String[] { "nurse@example.org", "supervisor@example.org", "admin@example.org" }, + sentMessage().getTo()); + } + + @Test + @DisplayName("a payload missing a required field is rejected rather than sending a partial message") + void payloadMissingARequiredFieldIsRejected() { + assertThrows(JSONException.class, () -> service.sendEmail("{\"to\":\"nurse@example.org\"}")); + assertThrows(JSONException.class, () -> service.sendEmail("{\"to\":\"nurse@example.org\"}", "template")); + } + + @Test + @DisplayName("an unparseable payload is rejected") + void unparseablePayloadIsRejected() { + assertThrows(JSONException.class, () -> service.sendEmail("not json at all {")); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/http/HTTPRequestInterceptorTest.java b/src/test/java/com/iemr/common/identity/utils/http/HTTPRequestInterceptorTest.java new file mode 100644 index 00000000..b0dabd1d --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/http/HTTPRequestInterceptorTest.java @@ -0,0 +1,169 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils.http; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.identity.utils.sessionobject.SessionObject; + +/** + * Tests for the request interceptor that keeps a caller's session alive. + * + *

+ * Every authenticated request refreshes the session's Redis TTL in + * {@code postHandle}, using the bearer token as the key. The behaviours worth + * pinning are that the {@code Bearer } prefix is stripped before the token is + * used as a key - otherwise every request writes a second, never-read entry - + * and that a Redis failure during the refresh cannot fail a request whose work + * is already done. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class HTTPRequestInterceptorTest { + + @Mock + private SessionObject sessionObject; + + private HTTPRequestInterceptor interceptor; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + interceptor = new HTTPRequestInterceptor(); + interceptor.setSessionObject(sessionObject); + ReflectionTestUtils.setField(interceptor, "allowedOrigins", "https://*.piramalswasthya.org"); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + @DisplayName("an ordinary request is allowed through") + void ordinaryRequestIsAllowedThrough() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/id/getByBenRegId"); + + assertTrue(interceptor.preHandle(request, response, new Object())); + } + + @Test + @DisplayName("a preflight request is allowed through without inspection") + void preflightRequestIsAllowedThrough() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/id/getByBenRegId"); + + assertTrue(interceptor.preHandle(request, response, new Object())); + } + + @ParameterizedTest + @ValueSource(strings = { "/version", "/swagger-ui.html", "/api-docs", "/userAuthenticate", "/forgetPassword" }) + @DisplayName("public endpoints are allowed through") + void publicEndpointsAreAllowedThrough(String uri) throws Exception { + request.setMethod("GET"); + request.setRequestURI(uri); + + assertTrue(interceptor.preHandle(request, response, new Object())); + } + + @Test + @DisplayName("a request routed to the error handler is stopped") + void requestRoutedToTheErrorHandlerIsStopped() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/error"); + + assertFalse(interceptor.preHandle(request, response, new Object())); + } + + @Test + @DisplayName("the bearer prefix is stripped before the token is used as a session key") + void bearerPrefixIsStrippedBeforeUseAsSessionKey() throws Exception { + // Keying on "Bearer " would write a parallel entry that the login + // path never reads, so the session would expire despite activity. + request.setRequestURI("/id/getByBenRegId"); + request.addHeader("Authorization", "Bearer session-token"); + when(sessionObject.getSessionObject("session-token")).thenReturn("{\"userName\":\"field.worker\"}"); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject).getSessionObject("session-token"); + verify(sessionObject).updateSessionObject("session-token", "{\"userName\":\"field.worker\"}"); + } + + @Test + @DisplayName("a bare token is used as the session key unchanged") + void bareTokenIsUsedUnchanged() throws Exception { + request.setRequestURI("/id/getByBenRegId"); + request.addHeader("Authorization", "session-token"); + when(sessionObject.getSessionObject("session-token")).thenReturn("{}"); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject).updateSessionObject("session-token", "{}"); + } + + @Test + @DisplayName("a request with no authorization header refreshes nothing") + void requestWithNoAuthorizationRefreshesNothing() throws Exception { + request.setRequestURI("/health"); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject, never()).updateSessionObject(anyString(), anyString()); + } + + @Test + @DisplayName("a failing session refresh does not fail the request") + void failingSessionRefreshDoesNotFailTheRequest() throws Exception { + // The response has already been produced by this point. + request.setRequestURI("/id/getByBenRegId"); + request.addHeader("Authorization", "Bearer session-token"); + when(sessionObject.getSessionObject(anyString())) + .thenThrow(new IllegalStateException("connection refused")); + + interceptor.postHandle(request, response, new Object(), null); + } + + @Test + @DisplayName("completion is a no-op that cannot fail a served request") + void completionIsANoOp() throws Exception { + interceptor.afterCompletion(request, response, new Object(), null); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/http/HttpUtilsTest.java b/src/test/java/com/iemr/common/identity/utils/http/HttpUtilsTest.java new file mode 100644 index 00000000..62fd1ee0 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/http/HttpUtilsTest.java @@ -0,0 +1,188 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +/** + * Tests for the outbound HTTP helper used to reach the teleconsultation and + * FHIR services. + * + *

+ * The helper's job is header assembly: an inbound {@code Authorization} header + * has to be forwarded on the outbound call, and a content type has to default + * to JSON when the caller does not name one. Dropping the authorization header + * turns an RMNCH lookup into a silent 401 that the calling code reports as "no + * data", which is what these tests are for. + * + *

+ * {@code uploadFile} is not covered here: its multipart branch touches + * {@code javax.ws.rs.core.MediaType}, whose static initialiser needs a JAX-RS + * runtime delegate that this service does not package. + */ +class HttpUtilsTest { + + private RestTemplate restTemplate; + private HttpUtils httpUtils; + + @BeforeEach + void setUp() { + restTemplate = mock(RestTemplate.class); + httpUtils = new HttpUtils(); + ReflectionTestUtils.setField(httpUtils, "rest", restTemplate); + } + + @SuppressWarnings("unchecked") + private void stubExchange(String responseBody) { + when(restTemplate.exchange(anyString(), any(HttpMethod.class), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>(responseBody, HttpStatus.OK)); + } + + @SuppressWarnings("unchecked") + private HttpEntity capturedRequest() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(HttpEntity.class); + org.mockito.Mockito.verify(restTemplate).exchange(anyString(), any(HttpMethod.class), captor.capture(), + eq(String.class)); + return captor.getValue(); + } + + @Test + @DisplayName("a plain GET returns the response body and records the status") + void plainGetReturnsBodyAndRecordsStatus() { + stubExchange("{\"data\":[]}"); + + assertEquals("{\"data\":[]}", httpUtils.get("http://localhost:8093/healthID/getBenhealthID")); + assertEquals(HttpStatus.OK, httpUtils.getStatus()); + } + + @Test + @DisplayName("a GET forwards the caller's authorization header") + void getForwardsTheAuthorizationHeader() { + stubExchange("{}"); + Map header = new HashMap<>(); + header.put(HttpHeaders.AUTHORIZATION, "Bearer inbound-token"); + + httpUtils.get("http://localhost:8089/ANC/getHRPStatus", header); + + assertEquals("Bearer inbound-token", capturedRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + @DisplayName("a GET defaults the content type to JSON when the caller does not name one") + void getDefaultsContentTypeToJson() { + stubExchange("{}"); + + httpUtils.get("http://localhost:8089/ANC/getHRPStatus", new HashMap<>()); + + assertEquals("application/json", capturedRequest().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + @DisplayName("a GET honours an explicit content type") + void getHonoursAnExplicitContentType() { + stubExchange("{}"); + Map header = new HashMap<>(); + header.put(HttpHeaders.CONTENT_TYPE, "application/xml"); + + httpUtils.get("http://localhost:8089/ANC/getHRPStatus", header); + + assertEquals("application/xml", capturedRequest().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + @DisplayName("a plain POST sends the body as JSON") + void plainPostSendsTheBodyAsJson() { + stubExchange("{\"data\":{\"isHRP\":true}}"); + + String response = httpUtils.post("http://localhost:8089/ANC/getHRPStatus", "{\"benRegID\":100200300}"); + + assertEquals("{\"data\":{\"isHRP\":true}}", response); + assertEquals("{\"benRegID\":100200300}", capturedRequest().getBody()); + assertEquals("application/json", capturedRequest().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + @DisplayName("a POST with headers forwards the authorization header along with the body") + void postWithHeadersForwardsAuthorizationAndBody() { + stubExchange("{}"); + Map header = new HashMap<>(); + header.put(HttpHeaders.AUTHORIZATION, "Bearer inbound-token"); + + httpUtils.post("http://localhost:8093/healthID/getBenhealthID", "{\"beneficiaryRegID\":100200300}", header); + + assertEquals("Bearer inbound-token", capturedRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + assertEquals("{\"beneficiaryRegID\":100200300}", capturedRequest().getBody()); + } + + @Test + @DisplayName("a POST without an authorization header sends none rather than an empty one") + void postWithoutAuthorizationSendsNone() { + stubExchange("{}"); + + httpUtils.post("http://localhost:8093/healthID/getBenhealthID", "{}", new HashMap<>()); + + org.junit.jupiter.api.Assertions + .assertNull(capturedRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + @DisplayName("an unreachable service propagates so the caller can degrade") + void unreachableServicePropagates() { + // The RMNCH service turns this into a null result and logs it; swallowing + // it here would make that indistinguishable from an empty response. + when(restTemplate.exchange(anyString(), any(HttpMethod.class), any(HttpEntity.class), eq(String.class))) + .thenThrow(new RestClientException("connection refused")); + + assertThrows(RestClientException.class, + () -> httpUtils.post("http://localhost:8089/ANC/getHRPStatus", "{}")); + } + + @Test + @DisplayName("the recorded status is readable and settable for callers that branch on it") + void recordedStatusIsReadableAndSettable() { + httpUtils.setStatus(HttpStatus.SERVICE_UNAVAILABLE); + + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, httpUtils.getStatus()); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/redis/RedisStorageTest.java b/src/test/java/com/iemr/common/identity/utils/redis/RedisStorageTest.java new file mode 100644 index 00000000..e78d100a --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/redis/RedisStorageTest.java @@ -0,0 +1,183 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils.redis; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisStringCommands.SetOption; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.types.Expiration; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Tests for the session store behind login sessions. + * + *

+ * Every entry is written with an expiry so an abandoned session cannot outlive + * its TTL, and a read refreshes that expiry so an active agent is not logged out + * mid-call. The distinction that matters is between "absent" and "present": a + * missing session must raise {@link RedisSessionException} so the caller + * re-authenticates, rather than returning null for the caller to misread as an + * empty-but-valid session. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RedisStorageTest { + + @Mock + private LettuceConnectionFactory connectionFactory; + @Mock + private RedisConnection connection; + + private RedisStorage storage; + + private static final String KEY = "session-key"; + private static final int TTL_SECONDS = 1800; + + @BeforeEach + void setUp() { + storage = new RedisStorage(); + ReflectionTestUtils.setField(storage, "connection", connectionFactory); + when(connectionFactory.getConnection()).thenReturn(connection); + } + + private byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + @Test + @DisplayName("a new session is written with an expiry") + void newSessionIsWrittenWithAnExpiry() throws Exception { + when(connection.get(bytes(KEY))).thenReturn(null); + + assertEquals(KEY, storage.setObject(KEY, "{\"userName\":\"field.worker\"}", TTL_SECONDS)); + + verify(connection).set(eq(bytes(KEY)), eq(bytes("{\"userName\":\"field.worker\"}")), + eq(Expiration.seconds(TTL_SECONDS)), eq(SetOption.UPSERT)); + } + + @Test + @DisplayName("an existing session is not overwritten by a fresh login") + void existingSessionIsNotOverwritten() throws Exception { + // A concurrent login for the same key keeps the session already in + // flight rather than resetting it. + when(connection.get(bytes(KEY))).thenReturn(bytes("{\"userName\":\"field.worker\"}")); + + assertEquals(KEY, storage.setObject(KEY, "{\"userName\":\"someone.else\"}", TTL_SECONDS)); + + verify(connection, never()).set(any(), any(), any(), any()); + } + + @Test + @DisplayName("a session held under an empty value is treated as absent and written") + void sessionHeldUnderAnEmptyValueIsWritten() throws Exception { + when(connection.get(bytes(KEY))).thenReturn(bytes("")); + + storage.setObject(KEY, "{\"userName\":\"field.worker\"}", TTL_SECONDS); + + verify(connection).set(any(), any(), any(), any()); + } + + @Test + @DisplayName("reading a session returns it and refreshes its expiry") + void readingASessionRefreshesItsExpiry() throws Exception { + when(connection.get(bytes(KEY))).thenReturn(bytes("{\"userName\":\"field.worker\"}")); + + assertEquals("{\"userName\":\"field.worker\"}", storage.getObject(KEY, true, TTL_SECONDS)); + + verify(connection).expire(bytes(KEY), TTL_SECONDS); + } + + @Test + @DisplayName("reading an absent session is reported so the caller re-authenticates") + void readingAnAbsentSessionIsReported() { + when(connection.get(bytes(KEY))).thenReturn(null); + + assertThrows(RedisSessionException.class, () -> storage.getObject(KEY, true, TTL_SECONDS)); + } + + @Test + @DisplayName("reading a blank session is reported as absent") + void readingABlankSessionIsReportedAsAbsent() { + when(connection.get(bytes(KEY))).thenReturn(bytes(" ")); + + assertThrows(RedisSessionException.class, () -> storage.getObject(KEY, true, TTL_SECONDS)); + } + + @Test + @DisplayName("updating an existing session rewrites its value and expiry") + void updatingAnExistingSessionRewritesItsValueAndExpiry() throws Exception { + when(connection.get(bytes(KEY))).thenReturn(bytes("{\"userName\":\"field.worker\"}")); + + assertEquals(KEY, storage.updateObject(KEY, "{\"userName\":\"field.worker\",\"role\":\"nurse\"}", true, + TTL_SECONDS)); + + verify(connection).set(eq(bytes(KEY)), eq(bytes("{\"userName\":\"field.worker\",\"role\":\"nurse\"}")), + eq(Expiration.seconds(TTL_SECONDS)), eq(SetOption.UPSERT)); + } + + @Test + @DisplayName("updating a session that has already expired is reported rather than recreating it") + void updatingAnExpiredSessionIsReported() { + // Recreating it would silently revive a session the user was logged out + // of. + when(connection.get(bytes(KEY))).thenReturn(null); + + assertThrows(RedisSessionException.class, + () -> storage.updateObject(KEY, "{\"userName\":\"field.worker\"}", true, TTL_SECONDS)); + } + + @Test + @DisplayName("deleting a session reports how many entries were removed") + void deletingASessionReportsHowManyWereRemoved() throws Exception { + when(connection.del(bytes(KEY))).thenReturn(1L); + + assertEquals(1L, storage.deleteObject(KEY)); + } + + @Test + @DisplayName("deleting a session that is not there reports zero") + void deletingAnAbsentSessionReportsZero() throws Exception { + when(connection.del(bytes(KEY))).thenReturn(0L); + + assertEquals(0L, storage.deleteObject(KEY)); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/response/OutputResponseTest.java b/src/test/java/com/iemr/common/identity/utils/response/OutputResponseTest.java new file mode 100644 index 00000000..c9c44be3 --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/response/OutputResponseTest.java @@ -0,0 +1,228 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils.response; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.ConnectException; +import java.sql.SQLException; +import java.text.ParseException; + +import org.json.JSONException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.iemr.common.identity.utils.exception.IEMRException; + +/** + * Tests for the response envelope every endpoint returns. + * + *

+ * The API answers HTTP 200 for almost everything and signals failure through a + * {@code statusCode} inside the body, so this class is what clients branch on. + * Two behaviours matter: a JSON payload has to be embedded as structure rather + * than as an escaped string (otherwise callers have to parse twice), and each + * exception class has to map to the status code clients already handle. + */ +class OutputResponseTest { + + @Nested + @DisplayName("successful responses") + class SuccessfulResponses { + + @Test + @DisplayName("a JSON object payload is embedded as structure, not as an escaped string") + void jsonObjectPayloadIsEmbeddedAsStructure() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setResponse("{\"benRegId\":100200300,\"firstName\":\"Asha\"}"); + + assertTrue(response.isSuccess()); + assertEquals(OutputResponse.SUCCESS, response.getStatusCode()); + assertEquals("Success", response.getStatus()); + assertTrue(response.toString().contains("\"firstName\":\"Asha\""), response.toString()); + assertFalse(response.toString().contains("\\\""), response.toString()); + } + + @Test + @DisplayName("a JSON array payload is embedded as an array") + void jsonArrayPayloadIsEmbeddedAsAnArray() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setResponse("[{\"benRegId\":1},{\"benRegId\":2}]"); + + assertTrue(response.getData().startsWith("["), response.getData()); + assertTrue(response.isSuccess()); + } + + @Test + @DisplayName("a plain-text payload is wrapped so the body is still valid JSON") + void plainTextPayloadIsWrapped() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setResponse("Updated successfully"); + + assertTrue(response.toString().contains("Updated successfully")); + assertTrue(response.getData().contains("response"), response.getData()); + } + + @Test + @DisplayName("the payload is readable back through the data accessor") + void payloadIsReadableBackThroughTheAccessor() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setResponse("{\"count\":42}"); + + assertTrue(response.getData().contains("42")); + } + + @Test + @DisplayName("a response with no payload reports no data rather than failing") + void responseWithNoPayloadReportsNoData() throws JSONException { + assertNull(new OutputResponse().getData()); + } + + @Test + @DisplayName("the serialising variant keeps null fields so clients see the full shape") + void serialisingVariantKeepsNullFields() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setResponse("{\"benRegId\":1}"); + + assertTrue(response.toStringWithSerialization().contains("statusCode")); + } + } + + @Nested + @DisplayName("error responses") + class ErrorResponses { + + @Test + @DisplayName("an explicit code and message are reported as given") + void explicitCodeAndMessageAreReportedAsGiven() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setError(OutputResponse.GENERIC_FAILURE, "Beneficiary not found"); + + assertFalse(response.isSuccess()); + assertEquals(OutputResponse.GENERIC_FAILURE, response.getStatusCode()); + assertEquals("Beneficiary not found", response.getErrorMessage()); + assertEquals("Beneficiary not found", response.getStatus()); + } + + @Test + @DisplayName("a separate status can be reported alongside the message") + void separateStatusCanBeReported() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setError(OutputResponse.PREVILAGE_FAILURE, "Not permitted", "failure"); + + assertEquals("Not permitted", response.getErrorMessage()); + assertEquals("failure", response.getStatus()); + } + + @Test + @DisplayName("a login failure is reported as a user-id failure so the UI can prompt again") + void loginFailureIsReportedAsUserIdFailure() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setError(new IEMRException("Invalid login key or session is expired")); + + assertEquals(OutputResponse.USERID_FAILURE, response.getStatusCode()); + assertEquals("User login failed", response.getStatus()); + assertEquals("Invalid login key or session is expired", response.getErrorMessage()); + } + + @Test + @DisplayName("a malformed-payload failure is reported as an object failure") + void malformedPayloadIsReportedAsObjectFailure() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setError(new JSONException("not an object")); + + assertEquals(OutputResponse.OBJECT_FAILURE, response.getStatusCode()); + assertEquals("Invalid object conversion", response.getErrorMessage()); + } + + @Test + @DisplayName("a database or coding fault is reported as a code exception, not as a bad request") + void databaseOrCodingFaultIsReportedAsCodeException() throws JSONException { + // These are not the caller's fault, so the message tells them to + // retry and escalate rather than to fix their request. + for (Throwable thrown : new Throwable[] { new SQLException("deadlock"), new ParseException("bad", 0), + new NullPointerException("npe"), new ArrayIndexOutOfBoundsException("aioobe") }) { + OutputResponse response = new OutputResponse(); + + response.setError(thrown); + + assertEquals(OutputResponse.CODE_EXCEPTION, response.getStatusCode(), + thrown.getClass().getSimpleName() + " should map to a code exception"); + assertTrue(response.getStatus().contains("contact your administrator")); + } + } + + @Test + @DisplayName("a connectivity fault is reported as an environment exception") + void connectivityFaultIsReportedAsEnvironmentException() throws JSONException { + for (Throwable thrown : new Throwable[] { new IOException("timeout"), + new ConnectException("connection refused") }) { + OutputResponse response = new OutputResponse(); + + response.setError(thrown); + + assertEquals(OutputResponse.ENVIRONMENT_EXCEPTION, response.getStatusCode(), + thrown.getClass().getSimpleName() + " should map to an environment exception"); + assertTrue(response.getStatus().contains("connection issues")); + } + } + + @Test + @DisplayName("an unrecognised fault falls back to a generic failure carrying its message") + void unrecognisedFaultFallsBackToGenericFailure() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setError(new IllegalStateException("something unexpected")); + + assertEquals(OutputResponse.GENERIC_FAILURE, response.getStatusCode()); + assertEquals("something unexpected", response.getErrorMessage()); + assertTrue(response.getStatus().contains("something unexpected")); + } + + @Test + @DisplayName("an error response still serialises to valid JSON") + void errorResponseStillSerialisesToValidJson() throws JSONException { + OutputResponse response = new OutputResponse(); + + response.setError(OutputResponse.CODE_EXCEPTION, "boom"); + + assertNotNull(new org.json.JSONObject(response.toString())); + } + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/sessionobject/SessionObjectTest.java b/src/test/java/com/iemr/common/identity/utils/sessionobject/SessionObjectTest.java new file mode 100644 index 00000000..5a3b29cb --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/sessionobject/SessionObjectTest.java @@ -0,0 +1,168 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils.sessionobject; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Properties; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.identity.utils.config.ConfigProperties; +import com.iemr.common.identity.utils.redis.RedisSessionException; +import com.iemr.common.identity.utils.redis.RedisStorage; + +/** + * Tests for the session facade the interceptor and validator go through. + * + *

+ * Besides storing the session under its key, it maintains a second entry keyed + * on the lower-cased username pointing back at the session key. That is what + * lets a new login find and displace a user's previous session, and it is + * derived from the session JSON, so a payload without a {@code userName} has to + * leave the primary write intact rather than fail the login. + * + *

+ * The expiry-extension flag it passes through resolves to {@code false} - see + * {@code ConfigPropertiesTest#extendExpiryFlagReadsTheWrongProperty} for why. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SessionObjectTest { + + @Mock + private RedisStorage objectStore; + + private SessionObject sessionObject; + + private static final String KEY = "session-key"; + private static final int TTL_SECONDS = 1800; + + @BeforeEach + void setUp() { + Properties properties = new Properties(); + properties.setProperty("iemr.session.expiry.time", String.valueOf(TTL_SECONDS)); + properties.setProperty("iemr.extend.expiry.time", "true"); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", properties); + ReflectionTestUtils.setField(ConfigProperties.class, "sessionExpiryTime", null); + ReflectionTestUtils.setField(ConfigProperties.class, "extendExpiryTime", null); + + sessionObject = new SessionObject(); + sessionObject.setObjectStore(objectStore); + } + + @Test + @DisplayName("a session is read from the store with the configured expiry") + void sessionIsReadWithTheConfiguredExpiry() throws Exception { + when(objectStore.getObject(KEY, false, TTL_SECONDS)).thenReturn("{\"userName\":\"field.worker\"}"); + + assertEquals("{\"userName\":\"field.worker\"}", sessionObject.getSessionObject(KEY)); + } + + @Test + @DisplayName("an absent session propagates so the caller re-authenticates") + void absentSessionPropagates() throws Exception { + when(objectStore.getObject(anyString(), anyBoolean(), anyInt())) + .thenThrow(new RedisSessionException("Unable to fetch session object from Redis server")); + + assertThrows(RedisSessionException.class, () -> sessionObject.getSessionObject(KEY)); + } + + @Test + @DisplayName("storing a session also indexes it under the lower-cased username") + void storingASessionIndexesItUnderTheUsername() throws Exception { + // The username index is how a fresh login finds and displaces the + // previous session for the same user. + when(objectStore.setObject(eq(KEY), anyString(), anyInt())).thenReturn(KEY); + + assertEquals(KEY, sessionObject.setSessionObject(KEY, "{\"userName\":\" Field.Worker \"}")); + + verify(objectStore).updateObject("field.worker", KEY, false, TTL_SECONDS); + verify(objectStore).setObject(eq(KEY), anyString(), eq(TTL_SECONDS)); + } + + @Test + @DisplayName("a session payload with no username is still stored") + void sessionWithNoUsernameIsStillStored() throws Exception { + when(objectStore.setObject(eq(KEY), anyString(), anyInt())).thenReturn(KEY); + + assertEquals(KEY, sessionObject.setSessionObject(KEY, "{\"role\":\"nurse\"}")); + + verify(objectStore, never()).updateObject(eq("field.worker"), anyString(), anyBoolean(), anyInt()); + } + + @Test + @DisplayName("an unparseable session payload is still stored under its key") + void unparseableSessionPayloadIsStillStored() throws Exception { + when(objectStore.setObject(eq(KEY), anyString(), anyInt())).thenReturn(KEY); + + assertEquals(KEY, sessionObject.setSessionObject(KEY, "not json at all {")); + } + + @Test + @DisplayName("a failure indexing the username does not stop the session being stored") + void failureIndexingTheUsernameDoesNotStopTheStore() throws Exception { + when(objectStore.updateObject(eq("field.worker"), anyString(), anyBoolean(), anyInt())) + .thenThrow(new RedisSessionException("no such key")); + when(objectStore.setObject(eq(KEY), anyString(), anyInt())).thenReturn(KEY); + + assertEquals(KEY, sessionObject.setSessionObject(KEY, "{\"userName\":\"field.worker\"}")); + } + + @Test + @DisplayName("updating a session refreshes both the session and its username index") + void updatingASessionRefreshesBothEntries() throws Exception { + when(objectStore.updateObject(eq(KEY), anyString(), anyBoolean(), anyInt())).thenReturn(KEY); + + assertEquals(KEY, sessionObject.updateSessionObject(KEY, "{\"userName\":\"field.worker\"}")); + + verify(objectStore).updateObject("field.worker", KEY, false, TTL_SECONDS); + verify(objectStore).updateObject(eq(KEY), anyString(), eq(false), eq(TTL_SECONDS)); + } + + @Test + @DisplayName("deleting a session removes it from the store") + void deletingASessionRemovesIt() throws Exception { + when(objectStore.deleteObject(KEY)).thenReturn(1L); + + sessionObject.deleteSessionObject(KEY); + + verify(objectStore).deleteObject(KEY); + } +} diff --git a/src/test/java/com/iemr/common/identity/utils/validator/ValidatorTest.java b/src/test/java/com/iemr/common/identity/utils/validator/ValidatorTest.java new file mode 100644 index 00000000..a762735a --- /dev/null +++ b/src/test/java/com/iemr/common/identity/utils/validator/ValidatorTest.java @@ -0,0 +1,242 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.utils.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Properties; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.identity.utils.config.ConfigProperties; +import com.iemr.common.identity.utils.exception.IEMRException; +import com.iemr.common.identity.utils.redis.RedisSessionException; +import com.iemr.common.identity.utils.sessionobject.SessionObject; + +/** + * Tests for login-session validation. + * + *

+ * The validator decides whether a login key is still usable, and optionally + * whether the request comes from the IP the session was created on. IP checking + * is configuration-gated, so both settings need covering: with it off, a + * session used from a new address must still work (field workers roam between + * networks), and with it on, the response must say which address holds the + * session rather than silently succeeding. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ValidatorTest { + + @Mock + private SessionObject session; + + private Validator validator; + + private static final String LOGIN_KEY = "session-key"; + + private void configureIpValidation(boolean enabled) { + Properties properties = new Properties(); + properties.setProperty("enableIPValidation", String.valueOf(enabled)); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", properties); + // enableIPValidation is a static field cached across instances; the + // constructor only re-reads configuration while it is false, so reset it + // to false rather than null (which the constructor would unbox). + ReflectionTestUtils.setField(Validator.class, "enableIPValidation", Boolean.FALSE); + validator = new Validator(); + validator.setSessionObject(session); + } + + private JSONObject loginResponse(String ipAddress) throws JSONException { + JSONObject response = new JSONObject(); + response.put("userName", "field.worker"); + response.put("loginIPAddress", ipAddress); + return response; + } + + @Nested + @DisplayName("with IP validation switched off") + class WithIpValidationOff { + + @BeforeEach + void setUp() throws Exception { + configureIpValidation(false); + } + + @Test + @DisplayName("a fresh login is stored and reported as successful") + void freshLoginIsStoredAndReportedSuccessful() throws Exception { + when(session.getSessionObject(LOGIN_KEY)).thenReturn(null); + + JSONObject result = validator.updateCacheObj(loginResponse("10.1.2.3"), LOGIN_KEY, "ipKey"); + + assertEquals("login success", result.getString("sessionStatus")); + assertEquals(LOGIN_KEY, result.getString("key")); + verify(session).setSessionObject(anyString(), anyString()); + } + + @Test + @DisplayName("a session already held from another address is still accepted") + void sessionHeldFromAnotherAddressIsStillAccepted() throws Exception { + // Field workers move between mobile networks mid-shift, so the + // address changing is not by itself suspicious. + when(session.getSessionObject(LOGIN_KEY)).thenReturn(loginResponse("10.9.9.9").toString()); + + JSONObject result = validator.updateCacheObj(loginResponse("10.1.2.3"), LOGIN_KEY, "ipKey"); + + assertEquals("login success", result.getString("sessionStatus")); + } + + @Test + @DisplayName("a session lookup failure still leaves the login usable") + void sessionLookupFailureStillLeavesTheLoginUsable() throws Exception { + when(session.getSessionObject(LOGIN_KEY)) + .thenThrow(new RedisSessionException("Unable to fetch session object")); + + JSONObject result = validator.updateCacheObj(loginResponse("10.1.2.3"), LOGIN_KEY, "ipKey"); + + assertEquals("login success", result.getString("sessionStatus")); + } + + @Test + @DisplayName("a failure storing the session is reported as a failed session creation") + void failureStoringTheSessionIsReported() throws Exception { + when(session.setSessionObject(anyString(), anyString())) + .thenThrow(new RedisSessionException("connection refused")); + + JSONObject result = validator.updateCacheObj(loginResponse("10.1.2.3"), LOGIN_KEY, "ipKey"); + + assertEquals("session creation failed", result.getString("sessionStatus")); + } + + @Test + @DisplayName("a known login key passes the existence check") + void knownLoginKeyPassesTheExistenceCheck() throws Exception { + when(session.getSessionObject(LOGIN_KEY)).thenReturn(loginResponse("10.1.2.3").toString()); + + validator.checkKeyExists(LOGIN_KEY, "10.1.2.3"); + } + + @Test + @DisplayName("a login key from a different address still passes when IP validation is off") + void loginKeyFromADifferentAddressStillPasses() throws Exception { + when(session.getSessionObject(LOGIN_KEY)).thenReturn(loginResponse("10.9.9.9").toString()); + + validator.checkKeyExists(LOGIN_KEY, "10.1.2.3"); + } + + @Test + @DisplayName("an expired login key is rejected") + void expiredLoginKeyIsRejected() throws Exception { + when(session.getSessionObject(LOGIN_KEY)) + .thenThrow(new RedisSessionException("Unable to fetch session object")); + + IEMRException thrown = assertThrows(IEMRException.class, + () -> validator.checkKeyExists(LOGIN_KEY, "10.1.2.3")); + + assertTrue(thrown.getMessage().contains("Invalid login key or session is expired")); + } + + @Test + @DisplayName("a session that is not valid JSON is rejected") + void sessionThatIsNotValidJsonIsRejected() throws Exception { + when(session.getSessionObject(LOGIN_KEY)).thenReturn("not json at all {"); + + assertThrows(IEMRException.class, () -> validator.checkKeyExists(LOGIN_KEY, "10.1.2.3")); + } + + @Test + @DisplayName("the stored session is readable through the validator") + void storedSessionIsReadableThroughTheValidator() throws Exception { + when(session.getSessionObject(LOGIN_KEY)).thenReturn("{\"userName\":\"field.worker\"}"); + + assertEquals("{\"userName\":\"field.worker\"}", validator.getSessionObject(LOGIN_KEY)); + } + } + + @Nested + @DisplayName("with IP validation switched on") + class WithIpValidationOn { + + @BeforeEach + void setUp() throws Exception { + configureIpValidation(true); + } + + @Test + @DisplayName("a login from the address that holds the session is accepted") + void loginFromTheHoldingAddressIsAccepted() throws Exception { + when(session.getSessionObject(LOGIN_KEY)).thenReturn(loginResponse("10.1.2.3").toString()); + + JSONObject result = validator.updateCacheObj(loginResponse("10.1.2.3"), LOGIN_KEY, "ipKey"); + + assertEquals("login success", result.getString("sessionStatus")); + } + + @Test + @DisplayName("a login from a new address reports where the session is held instead of the login payload") + void loginFromANewAddressReportsWhereTheSessionIsHeld() throws Exception { + // The response is replaced with a bare object, so the caller gets no + // user details - only the reason and the holding address. + when(session.getSessionObject(LOGIN_KEY)).thenReturn(loginResponse("10.9.9.9").toString()); + + JSONObject result = validator.updateCacheObj(loginResponse("10.1.2.3"), LOGIN_KEY, "ipKey"); + + assertTrue(result.getString("sessionStatus").contains("10.9.9.9")); + assertEquals(LOGIN_KEY, result.getString("key")); + org.junit.jupiter.api.Assertions.assertFalse(result.has("userName")); + verify(session, org.mockito.Mockito.never()).setSessionObject(anyString(), anyString()); + } + + @Test + @DisplayName("a login key used from a different address is rejected") + void loginKeyUsedFromADifferentAddressIsRejected() throws Exception { + when(session.getSessionObject(LOGIN_KEY)).thenReturn(loginResponse("10.9.9.9").toString()); + + assertThrows(IEMRException.class, () -> validator.checkKeyExists(LOGIN_KEY, "10.1.2.3")); + } + + @Test + @DisplayName("a login key used from the address that holds it is accepted") + void loginKeyUsedFromTheHoldingAddressIsAccepted() throws Exception { + when(session.getSessionObject(LOGIN_KEY)).thenReturn(loginResponse("10.1.2.3").toString()); + + validator.checkKeyExists(LOGIN_KEY, "10.1.2.3"); + } + } +} diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 00000000..e2107d98 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,10 @@ + + + + %d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n + + + + + +