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.
+ *
+ *
+ * {@link #populate(Class)} returns an instance with every writable
+ * property set to a deterministic non-null value, recursing into nested
+ * POJOs and collections. This drives the "value present" side of the
+ * mappers' null guards.
+ * {@link #blank(Class)} returns a bare instance so the same mapper call
+ * exercises the "value absent" side.
+ * {@link #exerciseAccessors(Object)} reads every property back, which
+ * covers the generated getters on the entity and DTO layers.
+ *
+ */
+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