From 5319aecddbefad71b089ea38ba5447f0cda1698e Mon Sep 17 00:00:00 2001 From: Vanitha S <116701245+vanitha1822@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:02:28 +0530 Subject: [PATCH 01/10] Fix the Health API Response format (#68) * fix: health response format * fix: pom version --- pom.xml | 2 +- .../controller/health/HealthController.java | 2 +- .../bengen/service/health/HealthService.java | 75 +++++++------------ 3 files changed, 28 insertions(+), 51 deletions(-) diff --git a/pom.xml b/pom.xml index 4be32b0..f37a05b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common bengen-api - 3.6.1 + 3.8.2 war BeneficiaryID-Generation-API diff --git a/src/main/java/com/iemr/common/bengen/controller/health/HealthController.java b/src/main/java/com/iemr/common/bengen/controller/health/HealthController.java index bf6976c..247e23a 100644 --- a/src/main/java/com/iemr/common/bengen/controller/health/HealthController.java +++ b/src/main/java/com/iemr/common/bengen/controller/health/HealthController.java @@ -53,7 +53,7 @@ public ResponseEntity> checkHealth() { Map errorResponse = Map.of( "status", "DOWN", - "timestamp", Instant.now().toString() + "checkedAt", Instant.now().toString() ); return new ResponseEntity<>(errorResponse, HttpStatus.SERVICE_UNAVAILABLE); diff --git a/src/main/java/com/iemr/common/bengen/service/health/HealthService.java b/src/main/java/com/iemr/common/bengen/service/health/HealthService.java index 3ce59df..2aca8c1 100644 --- a/src/main/java/com/iemr/common/bengen/service/health/HealthService.java +++ b/src/main/java/com/iemr/common/bengen/service/health/HealthService.java @@ -90,38 +90,47 @@ public void shutdown() { } public Map checkHealth() { - Map response = new LinkedHashMap<>(); - response.put("timestamp", Instant.now().toString()); - Map mysqlStatus = new ConcurrentHashMap<>(); Map redisStatus = new ConcurrentHashMap<>(); - + Future mysqlFuture = executorService.submit( () -> performHealthCheck("MySQL", mysqlStatus, this::checkMySQLHealthSync)); Future redisFuture = executorService.submit( () -> performHealthCheck("Redis", redisStatus, this::checkRedisHealthSync)); - + // Wait for both checks to complete with combined timeout (shared deadline) long maxTimeout = Math.max(MYSQL_TIMEOUT_SECONDS, REDIS_TIMEOUT_SECONDS) + 1; awaitHealthChecks(mysqlFuture, redisFuture, maxTimeout); - + // Ensure timed-out or unfinished components are marked DOWN ensurePopulated(mysqlStatus, "MySQL"); ensurePopulated(redisStatus, "Redis"); - - Map> components = new LinkedHashMap<>(); - components.put("mysql", mysqlStatus); - components.put("redis", redisStatus); - - response.put("components", components); - - // Compute overall status - String overallStatus = computeOverallStatus(components); - response.put(STATUS_KEY, overallStatus); - + + // Build response in the standardized AMRIT shape (aligned with Common-API): + // top-level status + checkedAt, then per-service status/severity summaries. + Map response = new LinkedHashMap<>(); + + String mysqlOverall = (String) mysqlStatus.get(STATUS_KEY); + String redisOverall = (String) redisStatus.get(STATUS_KEY); + boolean overallUp = !STATUS_DOWN.equals(mysqlOverall) && !STATUS_DOWN.equals(redisOverall); + + response.put(STATUS_KEY, overallUp ? STATUS_UP : STATUS_DOWN); + response.put("checkedAt", Instant.now().toString()); + + // Expose only status and severity; keep diagnostics (responseTime, messages) internal + response.put("mysql", summarize(mysqlStatus)); + response.put("redis", summarize(redisStatus)); + return response; } + private Map summarize(Map componentStatus) { + Map summary = new LinkedHashMap<>(); + summary.put(STATUS_KEY, componentStatus.get(STATUS_KEY)); + summary.put(SEVERITY_KEY, componentStatus.get(SEVERITY_KEY)); + return summary; + } + private void awaitHealthChecks(Future mysqlFuture, Future redisFuture, long maxTimeoutSeconds) { long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(maxTimeoutSeconds); try { @@ -258,38 +267,6 @@ private String determineSeverity(boolean isHealthy, long responseTimeMs, boolean return SEVERITY_OK; } - private String computeOverallStatus(Map> components) { - boolean hasCritical = false; - boolean hasDegraded = false; - - for (Map componentStatus : components.values()) { - String status = (String) componentStatus.get(STATUS_KEY); - String severity = (String) componentStatus.get(SEVERITY_KEY); - - if (STATUS_DOWN.equals(status) || SEVERITY_CRITICAL.equals(severity)) { - hasCritical = true; - } - - if (STATUS_DEGRADED.equals(status)) { - hasDegraded = true; - } - - if (SEVERITY_WARNING.equals(severity)) { - hasDegraded = true; - } - } - - if (hasCritical) { - return STATUS_DOWN; - } - - if (hasDegraded) { - return STATUS_DEGRADED; - } - - return STATUS_UP; - } - // Internal advanced health checks for MySQL - do not expose details in responses private boolean performAdvancedMySQLChecksWithThrottle() { if (!ADVANCED_HEALTH_CHECKS_ENABLED) { From 37e9d3c83ddbf9d6bb163da5b19fe25fa33b3654 Mon Sep 17 00:00:00 2001 From: Vanitha S <116701245+vanitha1822@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:49:10 +0530 Subject: [PATCH 02/10] Fix: Beneficiary ID Generation Issue and add Health Checkup (#69) * fix: remove hardcoded schema and update the health api to show the warning on ben id availability * fix: update the cron to run daily --- src/main/environment/bengen_docker.properties | 3 + .../environment/bengen_example.properties | 6 + .../common/bengen/repo/BeneficiaryIdRepo.java | 2 +- .../service/GenerateBeneficiaryService.java | 8 +- .../bengen/service/health/HealthService.java | 172 +++++++++++++++--- .../GenerateBeneficiaryServiceTest.java | 2 +- 6 files changed, 166 insertions(+), 27 deletions(-) diff --git a/src/main/environment/bengen_docker.properties b/src/main/environment/bengen_docker.properties index b4b6ad1..bb6fe16 100644 --- a/src/main/environment/bengen_docker.properties +++ b/src/main/environment/bengen_docker.properties @@ -21,6 +21,9 @@ no-of-benID-to-be-generate=25000 ### Total available Ben should not be less than this lower-limit-of-beneficiary=20000 +### Health API warns (status DEGRADED) when available Ben IDs fall below this +health.min-available-beneficiary-ids=5000 + ### Redis IP spring.redis.host=${REDIS_HOST} diff --git a/src/main/environment/bengen_example.properties b/src/main/environment/bengen_example.properties index 1c7a79c..bd3742c 100644 --- a/src/main/environment/bengen_example.properties +++ b/src/main/environment/bengen_example.properties @@ -13,6 +13,9 @@ start-bengen-scheduler=true # To Run scheduler Every MON,WED,FRI cron-scheduler-bengen=0 1 0 ? * MON,WED,FRI,SUN * +# To Run scheduler Every Day +# cron-scheduler-bengen=0 1 0 * * ? * + # To Run scheduler Every Minute #cron-scheduler-bengen=0 0/1 * * * ? * @@ -22,6 +25,9 @@ no-of-benID-to-be-generate=25000 ### Total available Ben should not be less than this lower-limit-of-beneficiary=20000 +### Health API warns (status DEGRADED) when available Ben IDs fall below this +health.min-available-beneficiary-ids=5000 + ### Redis IP spring.redis.host=localhost jwt.secret=my-32-character-ultra-secure-and-ultra-long-secret diff --git a/src/main/java/com/iemr/common/bengen/repo/BeneficiaryIdRepo.java b/src/main/java/com/iemr/common/bengen/repo/BeneficiaryIdRepo.java index 1a4a594..aa5bc98 100644 --- a/src/main/java/com/iemr/common/bengen/repo/BeneficiaryIdRepo.java +++ b/src/main/java/com/iemr/common/bengen/repo/BeneficiaryIdRepo.java @@ -116,7 +116,7 @@ Long countByReservedForPSMapIdAndProvisionedAndReserved( @Query(nativeQuery = true, value = "Select benregMap.benRegId, benregMap.beneficiaryId, " + "benregMap.CreatedDate " - + "from db_identity.m_beneficiaryregidmapping benregMap " + + "from m_beneficiaryregidmapping benregMap " + "where benregMap.provisioned =false and benregMap.reserved =true " + "and benregMap.vanID=:vanID order by benregMap.benRegId desc limit :num ") List getBenIDGenerated(@Param("vanID") Integer vanID, @Param("num") Long num); diff --git a/src/main/java/com/iemr/common/bengen/service/GenerateBeneficiaryService.java b/src/main/java/com/iemr/common/bengen/service/GenerateBeneficiaryService.java index ddb42fb..28bc8b2 100644 --- a/src/main/java/com/iemr/common/bengen/service/GenerateBeneficiaryService.java +++ b/src/main/java/com/iemr/common/bengen/service/GenerateBeneficiaryService.java @@ -103,11 +103,11 @@ public StringBuffer createQuery(Integer num) { Generator g = new Generator(); StringBuffer sb = new StringBuffer( - "INSERT INTO `db_identity`.`m_beneficiaryregidmapping` " + + "INSERT INTO `m_beneficiaryregidmapping` " + "(`BeneficiaryID`,`Provisioned`,`Deleted`," + "`CreatedDate`,`CreatedBy`) VALUES "); - // INSERT INTO `db_identity`.`m_beneficiaryregidmapping` + // INSERT INTO `m_beneficiaryregidmapping` // (`BeneficiaryID`,`Provisioned`,`Deleted`,`CreatedDate`,`CreatedBy`) VALUES // (<{BeneficiaryID: }>, <{Provisioned: b'0'}>, <{Deleted: b'0'}>, // <{CreatedDate: CURRENT_TIMESTAMP}>, <{CreatedBy: }>); @@ -143,7 +143,7 @@ public StringBuffer createQuery(Integer num) { public void testLoopGenr() { List strList = new ArrayList(); StringBuffer sb = new StringBuffer( - "INSERT INTO `db_identity`.`m_beneficiaryregidmapping` " + + "INSERT INTO `m_beneficiaryregidmapping` " + "(`BeneficiaryID`,`Provisioned`,`Deleted`,`CreatedDate`," + "`CreatedBy`) VALUES "); Timestamp ts = Timestamp.from(Instant.now()); @@ -167,7 +167,7 @@ public List getBeneficiaryIDs(Long num, Integer vanID long strt = System.currentTimeMillis(); Generator g = new Generator(); StringBuffer sb = new StringBuffer( - "INSERT INTO `db_identity`.`m_beneficiaryregidmapping` " + + "INSERT INTO `m_beneficiaryregidmapping` " + "(`BeneficiaryID`,`Provisioned`,`Deleted`,`Reserved`," + "`CreatedDate`,`CreatedBy`,`VanID`) VALUES "); Timestamp ts = Timestamp.from(Instant.now()); diff --git a/src/main/java/com/iemr/common/bengen/service/health/HealthService.java b/src/main/java/com/iemr/common/bengen/service/health/HealthService.java index 2aca8c1..dea23d7 100644 --- a/src/main/java/com/iemr/common/bengen/service/health/HealthService.java +++ b/src/main/java/com/iemr/common/bengen/service/health/HealthService.java @@ -24,6 +24,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -47,8 +48,16 @@ public class HealthService { private static final String RESPONSE_TIME_KEY = "responseTimeMs"; private static final long MYSQL_TIMEOUT_SECONDS = 3; private static final long REDIS_TIMEOUT_SECONDS = 3; + private static final long BENEFICIARY_POOL_TIMEOUT_SECONDS = 3; private static final long ADVANCED_CHECKS_THROTTLE_SECONDS = 30; + private static final long POOL_CHECK_THROTTLE_SECONDS = 30; private static final long RESPONSE_TIME_THRESHOLD_MS = 2000; + private static final String BENEFICIARY_POOL_KEY = "beneficiaryIdPool"; + private static final String AVAILABLE_IDS_KEY = "availableIds"; + private static final String THRESHOLD_KEY = "threshold"; + // Mirrors BeneficiaryIdRepo.countBenID(): unprovisioned and unreserved IDs are the usable pool. + private static final String COUNT_AVAILABLE_IDS_SQL = + "SELECT COUNT(*) FROM m_beneficiaryregidmapping WHERE Provisioned = 0 AND Reserved = 0"; private static final String DIAGNOSTIC_LOCK_WAIT = "MYSQL_LOCK_WAIT"; private static final String DIAGNOSTIC_SLOW_QUERIES = "MYSQL_SLOW_QUERIES"; private static final String DIAGNOSTIC_POOL_EXHAUSTED = "MYSQL_POOL_EXHAUSTED"; @@ -61,14 +70,24 @@ public class HealthService { private volatile long lastAdvancedCheckTime = 0; private volatile AdvancedCheckResult cachedAdvancedCheckResult = null; private final ReentrantReadWriteLock advancedCheckLock = new ReentrantReadWriteLock(); - + + // Warn when the usable beneficiary ID pool falls below this count + private final int minAvailableBeneficiaryIds; + + // Cached pool count so frequent health polls do not repeatedly scan the table. + // A benign race here only costs one extra COUNT query, so no lock is needed. + private volatile long lastPoolCheckTime = 0; + private volatile Long cachedAvailableIdCount = null; + // Advanced checks always enabled private static final boolean ADVANCED_HEALTH_CHECKS_ENABLED = true; public HealthService(DataSource dataSource, - @Autowired(required = false) RedisTemplate redisTemplate) { + @Autowired(required = false) RedisTemplate redisTemplate, + @Value("${health.min-available-beneficiary-ids:5000}") int minAvailableBeneficiaryIds) { this.dataSource = dataSource; this.redisTemplate = redisTemplate; + this.minAvailableBeneficiaryIds = minAvailableBeneficiaryIds; this.executorService = Executors.newFixedThreadPool(6); } @@ -92,34 +111,39 @@ public void shutdown() { public Map checkHealth() { Map mysqlStatus = new ConcurrentHashMap<>(); Map redisStatus = new ConcurrentHashMap<>(); + Map beneficiaryPoolStatus = new ConcurrentHashMap<>(); Future mysqlFuture = executorService.submit( () -> performHealthCheck("MySQL", mysqlStatus, this::checkMySQLHealthSync)); Future redisFuture = executorService.submit( () -> performHealthCheck("Redis", redisStatus, this::checkRedisHealthSync)); + Future beneficiaryPoolFuture = executorService.submit( + () -> checkBeneficiaryIdPool(beneficiaryPoolStatus)); - // Wait for both checks to complete with combined timeout (shared deadline) - long maxTimeout = Math.max(MYSQL_TIMEOUT_SECONDS, REDIS_TIMEOUT_SECONDS) + 1; - awaitHealthChecks(mysqlFuture, redisFuture, maxTimeout); + // Wait for all checks to complete with combined timeout (shared deadline) + long maxTimeout = Math.max(Math.max(MYSQL_TIMEOUT_SECONDS, REDIS_TIMEOUT_SECONDS), + BENEFICIARY_POOL_TIMEOUT_SECONDS) + 1; + awaitHealthChecks(maxTimeout, mysqlFuture, redisFuture, beneficiaryPoolFuture); // Ensure timed-out or unfinished components are marked DOWN ensurePopulated(mysqlStatus, "MySQL"); ensurePopulated(redisStatus, "Redis"); + if (!beneficiaryPoolStatus.containsKey(STATUS_KEY)) { + markPoolUnknown(beneficiaryPoolStatus, "Beneficiary ID pool check did not complete in time"); + } // Build response in the standardized AMRIT shape (aligned with Common-API): // top-level status + checkedAt, then per-service status/severity summaries. Map response = new LinkedHashMap<>(); - String mysqlOverall = (String) mysqlStatus.get(STATUS_KEY); - String redisOverall = (String) redisStatus.get(STATUS_KEY); - boolean overallUp = !STATUS_DOWN.equals(mysqlOverall) && !STATUS_DOWN.equals(redisOverall); - - response.put(STATUS_KEY, overallUp ? STATUS_UP : STATUS_DOWN); + response.put(STATUS_KEY, computeOverallStatus(mysqlStatus, redisStatus, beneficiaryPoolStatus)); response.put("checkedAt", Instant.now().toString()); // Expose only status and severity; keep diagnostics (responseTime, messages) internal response.put("mysql", summarize(mysqlStatus)); response.put("redis", summarize(redisStatus)); + // The pool also reports its count and threshold, which operators need to act on the warning + response.put(BENEFICIARY_POOL_KEY, summarizeBeneficiaryPool(beneficiaryPoolStatus)); return response; } @@ -131,30 +155,136 @@ private Map summarize(Map componentStatus) { return summary; } - private void awaitHealthChecks(Future mysqlFuture, Future redisFuture, long maxTimeoutSeconds) { + private Map summarizeBeneficiaryPool(Map componentStatus) { + Map summary = summarize(componentStatus); + if (componentStatus.containsKey(AVAILABLE_IDS_KEY)) { + summary.put(AVAILABLE_IDS_KEY, componentStatus.get(AVAILABLE_IDS_KEY)); + } + summary.put(THRESHOLD_KEY, minAvailableBeneficiaryIds); + if (componentStatus.containsKey(MESSAGE_KEY)) { + summary.put(MESSAGE_KEY, componentStatus.get(MESSAGE_KEY)); + } + if (componentStatus.containsKey(ERROR_KEY)) { + summary.put(ERROR_KEY, componentStatus.get(ERROR_KEY)); + } + return summary; + } + + /** + * Overall status is DOWN if any component is DOWN, DEGRADED if any component + * reports a warning (for example a low beneficiary ID pool), otherwise UP. + */ + @SafeVarargs + private final String computeOverallStatus(Map... componentStatuses) { + boolean hasDegraded = false; + for (Map componentStatus : componentStatuses) { + String status = (String) componentStatus.get(STATUS_KEY); + String severity = (String) componentStatus.get(SEVERITY_KEY); + if (STATUS_DOWN.equals(status) || SEVERITY_CRITICAL.equals(severity)) { + return STATUS_DOWN; + } + if (STATUS_DEGRADED.equals(status) || SEVERITY_WARNING.equals(severity)) { + hasDegraded = true; + } + } + return hasDegraded ? STATUS_DEGRADED : STATUS_UP; + } + + private void awaitHealthChecks(long maxTimeoutSeconds, Future... futures) { long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(maxTimeoutSeconds); try { - mysqlFuture.get(maxTimeoutSeconds, TimeUnit.SECONDS); - long remainingNs = deadlineNs - System.nanoTime(); - if (remainingNs > 0) { - redisFuture.get(remainingNs, TimeUnit.NANOSECONDS); - } else { - redisFuture.cancel(true); + for (Future future : futures) { + long remainingNs = deadlineNs - System.nanoTime(); + if (remainingNs <= 0) { + future.cancel(true); + continue; + } + future.get(remainingNs, TimeUnit.NANOSECONDS); } } catch (TimeoutException e) { logger.warn("Health check aggregate timeout after {} seconds", maxTimeoutSeconds); - mysqlFuture.cancel(true); - redisFuture.cancel(true); + cancelAll(futures); } catch (InterruptedException e) { Thread.currentThread().interrupt(); logger.warn("Health check was interrupted"); - mysqlFuture.cancel(true); - redisFuture.cancel(true); + cancelAll(futures); } catch (Exception e) { logger.warn("Health check execution error: {}", e.getMessage()); } } + private void cancelAll(Future... futures) { + for (Future future : futures) { + future.cancel(true); + } + } + + /** + * Flags a WARNING when the usable beneficiary ID pool drops below the configured + * threshold, so a draining pool is visible before registration stops entirely. + * Reported as DEGRADED rather than DOWN, so the endpoint still returns HTTP 200. + */ + private void checkBeneficiaryIdPool(Map status) { + long startTime = System.currentTimeMillis(); + try { + long availableIds = countAvailableBeneficiaryIdsWithThrottle(); + status.put(RESPONSE_TIME_KEY, System.currentTimeMillis() - startTime); + status.put(AVAILABLE_IDS_KEY, availableIds); + + if (availableIds < minAvailableBeneficiaryIds) { + status.put(STATUS_KEY, STATUS_DEGRADED); + status.put(SEVERITY_KEY, SEVERITY_WARNING); + status.put(MESSAGE_KEY, "Available beneficiary ID pool is below the configured threshold"); + logger.warn("Beneficiary ID pool low: {} available, warning threshold is {}", + availableIds, minAvailableBeneficiaryIds); + } else { + status.put(STATUS_KEY, STATUS_UP); + status.put(SEVERITY_KEY, SEVERITY_OK); + } + } catch (Exception e) { + logger.warn("Beneficiary ID pool check failed: {}", e.getMessage(), e); + status.put(RESPONSE_TIME_KEY, System.currentTimeMillis() - startTime); + // A failed count is not an outage: MySQL itself is checked separately, so this + // stays DEGRADED to avoid a slow count returning 503 and dropping the instance + // out of load-balancer rotation. + markPoolUnknown(status, "Beneficiary ID pool count could not be determined"); + } + } + + private void markPoolUnknown(Map status, String error) { + status.put(STATUS_KEY, STATUS_DEGRADED); + status.put(SEVERITY_KEY, SEVERITY_WARNING); + status.put(ERROR_KEY, error); + } + + private long countAvailableBeneficiaryIdsWithThrottle() throws Exception { + long currentTime = System.currentTimeMillis(); + Long cached = cachedAvailableIdCount; + if (cached != null && (currentTime - lastPoolCheckTime) < POOL_CHECK_THROTTLE_SECONDS * 1000) { + return cached; + } + + long availableIds = countAvailableBeneficiaryIds(); + lastPoolCheckTime = currentTime; + cachedAvailableIdCount = availableIds; + return availableIds; + } + + private long countAvailableBeneficiaryIds() throws Exception { + try (Connection connection = dataSource.getConnection(); + PreparedStatement stmt = connection.prepareStatement(COUNT_AVAILABLE_IDS_SQL)) { + + stmt.setQueryTimeout((int) BENEFICIARY_POOL_TIMEOUT_SECONDS); + + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + throw new IllegalStateException("No result from beneficiary ID pool count query"); + } + return rs.getLong(1); + } + } + } + private void ensurePopulated(Map status, String componentName) { if (!status.containsKey(STATUS_KEY)) { status.put(STATUS_KEY, STATUS_DOWN); diff --git a/src/test/java/com/iemr/common/bengen/service/GenerateBeneficiaryServiceTest.java b/src/test/java/com/iemr/common/bengen/service/GenerateBeneficiaryServiceTest.java index 6d38253..df240a0 100644 --- a/src/test/java/com/iemr/common/bengen/service/GenerateBeneficiaryServiceTest.java +++ b/src/test/java/com/iemr/common/bengen/service/GenerateBeneficiaryServiceTest.java @@ -73,7 +73,7 @@ class GenerateBeneficiaryServiceTest { Path tempDir; private static final BigInteger MOCKED_BENEFICIARY_ID = new BigInteger("12345678901"); - private static final String EXPECTED_TABLE_NAME = "`db_identity`.`m_beneficiaryregidmapping`"; + private static final String EXPECTED_TABLE_NAME = "`m_beneficiaryregidmapping`"; private static final String EXPECTED_CREATOR = "admin-batch"; @BeforeEach From 1229a62a316d92776051dd8f84e32b36e0078f91 Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Fri, 21 Aug 2026 11:18:15 +0530 Subject: [PATCH 03/10] test: align VersionControllerTest with flat version response The controller returns a LinkedHashMap of buildTimestamp/version/branch/ commitHash, but the test still asserted the old OutputResponse wrapper. Assert the four fields against git.properties on the classpath instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../version/VersionControllerTest.java | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/test/java/com/iemr/common/bengen/controller/version/VersionControllerTest.java b/src/test/java/com/iemr/common/bengen/controller/version/VersionControllerTest.java index 9530a10..e7c4b33 100644 --- a/src/test/java/com/iemr/common/bengen/controller/version/VersionControllerTest.java +++ b/src/test/java/com/iemr/common/bengen/controller/version/VersionControllerTest.java @@ -34,15 +34,12 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.stream.Collectors; +import java.util.Properties; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.iemr.common.bengen.utils.response.OutputResponse; - @ExtendWith(MockitoExtension.class) @DisplayName("VersionController Test Suite") class VersionControllerTest { @@ -60,29 +57,22 @@ void setUp() { } @Test - @DisplayName("Should return git.properties content when successfully read from test resources") + @DisplayName("Should return version details sourced from git.properties on the classpath") void versionInformation_shouldReturnGitPropertiesContent() throws Exception { - String expectedGitPropertiesContent; + Properties gitProperties = new Properties(); try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream("git.properties")) { if (inputStream == null) { throw new IOException("git.properties file not found in test resources."); } - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - expectedGitPropertiesContent = reader.lines().collect(Collectors.joining("\n")); - if (!expectedGitPropertiesContent.endsWith("\n")) { - expectedGitPropertiesContent += "\n"; - } - } + gitProperties.load(inputStream); } - OutputResponse expectedOutput = new OutputResponse(); - expectedOutput.setResponse(expectedGitPropertiesContent); - - String expectedJsonResponse = expectedOutput.toString(); - mockMvc.perform(get("/version")) .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk()) - .andExpect(content().json(expectedJsonResponse)); + .andExpect(jsonPath("$.buildTimestamp").value(gitProperties.getProperty("git.build.time", "unknown"))) + .andExpect(jsonPath("$.version").value(gitProperties.getProperty("git.build.version", "unknown"))) + .andExpect(jsonPath("$.branch").value(gitProperties.getProperty("git.branch", "unknown"))) + .andExpect(jsonPath("$.commitHash").value(gitProperties.getProperty("git.commit.id.abbrev", "unknown"))); } } From 1280fb3b4cd86e91f97e562730d5079ea2a4040d Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Fri, 21 Aug 2026 11:21:39 +0530 Subject: [PATCH 04/10] test: cover HealthService and HealthController Adds unit tests for the health check aggregation (UP/DEGRADED/DOWN rollup, Redis PING handling, Redis-not-configured skip, beneficiary ID pool threshold warning and count throttling, executor lifecycle) and for the controller's 200-vs-503 mapping. Line coverage: 28.3% -> 43.1% Co-Authored-By: Claude Opus 5 (1M context) --- .../health/HealthControllerTest.java | 106 ++++++ .../service/health/HealthServiceTest.java | 306 ++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 src/test/java/com/iemr/common/bengen/controller/health/HealthControllerTest.java create mode 100644 src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java diff --git a/src/test/java/com/iemr/common/bengen/controller/health/HealthControllerTest.java b/src/test/java/com/iemr/common/bengen/controller/health/HealthControllerTest.java new file mode 100644 index 0000000..7e0c17f --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/controller/health/HealthControllerTest.java @@ -0,0 +1,106 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.controller.health; + +import java.util.LinkedHashMap; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.iemr.common.bengen.service.health.HealthService; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HealthController Test Suite") +class HealthControllerTest { + + @Mock + private HealthService healthService; + + private MockMvc mockMvc; + + @BeforeEach + @DisplayName("Set up standalone MockMvc before each test") + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new HealthController(healthService)).build(); + } + + private Map healthResponse(String overallStatus) { + Map response = new LinkedHashMap<>(); + response.put("status", overallStatus); + response.put("checkedAt", "2025-06-25T10:00:00Z"); + return response; + } + + @Test + @DisplayName("checkHealth should return 200 with the payload when all services are UP") + void checkHealth_shouldReturnOkWhenStatusIsUp() throws Exception { + when(healthService.checkHealth()).thenReturn(healthResponse("UP")); + + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.checkedAt").value("2025-06-25T10:00:00Z")); + } + + @Test + @DisplayName("checkHealth should return 200 when DEGRADED, since the instance is still operational") + void checkHealth_shouldReturnOkWhenStatusIsDegraded() throws Exception { + when(healthService.checkHealth()).thenReturn(healthResponse("DEGRADED")); + + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DEGRADED")); + } + + @Test + @DisplayName("checkHealth should return 503 when a critical service is DOWN") + void checkHealth_shouldReturnServiceUnavailableWhenStatusIsDown() throws Exception { + when(healthService.checkHealth()).thenReturn(healthResponse("DOWN")); + + mockMvc.perform(get("/health")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.status").value("DOWN")); + } + + @Test + @DisplayName("checkHealth should return 503 with a DOWN payload when the service throws unexpectedly") + void checkHealth_shouldReturnServiceUnavailableWhenServiceThrows() throws Exception { + when(healthService.checkHealth()).thenThrow(new IllegalStateException("unexpected failure")); + + mockMvc.perform(get("/health")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.status").value("DOWN")) + .andExpect(jsonPath("$.checkedAt").exists()); + } +} diff --git a/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java b/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java new file mode 100644 index 0000000..76e1de3 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java @@ -0,0 +1,306 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.service.health; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Map; +import java.util.concurrent.RejectedExecutionException; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HealthService Test Suite") +class HealthServiceTest { + + private static final int THRESHOLD = 5000; + + @Mock + private DataSource dataSource; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private Connection connection; + + @Mock + private PreparedStatement preparedStatement; + + @Mock + private ResultSet resultSet; + + private HealthService healthService; + + @BeforeEach + @DisplayName("Set up the service with mocked infrastructure before each test") + void setUp() { + healthService = new HealthService(dataSource, redisTemplate, THRESHOLD); + } + + /** + * Wires the JDBC mock chain so every query the service issues succeeds. + * getLong(1) backs the beneficiary pool COUNT, getInt(1) backs the advanced + * lock-wait and slow-query diagnostics. + */ + private void stubJdbc(long availableIds, int diagnosticCount) throws SQLException { + lenient().when(dataSource.getConnection()).thenReturn(connection); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().doNothing().when(preparedStatement).setQueryTimeout(anyInt()); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(availableIds); + lenient().when(resultSet.getInt(1)).thenReturn(diagnosticCount); + } + + private void stubRedisPong(String pong) { + lenient().when(redisTemplate.execute(any(RedisCallback.class))).thenReturn(pong); + } + + @SuppressWarnings("unchecked") + private Map component(Map response, String key) { + return (Map) response.get(key); + } + + @Nested + @DisplayName("Overall health aggregation") + class OverallStatusTests { + + @Test + @DisplayName("checkHealth should report UP when MySQL, Redis and the ID pool are all healthy") + void checkHealth_shouldReportUpWhenAllComponentsHealthy() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("UP", response.get("status")); + assertNotNull(response.get("checkedAt")); + assertEquals("UP", component(response, "mysql").get("status")); + assertEquals("OK", component(response, "mysql").get("severity")); + assertEquals("UP", component(response, "redis").get("status")); + assertEquals("OK", component(response, "redis").get("severity")); + assertEquals("UP", component(response, "beneficiaryIdPool").get("status")); + } + + @Test + @DisplayName("checkHealth should expose only status and severity for MySQL and Redis") + void checkHealth_shouldExposeOnlyStatusAndSeverityForMysqlAndRedis() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals(2, component(response, "mysql").size()); + assertEquals(2, component(response, "redis").size()); + assertFalse(component(response, "mysql").containsKey("responseTimeMs")); + assertFalse(component(response, "redis").containsKey("responseTimeMs")); + } + + @Test + @DisplayName("checkHealth should report DOWN when MySQL cannot be reached") + void checkHealth_shouldReportDownWhenMysqlUnreachable() throws SQLException { + lenient().when(dataSource.getConnection()).thenThrow(new SQLException("connection refused")); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("DOWN", response.get("status")); + assertEquals("DOWN", component(response, "mysql").get("status")); + assertEquals("CRITICAL", component(response, "mysql").get("severity")); + } + + @Test + @DisplayName("checkHealth should report DEGRADED when MySQL reports lock waits and slow queries") + void checkHealth_shouldReportDegradedWhenAdvancedChecksFlagIssues() throws SQLException { + stubJdbc(THRESHOLD * 2L, 25); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("DEGRADED", response.get("status")); + assertEquals("DEGRADED", component(response, "mysql").get("status")); + assertEquals("WARNING", component(response, "mysql").get("severity")); + } + } + + @Nested + @DisplayName("Redis health check") + class RedisHealthTests { + + @Test + @DisplayName("checkRedisHealth should report DOWN when PING does not answer PONG") + void checkHealth_shouldReportDownWhenRedisPingFails() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("nope"); + + Map response = healthService.checkHealth(); + + assertEquals("DOWN", response.get("status")); + assertEquals("DOWN", component(response, "redis").get("status")); + assertEquals("CRITICAL", component(response, "redis").get("severity")); + } + + @Test + @DisplayName("checkRedisHealth should report DOWN when the Redis call throws") + void checkHealth_shouldReportDownWhenRedisThrows() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + lenient().when(redisTemplate.execute(any(RedisCallback.class))) + .thenThrow(new IllegalStateException("redis unavailable")); + + Map response = healthService.checkHealth(); + + assertEquals("DOWN", response.get("status")); + assertEquals("DOWN", component(response, "redis").get("status")); + } + + @Test + @DisplayName("checkRedisHealth should report UP and skip the check when Redis is not configured") + void checkHealth_shouldSkipRedisWhenNotConfigured() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + HealthService serviceWithoutRedis = new HealthService(dataSource, null, THRESHOLD); + + Map response = serviceWithoutRedis.checkHealth(); + + assertEquals("UP", response.get("status")); + assertEquals("UP", component(response, "redis").get("status")); + verify(redisTemplate, never()).execute(any(RedisCallback.class)); + serviceWithoutRedis.shutdown(); + } + } + + @Nested + @DisplayName("Beneficiary ID pool check") + class BeneficiaryPoolTests { + + @Test + @DisplayName("checkBeneficiaryIdPool should report the available count and threshold when healthy") + void checkHealth_shouldReportPoolCountAndThreshold() throws SQLException { + stubJdbc(12345L, 0); + stubRedisPong("PONG"); + + Map pool = component(healthService.checkHealth(), "beneficiaryIdPool"); + + assertEquals("UP", pool.get("status")); + assertEquals("OK", pool.get("severity")); + assertEquals(12345L, pool.get("availableIds")); + assertEquals(THRESHOLD, pool.get("threshold")); + } + + @Test + @DisplayName("checkBeneficiaryIdPool should flag DEGRADED with a warning when the pool is below threshold") + void checkHealth_shouldFlagDegradedWhenPoolBelowThreshold() throws SQLException { + stubJdbc(10L, 0); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + Map pool = component(response, "beneficiaryIdPool"); + + assertEquals("DEGRADED", response.get("status")); + assertEquals("DEGRADED", pool.get("status")); + assertEquals("WARNING", pool.get("severity")); + assertEquals(10L, pool.get("availableIds")); + assertEquals("Available beneficiary ID pool is below the configured threshold", + pool.get("message")); + } + + @Test + @DisplayName("checkBeneficiaryIdPool should stay DEGRADED rather than DOWN when the count query yields no row") + void checkHealth_shouldStayDegradedWhenCountQueryReturnsNoRow() throws SQLException { + lenient().when(dataSource.getConnection()).thenReturn(connection); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(false); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + Map pool = component(response, "beneficiaryIdPool"); + + assertEquals("DEGRADED", pool.get("status")); + assertEquals("WARNING", pool.get("severity")); + assertEquals("Beneficiary ID pool count could not be determined", pool.get("error")); + } + + @Test + @DisplayName("countAvailableBeneficiaryIds should be throttled so repeated polls reuse the cached count") + void checkHealth_shouldThrottleRepeatedPoolCounts() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("PONG"); + + healthService.checkHealth(); + healthService.checkHealth(); + + verify(connection, times(1)).prepareStatement( + "SELECT COUNT(*) FROM m_beneficiaryregidmapping WHERE Provisioned = 0 AND Reserved = 0"); + } + } + + @Nested + @DisplayName("Lifecycle") + class ShutdownTests { + + @Test + @DisplayName("shutdown should stop the executor so no further checks are accepted") + void shutdown_shouldStopTheExecutor() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("PONG"); + healthService.checkHealth(); + + healthService.shutdown(); + + assertThrows(RejectedExecutionException.class, () -> healthService.checkHealth()); + } + + @Test + @DisplayName("shutdown should be safe to call twice") + void shutdown_shouldBeIdempotent() { + healthService.shutdown(); + + assertDoesNotThrow(() -> healthService.shutdown()); + } + } +} From e457b708753b0b7dddbb03732e2e0f8bd554b3ec Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Fri, 21 Aug 2026 11:28:03 +0530 Subject: [PATCH 05/10] test: cover JwtUserIdValidationFilter, HTTPRequestInterceptor, OutputResponse - JwtUserIdValidationFilter: origin allow-list and CORS preflight, public endpoint bypass, cookie/header token paths, mobile (okhttp, java/) client handling with User-Agent context cleanup, userId cookie expiry. - HTTPRequestInterceptor: Authorization skip, whitelisted APIs, forwarded client address preference, error payload and conditional CORS headers, postHandle session refresh and failure swallowing. - utils/response/OutputResponse: defaults, JSON object/array/plain payload handling, and the full throwable-to-status-code mapping. Line coverage: 43.1% -> 59.0% (146 tests) Co-Authored-By: Claude Opus 5 (1M context) --- .../utils/JwtUserIdValidationFilterTest.java | 446 ++++++++++++++++++ .../http/HTTPRequestInterceptorTest.java | 303 ++++++++++++ .../utils/response/OutputResponseTest.java | 248 ++++++++++ 3 files changed, 997 insertions(+) create mode 100644 src/test/java/com/iemr/common/bengen/utils/JwtUserIdValidationFilterTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/http/HTTPRequestInterceptorTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java diff --git a/src/test/java/com/iemr/common/bengen/utils/JwtUserIdValidationFilterTest.java b/src/test/java/com/iemr/common/bengen/utils/JwtUserIdValidationFilterTest.java new file mode 100644 index 0000000..a08a6a6 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/JwtUserIdValidationFilterTest.java @@ -0,0 +1,446 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import com.iemr.common.bengen.utils.http.AuthorizationHeaderRequestWrapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +import java.util.Arrays; + +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.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JwtUserIdValidationFilter Test Suite") +class JwtUserIdValidationFilterTest { + + private static final String ALLOWED_ORIGINS = "https://amrit.example.org,http://localhost:*"; + private static final String ALLOWED_ORIGIN = "https://amrit.example.org"; + private static final String DISALLOWED_ORIGIN = "https://evil.example.com"; + + @Mock + private JwtAuthenticationUtil jwtAuthenticationUtil; + + @Mock + private FilterChain filterChain; + + private JwtUserIdValidationFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + @DisplayName("Set up the filter with a configured allow-list before each test") + void setUp() { + filter = new JwtUserIdValidationFilter(jwtAuthenticationUtil, ALLOWED_ORIGINS); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Nested + @DisplayName("Origin validation and CORS") + class OriginValidationTests { + + @Test + @DisplayName("doFilter should reject an OPTIONS request that carries no Origin header") + void doFilter_shouldRejectOptionsWithoutOrigin() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + assertEquals("OPTIONS request requires Origin header", response.getErrorMessage()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should reject an OPTIONS request from an origin outside the allow-list") + void doFilter_shouldRejectOptionsFromDisallowedOrigin() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", DISALLOWED_ORIGIN); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + assertEquals("Origin not allowed", response.getErrorMessage()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should answer an allowed OPTIONS preflight with 200 and the CORS headers") + void doFilter_shouldAnswerAllowedPreflightWithCorsHeaders() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", ALLOWED_ORIGIN); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertEquals(ALLOWED_ORIGIN, response.getHeader("Access-Control-Allow-Origin")); + assertEquals("GET, POST, PUT, PATCH, DELETE, OPTIONS", + response.getHeader("Access-Control-Allow-Methods")); + assertEquals("true", response.getHeader("Access-Control-Allow-Credentials")); + assertEquals("3600", response.getHeader("Access-Control-Max-Age")); + assertNotNull(response.getHeader("Access-Control-Allow-Headers")); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should match a wildcard localhost origin pattern") + void doFilter_shouldMatchWildcardLocalhostOrigin() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", "http://localhost:4200"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertEquals("http://localhost:4200", response.getHeader("Access-Control-Allow-Origin")); + } + + @Test + @DisplayName("doFilter should reject a non-OPTIONS request from an origin outside the allow-list") + void doFilter_shouldRejectNonOptionsFromDisallowedOrigin() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", DISALLOWED_ORIGIN); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + assertEquals("Origin not allowed", response.getErrorMessage()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should treat every origin as disallowed when no allow-list is configured") + void doFilter_shouldRejectAllOriginsWhenAllowListIsBlank() throws Exception { + JwtUserIdValidationFilter unconfiguredFilter = + new JwtUserIdValidationFilter(jwtAuthenticationUtil, " "); + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", ALLOWED_ORIGIN); + + unconfiguredFilter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + } + + @Test + @DisplayName("doFilter should not add CORS headers when the request carries no Origin header") + void doFilter_shouldNotAddCorsHeadersWithoutOrigin() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/health"); + + filter.doFilter(request, response, filterChain); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + verify(filterChain).doFilter(request, response); + } + } + + @Nested + @DisplayName("Public endpoints that bypass token validation") + class PublicEndpointTests { + + @Test + @DisplayName("doFilter should pass /health straight through without validating a token") + void doFilter_shouldSkipValidationForHealth() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/health"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should pass /version straight through without validating a token") + void doFilter_shouldSkipValidationForVersion() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/version"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should pass the login endpoint straight through without validating a token") + void doFilter_shouldSkipValidationForUserAuthenticate() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/user/userAuthenticate"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should pass any /public path straight through without validating a token") + void doFilter_shouldSkipValidationForPublicPaths() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/public/anything"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should pass the concurrent-session logout endpoint straight through") + void doFilter_shouldSkipValidationForConcurrentSessionLogout() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/user/logOutUserFromConcurrentSession"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + } + + @Nested + @DisplayName("JWT token validation") + class TokenValidationTests { + + @Test + @DisplayName("doFilter should continue the chain when the cookie token is valid") + void doFilter_shouldContinueChainWhenCookieTokenIsValid() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("cookie-token")).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(AuthorizationHeaderRequestWrapper.class), any(ServletResponse.class)); + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + } + + @Test + @DisplayName("doFilter should reject with 401 when the cookie token is rejected") + void doFilter_shouldRejectWhenCookieTokenIsInvalid() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("cookie-token")).thenReturn(false); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertEquals("Unauthorized: Invalid or missing token", response.getErrorMessage()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should continue the chain when the header token is valid") + void doFilter_shouldContinueChainWhenHeaderTokenIsValid() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("JwtToken", "header-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("header-token")).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(AuthorizationHeaderRequestWrapper.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should reject with 401 when the header token is rejected") + void doFilter_shouldRejectWhenHeaderTokenIsInvalid() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("JwtToken", "header-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("header-token")).thenReturn(false); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should reject with 401 when no token is present at all") + void doFilter_shouldRejectWhenNoTokenPresent() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertEquals("Unauthorized: Invalid or missing token", response.getErrorMessage()); + } + + @Test + @DisplayName("doFilter should surface a 401 carrying the message when validation throws") + void doFilter_shouldRejectWithErrorMessageWhenValidationThrows() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("JwtToken", "header-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("header-token")) + .thenThrow(new IllegalStateException("token expired")); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertTrue(response.getErrorMessage().contains("token expired"), + "error message should carry the underlying cause"); + } + } + + @Nested + @DisplayName("Mobile client handling") + class MobileClientTests { + + @Test + @DisplayName("doFilter should let an okhttp client through on its Authorization header alone") + void doFilter_shouldAllowOkHttpClientWithAuthorizationHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "okhttp/4.9.0"); + request.addHeader("Authorization", "some-session-key"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should let a java/ client through on its Authorization header alone") + void doFilter_shouldAllowJavaClientWithAuthorizationHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "Java/17.0.2"); + request.addHeader("Authorization", "some-session-key"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("doFilter should clear the User-Agent context once the mobile request completes") + void doFilter_shouldClearUserAgentContextAfterMobileRequest() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "okhttp/4.9.0"); + request.addHeader("Authorization", "some-session-key"); + + filter.doFilter(request, response, filterChain); + + assertNull(UserAgentContext.getUserAgent(), + "the thread-local User-Agent must not leak past the request"); + } + + @Test + @DisplayName("doFilter should reject a browser client that has no token") + void doFilter_shouldRejectBrowserClientWithoutToken() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "Mozilla/5.0"); + request.addHeader("Authorization", "some-session-key"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should reject a mobile client that sends no Authorization header") + void doFilter_shouldRejectMobileClientWithoutAuthorizationHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "okhttp/4.9.0"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + } + } + + @Nested + @DisplayName("userId cookie hygiene") + class UserIdCookieTests { + + @Test + @DisplayName("doFilter should expire any userId cookie the client sends") + void doFilter_shouldExpireUserIdCookie() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/health"); + request.setCookies(new Cookie("userId", "1234")); + + filter.doFilter(request, response, filterChain); + + Cookie cleared = Arrays.stream(response.getCookies()) + .filter(cookie -> "userId".equals(cookie.getName())) + .findFirst() + .orElse(null); + assertNotNull(cleared, "a userId cookie should have been sent back to expire it"); + assertEquals(0, cleared.getMaxAge()); + assertEquals("/", cleared.getPath()); + assertTrue(cleared.isHttpOnly()); + assertTrue(cleared.getSecure()); + } + + @Test + @DisplayName("doFilter should leave unrelated cookies untouched") + void doFilter_shouldLeaveUnrelatedCookiesUntouched() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/health"); + request.setCookies(new Cookie("theme", "dark")); + + filter.doFilter(request, response, filterChain); + + assertEquals(0, response.getCookies().length); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/http/HTTPRequestInterceptorTest.java b/src/test/java/com/iemr/common/bengen/utils/http/HTTPRequestInterceptorTest.java new file mode 100644 index 0000000..5d22c96 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/http/HTTPRequestInterceptorTest.java @@ -0,0 +1,303 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.http; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.bengen.utils.exception.IEMRException; +import com.iemr.common.bengen.utils.sessionobject.SessionObject; +import com.iemr.common.bengen.utils.validator.Validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HTTPRequestInterceptor Test Suite") +class HTTPRequestInterceptorTest { + + private static final String ALLOWED_ORIGIN = "https://amrit.example.org"; + private static final String AUTHORIZATION = "session-key-123"; + + @Mock + private Validator validator; + + @Mock + private SessionObject sessionObject; + + private HTTPRequestInterceptor interceptor; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + @DisplayName("Set up the interceptor with mocked collaborators before each test") + void setUp() { + interceptor = new HTTPRequestInterceptor(); + interceptor.setValidator(validator); + interceptor.setSessionObject(sessionObject); + ReflectionTestUtils.setField(interceptor, "allowedOrigins", ALLOWED_ORIGIN + ",http://localhost:*"); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Nested + @DisplayName("preHandle authorization") + class PreHandleTests { + + @Test + @DisplayName("preHandle should proceed without validation when no Authorization header is present") + void preHandle_shouldProceedWhenAuthorizationHeaderIsMissing() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + + assertTrue(interceptor.preHandle(request, response, new Object())); + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + + @Test + @DisplayName("preHandle should proceed without validation when the Authorization header is empty") + void preHandle_shouldProceedWhenAuthorizationHeaderIsEmpty() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", ""); + + assertTrue(interceptor.preHandle(request, response, new Object())); + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + + @Test + @DisplayName("preHandle should skip validation entirely for OPTIONS requests") + void preHandle_shouldSkipValidationForOptions() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + + assertTrue(interceptor.preHandle(request, response, new Object())); + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + + @Test + @DisplayName("preHandle should validate the session key against the client address for a guarded API") + void preHandle_shouldValidateSessionKeyForGuardedApi() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.setRemoteAddr("10.1.2.3"); + + assertTrue(interceptor.preHandle(request, response, new Object())); + verify(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + } + + @Test + @DisplayName("preHandle should prefer the X-FORWARDED-FOR address over the socket address") + void preHandle_shouldPreferForwardedForAddress() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("X-FORWARDED-FOR", "203.0.113.9"); + request.setRemoteAddr("10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + verify(validator).checkKeyExists(AUTHORIZATION, "203.0.113.9"); + } + + @Test + @DisplayName("preHandle should fall back to the socket address when X-FORWARDED-FOR is blank") + void preHandle_shouldFallBackWhenForwardedForIsBlank() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("X-FORWARDED-FOR", " "); + request.setRemoteAddr("10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + verify(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + } + + @Test + @DisplayName("preHandle should let whitelisted endpoints through without a session-key check") + void preHandle_shouldBypassValidationForWhitelistedEndpoints() throws Exception { + for (String api : new String[] { "userAuthenticate", "userAuthenticateNew", "userAuthenticateV1", + "forgetPassword", "setForgetPassword", "changePassword", "saveUserSecurityQuesAns", + "swagger-ui.html", "ui", "swagger-resources", "api-docs", "version" }) { + MockHttpServletRequest whitelisted = new MockHttpServletRequest(); + whitelisted.setMethod("POST"); + whitelisted.setRequestURI("/user/" + api); + whitelisted.addHeader("Authorization", AUTHORIZATION); + + assertTrue(interceptor.preHandle(whitelisted, new MockHttpServletResponse(), new Object()), + api + " should be allowed through"); + } + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + + @Test + @DisplayName("preHandle should halt the request for the error endpoint") + void preHandle_shouldHaltForErrorEndpoint() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/error"); + request.addHeader("Authorization", AUTHORIZATION); + + assertFalse(interceptor.preHandle(request, response, new Object())); + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + } + + @Nested + @DisplayName("preHandle failure handling") + class PreHandleFailureTests { + + @Test + @DisplayName("preHandle should halt and write the error payload when the session key is rejected") + void preHandle_shouldHaltAndWriteErrorWhenSessionKeyRejected() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.setRemoteAddr("10.1.2.3"); + doThrow(new IEMRException("Invalid session key")) + .when(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + + assertFalse(interceptor.preHandle(request, response, new Object())); + assertTrue(response.getContentAsString().contains("Invalid session key")); + assertEquals("application/json", response.getContentType()); + } + + @Test + @DisplayName("preHandle should echo CORS headers on the error response for an allowed origin") + void preHandle_shouldAddCorsHeadersOnErrorForAllowedOrigin() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("Origin", ALLOWED_ORIGIN); + request.setRemoteAddr("10.1.2.3"); + doThrow(new IEMRException("Invalid session key")) + .when(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + assertEquals(ALLOWED_ORIGIN, response.getHeader("Access-Control-Allow-Origin")); + assertEquals("true", response.getHeader("Access-Control-Allow-Credentials")); + } + + @Test + @DisplayName("preHandle should withhold CORS headers on the error response for an unauthorized origin") + void preHandle_shouldWithholdCorsHeadersOnErrorForUnauthorizedOrigin() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("Origin", "https://evil.example.com"); + request.setRemoteAddr("10.1.2.3"); + doThrow(new IEMRException("Invalid session key")) + .when(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + } + + @Test + @DisplayName("preHandle should withhold CORS headers when no origin allow-list is configured") + void preHandle_shouldWithholdCorsHeadersWhenAllowListIsBlank() throws Exception { + ReflectionTestUtils.setField(interceptor, "allowedOrigins", ""); + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("Origin", ALLOWED_ORIGIN); + request.setRemoteAddr("10.1.2.3"); + doThrow(new IEMRException("Invalid session key")) + .when(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + } + } + + @Nested + @DisplayName("postHandle and afterCompletion") + class PostHandleTests { + + @Test + @DisplayName("postHandle should refresh the session object when an Authorization header is present") + void postHandle_shouldRefreshSessionObject() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + when(sessionObject.getSessionObject(AUTHORIZATION)).thenReturn("session-payload"); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject).updateSessionObject(AUTHORIZATION, "session-payload"); + } + + @Test + @DisplayName("postHandle should do nothing when no Authorization header is present") + void postHandle_shouldDoNothingWithoutAuthorizationHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject, never()).updateSessionObject(anyString(), anyString()); + } + + @Test + @DisplayName("postHandle should swallow a session store failure so the response still completes") + void postHandle_shouldSwallowSessionStoreFailure() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + when(sessionObject.getSessionObject(AUTHORIZATION)) + .thenThrow(new IllegalStateException("redis down")); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject, never()).updateSessionObject(anyString(), anyString()); + } + + @Test + @DisplayName("afterCompletion should complete without touching the response") + void afterCompletion_shouldLeaveResponseUntouched() throws Exception { + interceptor.afterCompletion(request, response, new Object(), null); + + assertEquals(200, response.getStatus()); + assertEquals("", response.getContentAsString()); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java b/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java new file mode 100644 index 0000000..9718686 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java @@ -0,0 +1,248 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.response; + +import java.io.IOException; +import java.net.ConnectException; +import java.sql.SQLException; +import java.text.ParseException; + +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.iemr.common.bengen.utils.exception.IEMRException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("OutputResponse (response package) Test Suite") +class OutputResponseTest { + + private OutputResponse outputResponse; + + @BeforeEach + @DisplayName("Create a fresh response object before each test") + void setUp() { + outputResponse = new OutputResponse(); + } + + @Nested + @DisplayName("Default state") + class DefaultStateTests { + + @Test + @DisplayName("a new response should default to a generic failure") + void newResponse_shouldDefaultToGenericFailure() { + assertEquals(OutputResponse.GENERIC_FAILURE, outputResponse.getStatusCode()); + assertEquals("Failed with generic error", outputResponse.getErrorMessage()); + assertEquals("FAILURE", outputResponse.getStatus()); + assertFalse(outputResponse.isSuccess()); + } + + @Test + @DisplayName("getData should return null when no data has been set") + void getData_shouldReturnNullWhenNoDataSet() { + assertNull(outputResponse.getData()); + } + } + + @Nested + @DisplayName("setResponse") + class SetResponseTests { + + @Test + @DisplayName("setResponse should mark the response successful") + void setResponse_shouldMarkResponseSuccessful() { + outputResponse.setResponse("done"); + + assertEquals(OutputResponse.SUCCESS, outputResponse.getStatusCode()); + assertEquals("Success", outputResponse.getErrorMessage()); + assertEquals("Success", outputResponse.getStatus()); + assertTrue(outputResponse.isSuccess()); + } + + @Test + @DisplayName("setResponse should keep a JSON object payload as an object") + void setResponse_shouldKeepJsonObjectPayload() { + outputResponse.setResponse("{\"beneficiaryId\":12345}"); + + assertTrue(outputResponse.getData().contains("\"beneficiaryId\"")); + assertTrue(outputResponse.getData().startsWith("{")); + } + + @Test + @DisplayName("setResponse should keep a JSON array payload as an array") + void setResponse_shouldKeepJsonArrayPayload() { + outputResponse.setResponse("[1,2,3]"); + + assertTrue(outputResponse.getData().startsWith("[")); + assertTrue(outputResponse.getData().contains("1")); + } + + @Test + @DisplayName("setResponse should wrap a plain string payload under a response key") + void setResponse_shouldWrapPlainStringPayload() { + outputResponse.setResponse("plain text"); + + assertTrue(outputResponse.getData().contains("response")); + assertTrue(outputResponse.getData().contains("plain text")); + } + + @Test + @DisplayName("toString should serialise the exposed fields as JSON") + void toString_shouldSerialiseExposedFields() { + outputResponse.setResponse("done"); + + String json = outputResponse.toString(); + + assertTrue(json.contains("\"statusCode\":200")); + assertTrue(json.contains("\"status\":\"Success\"")); + assertTrue(json.contains("\"errorMessage\":\"Success\"")); + assertTrue(json.contains("\"data\"")); + } + + @Test + @DisplayName("toString should omit null fields while toStringWithSerialization keeps them") + void toString_shouldOmitNullsUnlikeToStringWithSerialization() { + assertFalse(outputResponse.toString().contains("\"data\"")); + assertTrue(outputResponse.toStringWithSerialization().contains("\"data\":null")); + } + } + + @Nested + @DisplayName("setError with an explicit code") + class SetErrorWithCodeTests { + + @Test + @DisplayName("setError should apply the supplied code, message and status") + void setError_shouldApplySuppliedCodeMessageAndStatus() { + outputResponse.setError(OutputResponse.PREVILAGE_FAILURE, "not permitted", "PRIVILEGE"); + + assertEquals(OutputResponse.PREVILAGE_FAILURE, outputResponse.getStatusCode()); + assertEquals("not permitted", outputResponse.getErrorMessage()); + assertEquals("PRIVILEGE", outputResponse.getStatus()); + assertFalse(outputResponse.isSuccess()); + } + + @Test + @DisplayName("setError should reuse the message as the status when only a message is supplied") + void setError_shouldReuseMessageAsStatus() { + outputResponse.setError(OutputResponse.PASSWORD_FAILURE, "bad password"); + + assertEquals(OutputResponse.PASSWORD_FAILURE, outputResponse.getStatusCode()); + assertEquals("bad password", outputResponse.getErrorMessage()); + assertEquals("bad password", outputResponse.getStatus()); + } + } + + @Nested + @DisplayName("setError mapped from a throwable") + class SetErrorFromThrowableTests { + + @Test + @DisplayName("setError should map IEMRException to a user login failure") + void setError_shouldMapIemrExceptionToUserIdFailure() { + outputResponse.setError(new IEMRException("invalid credentials")); + + assertEquals(OutputResponse.USERID_FAILURE, outputResponse.getStatusCode()); + assertEquals("User login failed", outputResponse.getStatus()); + assertEquals("invalid credentials", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map JSONException to an object conversion failure") + void setError_shouldMapJsonExceptionToObjectFailure() { + outputResponse.setError(new JSONException("bad json")); + + assertEquals(OutputResponse.OBJECT_FAILURE, outputResponse.getStatusCode()); + assertEquals("Invalid object conversion", outputResponse.getStatus()); + assertEquals("Invalid object conversion", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map SQLException to a code exception") + void setError_shouldMapSqlExceptionToCodeException() { + outputResponse.setError(new SQLException("deadlock")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + assertTrue(outputResponse.getStatus().startsWith("Failed with critical errors at ")); + assertEquals("deadlock", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map NullPointerException to a code exception") + void setError_shouldMapNullPointerExceptionToCodeException() { + outputResponse.setError(new NullPointerException("npe")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should map ParseException to a code exception") + void setError_shouldMapParseExceptionToCodeException() { + outputResponse.setError(new ParseException("bad date", 0)); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should map ArrayIndexOutOfBoundsException to a code exception") + void setError_shouldMapArrayIndexExceptionToCodeException() { + outputResponse.setError(new ArrayIndexOutOfBoundsException("index 5")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should map IOException to an environment exception") + void setError_shouldMapIoExceptionToEnvironmentException() { + outputResponse.setError(new IOException("disk full")); + + assertEquals(OutputResponse.ENVIRONMENT_EXCEPTION, outputResponse.getStatusCode()); + assertTrue(outputResponse.getStatus().startsWith("Failed with connection issues at ")); + assertEquals("disk full", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map ConnectException to an environment exception") + void setError_shouldMapConnectExceptionToEnvironmentException() { + outputResponse.setError(new ConnectException("refused")); + + assertEquals(OutputResponse.ENVIRONMENT_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should fall back to a generic failure for an unmapped exception") + void setError_shouldFallBackToGenericFailureForUnmappedException() { + outputResponse.setError(new IllegalStateException("something odd")); + + assertEquals(OutputResponse.GENERIC_FAILURE, outputResponse.getStatusCode()); + assertTrue(outputResponse.getStatus().startsWith("Failed with something odd at ")); + assertEquals("something odd", outputResponse.getErrorMessage()); + } + } +} From 90454b45bf4698b63eedffa7c6e3298b5e1fd031 Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Fri, 21 Aug 2026 11:34:17 +0530 Subject: [PATCH 06/10] test: cover JwtAuthenticationUtil, ConfigProperties, HttpUtils, OutputResponse.Builder - JwtAuthenticationUtil: cookie token validation responses, Redis cache hit, DB fallback with 30-minute re-cache, and the rejection paths for invalid tokens, unknown users, non-numeric ids and a Redis outage. - ConfigProperties: typed property accessors and their non-numeric fallbacks, session/redis accessors, plain and Base64-tagged passwords. - HttpUtils: GET/POST bodies and status recording, Authorization and Content-Type header forwarding, default JSON content type. - utils/OutputResponse.Builder: fluent assembly, chaining, JSON shape and the throwable-to-status-code mapping. Line coverage: 59.0% -> 68.7% (199 tests) Co-Authored-By: Claude Opus 5 (1M context) --- .../utils/JwtAuthenticationUtilTest.java | 253 ++++++++++++++++++ .../utils/OutputResponseBuilderTest.java | 209 +++++++++++++++ .../utils/config/ConfigPropertiesTest.java | 169 ++++++++++++ .../bengen/utils/http/HttpUtilsTest.java | 243 +++++++++++++++++ 4 files changed, 874 insertions(+) create mode 100644 src/test/java/com/iemr/common/bengen/utils/JwtAuthenticationUtilTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/config/ConfigPropertiesTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/http/HttpUtilsTest.java diff --git a/src/test/java/com/iemr/common/bengen/utils/JwtAuthenticationUtilTest.java b/src/test/java/com/iemr/common/bengen/utils/JwtAuthenticationUtilTest.java new file mode 100644 index 0000000..b3c8ffc --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/JwtAuthenticationUtilTest.java @@ -0,0 +1,253 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.bengen.data.user.User; +import com.iemr.common.bengen.repo.UserLoginRepo; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JwtAuthenticationUtil Test Suite") +class JwtAuthenticationUtilTest { + + private static final String JWT_TOKEN = "a.jwt.token"; + private static final String USER_ID = "42"; + private static final String REDIS_KEY = "user_42"; + + @Mock + private CookieUtil cookieUtil; + + @Mock + private JwtUtil jwtUtil; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private UserLoginRepo userLoginRepo; + + @Mock + private Claims claims; + + @Mock + private HttpServletRequest request; + + private JwtAuthenticationUtil jwtAuthenticationUtil; + + @BeforeEach + @DisplayName("Wire the util with mocked cookie, JWT, Redis and repository collaborators") + void setUp() { + jwtAuthenticationUtil = new JwtAuthenticationUtil(cookieUtil, jwtUtil); + ReflectionTestUtils.setField(jwtAuthenticationUtil, "redisTemplate", redisTemplate); + ReflectionTestUtils.setField(jwtAuthenticationUtil, "userLoginRepo", userLoginRepo); + } + + private User user(long id, String name) { + User user = new User(); + user.setUserID(id); + user.setUserName(name); + return user; + } + + @Nested + @DisplayName("validateJwtToken from the request cookie") + class ValidateJwtTokenTests { + + @Test + @DisplayName("validateJwtToken should return 401 when the Jwttoken cookie is absent") + void validateJwtToken_shouldReturnUnauthorizedWhenCookieMissing() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.empty()); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + assertEquals("Error 401: Unauthorized - JWT Token is not set!", result.getBody()); + verify(jwtUtil, never()).validateToken(anyString()); + } + + @Test + @DisplayName("validateJwtToken should return 401 when the token cannot be validated") + void validateJwtToken_shouldReturnUnauthorizedWhenTokenInvalid() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.of(JWT_TOKEN)); + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(null); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + assertEquals("Error 401: Unauthorized - Invalid JWT Token!", result.getBody()); + } + + @Test + @DisplayName("validateJwtToken should return 401 when the token carries no subject") + void validateJwtToken_shouldReturnUnauthorizedWhenSubjectMissing() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.of(JWT_TOKEN)); + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.getSubject()).thenReturn(null); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + assertEquals("Error 401: Unauthorized - Username is missing!", result.getBody()); + } + + @Test + @DisplayName("validateJwtToken should return 401 when the subject is blank") + void validateJwtToken_shouldReturnUnauthorizedWhenSubjectIsBlank() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.of(JWT_TOKEN)); + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.getSubject()).thenReturn(""); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + } + + @Test + @DisplayName("validateJwtToken should return 200 with the username for a valid token") + void validateJwtToken_shouldReturnUsernameForValidToken() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.of(JWT_TOKEN)); + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.getSubject()).thenReturn("amrit-user"); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("amrit-user", result.getBody()); + } + } + + @Nested + @DisplayName("validateUserIdAndJwtToken") + class ValidateUserIdAndJwtTokenTests { + + @Test + @DisplayName("validateUserIdAndJwtToken should accept a token whose user is already cached in Redis") + void validateUserIdAndJwtToken_shouldAcceptUserFromRedisCache() throws Exception { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(USER_ID); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(REDIS_KEY)).thenReturn(user(42L, "amrit-user")); + + assertTrue(jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + verify(userLoginRepo, never()).getUserByUserID(anyLong()); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should fall back to the database and cache the user on a Redis miss") + void validateUserIdAndJwtToken_shouldFallBackToDatabaseAndCacheUser() throws Exception { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(USER_ID); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(REDIS_KEY)).thenReturn(null); + when(userLoginRepo.getUserByUserID(42L)).thenReturn(user(42L, "amrit-user")); + + assertTrue(jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + verify(valueOperations).set(eq(REDIS_KEY), any(User.class), eq(30L), eq(TimeUnit.MINUTES)); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should reject a token that cannot be validated") + void validateUserIdAndJwtToken_shouldRejectInvalidToken() { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(null); + + Exception thrown = assertThrows(Exception.class, + () -> jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + + assertTrue(thrown.getMessage().contains("Invalid JWT token.")); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should reject when the user exists in neither Redis nor the database") + void validateUserIdAndJwtToken_shouldRejectUnknownUser() { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(USER_ID); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(REDIS_KEY)).thenReturn(null); + when(userLoginRepo.getUserByUserID(42L)).thenReturn(null); + + Exception thrown = assertThrows(Exception.class, + () -> jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + + assertTrue(thrown.getMessage().contains("Invalid User ID.")); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should reject a non-numeric userId claim") + void validateUserIdAndJwtToken_shouldRejectNonNumericUserId() { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn("not-a-number"); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(valueOperations.get("user_not-a-number")).thenReturn(null); + + Exception thrown = assertThrows(Exception.class, + () -> jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + + assertTrue(thrown.getMessage().startsWith("Validation error: ")); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should wrap a Redis outage as a validation error") + void validateUserIdAndJwtToken_shouldWrapRedisOutage() { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(USER_ID); + when(redisTemplate.opsForValue()).thenThrow(new IllegalStateException("redis down")); + + Exception thrown = assertThrows(Exception.class, + () -> jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + + assertTrue(thrown.getMessage().contains("redis down")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java b/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java new file mode 100644 index 0000000..42ebb58 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java @@ -0,0 +1,209 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import java.io.IOException; +import java.net.ConnectException; +import java.sql.SQLException; +import java.text.ParseException; + +import org.json.JSONException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; +import com.iemr.common.bengen.utils.exception.IEMRException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("OutputResponse.Builder Test Suite") +class OutputResponseBuilderTest { + + private JsonObject build(OutputResponse.Builder builder) { + return builder.build().getResponse().getAsJsonObject(); + } + + @Nested + @DisplayName("Fluent field assembly") + class FieldAssemblyTests { + + @Test + @DisplayName("build should default every field to an empty value") + void build_shouldDefaultEveryFieldToEmptyValue() { + JsonObject response = build(new OutputResponse.Builder()); + + assertEquals("", response.get("methodName").getAsString()); + assertEquals("", response.get("dataObjectType").getAsString()); + assertEquals("", response.get("dataJsonType").getAsString()); + assertEquals("", response.get("data").getAsString()); + assertEquals(0, response.get("statusCode").getAsInt()); + assertEquals("", response.get("statusMessage").getAsString()); + assertEquals("", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("build should carry every explicitly set field into the response") + void build_shouldCarryEverySetFieldIntoResponse() { + JsonObject response = build(new OutputResponse.Builder() + .setMethodName("generateBeneficiaryIDs") + .setDataObjectType("BeneficiaryId") + .setDataJsonType("array") + .setData("[1,2,3]") + .setStatusCode(OutputResponse.Builder.SUCCESS) + .setStatusMessage("Success") + .setStatusMessageLong("Generated successfully")); + + assertEquals("generateBeneficiaryIDs", response.get("methodName").getAsString()); + assertEquals("BeneficiaryId", response.get("dataObjectType").getAsString()); + assertEquals("array", response.get("dataJsonType").getAsString()); + assertEquals("[1,2,3]", response.get("data").getAsString()); + assertEquals(200, response.get("statusCode").getAsInt()); + assertEquals("Success", response.get("statusMessage").getAsString()); + assertEquals("Generated successfully", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("each setter should return the same builder so calls can be chained") + void setters_shouldReturnSameBuilderForChaining() { + OutputResponse.Builder builder = new OutputResponse.Builder(); + + assertEquals(builder, builder.setMethodName("m")); + assertEquals(builder, builder.setDataObjectType("o")); + assertEquals(builder, builder.setDataJsonType("object")); + assertEquals(builder, builder.setData("{}")); + assertEquals(builder, builder.setStatusCode(200)); + assertEquals(builder, builder.setStatusMessage("s")); + assertEquals(builder, builder.setStatusMessageLong("l")); + assertEquals(builder, builder.setErrorMessage(new IEMRException("x"))); + } + + @Test + @DisplayName("toString should serialise only the exposed response field") + void toString_shouldSerialiseOnlyExposedResponseField() { + String json = new OutputResponse.Builder() + .setMethodName("generateBeneficiaryIDs") + .setStatusCode(OutputResponse.Builder.SUCCESS) + .build() + .toString(); + + assertTrue(json.startsWith("{\"response\":")); + assertTrue(json.contains("generateBeneficiaryIDs")); + assertTrue(json.contains("\"statusCode\":200")); + } + } + + @Nested + @DisplayName("setErrorMessage mapped from a throwable") + class ErrorMappingTests { + + @Test + @DisplayName("setErrorMessage should map IEMRException to a user login failure") + void setErrorMessage_shouldMapIemrExceptionToUserIdFailure() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new IEMRException("invalid credentials"))); + + assertEquals(OutputResponse.Builder.USERID_FAILURE, response.get("statusCode").getAsInt()); + assertEquals("User login failed", response.get("statusMessage").getAsString()); + assertEquals("invalid credentials", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("setErrorMessage should map JSONException to an object conversion failure") + void setErrorMessage_shouldMapJsonExceptionToObjectFailure() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new JSONException("bad json"))); + + assertEquals(OutputResponse.Builder.OBJECT_FAILURE, response.get("statusCode").getAsInt()); + assertEquals("Invalid object conversion", response.get("statusMessage").getAsString()); + } + + @Test + @DisplayName("setErrorMessage should map SQLException to a code exception") + void setErrorMessage_shouldMapSqlExceptionToCodeException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new SQLException("deadlock"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + assertTrue(response.get("statusMessage").getAsString().startsWith("Failed with critical errors at ")); + assertEquals("deadlock", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("setErrorMessage should map ParseException to a code exception") + void setErrorMessage_shouldMapParseExceptionToCodeException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new ParseException("bad date", 0))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should map NullPointerException to a code exception") + void setErrorMessage_shouldMapNullPointerExceptionToCodeException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new NullPointerException("npe"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should map ArrayIndexOutOfBoundsException to a code exception") + void setErrorMessage_shouldMapArrayIndexExceptionToCodeException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new ArrayIndexOutOfBoundsException("index 5"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should map IOException to an environment exception") + void setErrorMessage_shouldMapIoExceptionToEnvironmentException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new IOException("disk full"))); + + assertEquals(OutputResponse.Builder.ENVIRONMENT_EXCEPTION, response.get("statusCode").getAsInt()); + assertTrue(response.get("statusMessage").getAsString().startsWith("Failed with connection issues at ")); + } + + @Test + @DisplayName("setErrorMessage should map ConnectException to an environment exception") + void setErrorMessage_shouldMapConnectExceptionToEnvironmentException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new ConnectException("refused"))); + + assertEquals(OutputResponse.Builder.ENVIRONMENT_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should fall back to a generic failure for an unmapped exception") + void setErrorMessage_shouldFallBackToGenericFailureForUnmappedException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new IllegalStateException("something odd"))); + + assertEquals(OutputResponse.Builder.GENERIC_FAILURE, response.get("statusCode").getAsInt()); + assertEquals("Failed with generic exception", response.get("statusMessage").getAsString()); + assertEquals("something odd", response.get("statusMessageLong").getAsString()); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/config/ConfigPropertiesTest.java b/src/test/java/com/iemr/common/bengen/utils/config/ConfigPropertiesTest.java new file mode 100644 index 0000000..90aa398 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/config/ConfigPropertiesTest.java @@ -0,0 +1,169 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.config; + +import java.util.Base64; +import java.util.Properties; + +import org.junit.jupiter.api.AfterEach; +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.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("ConfigProperties Test Suite") +class ConfigPropertiesTest { + + private Properties originalProperties; + + @BeforeEach + @DisplayName("Instantiate the holder so application.properties is loaded, keeping the original statics") + void setUp() { + new ConfigProperties(); + originalProperties = (Properties) ReflectionTestUtils.getField(ConfigProperties.class, "properties"); + } + + @AfterEach + @DisplayName("Restore the shared static properties after each test") + void tearDown() { + ReflectionTestUtils.setField(ConfigProperties.class, "properties", originalProperties); + } + + @Nested + @DisplayName("Reading values from application.properties") + class PropertyLookupTests { + + @Test + @DisplayName("getPropertyByName should return the configured value for a known key") + void getPropertyByName_shouldReturnConfiguredValue() { + assertEquals("6379", ConfigProperties.getPropertyByName("spring.redis.port")); + } + + @Test + @DisplayName("getPropertyByName should return null for a key that is not configured") + void getPropertyByName_shouldReturnNullForUnknownKey() { + assertNull(ConfigProperties.getPropertyByName("no.such.key.configured")); + } + + @Test + @DisplayName("getBoolean should parse a boolean property") + void getBoolean_shouldParseBooleanProperty() { + assertTrue(ConfigProperties.getBoolean("iemr.extend.expiry.time")); + } + + @Test + @DisplayName("getBoolean should return false for a value that is not a boolean") + void getBoolean_shouldReturnFalseForNonBooleanValue() { + assertEquals(false, ConfigProperties.getBoolean("spring.redis.port")); + } + + @Test + @DisplayName("getInteger should parse an integer property") + void getInteger_shouldParseIntegerProperty() { + assertEquals(7200, ConfigProperties.getInteger("iemr.session.expiry.time")); + } + + @Test + @DisplayName("getInteger should fall back to zero when the value is not a number") + void getInteger_shouldFallBackToZeroForNonNumericValue() { + assertEquals(0, ConfigProperties.getInteger("spring.session.store-type")); + } + + @Test + @DisplayName("getLong should parse a long property") + void getLong_shouldParseLongProperty() { + assertEquals(7200L, ConfigProperties.getLong("iemr.session.expiry.time")); + } + + @Test + @DisplayName("getLong should fall back to zero when the value is not a number") + void getLong_shouldFallBackToZeroForNonNumericValue() { + assertEquals(0L, ConfigProperties.getLong("spring.session.store-type")); + } + + @Test + @DisplayName("getFloat should parse a numeric property") + void getFloat_shouldParseNumericProperty() { + assertEquals(6379F, ConfigProperties.getFloat("spring.redis.port")); + } + + @Test + @DisplayName("getFloat should fall back to zero when the value is not a number") + void getFloat_shouldFallBackToZeroForNonNumericValue() { + assertEquals(0F, ConfigProperties.getFloat("spring.session.store-type")); + } + } + + @Nested + @DisplayName("Session and Redis accessors") + class AccessorTests { + + @Test + @DisplayName("getSessionExpiryTime should resolve the configured session expiry") + void getSessionExpiryTime_shouldResolveConfiguredExpiry() { + assertEquals(7200, ConfigProperties.getSessionExpiryTime()); + } + + @Test + @DisplayName("getRedisPort should fall back to zero when no iemr.redis.port is configured") + void getRedisPort_shouldFallBackToZeroWhenUnconfigured() { + assertEquals(0, ConfigProperties.getRedisPort()); + } + + @Test + @DisplayName("getRedisUrl should return null when no iemr.redis.url is configured") + void getRedisUrl_shouldReturnNullWhenUnconfigured() { + assertNull(ConfigProperties.getRedisUrl()); + } + } + + @Nested + @DisplayName("Password handling") + class PasswordTests { + + @Test + @DisplayName("getPassword should return a plain-text password unchanged") + void getPassword_shouldReturnPlainTextUnchanged() { + Properties stub = new Properties(); + stub.setProperty("db.password", "plainSecret"); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", stub); + + assertEquals("plainSecret", ConfigProperties.getPassword("db.password")); + } + + @Test + @DisplayName("getPassword should Base64-decode a password tagged with the 0X10 prefix") + void getPassword_shouldBase64DecodeTaggedPassword() { + String encoded = Base64.getEncoder().encodeToString("s3cr3t".getBytes()); + Properties stub = new Properties(); + stub.setProperty("db.password", "0X10:" + encoded); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", stub); + + assertEquals("s3cr3t", ConfigProperties.getPassword("db.password")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/http/HttpUtilsTest.java b/src/test/java/com/iemr/common/bengen/utils/http/HttpUtilsTest.java new file mode 100644 index 0000000..4db5b88 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/http/HttpUtilsTest.java @@ -0,0 +1,243 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.http; + +import java.util.HashMap; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HttpUtils Test Suite") +class HttpUtilsTest { + + private static final String URI = "http://localhost:8080/api/resource"; + + @Mock + private RestTemplate restTemplate; + + private HttpUtils httpUtils; + + @BeforeEach + @DisplayName("Replace the internal RestTemplate with a mock before each test") + void setUp() { + httpUtils = new HttpUtils(); + ReflectionTestUtils.setField(httpUtils, "rest", restTemplate); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> captureRequest(HttpMethod method, ResponseEntity reply) { + ArgumentCaptor> captor = ArgumentCaptor.forClass(HttpEntity.class); + when(restTemplate.exchange(eq(URI), eq(method), captor.capture(), eq(String.class))).thenReturn(reply); + return captor; + } + + @Nested + @DisplayName("GET requests") + class GetTests + + { + @Test + @DisplayName("get should return the response body and record the status") + void get_shouldReturnBodyAndRecordStatus() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>("{\"ok\":true}", HttpStatus.OK)); + + assertEquals("{\"ok\":true}", httpUtils.get(URI)); + assertEquals(HttpStatus.OK, httpUtils.getStatus()); + } + + @Test + @DisplayName("get should send the default JSON content type") + void get_shouldSendDefaultJsonContentType() { + ArgumentCaptor> captor = + captureRequest(HttpMethod.GET, new ResponseEntity<>("body", HttpStatus.OK)); + + httpUtils.get(URI); + + assertEquals("application/json", + captor.getValue().getHeaders().getFirst("Content-Type")); + } + + @Test + @DisplayName("get should record a non-OK status returned by the server") + void get_shouldRecordNonOkStatus() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>(null, HttpStatus.NOT_FOUND)); + + assertNull(httpUtils.get(URI)); + assertEquals(HttpStatus.NOT_FOUND, httpUtils.getStatus()); + } + + @Test + @DisplayName("get with headers should forward the supplied Authorization header") + void get_shouldForwardSuppliedAuthorizationHeader() { + HashMap header = new HashMap<>(); + header.put(HttpHeaders.AUTHORIZATION, "session-key-123"); + ArgumentCaptor> captor = + captureRequest(HttpMethod.GET, new ResponseEntity<>("body", HttpStatus.OK)); + + assertEquals("body", httpUtils.get(URI, header)); + assertEquals("session-key-123", + captor.getValue().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + @DisplayName("get with headers should forward an explicit Content-Type") + void get_shouldForwardExplicitContentType() { + HashMap header = new HashMap<>(); + header.put(HttpHeaders.CONTENT_TYPE, "application/xml"); + ArgumentCaptor> captor = + captureRequest(HttpMethod.GET, new ResponseEntity<>("body", HttpStatus.OK)); + + httpUtils.get(URI, header); + + assertEquals("application/xml", + captor.getValue().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + @DisplayName("get with headers should default the Content-Type to JSON when none is supplied") + void get_shouldDefaultContentTypeToJson() { + ArgumentCaptor> captor = + captureRequest(HttpMethod.GET, new ResponseEntity<>("body", HttpStatus.OK)); + + httpUtils.get(URI, new HashMap<>()); + + assertEquals("application/json", + captor.getValue().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + @DisplayName("get should propagate a transport failure to the caller") + void get_shouldPropagateTransportFailure() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenThrow(new RestClientException("connection refused")); + + assertThrows(RestClientException.class, () -> httpUtils.get(URI)); + } + } + + @Nested + @DisplayName("POST requests") + class PostTests { + + @Test + @DisplayName("post should send the JSON payload and return the response body") + void post_shouldSendPayloadAndReturnBody() { + ArgumentCaptor> captor = + captureRequest(HttpMethod.POST, new ResponseEntity<>("created", HttpStatus.CREATED)); + + assertEquals("created", httpUtils.post(URI, "{\"count\":5}")); + assertEquals("{\"count\":5}", captor.getValue().getBody()); + assertEquals(HttpStatus.CREATED, httpUtils.getStatus()); + } + + @Test + @DisplayName("post with headers should forward the supplied Authorization header") + void post_shouldForwardSuppliedAuthorizationHeader() { + HashMap header = new HashMap<>(); + header.put(HttpHeaders.AUTHORIZATION, "session-key-123"); + ArgumentCaptor> captor = + captureRequest(HttpMethod.POST, new ResponseEntity<>("created", HttpStatus.CREATED)); + + assertEquals("created", httpUtils.post(URI, "{\"count\":5}", header)); + assertEquals("session-key-123", + captor.getValue().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + assertEquals("{\"count\":5}", captor.getValue().getBody()); + } + + @Test + @DisplayName("post with headers should omit the Authorization header when none is supplied") + void post_shouldOmitAuthorizationHeaderWhenNoneSupplied() { + ArgumentCaptor> captor = + captureRequest(HttpMethod.POST, new ResponseEntity<>("created", HttpStatus.CREATED)); + + httpUtils.post(URI, "{}", new HashMap<>()); + + assertNull(captor.getValue().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + @DisplayName("post should record a server error status") + void post_shouldRecordServerErrorStatus() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>("boom", HttpStatus.INTERNAL_SERVER_ERROR)); + + httpUtils.post(URI, "{}"); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, httpUtils.getStatus()); + } + + @Test + @DisplayName("post should issue the request against the supplied URI with the POST method") + void post_shouldIssueRequestWithPostMethod() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>("created", HttpStatus.CREATED)); + + httpUtils.post(URI, "{}"); + + verify(restTemplate).exchange(eq(URI), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)); + } + } + + @Nested + @DisplayName("Status tracking") + class StatusTests { + + @Test + @DisplayName("getStatus should be null until a request has been made") + void getStatus_shouldBeNullBeforeAnyRequest() { + assertNull(httpUtils.getStatus()); + } + + @Test + @DisplayName("setStatus should record the supplied status code") + void setStatus_shouldRecordSuppliedStatusCode() { + httpUtils.setStatus(HttpStatus.ACCEPTED); + + assertEquals(HttpStatus.ACCEPTED, httpUtils.getStatus()); + } + } +} From fa13fb2371a160768c817445dddcb29c9170d148 Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Fri, 21 Aug 2026 11:40:09 +0530 Subject: [PATCH 07/10] test: cover RedisStorage, GenericEmailServiceImpl, JwtUtil, TokenDenylist, SessionObject - RedisStorage: set/get/update/delete against a mocked Lettuce connection, including the write-only-when-absent rule and the missing-session RedisSessionException paths. - GenericEmailServiceImpl: message assembly from the JSON request, semicolon recipient splitting, missing-field and transport failures. - JwtUtil: real HS256 tokens for the valid, denylisted, foreign-secret, expired, malformed and unconfigured-secret cases plus claim extraction. - TokenDenylist: prefixed key with expiry, jti and expiry guards, Redis failure surfacing on write and fail-open behaviour on read. - SessionObject: delegation to the store with configured expiry settings and propagation of store failures. Line coverage: 68.7% -> 77.0% (247 tests) Co-Authored-By: Claude Opus 5 (1M context) --- .../iemr/common/bengen/utils/JwtUtilTest.java | 190 ++++++++++++++++++ .../bengen/utils/TokenDenylistTest.java | 185 +++++++++++++++++ .../email/GenericEmailServiceImplTest.java | 156 ++++++++++++++ .../bengen/utils/redis/RedisStorageTest.java | 189 +++++++++++++++++ .../sessionobject/SessionObjectTest.java | 143 +++++++++++++ 5 files changed, 863 insertions(+) create mode 100644 src/test/java/com/iemr/common/bengen/utils/JwtUtilTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/TokenDenylistTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/gateway/email/GenericEmailServiceImplTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/redis/RedisStorageTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/sessionobject/SessionObjectTest.java diff --git a/src/test/java/com/iemr/common/bengen/utils/JwtUtilTest.java b/src/test/java/com/iemr/common/bengen/utils/JwtUtilTest.java new file mode 100644 index 0000000..c44b2e6 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/JwtUtilTest.java @@ -0,0 +1,190 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +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.assertThrows; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JwtUtil Test Suite") +class JwtUtilTest { + + private static final String SECRET = "amrit-bengen-test-secret-key-that-is-long-enough-for-hs256"; + private static final String OTHER_SECRET = "a-completely-different-secret-key-also-long-enough-for-hs256"; + + @Mock + private TokenDenylist tokenDenylist; + + private JwtUtil jwtUtil; + + @BeforeEach + @DisplayName("Wire the util with a test secret and a mocked denylist before each test") + void setUp() { + jwtUtil = new JwtUtil(); + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", SECRET); + ReflectionTestUtils.setField(jwtUtil, "tokenDenylist", tokenDenylist); + } + + private String token(String secret, String subject, String jti, Date expiry) { + SecretKey key = Keys.hmacShaKeyFor(secret.getBytes()); + var builder = Jwts.builder().subject(subject).signWith(key); + if (jti != null) { + builder.id(jti); + } + if (expiry != null) { + builder.expiration(expiry); + } + return builder.compact(); + } + + private String validToken(String subject, String jti) { + return token(SECRET, subject, jti, new Date(System.currentTimeMillis() + 600_000)); + } + + @Nested + @DisplayName("validateToken") + class ValidateTokenTests { + + @Test + @DisplayName("validateToken should return the claims for a correctly signed token") + void validateToken_shouldReturnClaimsForValidToken() { + when(tokenDenylist.isTokenDenylisted("jti-1")).thenReturn(false); + + Claims claims = jwtUtil.validateToken(validToken("amrit-user", "jti-1")); + + assertNotNull(claims); + assertEquals("amrit-user", claims.getSubject()); + assertEquals("jti-1", claims.getId()); + } + + @Test + @DisplayName("validateToken should skip the denylist check for a token without a jti") + void validateToken_shouldSkipDenylistCheckWithoutJti() { + Claims claims = jwtUtil.validateToken(validToken("amrit-user", null)); + + assertNotNull(claims); + assertEquals("amrit-user", claims.getSubject()); + } + + @Test + @DisplayName("validateToken should reject a token whose jti has been denylisted") + void validateToken_shouldRejectDenylistedToken() { + when(tokenDenylist.isTokenDenylisted("jti-1")).thenReturn(true); + + assertNull(jwtUtil.validateToken(validToken("amrit-user", "jti-1"))); + } + + @Test + @DisplayName("validateToken should reject a token signed with a different secret") + void validateToken_shouldRejectTokenSignedWithDifferentSecret() { + String foreign = token(OTHER_SECRET, "amrit-user", "jti-1", + new Date(System.currentTimeMillis() + 600_000)); + + assertNull(jwtUtil.validateToken(foreign)); + } + + @Test + @DisplayName("validateToken should reject an expired token") + void validateToken_shouldRejectExpiredToken() { + String expired = token(SECRET, "amrit-user", "jti-1", + new Date(System.currentTimeMillis() - 60_000)); + + assertNull(jwtUtil.validateToken(expired)); + } + + @Test + @DisplayName("validateToken should reject a malformed token") + void validateToken_shouldRejectMalformedToken() { + assertNull(jwtUtil.validateToken("not-a-jwt")); + } + + @Test + @DisplayName("validateToken should reject a null token") + void validateToken_shouldRejectNullToken() { + assertNull(jwtUtil.validateToken(null)); + } + + @Test + @DisplayName("validateToken should reject every token when no secret is configured") + void validateToken_shouldRejectWhenSecretIsNotConfigured() { + String signed = validToken("amrit-user", null); + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", null); + + assertNull(jwtUtil.validateToken(signed)); + } + } + + @Nested + @DisplayName("Claim extraction") + class ClaimExtractionTests { + + @Test + @DisplayName("extractUsername should return the token subject") + void extractUsername_shouldReturnSubject() { + assertEquals("amrit-user", jwtUtil.extractUsername(validToken("amrit-user", null))); + } + + @Test + @DisplayName("extractClaim should apply the supplied resolver to the claims") + void extractClaim_shouldApplySuppliedResolver() { + lenient().when(tokenDenylist.isTokenDenylisted("jti-9")).thenReturn(false); + + assertEquals("jti-9", jwtUtil.extractClaim(validToken("amrit-user", "jti-9"), Claims::getId)); + } + + @Test + @DisplayName("extractClaim should raise when the token cannot be parsed") + void extractClaim_shouldRaiseForMalformedToken() { + assertThrows(Exception.class, () -> jwtUtil.extractClaim("not-a-jwt", Claims::getSubject)); + } + + @Test + @DisplayName("extractUsername should raise when no secret is configured") + void extractUsername_shouldRaiseWhenSecretIsNotConfigured() { + String signed = validToken("amrit-user", null); + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", ""); + + assertThrows(IllegalStateException.class, () -> jwtUtil.extractUsername(signed)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/TokenDenylistTest.java b/src/test/java/com/iemr/common/bengen/utils/TokenDenylistTest.java new file mode 100644 index 0000000..b34208a --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/TokenDenylistTest.java @@ -0,0 +1,185 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TokenDenylist Test Suite") +class TokenDenylistTest { + + private static final String JTI = "jti-1"; + private static final String KEY = "denied_jti-1"; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private TokenDenylist tokenDenylist; + + @BeforeEach + @DisplayName("Wire the denylist with a mocked Redis template before each test") + void setUp() { + tokenDenylist = new TokenDenylist(); + ReflectionTestUtils.setField(tokenDenylist, "redisTemplate", redisTemplate); + } + + @Nested + @DisplayName("addTokenToDenylist") + class AddTokenTests { + + @Test + @DisplayName("addTokenToDenylist should store the prefixed key with the supplied expiry") + void addTokenToDenylist_shouldStorePrefixedKeyWithExpiry() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + tokenDenylist.addTokenToDenylist(JTI, 60_000L); + + verify(valueOperations).set(KEY, " ", 60_000L, TimeUnit.MILLISECONDS); + } + + @Test + @DisplayName("addTokenToDenylist should ignore a null jti") + void addTokenToDenylist_shouldIgnoreNullJti() { + tokenDenylist.addTokenToDenylist(null, 60_000L); + + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("addTokenToDenylist should ignore a blank jti") + void addTokenToDenylist_shouldIgnoreBlankJti() { + tokenDenylist.addTokenToDenylist(" ", 60_000L); + + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("addTokenToDenylist should reject a null expiry") + void addTokenToDenylist_shouldRejectNullExpiry() { + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> tokenDenylist.addTokenToDenylist(JTI, null)); + + assertTrue(thrown.getMessage().contains("Expiration time must be positive")); + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("addTokenToDenylist should reject a non-positive expiry") + void addTokenToDenylist_shouldRejectNonPositiveExpiry() { + assertThrows(IllegalArgumentException.class, () -> tokenDenylist.addTokenToDenylist(JTI, 0L)); + assertThrows(IllegalArgumentException.class, () -> tokenDenylist.addTokenToDenylist(JTI, -5L)); + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("addTokenToDenylist should surface a Redis failure as a runtime exception") + void addTokenToDenylist_shouldSurfaceRedisFailure() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + doThrow(new IllegalStateException("redis down")) + .when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> tokenDenylist.addTokenToDenylist(JTI, 60_000L)); + + assertTrue(thrown.getMessage().contains("Failed to denylist token")); + } + } + + @Nested + @DisplayName("isTokenDenylisted") + class IsTokenDenylistedTests { + + @Test + @DisplayName("isTokenDenylisted should report true when the prefixed key exists") + void isTokenDenylisted_shouldReportTrueWhenKeyExists() { + when(redisTemplate.hasKey(KEY)).thenReturn(true); + + assertTrue(tokenDenylist.isTokenDenylisted(JTI)); + } + + @Test + @DisplayName("isTokenDenylisted should report false when the key does not exist") + void isTokenDenylisted_shouldReportFalseWhenKeyAbsent() { + when(redisTemplate.hasKey(KEY)).thenReturn(false); + + assertFalse(tokenDenylist.isTokenDenylisted(JTI)); + } + + @Test + @DisplayName("isTokenDenylisted should report false when Redis answers null") + void isTokenDenylisted_shouldReportFalseWhenRedisAnswersNull() { + when(redisTemplate.hasKey(KEY)).thenReturn(null); + + assertFalse(tokenDenylist.isTokenDenylisted(JTI)); + } + + @Test + @DisplayName("isTokenDenylisted should report false for a null jti without touching Redis") + void isTokenDenylisted_shouldReportFalseForNullJti() { + assertFalse(tokenDenylist.isTokenDenylisted(null)); + + verify(redisTemplate, never()).hasKey(anyString()); + } + + @Test + @DisplayName("isTokenDenylisted should report false for a blank jti without touching Redis") + void isTokenDenylisted_shouldReportFalseForBlankJti() { + assertFalse(tokenDenylist.isTokenDenylisted(" ")); + + verify(redisTemplate, never()).hasKey(anyString()); + } + + @Test + @DisplayName("isTokenDenylisted should fail open rather than block requests when Redis is down") + void isTokenDenylisted_shouldFailOpenWhenRedisIsDown() { + when(redisTemplate.hasKey(KEY)).thenThrow(new IllegalStateException("redis down")); + + assertFalse(tokenDenylist.isTokenDenylisted(JTI)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/gateway/email/GenericEmailServiceImplTest.java b/src/test/java/com/iemr/common/bengen/utils/gateway/email/GenericEmailServiceImplTest.java new file mode 100644 index 0000000..4d8ee78 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/gateway/email/GenericEmailServiceImplTest.java @@ -0,0 +1,156 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.gateway.email; + +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GenericEmailServiceImpl Test Suite") +class GenericEmailServiceImplTest { + + @Mock + private JavaMailSender javaMailSender; + + private GenericEmailServiceImpl emailService; + + @BeforeEach + @DisplayName("Wire the service with a mocked mail sender before each test") + void setUp() { + emailService = new GenericEmailServiceImpl(); + emailService.setJavaMailSender(javaMailSender); + } + + private String request(String to) { + return String.format( + "{\"to\":\"%s\",\"from\":\"no-reply@amrit.example.org\"," + + "\"subject\":\"Beneficiary ID pool low\"," + + "\"message\":\"Only 100 IDs remain in the pool.\"}", + to); + } + + private SimpleMailMessage captureSentMessage() { + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(javaMailSender).send(captor.capture()); + return captor.getValue(); + } + + @Nested + @DisplayName("sendEmail without a template") + class SendEmailTests { + + @Test + @DisplayName("sendEmail should populate every field of the message from the JSON request") + void sendEmail_shouldPopulateMessageFromJsonRequest() { + emailService.sendEmail(request("ops@amrit.example.org")); + + SimpleMailMessage sent = captureSentMessage(); + assertArrayEquals(new String[] { "ops@amrit.example.org" }, sent.getTo()); + assertEquals("no-reply@amrit.example.org", sent.getFrom()); + assertEquals("Beneficiary ID pool low", sent.getSubject()); + assertEquals("Only 100 IDs remain in the pool.", sent.getText()); + } + + @Test + @DisplayName("sendEmail should split a semicolon-separated recipient list into multiple addresses") + void sendEmail_shouldSplitSemicolonSeparatedRecipients() { + emailService.sendEmail(request("ops@amrit.example.org;admin@amrit.example.org")); + + assertArrayEquals(new String[] { "ops@amrit.example.org", "admin@amrit.example.org" }, + captureSentMessage().getTo()); + } + + @Test + @DisplayName("sendEmail should reject a request that is missing a mandatory field") + void sendEmail_shouldRejectRequestMissingMandatoryField() { + String incomplete = "{\"to\":\"ops@amrit.example.org\"}"; + + assertThrows(JSONException.class, () -> emailService.sendEmail(incomplete)); + verify(javaMailSender, never()).send(org.mockito.ArgumentMatchers.any(SimpleMailMessage.class)); + } + + @Test + @DisplayName("sendEmail should propagate a mail transport failure") + void sendEmail_shouldPropagateTransportFailure() { + doThrow(new MailSendException("smtp unreachable")) + .when(javaMailSender).send(org.mockito.ArgumentMatchers.any(SimpleMailMessage.class)); + + assertThrows(MailSendException.class, () -> emailService.sendEmail(request("ops@amrit.example.org"))); + } + } + + @Nested + @DisplayName("sendEmail with a template") + class SendEmailWithTemplateTests { + + @Test + @DisplayName("sendEmail with a template should populate the message from the JSON request") + void sendEmail_withTemplate_shouldPopulateMessageFromJsonRequest() { + emailService.sendEmail(request("ops@amrit.example.org"), "pool-warning-template"); + + SimpleMailMessage sent = captureSentMessage(); + assertArrayEquals(new String[] { "ops@amrit.example.org" }, sent.getTo()); + assertEquals("Beneficiary ID pool low", sent.getSubject()); + assertEquals("Only 100 IDs remain in the pool.", sent.getText()); + } + + @Test + @DisplayName("sendEmail with a template should keep a semicolon list as a single recipient") + void sendEmail_withTemplate_shouldKeepRecipientListUnsplit() { + emailService.sendEmail(request("ops@amrit.example.org;admin@amrit.example.org"), + "pool-warning-template"); + + assertArrayEquals(new String[] { "ops@amrit.example.org;admin@amrit.example.org" }, + captureSentMessage().getTo()); + } + } + + @Nested + @DisplayName("sendEmailWithAttachment") + class SendEmailWithAttachmentTests { + + @Test + @DisplayName("sendEmailWithAttachment is not implemented and should send nothing") + void sendEmailWithAttachment_shouldSendNothing() { + emailService.sendEmailWithAttachment(request("ops@amrit.example.org"), "template"); + + verify(javaMailSender, never()).send(org.mockito.ArgumentMatchers.any(SimpleMailMessage.class)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/redis/RedisStorageTest.java b/src/test/java/com/iemr/common/bengen/utils/redis/RedisStorageTest.java new file mode 100644 index 0000000..79e8aea --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/redis/RedisStorageTest.java @@ -0,0 +1,189 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.redis; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisStringCommands.SetOption; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.types.Expiration; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisStorage Test Suite") +class RedisStorageTest { + + private static final String KEY = "session-key"; + private static final int EXPIRY_SECONDS = 7200; + + @Mock + private LettuceConnectionFactory connectionFactory; + + @Mock + private RedisConnection redisConnection; + + private RedisStorage redisStorage; + + @BeforeEach + @DisplayName("Wire the store with a mocked Lettuce connection factory before each test") + void setUp() { + redisStorage = new RedisStorage(); + ReflectionTestUtils.setField(redisStorage, "connection", connectionFactory); + when(connectionFactory.getConnection()).thenReturn(redisConnection); + } + + private byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + @Nested + @DisplayName("setObject") + class SetObjectTests { + + @Test + @DisplayName("setObject should write the value when no session is stored yet") + void setObject_shouldWriteValueWhenKeyIsAbsent() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(null); + + assertEquals(KEY, redisStorage.setObject(KEY, "payload", EXPIRY_SECONDS)); + verify(redisConnection).set(eq(bytes(KEY)), eq(bytes("payload")), + eq(Expiration.seconds(EXPIRY_SECONDS)), eq(SetOption.UPSERT)); + } + + @Test + @DisplayName("setObject should write the value when the stored session is empty") + void setObject_shouldWriteValueWhenStoredSessionIsEmpty() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes("")); + + assertEquals(KEY, redisStorage.setObject(KEY, "payload", EXPIRY_SECONDS)); + verify(redisConnection).set(any(byte[].class), any(byte[].class), any(Expiration.class), any(SetOption.class)); + } + + @Test + @DisplayName("setObject should leave an existing session untouched") + void setObject_shouldLeaveExistingSessionUntouched() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes("existing")); + + assertEquals(KEY, redisStorage.setObject(KEY, "payload", EXPIRY_SECONDS)); + verify(redisConnection, never()).set(any(byte[].class), any(byte[].class), + any(Expiration.class), any(SetOption.class)); + } + } + + @Nested + @DisplayName("getObject") + class GetObjectTests { + + @Test + @DisplayName("getObject should return the stored session and extend its expiry") + void getObject_shouldReturnStoredSessionAndExtendExpiry() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes("payload")); + + assertEquals("payload", redisStorage.getObject(KEY, true, EXPIRY_SECONDS)); + verify(redisConnection).expire(bytes(KEY), EXPIRY_SECONDS); + } + + @Test + @DisplayName("getObject should raise a session exception when the key is absent") + void getObject_shouldRaiseWhenKeyIsAbsent() { + when(redisConnection.get(bytes(KEY))).thenReturn(null); + + RedisSessionException thrown = assertThrows(RedisSessionException.class, + () -> redisStorage.getObject(KEY, true, EXPIRY_SECONDS)); + + assertEquals("Unable to fetch session object from Redis server", thrown.getMessage()); + } + + @Test + @DisplayName("getObject should raise a session exception when the stored value is blank") + void getObject_shouldRaiseWhenStoredValueIsBlank() { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes(" ")); + + assertThrows(RedisSessionException.class, + () -> redisStorage.getObject(KEY, true, EXPIRY_SECONDS)); + verify(redisConnection, never()).expire(any(byte[].class), any(Long.class)); + } + } + + @Nested + @DisplayName("updateObject") + class UpdateObjectTests { + + @Test + @DisplayName("updateObject should overwrite an existing session") + void updateObject_shouldOverwriteExistingSession() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes("old")); + + assertEquals(KEY, redisStorage.updateObject(KEY, "new", true, EXPIRY_SECONDS)); + verify(redisConnection).set(eq(bytes(KEY)), eq(bytes("new")), + eq(Expiration.seconds(EXPIRY_SECONDS)), eq(SetOption.UPSERT)); + } + + @Test + @DisplayName("updateObject should raise a session exception when there is nothing to update") + void updateObject_shouldRaiseWhenKeyIsAbsent() { + when(redisConnection.get(bytes(KEY))).thenReturn(null); + + RedisSessionException thrown = assertThrows(RedisSessionException.class, + () -> redisStorage.updateObject(KEY, "new", true, EXPIRY_SECONDS)); + + assertEquals("Unable to fetch session object from Redis server", thrown.getMessage()); + } + } + + @Nested + @DisplayName("deleteObject") + class DeleteObjectTests { + + @Test + @DisplayName("deleteObject should return the number of keys Redis removed") + void deleteObject_shouldReturnNumberOfKeysRemoved() throws RedisSessionException { + when(redisConnection.del(bytes(KEY))).thenReturn(1L); + + assertEquals(1L, redisStorage.deleteObject(KEY)); + } + + @Test + @DisplayName("deleteObject should return zero when the key was not present") + void deleteObject_shouldReturnZeroWhenKeyAbsent() throws RedisSessionException { + when(redisConnection.del(bytes(KEY))).thenReturn(0L); + + assertEquals(0L, redisStorage.deleteObject(KEY)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/sessionobject/SessionObjectTest.java b/src/test/java/com/iemr/common/bengen/utils/sessionobject/SessionObjectTest.java new file mode 100644 index 0000000..0b96034 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/sessionobject/SessionObjectTest.java @@ -0,0 +1,143 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.sessionobject; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.iemr.common.bengen.utils.config.ConfigProperties; +import com.iemr.common.bengen.utils.redis.RedisSessionException; +import com.iemr.common.bengen.utils.redis.RedisStorage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SessionObject Test Suite") +class SessionObjectTest { + + private static final String KEY = "session-key"; + private static final String VALUE = "{\"userName\":\"amrit-user\"}"; + + @Mock + private RedisStorage objectStore; + + private SessionObject sessionObject; + private int expectedExpiry; + private boolean expectedExtend; + + @BeforeEach + @DisplayName("Wire the session holder with a mocked Redis store before each test") + void setUp() { + sessionObject = new SessionObject(); + sessionObject.setObjectStore(objectStore); + expectedExpiry = ConfigProperties.getSessionExpiryTime(); + expectedExtend = ConfigProperties.getExtendExpiryTime(); + } + + @Nested + @DisplayName("Reading and writing the session") + class ReadWriteTests { + + @Test + @DisplayName("getSessionObject should delegate to the store with the configured expiry settings") + void getSessionObject_shouldDelegateWithConfiguredExpiry() throws RedisSessionException { + when(objectStore.getObject(KEY, expectedExtend, expectedExpiry)).thenReturn(VALUE); + + assertEquals(VALUE, sessionObject.getSessionObject(KEY)); + verify(objectStore).getObject(KEY, expectedExtend, expectedExpiry); + } + + @Test + @DisplayName("setSessionObject should delegate to the store with the configured expiry") + void setSessionObject_shouldDelegateWithConfiguredExpiry() throws RedisSessionException { + when(objectStore.setObject(KEY, VALUE, expectedExpiry)).thenReturn(KEY); + + assertEquals(KEY, sessionObject.setSessionObject(KEY, VALUE)); + verify(objectStore).setObject(KEY, VALUE, expectedExpiry); + } + + @Test + @DisplayName("updateSessionObject should delegate to the store with the configured expiry settings") + void updateSessionObject_shouldDelegateWithConfiguredExpiry() throws RedisSessionException { + when(objectStore.updateObject(KEY, VALUE, expectedExtend, expectedExpiry)).thenReturn(KEY); + + assertEquals(KEY, sessionObject.updateSessionObject(KEY, VALUE)); + verify(objectStore).updateObject(KEY, VALUE, expectedExtend, expectedExpiry); + } + + @Test + @DisplayName("deleteSessionObject should delegate the removal to the store") + void deleteSessionObject_shouldDelegateRemoval() throws RedisSessionException { + when(objectStore.deleteObject(KEY)).thenReturn(1L); + + sessionObject.deleteSessionObject(KEY); + + verify(objectStore).deleteObject(KEY); + } + } + + @Nested + @DisplayName("Propagating store failures") + class FailureTests { + + @Test + @DisplayName("getSessionObject should propagate a missing-session failure") + void getSessionObject_shouldPropagateMissingSessionFailure() throws RedisSessionException { + when(objectStore.getObject(anyString(), anyBoolean(), anyInt())) + .thenThrow(new RedisSessionException("Unable to fetch session object from Redis server")); + + RedisSessionException thrown = assertThrows(RedisSessionException.class, + () -> sessionObject.getSessionObject(KEY)); + + assertEquals("Unable to fetch session object from Redis server", thrown.getMessage()); + } + + @Test + @DisplayName("updateSessionObject should propagate a missing-session failure") + void updateSessionObject_shouldPropagateMissingSessionFailure() throws RedisSessionException { + when(objectStore.updateObject(eq(KEY), eq(VALUE), anyBoolean(), anyInt())) + .thenThrow(new RedisSessionException("Unable to fetch session object from Redis server")); + + assertThrows(RedisSessionException.class, () -> sessionObject.updateSessionObject(KEY, VALUE)); + } + + @Test + @DisplayName("deleteSessionObject should propagate a store failure") + void deleteSessionObject_shouldPropagateStoreFailure() throws RedisSessionException { + when(objectStore.deleteObject(KEY)).thenThrow(new RedisSessionException("redis down")); + + assertThrows(RedisSessionException.class, () -> sessionObject.deleteSessionObject(KEY)); + } + } +} From 41ca9358f63143471111843ceb825248cf459451 Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Fri, 21 Aug 2026 11:49:35 +0530 Subject: [PATCH 08/10] test: cover QuartzConfig, BeneficiaryId, RestTemplateUtil, Generator, Hikari diagnostics - HealthService: HikariCP pool-exhaustion detection via a mocked HikariDataSource and HikariPoolMXBean, the missing-MXBean and failed diagnostic-query paths, and the aggregate timeout that marks a component DOWN when its check never completes. - QuartzConfig: job, cron trigger (default vs configured schedule), scheduler and quartz-properties beans called directly. - AutowiringSpringBeanJobFactory: job instances autowired from the application context. - BeneficiaryId: field round-tripping and Lombok value semantics. - RestTemplateUtil: header assembly with and without a bound request, JwtToken header/cookie replay, mobile User-Agent propagation. - Generator: digit counting, random helpers, diagnostics. Note: Generator computes the Verhoeff digit over the 12-digit base while its final position is still zero, rather than over the 11-digit prefix, so generated ids do not satisfy a standard Verhoeff validation. The test pins the invariant the code actually guarantees and documents this. Line coverage: 77.0% -> 86.0% (329 tests) Co-Authored-By: Claude Opus 5 (1M context) --- .../AutowiringSpringBeanJobFactoryTest.java | 104 ++++++++++ .../config/quartz/QuartzConfigTest.java | 183 +++++++++++++++++ .../bengen/domain/BeneficiaryIdTest.java | 194 ++++++++++++++++++ .../service/health/HealthServiceTest.java | 116 +++++++++++ .../common/bengen/utils/GeneratorTest.java | 174 ++++++++++++++++ .../bengen/utils/RestTemplateUtilTest.java | 158 ++++++++++++++ 6 files changed, 929 insertions(+) create mode 100644 src/test/java/com/iemr/common/bengen/config/quartz/AutowiringSpringBeanJobFactoryTest.java create mode 100644 src/test/java/com/iemr/common/bengen/config/quartz/QuartzConfigTest.java create mode 100644 src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/GeneratorTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/RestTemplateUtilTest.java diff --git a/src/test/java/com/iemr/common/bengen/config/quartz/AutowiringSpringBeanJobFactoryTest.java b/src/test/java/com/iemr/common/bengen/config/quartz/AutowiringSpringBeanJobFactoryTest.java new file mode 100644 index 0000000..1558128 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/quartz/AutowiringSpringBeanJobFactoryTest.java @@ -0,0 +1,104 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.config.quartz; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.quartz.JobDetail; +import org.quartz.JobDataMap; +import org.quartz.spi.OperableTrigger; +import org.quartz.spi.TriggerFiredBundle; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; + +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.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AutowiringSpringBeanJobFactory Test Suite") +class AutowiringSpringBeanJobFactoryTest { + + @Mock + private ApplicationContext applicationContext; + + @Mock + private AutowireCapableBeanFactory beanFactory; + + @Mock + private TriggerFiredBundle triggerFiredBundle; + + @Mock + private OperableTrigger trigger; + + private AutowiringSpringBeanJobFactory jobFactory; + + @BeforeEach + @DisplayName("Create the job factory before each test") + void setUp() { + jobFactory = new AutowiringSpringBeanJobFactory(); + } + + private void stubBundle() { + lenient().when(triggerFiredBundle.getJobDetail()).thenReturn(benGenJobDetail()); + lenient().when(triggerFiredBundle.getTrigger()).thenReturn(trigger); + lenient().when(trigger.getJobDataMap()).thenReturn(new JobDataMap()); + } + + private JobDetail benGenJobDetail() { + JobDetailFactoryBean factory = new JobDetailFactoryBean(); + factory.setJobClass(ScheduleJobServiceForBenGen.class); + factory.setName("bengen-job"); + factory.afterPropertiesSet(); + return factory.getObject(); + } + + @Test + @DisplayName("createJobInstance should autowire the new job through the application context") + void createJobInstance_shouldAutowireJobThroughApplicationContext() throws Exception { + when(applicationContext.getAutowireCapableBeanFactory()).thenReturn(beanFactory); + stubBundle(); + jobFactory.setApplicationContext(applicationContext); + + Object job = jobFactory.createJobInstance(triggerFiredBundle); + + assertNotNull(job); + assertTrue(job instanceof ScheduleJobServiceForBenGen); + verify(beanFactory).autowireBean(job); + } + + @Test + @DisplayName("createJobInstance should fail when no application context has been supplied") + void createJobInstance_shouldFailWithoutApplicationContext() { + stubBundle(); + + assertThrows(NullPointerException.class, () -> jobFactory.createJobInstance(triggerFiredBundle)); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/quartz/QuartzConfigTest.java b/src/test/java/com/iemr/common/bengen/config/quartz/QuartzConfigTest.java new file mode 100644 index 0000000..9f4bfb5 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/quartz/QuartzConfigTest.java @@ -0,0 +1,183 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.config.quartz; + +import java.util.Properties; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.quartz.CronTrigger; +import org.quartz.JobDetail; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; + +import com.iemr.common.bengen.utils.config.ConfigProperties; + +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.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("QuartzConfig Test Suite") +class QuartzConfigTest { + + private static final String DEFAULT_CRON = "1 0 0 * * ?"; + + @Mock + private PlatformTransactionManager transactionManager; + + @Mock + private ApplicationContext applicationContext; + + @Mock + private AutowireCapableBeanFactory beanFactory; + + private QuartzConfig quartzConfig; + private Properties originalConfigProperties; + + @BeforeEach + @DisplayName("Wire the configuration with mocked Spring collaborators before each test") + void setUp() { + quartzConfig = new QuartzConfig(); + ReflectionTestUtils.setField(quartzConfig, "transactionManager", transactionManager); + ReflectionTestUtils.setField(quartzConfig, "applicationContext", applicationContext); + new ConfigProperties(); + originalConfigProperties = (Properties) ReflectionTestUtils.getField(ConfigProperties.class, "properties"); + } + + @AfterEach + @DisplayName("Restore the shared ConfigProperties statics after each test") + void tearDown() { + ReflectionTestUtils.setField(ConfigProperties.class, "properties", originalConfigProperties); + } + + @Nested + @DisplayName("Job and trigger beans") + class JobAndTriggerTests { + + @Test + @DisplayName("processMQJobForBenGen should build the beneficiary generation job in the spring-quartz group") + void processMQJobForBenGen_shouldBuildJobInSpringQuartzGroup() { + JobDetailFactoryBean factory = quartzConfig.processMQJobForBenGen(); + factory.afterPropertiesSet(); + + JobDetail jobDetail = factory.getObject(); + assertNotNull(jobDetail); + assertEquals(ScheduleJobServiceForBenGen.class, jobDetail.getJobClass()); + assertEquals("spring-quartz", jobDetail.getKey().getGroup()); + } + + @Test + @DisplayName("processMQTriggerForBenGen should use the midnight default when the scheduler is disabled") + void processMQTriggerForBenGen_shouldUseDefaultCronWhenSchedulerDisabled() throws Exception { + Properties stub = new Properties(); + stub.setProperty("start-bengen-scheduler", "false"); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", stub); + + CronTriggerFactoryBean factory = quartzConfig.processMQTriggerForBenGen(); + factory.afterPropertiesSet(); + + CronTrigger trigger = factory.getObject(); + assertEquals(DEFAULT_CRON, trigger.getCronExpression()); + assertEquals("spring-quartz", trigger.getKey().getGroup()); + } + + @Test + @DisplayName("processMQTriggerForBenGen should use the configured cron when the scheduler is enabled") + void processMQTriggerForBenGen_shouldUseConfiguredCronWhenSchedulerEnabled() throws Exception { + Properties stub = new Properties(); + stub.setProperty("start-bengen-scheduler", "true"); + stub.setProperty("cron-scheduler-bengen", "0 0 2 * * ?"); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", stub); + + CronTriggerFactoryBean factory = quartzConfig.processMQTriggerForBenGen(); + factory.afterPropertiesSet(); + + assertEquals("0 0 2 * * ?", factory.getObject().getCronExpression()); + } + } + + @Nested + @DisplayName("Scheduler bean") + class SchedulerTests { + + @Test + @DisplayName("quartzScheduler should overwrite existing jobs and carry the configured scheduler name") + void quartzScheduler_shouldOverwriteExistingJobsWithConfiguredName() { + SchedulerFactoryBean scheduler = quartzConfig.quartzScheduler(); + + assertNotNull(scheduler); + assertEquals(Boolean.TRUE, ReflectionTestUtils.getField(scheduler, "overwriteExistingJobs")); + assertEquals("jelies-quartz-scheduler", ReflectionTestUtils.getField(scheduler, "schedulerName")); + } + + @Test + @DisplayName("quartzScheduler should install an autowiring job factory bound to the application context") + void quartzScheduler_shouldInstallAutowiringJobFactory() { + when(applicationContext.getAutowireCapableBeanFactory()).thenReturn(beanFactory); + + SchedulerFactoryBean scheduler = quartzConfig.quartzScheduler(); + + Object jobFactory = ReflectionTestUtils.getField(scheduler, "jobFactory"); + assertTrue(jobFactory instanceof AutowiringSpringBeanJobFactory); + assertSame(beanFactory, ReflectionTestUtils.getField(jobFactory, "beanFactory"), + "the job factory must autowire jobs from the application context"); + } + + @Test + @DisplayName("quartzScheduler should use the injected transaction manager") + void quartzScheduler_shouldUseInjectedTransactionManager() { + SchedulerFactoryBean scheduler = quartzConfig.quartzScheduler(); + + assertSame(transactionManager, ReflectionTestUtils.getField(scheduler, "transactionManager")); + } + } + + @Nested + @DisplayName("Quartz properties bean") + class QuartzPropertiesTests { + + @Test + @DisplayName("quartzProperties should load the Quartz thread pool settings from application.properties") + void quartzProperties_shouldLoadThreadPoolSettings() { + Properties properties = quartzConfig.quartzProperties(); + + assertNotNull(properties); + assertEquals("org.quartz.simpl.SimpleThreadPool", + properties.getProperty("org.quartz.threadPool.class")); + assertEquals("true", properties.getProperty("org.quartz.threadPool.makeThreadsDaemons")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java b/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java new file mode 100644 index 0000000..89cdb4f --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java @@ -0,0 +1,194 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.domain; + +import java.math.BigInteger; +import java.sql.Timestamp; + +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("BeneficiaryId Test Suite") +class BeneficiaryIdTest { + + private static final BigInteger BEN_REG_ID = new BigInteger("753812721192"); + private static final BigInteger BENEFICIARY_ID = new BigInteger("796702837334"); + private static final Timestamp RESERVED_ON = Timestamp.valueOf("2025-06-25 10:00:00"); + private static final Timestamp RESERVED_UNTIL = Timestamp.valueOf("2025-06-26 10:00:00"); + private static final Timestamp PROVISIONED_ON = Timestamp.valueOf("2025-06-25 11:00:00"); + private static final Timestamp CREATED_DATE = Timestamp.valueOf("2025-06-01 09:00:00"); + + private BeneficiaryId beneficiaryId; + + @BeforeEach + @DisplayName("Create an unpopulated mapping row before each test") + void setUp() { + beneficiaryId = new BeneficiaryId(); + } + + private BeneficiaryId populated() { + BeneficiaryId row = new BeneficiaryId(); + row.setBenRegId(BEN_REG_ID); + row.setBeneficiaryId(BENEFICIARY_ID); + row.setProvisioned(true); + row.setProvisionedById(7); + row.setProvisionedBy("admin"); + row.setProvisionedOn(PROVISIONED_ON); + row.setReserved(true); + row.setReservedForId(11); + row.setReservedForName("MMU"); + row.setReservedForCountryId(1); + row.setReservedForCountryName("India"); + row.setReservedForStateId(2); + row.setReservedForStateName("Karnataka"); + row.setReservedForDistrictId(3); + row.setReservedForDistrictName("Bengaluru Urban"); + row.setReservedForPSMapId(4); + row.setReservedForPSMapName("PS-Map-4"); + row.setReservedById(5); + row.setReservedByName("scheduler"); + row.setReservedOn(RESERVED_ON); + row.setReservedUntil(RESERVED_UNTIL); + row.setCreatedBy("admin"); + row.setCreatedDate(CREATED_DATE); + return row; + } + + @Nested + @DisplayName("Field round-tripping") + class FieldTests { + + @Test + @DisplayName("a new mapping row should leave every field unset") + void newRow_shouldLeaveEveryFieldUnset() { + assertNull(beneficiaryId.getBenRegId()); + assertNull(beneficiaryId.getBeneficiaryId()); + assertNull(beneficiaryId.getProvisioned()); + assertNull(beneficiaryId.getReserved()); + assertNull(beneficiaryId.getCreatedBy()); + } + + @Test + @DisplayName("the identity and provisioning fields should round-trip through their accessors") + void identityAndProvisioningFields_shouldRoundTrip() { + BeneficiaryId row = populated(); + + assertEquals(BEN_REG_ID, row.getBenRegId()); + assertEquals(BENEFICIARY_ID, row.getBeneficiaryId()); + assertTrue(row.getProvisioned()); + assertEquals(7, row.getProvisionedById()); + assertEquals("admin", row.getProvisionedBy()); + assertEquals(PROVISIONED_ON, row.getProvisionedOn()); + } + + @Test + @DisplayName("the reservation fields should round-trip through their accessors") + void reservationFields_shouldRoundTrip() { + BeneficiaryId row = populated(); + + assertTrue(row.getReserved()); + assertEquals(11, row.getReservedForId()); + assertEquals("MMU", row.getReservedForName()); + assertEquals(1, row.getReservedForCountryId()); + assertEquals("India", row.getReservedForCountryName()); + assertEquals(2, row.getReservedForStateId()); + assertEquals("Karnataka", row.getReservedForStateName()); + assertEquals(3, row.getReservedForDistrictId()); + assertEquals("Bengaluru Urban", row.getReservedForDistrictName()); + assertEquals(4, row.getReservedForPSMapId()); + assertEquals("PS-Map-4", row.getReservedForPSMapName()); + assertEquals(5, row.getReservedById()); + assertEquals("scheduler", row.getReservedByName()); + assertEquals(RESERVED_ON, row.getReservedOn()); + assertEquals(RESERVED_UNTIL, row.getReservedUntil()); + } + + @Test + @DisplayName("the audit fields should round-trip through their accessors") + void auditFields_shouldRoundTrip() { + BeneficiaryId row = populated(); + + assertEquals("admin", row.getCreatedBy()); + assertEquals(CREATED_DATE, row.getCreatedDate()); + } + } + + @Nested + @DisplayName("Value semantics") + class ValueSemanticsTests { + + @Test + @DisplayName("two rows holding the same values should be equal and share a hash code") + void rowsWithSameValues_shouldBeEqual() { + assertEquals(populated(), populated()); + assertEquals(populated().hashCode(), populated().hashCode()); + } + + @Test + @DisplayName("two unpopulated rows should be equal") + void unpopulatedRows_shouldBeEqual() { + assertEquals(new BeneficiaryId(), new BeneficiaryId()); + } + + @Test + @DisplayName("rows differing in the beneficiary id should not be equal") + void rowsDifferingInBeneficiaryId_shouldNotBeEqual() { + BeneficiaryId other = populated(); + other.setBeneficiaryId(new BigInteger("111111111111")); + + assertNotEquals(populated(), other); + } + + @Test + @DisplayName("rows differing in reservation state should not be equal") + void rowsDifferingInReservationState_shouldNotBeEqual() { + BeneficiaryId other = populated(); + other.setReserved(false); + + assertNotEquals(populated(), other); + } + + @Test + @DisplayName("a row should not equal an unrelated object") + void row_shouldNotEqualUnrelatedObject() { + assertNotEquals(populated(), "not a BeneficiaryId"); + } + + @Test + @DisplayName("toString should name the type and expose the identity fields") + void toString_shouldNameTypeAndExposeIdentityFields() { + String text = populated().toString(); + + assertTrue(text.startsWith("BeneficiaryId(")); + assertTrue(text.contains(BEN_REG_ID.toString())); + assertTrue(text.contains(BENEFICIARY_ID.toString())); + assertTrue(text.contains("scheduler")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java b/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java index 76e1de3..c14d1d9 100644 --- a/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java +++ b/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java @@ -30,6 +30,9 @@ import javax.sql.DataSource; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -48,6 +51,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -58,6 +63,8 @@ class HealthServiceTest { private static final int THRESHOLD = 5000; + private static final String COUNT_SQL = + "SELECT COUNT(*) FROM m_beneficiaryregidmapping WHERE Provisioned = 0 AND Reserved = 0"; @Mock private DataSource dataSource; @@ -303,4 +310,113 @@ void shutdown_shouldBeIdempotent() { assertDoesNotThrow(() -> healthService.shutdown()); } } + + @Nested + @DisplayName("Advanced MySQL diagnostics") + class AdvancedDiagnosticsTests { + + @Test + @DisplayName("checkHealth should flag MySQL DEGRADED when the pool is more than 80% exhausted") + void checkHealth_shouldFlagDegradedWhenHikariPoolNearlyExhausted() throws SQLException { + HikariDataSource hikariDataSource = mock(HikariDataSource.class); + HikariPoolMXBean poolMXBean = mock(HikariPoolMXBean.class); + lenient().when(hikariDataSource.getConnection()).thenReturn(connection); + lenient().when(hikariDataSource.getHikariPoolMXBean()).thenReturn(poolMXBean); + lenient().when(hikariDataSource.getMaximumPoolSize()).thenReturn(10); + lenient().when(poolMXBean.getActiveConnections()).thenReturn(9); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(THRESHOLD * 2L); + lenient().when(resultSet.getInt(1)).thenReturn(0); + stubRedisPong("PONG"); + HealthService service = new HealthService(hikariDataSource, redisTemplate, THRESHOLD); + + Map response = service.checkHealth(); + + assertEquals("DEGRADED", component(response, "mysql").get("status")); + assertEquals("WARNING", component(response, "mysql").get("severity")); + service.shutdown(); + } + + @Test + @DisplayName("checkHealth should stay UP when the Hikari pool is comfortably below the threshold") + void checkHealth_shouldStayUpWhenHikariPoolHasHeadroom() throws SQLException { + HikariDataSource hikariDataSource = mock(HikariDataSource.class); + HikariPoolMXBean poolMXBean = mock(HikariPoolMXBean.class); + lenient().when(hikariDataSource.getConnection()).thenReturn(connection); + lenient().when(hikariDataSource.getHikariPoolMXBean()).thenReturn(poolMXBean); + lenient().when(hikariDataSource.getMaximumPoolSize()).thenReturn(10); + lenient().when(poolMXBean.getActiveConnections()).thenReturn(2); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(THRESHOLD * 2L); + lenient().when(resultSet.getInt(1)).thenReturn(0); + stubRedisPong("PONG"); + HealthService service = new HealthService(hikariDataSource, redisTemplate, THRESHOLD); + + Map response = service.checkHealth(); + + assertEquals("UP", component(response, "mysql").get("status")); + service.shutdown(); + } + + @Test + @DisplayName("checkHealth should stay UP when the Hikari pool exposes no MXBean") + void checkHealth_shouldStayUpWhenHikariMxBeanUnavailable() throws SQLException { + HikariDataSource hikariDataSource = mock(HikariDataSource.class); + lenient().when(hikariDataSource.getConnection()).thenReturn(connection); + lenient().when(hikariDataSource.getHikariPoolMXBean()).thenReturn(null); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(THRESHOLD * 2L); + lenient().when(resultSet.getInt(1)).thenReturn(0); + stubRedisPong("PONG"); + HealthService service = new HealthService(hikariDataSource, redisTemplate, THRESHOLD); + + Map response = service.checkHealth(); + + assertEquals("UP", component(response, "mysql").get("status")); + service.shutdown(); + } + + @Test + @DisplayName("checkHealth should treat MySQL as DEGRADED when a diagnostic query fails outright") + void checkHealth_shouldTreatDiagnosticQueryFailureAsDegraded() throws SQLException { + lenient().when(dataSource.getConnection()).thenReturn(connection); + lenient().when(connection.prepareStatement("SELECT 1 as health_check")).thenReturn(preparedStatement); + lenient().when(connection.prepareStatement(COUNT_SQL)).thenReturn(preparedStatement); + lenient().when(connection.prepareStatement(argThat(sql -> + sql != null && sql.contains("INFORMATION_SCHEMA.PROCESSLIST")))) + .thenThrow(new SQLException("diagnostics denied")); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(THRESHOLD * 2L); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("UP", component(response, "mysql").get("status"), + "a failed diagnostic query is swallowed and must not degrade the component"); + } + + @Test + @DisplayName("checkHealth should mark a component DOWN when its check does not finish in time") + void checkHealth_shouldMarkComponentDownWhenCheckTimesOut() throws SQLException { + lenient().when(dataSource.getConnection()).thenAnswer(invocation -> { + Thread.sleep(6_000); + return connection; + }); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("DOWN", response.get("status")); + assertEquals("DOWN", component(response, "mysql").get("status")); + assertEquals("CRITICAL", component(response, "mysql").get("severity")); + assertEquals("DEGRADED", component(response, "beneficiaryIdPool").get("status")); + } + } } diff --git a/src/test/java/com/iemr/common/bengen/utils/GeneratorTest.java b/src/test/java/com/iemr/common/bengen/utils/GeneratorTest.java new file mode 100644 index 0000000..c68ef69 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/GeneratorTest.java @@ -0,0 +1,174 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import java.math.BigInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("Generator Test Suite") +class GeneratorTest { + + private static Level originalLevel; + + private Generator generator; + + @BeforeAll + @DisplayName("Enable debug logging so the generator's diagnostic branches are exercised") + static void enableDebugLogging() { + Logger logger = (Logger) LoggerFactory.getLogger(Generator.class); + originalLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + } + + @AfterAll + @DisplayName("Restore the original log level") + static void restoreLogging() { + ((Logger) LoggerFactory.getLogger(Generator.class)).setLevel(originalLevel); + } + + @BeforeEach + @DisplayName("Create a generator before each test") + void setUp() { + generator = new Generator(); + } + + @Nested + @DisplayName("Beneficiary id generation") + class GenerateBeneficiaryIdTests { + + @RepeatedTest(value = 5, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("generateBeneficiaryId should produce a 12-digit id") + void generateBeneficiaryId_shouldProduceTwelveDigitId() { + BigInteger id = generator.generateBeneficiaryId(); + + assertEquals(12, id.toString().length(), "beneficiary ids are 12 digits: " + id); + } + + @RepeatedTest(value = 5, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("generateBeneficiaryId should end with the Verhoeff digit of its zero-terminated base") + void generateBeneficiaryId_shouldEndWithVerhoeffDigitOfZeroTerminatedBase() { + String id = generator.generateBeneficiaryId().toString(); + + // The generator derives the check digit from the base value while its last + // position is still a zero, then adds the digit into that position. Note this + // is NOT the standard convention, under which the digit would be derived from + // the 11-digit prefix alone, so Verhoeff.validateVerhoeff(id) usually fails. + String zeroTerminatedBase = id.substring(0, id.length() - 1) + "0"; + assertEquals(id.substring(id.length() - 1), Verhoeff.generateVerhoeff(zeroTerminatedBase), + "the trailing digit must be the Verhoeff digit of " + zeroTerminatedBase); + } + + @RepeatedTest(value = 5, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("generateFirst should start with a digit between 2 and 9") + void generateFirst_shouldStartWithDigitBetweenTwoAndNine() { + char leading = generator.generateFirst().toString().charAt(0); + + assertTrue(leading >= '2' && leading <= '9', "leading digit was " + leading); + } + } + + @Nested + @DisplayName("Digit counting") + class DigitCountTests { + + @Test + @DisplayName("getDigitCount should count the digits of a single-digit number") + void getDigitCount_shouldCountSingleDigit() { + assertEquals(1, generator.getDigitCount(BigInteger.valueOf(7))); + } + + @Test + @DisplayName("getDigitCount should count the digits at a power-of-ten boundary") + void getDigitCount_shouldCountAtPowerOfTenBoundary() { + assertEquals(3, generator.getDigitCount(BigInteger.valueOf(100))); + assertEquals(3, generator.getDigitCount(BigInteger.valueOf(999))); + assertEquals(4, generator.getDigitCount(BigInteger.valueOf(1000))); + } + + @Test + @DisplayName("getDigitCount should count the digits of a twelve-digit beneficiary id") + void getDigitCount_shouldCountTwelveDigitId() { + assertEquals(12, generator.getDigitCount(new BigInteger("753812721192"))); + } + } + + @Nested + @DisplayName("Random helpers") + class RandomHelperTests { + + @RepeatedTest(value = 10, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("getRandomNum should return a single decimal digit") + void getRandomNum_shouldReturnSingleDecimalDigit() { + int num = generator.getRandomNum(); + + assertTrue(num >= 0 && num <= 9, "expected a single digit but got " + num); + } + + @RepeatedTest(value = 10, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("getRandomNumRad should return a value inside the requested radix") + void getRandomNumRad_shouldReturnValueInsideRadix() { + int num = generator.getRandomNumRad(5); + + assertTrue(num >= 0 && num < 5, "expected a value below the radix but got " + num); + } + + @RepeatedTest(value = 10, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("getRandomNumRadRange should return a value inside the requested range") + void getRandomNumRadRange_shouldReturnValueInsideRange() { + int num = generator.getRandomNumRadRange(2, 9); + + assertTrue(num >= 2 && num <= 9, "expected a value within 2..9 but got " + num); + } + } + + @Nested + @DisplayName("Diagnostics") + class DiagnosticsTests { + + @Test + @DisplayName("displayArrays should log both arrays without failing") + void displayArrays_shouldLogBothArrays() { + assertDoesNotThrow(() -> generator.displayArrays(new int[] { 1, 2, 3 }, new int[] { 4, 5, 6 })); + } + + @Test + @DisplayName("displayArrays should tolerate empty arrays") + void displayArrays_shouldTolerateEmptyArrays() { + assertDoesNotThrow(() -> generator.displayArrays(new int[0], new int[0])); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/RestTemplateUtilTest.java b/src/test/java/com/iemr/common/bengen/utils/RestTemplateUtilTest.java new file mode 100644 index 0000000..9e1d9f7 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/RestTemplateUtilTest.java @@ -0,0 +1,158 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import org.junit.jupiter.api.AfterEach; +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.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.Cookie; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +@DisplayName("RestTemplateUtil Test Suite") +class RestTemplateUtilTest { + + private static final String AUTHORIZATION = "session-key-123"; + private static final String BODY = "{\"benCount\":5}"; + private static final String JSON_UTF8 = "application/json;charset=utf-8"; + + private MockHttpServletRequest request; + + @BeforeEach + @DisplayName("Bind a fresh mock request to the request context before each test") + void setUp() { + request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + @DisplayName("Clear the request context and User-Agent thread local after each test") + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + UserAgentContext.clear(); + } + + @Nested + @DisplayName("Outside a web request") + class NoRequestContextTests { + + @Test + @DisplayName("createRequestEntity should build a minimal entity when no request is bound") + void createRequestEntity_shouldBuildMinimalEntityWithoutRequestContext() { + RequestContextHolder.resetRequestAttributes(); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertSame(BODY, entity.getBody()); + assertEquals(JSON_UTF8, entity.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + assertEquals(AUTHORIZATION, entity.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + assertFalse(entity.getHeaders().containsKey("JwtToken")); + assertFalse(entity.getHeaders().containsKey(HttpHeaders.COOKIE)); + } + } + + @Nested + @DisplayName("Inside a web request") + class WithRequestContextTests { + + @Test + @DisplayName("createRequestEntity should carry the content type and authorization from the caller") + void createRequestEntity_shouldCarryContentTypeAndAuthorization() { + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertSame(BODY, entity.getBody()); + assertEquals(JSON_UTF8, entity.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + assertEquals(AUTHORIZATION, entity.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + @DisplayName("createRequestEntity should forward the inbound JwtToken header") + void createRequestEntity_shouldForwardInboundJwtTokenHeader() { + request.addHeader("JwtToken", "header-token"); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertEquals("header-token", entity.getHeaders().getFirst("JwtToken")); + } + + @Test + @DisplayName("createRequestEntity should replay the Jwttoken cookie as a Cookie header") + void createRequestEntity_shouldReplayJwtTokenCookie() { + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertEquals("Jwttoken=cookie-token", entity.getHeaders().getFirst(HttpHeaders.COOKIE)); + } + + @Test + @DisplayName("createRequestEntity should omit the Cookie header when no Jwttoken cookie is present") + void createRequestEntity_shouldOmitCookieHeaderWithoutJwtTokenCookie() { + request.setCookies(new Cookie("theme", "dark")); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertNull(entity.getHeaders().getFirst(HttpHeaders.COOKIE)); + } + + @Test + @DisplayName("createRequestEntity should propagate the mobile User-Agent when one is in scope") + void createRequestEntity_shouldPropagateMobileUserAgent() { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertEquals("okhttp/4.9.0", entity.getHeaders().getFirst(HttpHeaders.USER_AGENT)); + } + + @Test + @DisplayName("createRequestEntity should omit the User-Agent header when none is in scope") + void createRequestEntity_shouldOmitUserAgentWhenNoneInScope() { + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertNull(entity.getHeaders().getFirst(HttpHeaders.USER_AGENT)); + } + + @Test + @DisplayName("createRequestEntity should carry both the cookie and header tokens together") + void createRequestEntity_shouldCarryBothCookieAndHeaderTokens() { + request.addHeader("JwtToken", "header-token"); + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertEquals("header-token", entity.getHeaders().getFirst("JwtToken")); + assertEquals("Jwttoken=cookie-token", entity.getHeaders().getFirst(HttpHeaders.COOKIE)); + } + } +} From 6569858e477c730023d89a20e0926d8382a4c693 Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Fri, 21 Aug 2026 11:58:52 +0530 Subject: [PATCH 09/10] =?UTF-8?q?test:=20cover=20config=20beans,=20excepti?= =?UTF-8?q?ons,=20wrapper=20and=20domain=20POJOs=20=E2=80=94=2092.2%=20cov?= =?UTF-8?q?erage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CorsConfig, SwaggerConfig, RedisConfig, InterceptorConfig, FilterConfig and IEMRApplBeans: @Bean/override methods called directly, asserting on the registrations and objects they return. - AuthorizationHeaderRequestWrapper: overridden header accessors, case-insensitive matching, pass-through and no-duplicate header names. - IEMRException and InventoryException: message and cause constructors, including the adopted stack trace and deliberately unchained cause. - M_BeneficiaryRegidMapping: construction, value semantics and the Gson toString that exposes only annotated fields. - UserAgentContext: thread-confinement of the per-request User-Agent. Line coverage: 86.0% -> 92.2% (1392/1510 lines, 380 tests) Co-Authored-By: Claude Opus 5 (1M context) --- .../common/bengen/config/CorsConfigTest.java | 112 +++++++++++++ .../bengen/config/InterceptorConfigTest.java | 67 ++++++++ .../common/bengen/config/RedisConfigTest.java | 80 +++++++++ .../bengen/config/SwaggerConfigTest.java | 101 ++++++++++++ .../domain/M_BeneficiaryRegidMappingTest.java | 152 ++++++++++++++++++ .../common/bengen/utils/FilterConfigTest.java | 84 ++++++++++ .../bengen/utils/IEMRApplBeansTest.java | 101 ++++++++++++ .../bengen/utils/UserAgentContextTest.java | 88 ++++++++++ .../utils/exception/ExceptionsTest.java | 114 +++++++++++++ ...AuthorizationHeaderRequestWrapperTest.java | 128 +++++++++++++++ 10 files changed, 1027 insertions(+) create mode 100644 src/test/java/com/iemr/common/bengen/config/CorsConfigTest.java create mode 100644 src/test/java/com/iemr/common/bengen/config/InterceptorConfigTest.java create mode 100644 src/test/java/com/iemr/common/bengen/config/RedisConfigTest.java create mode 100644 src/test/java/com/iemr/common/bengen/config/SwaggerConfigTest.java create mode 100644 src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/FilterConfigTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/IEMRApplBeansTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/UserAgentContextTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/exception/ExceptionsTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/http/AuthorizationHeaderRequestWrapperTest.java diff --git a/src/test/java/com/iemr/common/bengen/config/CorsConfigTest.java b/src/test/java/com/iemr/common/bengen/config/CorsConfigTest.java new file mode 100644 index 0000000..8f72d73 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/CorsConfigTest.java @@ -0,0 +1,112 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.config; + +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.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; + +import java.util.Map; + +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CorsConfig Test Suite") +class CorsConfigTest { + + private static final String ALLOWED_ORIGINS = "https://amrit.example.org, http://localhost:*"; + + private CorsConfig corsConfig; + + @BeforeEach + @DisplayName("Configure the allow-list before each test") + void setUp() { + corsConfig = new CorsConfig(); + ReflectionTestUtils.setField(corsConfig, "allowedOrigins", ALLOWED_ORIGINS); + } + + // CorsRegistry.getCorsConfigurations() is protected, so read the registrations + // back reflectively — the same map Spring MVC itself consumes. + @SuppressWarnings("unchecked") + private Map corsConfigurations(CorsRegistry registry) { + return (Map) + ReflectionTestUtils.invokeMethod(registry, "getCorsConfigurations"); + } + + @Test + @DisplayName("addCorsMappings should register a mapping for every path") + void addCorsMappings_shouldRegisterMappingForEveryPath() { + CorsRegistry registry = new CorsRegistry(); + + corsConfig.addCorsMappings(registry); + + Map configurations = corsConfigurations(registry); + assertEquals(1, configurations.size()); + assertNotNull(configurations.get("/**")); + } + + @Test + @DisplayName("addCorsMappings should trim and register each configured origin pattern") + void addCorsMappings_shouldTrimAndRegisterOriginPatterns() { + CorsRegistry registry = new CorsRegistry(); + + corsConfig.addCorsMappings(registry); + + CorsConfiguration configuration = corsConfigurations(registry).get("/**"); + assertEquals(java.util.List.of("https://amrit.example.org", "http://localhost:*"), + configuration.getAllowedOriginPatterns()); + } + + @Test + @DisplayName("addCorsMappings should permit the documented methods and headers with credentials") + void addCorsMappings_shouldPermitDocumentedMethodsAndHeaders() { + CorsRegistry registry = new CorsRegistry(); + + corsConfig.addCorsMappings(registry); + + CorsConfiguration configuration = corsConfigurations(registry).get("/**"); + assertEquals(java.util.List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"), + configuration.getAllowedMethods()); + assertTrue(configuration.getAllowedHeaders().contains("Jwttoken")); + assertTrue(configuration.getExposedHeaders().contains("Authorization")); + assertTrue(configuration.getExposedHeaders().contains("Jwttoken")); + assertEquals(Boolean.TRUE, configuration.getAllowCredentials()); + assertEquals(3600L, configuration.getMaxAge()); + } + + @Test + @DisplayName("addCorsMappings should fail fast when no origin allow-list is configured") + void addCorsMappings_shouldFailFastWithoutAllowList() { + ReflectionTestUtils.setField(corsConfig, "allowedOrigins", null); + CorsRegistry registry = new CorsRegistry(); + + assertThrows(NullPointerException.class, () -> corsConfig.addCorsMappings(registry)); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/InterceptorConfigTest.java b/src/test/java/com/iemr/common/bengen/config/InterceptorConfigTest.java new file mode 100644 index 0000000..708ca3d --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/InterceptorConfigTest.java @@ -0,0 +1,67 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +import com.iemr.common.bengen.utils.http.HTTPRequestInterceptor; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +@ExtendWith(MockitoExtension.class) +@DisplayName("InterceptorConfig Test Suite") +class InterceptorConfigTest { + + @Mock + private HTTPRequestInterceptor requestInterceptor; + + private InterceptorConfig interceptorConfig; + + @BeforeEach + @DisplayName("Wire the configuration with a mocked interceptor before each test") + void setUp() { + interceptorConfig = new InterceptorConfig(); + ReflectionTestUtils.setField(interceptorConfig, "requestInterceptor", requestInterceptor); + } + + @Test + @DisplayName("addInterceptors should register the HTTP request interceptor exactly once") + void addInterceptors_shouldRegisterRequestInterceptorOnce() { + InterceptorRegistry registry = new InterceptorRegistry(); + + interceptorConfig.addInterceptors(registry); + + List interceptors = (List) ReflectionTestUtils.invokeMethod(registry, "getInterceptors"); + assertEquals(1, interceptors.size()); + assertSame(requestInterceptor, interceptors.get(0)); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/RedisConfigTest.java b/src/test/java/com/iemr/common/bengen/config/RedisConfigTest.java new file mode 100644 index 0000000..59b3d80 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/RedisConfigTest.java @@ -0,0 +1,80 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.session.data.redis.config.ConfigureRedisAction; + +import com.iemr.common.bengen.data.user.User; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisConfig Test Suite") +class RedisConfigTest { + + @Mock + private RedisConnectionFactory connectionFactory; + + private RedisConfig redisConfig; + + @BeforeEach + @DisplayName("Create the configuration before each test") + void setUp() { + redisConfig = new RedisConfig(); + } + + @Test + @DisplayName("configureRedisAction should disable Spring Session's CONFIG command probing") + void configureRedisAction_shouldDisableConfigCommandProbing() { + assertSame(ConfigureRedisAction.NO_OP, redisConfig.configureRedisAction()); + } + + @Test + @DisplayName("redisTemplate should bind the supplied connection factory") + void redisTemplate_shouldBindSuppliedConnectionFactory() { + RedisTemplate template = redisConfig.redisTemplate(connectionFactory); + + assertNotNull(template); + assertSame(connectionFactory, template.getConnectionFactory()); + } + + @Test + @DisplayName("redisTemplate should serialise keys as plain strings and values as User JSON") + void redisTemplate_shouldSerialiseKeysAsStringsAndValuesAsJson() { + RedisTemplate template = redisConfig.redisTemplate(connectionFactory); + + assertTrue(template.getKeySerializer() instanceof StringRedisSerializer); + assertTrue(template.getValueSerializer() instanceof Jackson2JsonRedisSerializer); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/SwaggerConfigTest.java b/src/test/java/com/iemr/common/bengen/config/SwaggerConfigTest.java new file mode 100644 index 0000000..c3a82e4 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/SwaggerConfigTest.java @@ -0,0 +1,101 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.config; + +import org.junit.jupiter.api.BeforeEach; +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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("SwaggerConfig Test Suite") +class SwaggerConfigTest { + + private static final String SECURITY_SCHEME_NAME = "my security"; + private static final String DEFAULT_URL = "http://localhost:9090"; + + private SwaggerConfig swaggerConfig; + private MockEnvironment environment; + + @BeforeEach + @DisplayName("Create the configuration and an empty environment before each test") + void setUp() { + swaggerConfig = new SwaggerConfig(); + environment = new MockEnvironment(); + } + + @Test + @DisplayName("customOpenAPI should describe the beneficiary generation API") + void customOpenAPI_shouldDescribeTheApi() { + OpenAPI openAPI = swaggerConfig.customOpenAPI(environment); + + assertNotNull(openAPI.getInfo()); + assertEquals("BeneficiaryID-Generation API", openAPI.getInfo().getTitle()); + assertEquals("1.0", openAPI.getInfo().getVersion()); + assertTrue(openAPI.getInfo().getDescription().contains("unique beneficiary registration Id")); + } + + @Test + @DisplayName("customOpenAPI should declare a bearer security scheme and require it") + void customOpenAPI_shouldDeclareBearerSecurityScheme() { + OpenAPI openAPI = swaggerConfig.customOpenAPI(environment); + + SecurityScheme scheme = openAPI.getComponents().getSecuritySchemes().get(SECURITY_SCHEME_NAME); + assertNotNull(scheme); + assertEquals(SecurityScheme.Type.HTTP, scheme.getType()); + assertEquals("bearer", scheme.getScheme()); + assertEquals(1, openAPI.getSecurity().size()); + assertTrue(openAPI.getSecurity().get(0).containsKey(SECURITY_SCHEME_NAME)); + } + + @Test + @DisplayName("customOpenAPI should fall back to localhost for every unset server url") + void customOpenAPI_shouldFallBackToLocalhostForUnsetUrls() { + OpenAPI openAPI = swaggerConfig.customOpenAPI(environment); + + assertEquals(3, openAPI.getServers().size()); + openAPI.getServers().forEach(server -> assertEquals(DEFAULT_URL, server.getUrl())); + assertEquals("Dev", openAPI.getServers().get(0).getDescription()); + assertEquals("UAT", openAPI.getServers().get(1).getDescription()); + assertEquals("Demo", openAPI.getServers().get(2).getDescription()); + } + + @Test + @DisplayName("customOpenAPI should use the configured dev, UAT and demo urls when present") + void customOpenAPI_shouldUseConfiguredUrls() { + environment.setProperty("api.dev.url", "https://dev.amrit.example.org"); + environment.setProperty("api.uat.url", "https://uat.amrit.example.org"); + environment.setProperty("api.demo.url", "https://demo.amrit.example.org"); + + OpenAPI openAPI = swaggerConfig.customOpenAPI(environment); + + assertEquals("https://dev.amrit.example.org", openAPI.getServers().get(0).getUrl()); + assertEquals("https://uat.amrit.example.org", openAPI.getServers().get(1).getUrl()); + assertEquals("https://demo.amrit.example.org", openAPI.getServers().get(2).getUrl()); + } +} diff --git a/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java b/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java new file mode 100644 index 0000000..827cb51 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java @@ -0,0 +1,152 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.domain; + +import java.sql.Timestamp; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("M_BeneficiaryRegidMapping Test Suite") +class M_BeneficiaryRegidMappingTest { + + private static final Long BEN_REG_ID = 753812721192L; + private static final Long BENEFICIARY_ID = 796702837334L; + private static final Timestamp CREATED_DATE = Timestamp.valueOf("2025-06-25 10:00:00"); + + private M_BeneficiaryRegidMapping row() { + return new M_BeneficiaryRegidMapping(BEN_REG_ID, BENEFICIARY_ID, CREATED_DATE, "admin"); + } + + private M_BeneficiaryRegidMapping fullyPopulated() { + M_BeneficiaryRegidMapping row = row(); + row.setProvisioned(false); + row.setReserved(false); + row.setVanID(12); + row.setBenIDRequired(500L); + return row; + } + + @Nested + @DisplayName("Construction and field access") + class FieldTests { + + @Test + @DisplayName("the constructor should populate the identity and audit fields") + void constructor_shouldPopulateIdentityAndAuditFields() { + M_BeneficiaryRegidMapping row = row(); + + assertEquals(BEN_REG_ID, row.getBenRegId()); + assertEquals(BENEFICIARY_ID, row.getBeneficiaryId()); + assertEquals(CREATED_DATE, row.getCreatedDate()); + assertEquals("admin", row.getCreatedBy()); + } + + @Test + @DisplayName("the constructor should leave the provisioning and reservation flags unset") + void constructor_shouldLeaveFlagsUnset() { + M_BeneficiaryRegidMapping row = row(); + + assertNull(row.getProvisioned()); + assertNull(row.getReserved()); + assertNull(row.getVanID()); + assertNull(row.getBenIDRequired()); + } + + @Test + @DisplayName("the provisioning, reservation and van fields should round-trip through their accessors") + void mutableFields_shouldRoundTrip() { + M_BeneficiaryRegidMapping row = fullyPopulated(); + + assertEquals(false, row.getProvisioned()); + assertEquals(false, row.getReserved()); + assertEquals(12, row.getVanID()); + assertEquals(500L, row.getBenIDRequired()); + } + } + + @Nested + @DisplayName("Value semantics and serialisation") + class ValueSemanticsTests { + + @Test + @DisplayName("two rows holding the same values should be equal and share a hash code") + void rowsWithSameValues_shouldBeEqual() { + assertEquals(fullyPopulated(), fullyPopulated()); + assertEquals(fullyPopulated().hashCode(), fullyPopulated().hashCode()); + } + + @Test + @DisplayName("rows differing in provisioning state should not be equal") + void rowsDifferingInProvisioningState_shouldNotBeEqual() { + M_BeneficiaryRegidMapping other = fullyPopulated(); + other.setProvisioned(true); + + assertNotEquals(fullyPopulated(), other); + } + + @Test + @DisplayName("a row should not equal an unrelated object") + void row_shouldNotEqualUnrelatedObject() { + assertNotEquals(fullyPopulated(), "not a mapping row"); + } + + @Test + @DisplayName("toString should render the exposed fields as JSON with ids serialised as strings") + void toString_shouldRenderExposedFieldsAsJson() { + String json = fullyPopulated().toString(); + + assertTrue(json.contains("\"benRegId\":\"753812721192\""), json); + assertTrue(json.contains("\"beneficiaryId\":\"796702837334\""), json); + assertTrue(json.contains("\"createdBy\":\"admin\""), json); + } + + @Test + @DisplayName("toString should omit the fields that are not exposed for serialisation") + void toString_shouldOmitUnexposedFields() { + String json = fullyPopulated().toString(); + + assertTrue(!json.contains("provisioned"), json); + assertTrue(!json.contains("reserved"), json); + assertTrue(!json.contains("vanID"), json); + assertTrue(!json.contains("benIDRequired"), json); + } + + @Test + @DisplayName("toString should serialise a null audit field rather than dropping it") + void toString_shouldSerialiseNullAuditField() { + M_BeneficiaryRegidMapping row = + new M_BeneficiaryRegidMapping(BEN_REG_ID, BENEFICIARY_ID, null, null); + + String json = row.toString(); + + assertTrue(json.contains("\"createdDate\":null"), json); + assertTrue(json.contains("\"createdBy\":null"), json); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/FilterConfigTest.java b/src/test/java/com/iemr/common/bengen/utils/FilterConfigTest.java new file mode 100644 index 0000000..7adab09 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/FilterConfigTest.java @@ -0,0 +1,84 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.core.Ordered; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FilterConfig Test Suite") +class FilterConfigTest { + + private static final String ALLOWED_ORIGINS = "https://amrit.example.org,http://localhost:*"; + + @Mock + private JwtAuthenticationUtil jwtAuthenticationUtil; + + private FilterConfig filterConfig; + + @BeforeEach + @DisplayName("Configure the allow-list before each test") + void setUp() { + filterConfig = new FilterConfig(); + ReflectionTestUtils.setField(filterConfig, "allowedOrigins", ALLOWED_ORIGINS); + } + + @Test + @DisplayName("jwtUserIdValidationFilter should register the JWT filter across every url pattern") + void jwtUserIdValidationFilter_shouldRegisterFilterForEveryUrlPattern() { + FilterRegistrationBean registration = + filterConfig.jwtUserIdValidationFilter(jwtAuthenticationUtil); + + assertNotNull(registration.getFilter()); + assertEquals(java.util.Set.of("/*"), registration.getUrlPatterns()); + } + + @Test + @DisplayName("jwtUserIdValidationFilter should run at the highest precedence so auth happens first") + void jwtUserIdValidationFilter_shouldRunAtHighestPrecedence() { + FilterRegistrationBean registration = + filterConfig.jwtUserIdValidationFilter(jwtAuthenticationUtil); + + assertEquals(Ordered.HIGHEST_PRECEDENCE, registration.getOrder()); + } + + @Test + @DisplayName("jwtUserIdValidationFilter should hand the filter the configured origins and auth util") + void jwtUserIdValidationFilter_shouldPassOriginsAndAuthUtilToFilter() { + JwtUserIdValidationFilter filter = + filterConfig.jwtUserIdValidationFilter(jwtAuthenticationUtil).getFilter(); + + assertEquals(ALLOWED_ORIGINS, ReflectionTestUtils.getField(filter, "allowedOrigins")); + assertSame(jwtAuthenticationUtil, ReflectionTestUtils.getField(filter, "jwtAuthenticationUtil")); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/IEMRApplBeansTest.java b/src/test/java/com/iemr/common/bengen/utils/IEMRApplBeansTest.java new file mode 100644 index 0000000..35351fd --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/IEMRApplBeansTest.java @@ -0,0 +1,101 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.bengen.utils.gateway.email.EmailService; +import com.iemr.common.bengen.utils.gateway.email.GenericEmailServiceImpl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("IEMRApplBeans Test Suite") +class IEMRApplBeansTest { + + private IEMRApplBeans beans; + + @BeforeEach + @DisplayName("Create the bean configuration with Redis coordinates before each test") + void setUp() { + beans = new IEMRApplBeans(); + ReflectionTestUtils.setField(beans, "redisHost", "redis.example.org"); + ReflectionTestUtils.setField(beans, "redisPort", 6379); + } + + @Test + @DisplayName("getVaidator should supply a validator bean") + void getVaidator_shouldSupplyValidatorBean() { + assertNotNull(beans.getVaidator()); + } + + @Test + @DisplayName("getEmailService should supply the generic email implementation") + void getEmailService_shouldSupplyGenericImplementation() { + EmailService emailService = beans.getEmailService(); + + assertTrue(emailService instanceof GenericEmailServiceImpl); + } + + @Test + @DisplayName("getJavaMailSender should supply a JavaMailSender implementation") + void getJavaMailSender_shouldSupplyMailSenderImplementation() { + JavaMailSender mailSender = beans.getJavaMailSender(); + + assertTrue(mailSender instanceof JavaMailSenderImpl); + } + + @Test + @DisplayName("configProperties should supply a fresh properties holder on each call") + void configProperties_shouldSupplyFreshHolder() { + assertNotSame(beans.configProperties(), beans.configProperties()); + } + + @Test + @DisplayName("sessionObject should supply a session holder bean") + void sessionObject_shouldSupplySessionHolder() { + assertNotNull(beans.sessionObject()); + } + + @Test + @DisplayName("redisStorage should supply a Redis store bean") + void redisStorage_shouldSupplyRedisStore() { + assertNotNull(beans.redisStorage()); + } + + @Test + @DisplayName("connectionFactory should point Lettuce at the configured host and port") + void connectionFactory_shouldPointAtConfiguredHostAndPort() { + LettuceConnectionFactory factory = beans.connectionFactory(); + + assertEquals("redis.example.org", factory.getHostName()); + assertEquals(6379, factory.getPort()); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/UserAgentContextTest.java b/src/test/java/com/iemr/common/bengen/utils/UserAgentContextTest.java new file mode 100644 index 0000000..7e9cffe --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/UserAgentContextTest.java @@ -0,0 +1,88 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@DisplayName("UserAgentContext Test Suite") +class UserAgentContextTest { + + @AfterEach + @DisplayName("Clear the thread local after each test") + void tearDown() { + UserAgentContext.clear(); + } + + @Test + @DisplayName("getUserAgent should be empty before anything is set") + void getUserAgent_shouldBeEmptyByDefault() { + assertNull(UserAgentContext.getUserAgent()); + } + + @Test + @DisplayName("setUserAgent should make the value readable on the same thread") + void setUserAgent_shouldBeReadableOnSameThread() { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + + assertEquals("okhttp/4.9.0", UserAgentContext.getUserAgent()); + } + + @Test + @DisplayName("setUserAgent should overwrite a previously stored value") + void setUserAgent_shouldOverwritePreviousValue() { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + UserAgentContext.setUserAgent("Java/17.0.2"); + + assertEquals("Java/17.0.2", UserAgentContext.getUserAgent()); + } + + @Test + @DisplayName("clear should remove the stored value") + void clear_shouldRemoveStoredValue() { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + + UserAgentContext.clear(); + + assertNull(UserAgentContext.getUserAgent()); + } + + @Test + @DisplayName("the stored value should not leak into another thread") + void storedValue_shouldNotLeakAcrossThreads() throws Exception { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + Future otherThreadValue = executor.submit(UserAgentContext::getUserAgent); + + assertNull(otherThreadValue.get(), "the User-Agent is per-request, so must stay thread-confined"); + executor.shutdown(); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/exception/ExceptionsTest.java b/src/test/java/com/iemr/common/bengen/utils/exception/ExceptionsTest.java new file mode 100644 index 0000000..df453d8 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/exception/ExceptionsTest.java @@ -0,0 +1,114 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.exception; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@DisplayName("AMRIT exception types Test Suite") +class ExceptionsTest { + + private static final String MESSAGE = "Invalid session key"; + + private RuntimeException causeWithStackTrace() { + RuntimeException cause = new RuntimeException("root cause"); + cause.setStackTrace(new StackTraceElement[] { + new StackTraceElement("com.iemr.Origin", "failingMethod", "Origin.java", 42) }); + return cause; + } + + @Nested + @DisplayName("IEMRException") + class IEMRExceptionTests { + + @Test + @DisplayName("the message constructor should expose the message through both accessors") + void messageConstructor_shouldExposeMessage() { + IEMRException exception = new IEMRException(MESSAGE); + + assertEquals(MESSAGE, exception.getMessage()); + assertEquals(MESSAGE, exception.toString()); + } + + @Test + @DisplayName("the cause constructor should adopt the stack trace of the cause") + void causeConstructor_shouldAdoptCauseStackTrace() { + RuntimeException cause = causeWithStackTrace(); + + IEMRException exception = new IEMRException(MESSAGE, cause); + + assertEquals(MESSAGE, exception.getMessage()); + assertArrayEquals(cause.getStackTrace(), exception.getStackTrace()); + } + + @Test + @DisplayName("the cause constructor should not chain the cause itself") + void causeConstructor_shouldNotChainCause() { + IEMRException exception = new IEMRException(MESSAGE, causeWithStackTrace()); + + assertNull(exception.getCause(), + "only the stack trace is adopted; the cause is deliberately not chained"); + } + + @Test + @DisplayName("toString should return null when constructed with a null message") + void toString_shouldReturnNullForNullMessage() { + assertNull(new IEMRException(null).toString()); + } + } + + @Nested + @DisplayName("InventoryException") + class InventoryExceptionTests { + + @Test + @DisplayName("the message constructor should expose the message through both accessors") + void messageConstructor_shouldExposeMessage() { + InventoryException exception = new InventoryException(MESSAGE); + + assertEquals(MESSAGE, exception.getMessage()); + assertEquals(MESSAGE, exception.toString()); + } + + @Test + @DisplayName("the cause constructor should adopt the stack trace of the cause") + void causeConstructor_shouldAdoptCauseStackTrace() { + RuntimeException cause = causeWithStackTrace(); + + InventoryException exception = new InventoryException(MESSAGE, cause); + + assertEquals(MESSAGE, exception.getMessage()); + assertArrayEquals(cause.getStackTrace(), exception.getStackTrace()); + } + + @Test + @DisplayName("the cause constructor should not chain the cause itself") + void causeConstructor_shouldNotChainCause() { + assertNull(new InventoryException(MESSAGE, causeWithStackTrace()).getCause()); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/http/AuthorizationHeaderRequestWrapperTest.java b/src/test/java/com/iemr/common/bengen/utils/http/AuthorizationHeaderRequestWrapperTest.java new file mode 100644 index 0000000..85bcea5 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/http/AuthorizationHeaderRequestWrapperTest.java @@ -0,0 +1,128 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils.http; + +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.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("AuthorizationHeaderRequestWrapper Test Suite") +class AuthorizationHeaderRequestWrapperTest { + + private MockHttpServletRequest request; + + @BeforeEach + @DisplayName("Create a request carrying an inbound Authorization header before each test") + void setUp() { + request = new MockHttpServletRequest(); + request.addHeader("Authorization", "inbound-key"); + request.addHeader("JwtToken", "header-token"); + } + + @Test + @DisplayName("getHeader should return the overridden value for Authorization") + void getHeader_shouldReturnOverriddenAuthorization() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals("overridden-key", wrapper.getHeader("Authorization")); + } + + @Test + @DisplayName("getHeader should match the Authorization name case-insensitively") + void getHeader_shouldMatchAuthorizationCaseInsensitively() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals("overridden-key", wrapper.getHeader("authorization")); + assertEquals("overridden-key", wrapper.getHeader("AUTHORIZATION")); + } + + @Test + @DisplayName("getHeader should pass every other header through to the wrapped request") + void getHeader_shouldPassOtherHeadersThrough() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals("header-token", wrapper.getHeader("JwtToken")); + assertNull(wrapper.getHeader("X-Not-Present")); + } + + @Test + @DisplayName("getHeader should return the blank override the JWT filter installs") + void getHeader_shouldReturnBlankOverride() { + AuthorizationHeaderRequestWrapper wrapper = new AuthorizationHeaderRequestWrapper(request, ""); + + assertEquals("", wrapper.getHeader("Authorization"), + "the filter blanks Authorization once the JWT has been validated"); + } + + @Test + @DisplayName("getHeaders should return the overridden Authorization as a single-valued enumeration") + void getHeaders_shouldReturnOverriddenAuthorizationAsSingleValue() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals(List.of("overridden-key"), Collections.list(wrapper.getHeaders("Authorization"))); + } + + @Test + @DisplayName("getHeaders should pass every other header through to the wrapped request") + void getHeaders_shouldPassOtherHeadersThrough() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals(List.of("header-token"), Collections.list(wrapper.getHeaders("JwtToken"))); + } + + @Test + @DisplayName("getHeaderNames should still list Authorization alongside the wrapped names") + void getHeaderNames_shouldListAuthorizationAlongsideWrappedNames() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + List names = Collections.list(wrapper.getHeaderNames()); + assertTrue(names.contains("Authorization")); + assertTrue(names.contains("JwtToken")); + assertEquals(1, names.stream().filter("Authorization"::equals).count(), + "Authorization must not be duplicated when the wrapped request already carries it"); + } + + @Test + @DisplayName("getHeaderNames should add Authorization when the wrapped request lacks it") + void getHeaderNames_shouldAddAuthorizationWhenWrappedRequestLacksIt() { + MockHttpServletRequest bare = new MockHttpServletRequest(); + bare.addHeader("JwtToken", "header-token"); + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(bare, "overridden-key"); + + assertTrue(Collections.list(wrapper.getHeaderNames()).contains("Authorization")); + } +} From 4b7194b293d0b2f90cc38eebe216e10650e79342 Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Fri, 21 Aug 2026 13:18:00 +0530 Subject: [PATCH 10/10] test: bring every JaCoCo metric above 90% The report's headline column is instruction coverage, which read 89% even though line coverage was 92%. This closes the gaps on all five metrics. - User: field access and full value semantics (was 0% branch coverage). - BeneficiaryId, M_BeneficiaryRegidMapping: one variant per field so every Lombok-generated equality branch runs, including the null-on-both-sides and symmetry cases, plus hashCode tolerance of nulls. - utils/OutputResponse: value semantics and payload replacement. - OutputResponse.Builder and utils/response/OutputResponse: the switch arms for exception types owned by other AMRIT modules (MissingMandatoryFields, IllegalAction, JDBC, SQLGrammar, ConstraintViolation), reached via test-local types since the mapping switches on getSimpleName(). - Verhoeff: generate/validate round-trip, wrong check digit and adjacent transposition, the error the algorithm exists to catch. - BeneficiaryGenApplication and CommonMain: bean methods and the WAR configure() hook called directly. Instruction 89.9% -> 96.3%, branch 69.8% -> 91.6%, line 91.9% -> 94.2%, method 89.0% -> 95.8%, class 88.5% -> 92.3% (424 tests) Co-Authored-By: Claude Opus 5 (1M context) --- .../bengen/BeneficiaryGenApplicationTest.java | 97 +++++++++++ .../common/bengen/data/user/UserTest.java | 151 +++++++++++++++++ .../bengen/domain/BeneficiaryIdTest.java | 110 ++++++++++++ .../domain/M_BeneficiaryRegidMappingTest.java | 94 +++++++++++ .../common/bengen/utils/CommonMainTest.java | 60 +++++++ .../utils/OutputResponseBuilderTest.java | 157 ++++++++++++++++++ .../common/bengen/utils/VerhoeffTest.java | 82 +++++++++ .../utils/response/OutputResponseTest.java | 51 ++++++ 8 files changed, 802 insertions(+) create mode 100644 src/test/java/com/iemr/common/bengen/BeneficiaryGenApplicationTest.java create mode 100644 src/test/java/com/iemr/common/bengen/data/user/UserTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/CommonMainTest.java create mode 100644 src/test/java/com/iemr/common/bengen/utils/VerhoeffTest.java diff --git a/src/test/java/com/iemr/common/bengen/BeneficiaryGenApplicationTest.java b/src/test/java/com/iemr/common/bengen/BeneficiaryGenApplicationTest.java new file mode 100644 index 0000000..574d51e --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/BeneficiaryGenApplicationTest.java @@ -0,0 +1,97 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BeneficiaryGenApplication Test Suite") +class BeneficiaryGenApplicationTest { + + @Mock + private RedisConnectionFactory connectionFactory; + + private BeneficiaryGenApplication application; + + @BeforeEach + @DisplayName("Create the application configuration before each test") + void setUp() { + application = new BeneficiaryGenApplication(); + } + + @Test + @DisplayName("restTemplate should supply a RestTemplate bean for outbound calls") + void restTemplate_shouldSupplyRestTemplateBean() { + RestTemplate restTemplate = application.restTemplate(); + + assertNotNull(restTemplate); + } + + @Test + @DisplayName("redisTemplate should bind the supplied connection factory") + void redisTemplate_shouldBindSuppliedConnectionFactory() { + RedisTemplate template = application.redisTemplate(connectionFactory); + + assertNotNull(template); + assertSame(connectionFactory, template.getConnectionFactory()); + } + + @Test + @DisplayName("redisTemplate should serialise keys as plain strings and values as User JSON") + void redisTemplate_shouldSerialiseKeysAsStringsAndValuesAsJson() { + RedisTemplate template = application.redisTemplate(connectionFactory); + + assertTrue(template.getKeySerializer() instanceof StringRedisSerializer); + assertTrue(template.getValueSerializer() instanceof Jackson2JsonRedisSerializer); + } + + @Test + @DisplayName("configure should register the application class as the WAR deployment source") + void configure_shouldRegisterApplicationClassAsSource() { + SpringApplicationBuilder builder = new SpringApplicationBuilder(); + + SpringApplicationBuilder configured = application.configure(builder); + + assertSame(builder, configured, "configure should keep building on the supplied builder"); + // SpringApplicationBuilder stages sources until build(), so read them back directly. + Set> sources = (Set>) ReflectionTestUtils.getField(configured, "sources"); + assertTrue(sources.contains(BeneficiaryGenApplication.class)); + } +} diff --git a/src/test/java/com/iemr/common/bengen/data/user/UserTest.java b/src/test/java/com/iemr/common/bengen/data/user/UserTest.java new file mode 100644 index 0000000..a4e4c36 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/data/user/UserTest.java @@ -0,0 +1,151 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.data.user; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("User Test Suite") +class UserTest { + + private User populated() { + User user = new User(); + user.setUserID(42L); + user.setUserName("amrit-user"); + user.setDeleted(false); + return user; + } + + /** One mutator per field, so every generated equality branch is exercised. */ + private List>> differingValues() { + return List.of( + Map.entry("userID", (Consumer) user -> user.setUserID(99L)), + Map.entry("userName", (Consumer) user -> user.setUserName("someone-else")), + Map.entry("deleted", (Consumer) user -> user.setDeleted(true))); + } + + private List>> nullValues() { + return List.of( + Map.entry("userID", (Consumer) user -> user.setUserID(null)), + Map.entry("userName", (Consumer) user -> user.setUserName(null)), + Map.entry("deleted", (Consumer) user -> user.setDeleted(null))); + } + + @Nested + @DisplayName("Field access") + class FieldTests { + + @Test + @DisplayName("a new user should leave every field unset") + void newUser_shouldLeaveEveryFieldUnset() { + User user = new User(); + + assertNull(user.getUserID()); + assertNull(user.getUserName()); + assertNull(user.getDeleted()); + } + + @Test + @DisplayName("every field should round-trip through its accessors") + void everyField_shouldRoundTrip() { + User user = populated(); + + assertEquals(42L, user.getUserID()); + assertEquals("amrit-user", user.getUserName()); + assertEquals(false, user.getDeleted()); + } + } + + @Nested + @DisplayName("Value semantics") + class ValueSemanticsTests { + + @Test + @DisplayName("two users holding the same values should be equal and share a hash code") + void usersWithSameValues_shouldBeEqual() { + assertEquals(populated(), populated()); + assertEquals(populated().hashCode(), populated().hashCode()); + } + + @Test + @DisplayName("a user should equal itself and never equal null or an unrelated type") + void user_shouldEqualItselfAndNotOtherTypes() { + User user = populated(); + + assertEquals(user, user); + assertNotEquals(user, null); + assertNotEquals(user, "not a User"); + } + + @Test + @DisplayName("a difference in any single field should break equality") + void equals_shouldDetectDifferenceInEveryField() { + assertAll(differingValues().stream().map(field -> () -> { + User variant = populated(); + field.getValue().accept(variant); + assertNotEquals(populated(), variant, + "changing " + field.getKey() + " must break equality"); + })); + } + + @Test + @DisplayName("a null in any single field should break equality in both directions") + void equals_shouldDetectNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + User variant = populated(); + field.getValue().accept(variant); + assertNotEquals(populated(), variant, + "nulling " + field.getKey() + " must break equality"); + assertNotEquals(variant, populated(), + "equality must stay symmetric when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("two unset users should be equal and share a hash code") + void unsetUsers_shouldBeEqual() { + assertEquals(new User(), new User()); + assertEquals(new User().hashCode(), new User().hashCode()); + } + + @Test + @DisplayName("toString should name the type and expose the user identity") + void toString_shouldNameTypeAndExposeIdentity() { + String text = populated().toString(); + + assertTrue(text.startsWith("User(")); + assertTrue(text.contains("42")); + assertTrue(text.contains("amrit-user")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java b/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java index 89cdb4f..9c72e10 100644 --- a/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java +++ b/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java @@ -23,12 +23,17 @@ import java.math.BigInteger; import java.sql.Timestamp; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; 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 static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -43,6 +48,7 @@ class BeneficiaryIdTest { private static final Timestamp RESERVED_UNTIL = Timestamp.valueOf("2025-06-26 10:00:00"); private static final Timestamp PROVISIONED_ON = Timestamp.valueOf("2025-06-25 11:00:00"); private static final Timestamp CREATED_DATE = Timestamp.valueOf("2025-06-01 09:00:00"); + private static final Timestamp OTHER_TIMESTAMP = Timestamp.valueOf("2030-01-01 00:00:00"); private BeneficiaryId beneficiaryId; @@ -191,4 +197,108 @@ void toString_shouldNameTypeAndExposeIdentityFields() { assertTrue(text.contains("scheduler")); } } + + @Nested + @DisplayName("Generated equality across every field") + class PerFieldEqualityTests { + + /** One mutator per field, so every generated equality branch is exercised. */ + private List>> differingValues() { + return List.of( + Map.entry("benRegId", (Consumer) r -> r.setBenRegId(BigInteger.ONE)), + Map.entry("beneficiaryId", (Consumer) r -> r.setBeneficiaryId(BigInteger.TWO)), + Map.entry("provisioned", (Consumer) r -> r.setProvisioned(false)), + Map.entry("provisionedById", (Consumer) r -> r.setProvisionedById(99)), + Map.entry("provisionedBy", (Consumer) r -> r.setProvisionedBy("someone")), + Map.entry("provisionedOn", (Consumer) r -> r.setProvisionedOn(OTHER_TIMESTAMP)), + Map.entry("reserved", (Consumer) r -> r.setReserved(false)), + Map.entry("reservedForId", (Consumer) r -> r.setReservedForId(99)), + Map.entry("reservedForName", (Consumer) r -> r.setReservedForName("Other")), + Map.entry("reservedForCountryId", (Consumer) r -> r.setReservedForCountryId(99)), + Map.entry("reservedForCountryName", (Consumer) r -> r.setReservedForCountryName("Nepal")), + Map.entry("reservedForStateId", (Consumer) r -> r.setReservedForStateId(99)), + Map.entry("reservedForStateName", (Consumer) r -> r.setReservedForStateName("Kerala")), + Map.entry("reservedForDistrictId", (Consumer) r -> r.setReservedForDistrictId(99)), + Map.entry("reservedForDistrictName", (Consumer) r -> r.setReservedForDistrictName("Mysuru")), + Map.entry("reservedForPSMapId", (Consumer) r -> r.setReservedForPSMapId(99)), + Map.entry("reservedForPSMapName", (Consumer) r -> r.setReservedForPSMapName("PS-Map-99")), + Map.entry("reservedById", (Consumer) r -> r.setReservedById(99)), + Map.entry("reservedByName", (Consumer) r -> r.setReservedByName("operator")), + Map.entry("reservedOn", (Consumer) r -> r.setReservedOn(OTHER_TIMESTAMP)), + Map.entry("reservedUntil", (Consumer) r -> r.setReservedUntil(OTHER_TIMESTAMP)), + Map.entry("createdBy", (Consumer) r -> r.setCreatedBy("someone")), + Map.entry("createdDate", (Consumer) r -> r.setCreatedDate(OTHER_TIMESTAMP))); + } + + private List>> nullValues() { + return List.of( + Map.entry("benRegId", (Consumer) r -> r.setBenRegId(null)), + Map.entry("beneficiaryId", (Consumer) r -> r.setBeneficiaryId(null)), + Map.entry("provisioned", (Consumer) r -> r.setProvisioned(null)), + Map.entry("provisionedById", (Consumer) r -> r.setProvisionedById(null)), + Map.entry("provisionedBy", (Consumer) r -> r.setProvisionedBy(null)), + Map.entry("provisionedOn", (Consumer) r -> r.setProvisionedOn(null)), + Map.entry("reserved", (Consumer) r -> r.setReserved(null)), + Map.entry("reservedForId", (Consumer) r -> r.setReservedForId(null)), + Map.entry("reservedForName", (Consumer) r -> r.setReservedForName(null)), + Map.entry("reservedForCountryId", (Consumer) r -> r.setReservedForCountryId(null)), + Map.entry("reservedForCountryName", (Consumer) r -> r.setReservedForCountryName(null)), + Map.entry("reservedForStateId", (Consumer) r -> r.setReservedForStateId(null)), + Map.entry("reservedForStateName", (Consumer) r -> r.setReservedForStateName(null)), + Map.entry("reservedForDistrictId", (Consumer) r -> r.setReservedForDistrictId(null)), + Map.entry("reservedForDistrictName", (Consumer) r -> r.setReservedForDistrictName(null)), + Map.entry("reservedForPSMapId", (Consumer) r -> r.setReservedForPSMapId(null)), + Map.entry("reservedForPSMapName", (Consumer) r -> r.setReservedForPSMapName(null)), + Map.entry("reservedById", (Consumer) r -> r.setReservedById(null)), + Map.entry("reservedByName", (Consumer) r -> r.setReservedByName(null)), + Map.entry("reservedOn", (Consumer) r -> r.setReservedOn(null)), + Map.entry("reservedUntil", (Consumer) r -> r.setReservedUntil(null)), + Map.entry("createdBy", (Consumer) r -> r.setCreatedBy(null)), + Map.entry("createdDate", (Consumer) r -> r.setCreatedDate(null))); + } + + @Test + @DisplayName("a difference in any single field should break equality") + void equals_shouldDetectDifferenceInEveryField() { + assertAll(differingValues().stream().map(field -> () -> { + BeneficiaryId variant = populated(); + field.getValue().accept(variant); + assertNotEquals(populated(), variant, + "changing " + field.getKey() + " must break equality"); + })); + } + + @Test + @DisplayName("a null in any single field should break equality in both directions") + void equals_shouldDetectNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + BeneficiaryId variant = populated(); + field.getValue().accept(variant); + assertNotEquals(populated(), variant, + "nulling " + field.getKey() + " must break equality"); + assertNotEquals(variant, populated(), + "equality must stay symmetric when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("hashCode should tolerate a null in any single field") + void hashCode_shouldTolerateNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + BeneficiaryId variant = populated(); + field.getValue().accept(variant); + assertDoesNotThrow(variant::hashCode, + "hashCode must not fail when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("a row should equal itself and never equal null") + void row_shouldEqualItselfAndNotNull() { + BeneficiaryId row = populated(); + + assertEquals(row, row); + assertNotEquals(row, null); + } + } } diff --git a/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java b/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java index 827cb51..c218b83 100644 --- a/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java +++ b/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java @@ -22,11 +22,16 @@ package com.iemr.common.bengen.domain; import java.sql.Timestamp; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -38,6 +43,7 @@ class M_BeneficiaryRegidMappingTest { private static final Long BEN_REG_ID = 753812721192L; private static final Long BENEFICIARY_ID = 796702837334L; private static final Timestamp CREATED_DATE = Timestamp.valueOf("2025-06-25 10:00:00"); + private static final Timestamp OTHER_DATE = Timestamp.valueOf("2030-01-01 00:00:00"); private M_BeneficiaryRegidMapping row() { return new M_BeneficiaryRegidMapping(BEN_REG_ID, BENEFICIARY_ID, CREATED_DATE, "admin"); @@ -149,4 +155,92 @@ void toString_shouldSerialiseNullAuditField() { assertTrue(json.contains("\"createdBy\":null"), json); } } + + @Nested + @DisplayName("Generated equality across every field") + class PerFieldEqualityTests { + + /** One mutator per field, so every generated equality branch is exercised. */ + private List>> differingValues() { + return List.of( + Map.entry("benRegId", (Consumer) r -> r.setBenRegId(1L)), + Map.entry("beneficiaryId", (Consumer) r -> r.setBeneficiaryId(2L)), + Map.entry("provisioned", (Consumer) r -> r.setProvisioned(true)), + Map.entry("reserved", (Consumer) r -> r.setReserved(true)), + Map.entry("vanID", (Consumer) r -> r.setVanID(99)), + Map.entry("createdDate", (Consumer) r -> r.setCreatedDate(OTHER_DATE)), + Map.entry("createdBy", (Consumer) r -> r.setCreatedBy("someone")), + Map.entry("benIDRequired", (Consumer) r -> r.setBenIDRequired(1L))); + } + + private List>> nullValues() { + return List.of( + Map.entry("benRegId", (Consumer) r -> r.setBenRegId(null)), + Map.entry("beneficiaryId", (Consumer) r -> r.setBeneficiaryId(null)), + Map.entry("provisioned", (Consumer) r -> r.setProvisioned(null)), + Map.entry("reserved", (Consumer) r -> r.setReserved(null)), + Map.entry("vanID", (Consumer) r -> r.setVanID(null)), + Map.entry("createdDate", (Consumer) r -> r.setCreatedDate(null)), + Map.entry("createdBy", (Consumer) r -> r.setCreatedBy(null)), + Map.entry("benIDRequired", (Consumer) r -> r.setBenIDRequired(null))); + } + + @Test + @DisplayName("a difference in any single field should break equality") + void equals_shouldDetectDifferenceInEveryField() { + assertAll(differingValues().stream().map(field -> () -> { + M_BeneficiaryRegidMapping variant = fullyPopulated(); + field.getValue().accept(variant); + assertNotEquals(fullyPopulated(), variant, + "changing " + field.getKey() + " must break equality"); + })); + } + + @Test + @DisplayName("a null in any single field should break equality in both directions") + void equals_shouldDetectNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + M_BeneficiaryRegidMapping variant = fullyPopulated(); + field.getValue().accept(variant); + assertNotEquals(fullyPopulated(), variant, + "nulling " + field.getKey() + " must break equality"); + assertNotEquals(variant, fullyPopulated(), + "equality must stay symmetric when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("hashCode should tolerate a null in any single field") + void hashCode_shouldTolerateNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + M_BeneficiaryRegidMapping variant = fullyPopulated(); + field.getValue().accept(variant); + assertDoesNotThrow(variant::hashCode, + "hashCode must not fail when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("two rows sharing the same null field should still be equal") + void equals_shouldTreatMatchingNullFieldsAsEqual() { + assertAll(nullValues().stream().map(field -> () -> { + M_BeneficiaryRegidMapping first = fullyPopulated(); + M_BeneficiaryRegidMapping second = fullyPopulated(); + field.getValue().accept(first); + field.getValue().accept(second); + assertEquals(first, second, + "rows sharing a null " + field.getKey() + " must remain equal"); + assertEquals(first.hashCode(), second.hashCode()); + })); + } + + @Test + @DisplayName("a row should equal itself and never equal null") + void row_shouldEqualItselfAndNotNull() { + M_BeneficiaryRegidMapping row = fullyPopulated(); + + assertEquals(row, row); + assertNotEquals(row, null); + } + } } diff --git a/src/test/java/com/iemr/common/bengen/utils/CommonMainTest.java b/src/test/java/com/iemr/common/bengen/utils/CommonMainTest.java new file mode 100644 index 0000000..d926ad3 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/CommonMainTest.java @@ -0,0 +1,60 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +@DisplayName("CommonMain Test Suite") +class CommonMainTest { + + private CommonMain commonMain; + + @BeforeEach + @DisplayName("Create the bean configuration before each test") + void setUp() { + commonMain = new CommonMain(); + } + + @Test + @DisplayName("configProperties should supply a fresh properties holder on each call") + void configProperties_shouldSupplyFreshHolder() { + assertNotNull(commonMain.configProperties()); + assertNotSame(commonMain.configProperties(), commonMain.configProperties()); + } + + @Test + @DisplayName("redisSession should supply a Spring Session Redis configuration") + void redisSession_shouldSupplySessionConfiguration() { + assertNotNull(commonMain.redisSession()); + } + + @Test + @DisplayName("redisStorage should supply a Redis store bean") + void redisStorage_shouldSupplyRedisStore() { + assertNotNull(commonMain.redisStorage()); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java b/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java index 42ebb58..e59956f 100644 --- a/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java +++ b/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java @@ -31,10 +31,14 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.iemr.common.bengen.utils.exception.IEMRException; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @DisplayName("OutputResponse.Builder Test Suite") @@ -206,4 +210,157 @@ void setErrorMessage_shouldFallBackToGenericFailureForUnmappedException() { assertEquals("something odd", response.get("statusMessageLong").getAsString()); } } + + @Nested + @DisplayName("Value semantics of the built response") + class ValueSemanticsTests { + + private OutputResponse response(String methodName) { + return new OutputResponse.Builder().setMethodName(methodName).build(); + } + + @Test + @DisplayName("two responses built from the same fields should be equal and share a hash code") + void responsesFromSameFields_shouldBeEqual() { + assertEquals(response("m"), response("m")); + assertEquals(response("m").hashCode(), response("m").hashCode()); + } + + @Test + @DisplayName("responses built from different fields should not be equal") + void responsesFromDifferentFields_shouldNotBeEqual() { + assertNotEquals(response("first"), response("second")); + } + + @Test + @DisplayName("a response should equal itself and never equal null or an unrelated type") + void response_shouldEqualItselfAndNotOtherTypes() { + OutputResponse built = response("m"); + + assertEquals(built, built); + assertNotEquals(built, null); + assertNotEquals(built, "not an OutputResponse"); + } + + @Test + @DisplayName("getResponse should expose the assembled JSON element") + void getResponse_shouldExposeAssembledJsonElement() { + JsonElement element = response("generateBeneficiaryIDs").getResponse(); + + assertNotNull(element); + assertEquals("generateBeneficiaryIDs", + element.getAsJsonObject().get("methodName").getAsString()); + } + + @Test + @DisplayName("setResponse should replace the assembled payload") + void setResponse_shouldReplaceAssembledPayload() { + OutputResponse built = response("m"); + JsonObject replacement = new JsonObject(); + replacement.addProperty("methodName", "replaced"); + + built.setResponse(replacement); + + assertEquals("replaced", built.getResponse().getAsJsonObject().get("methodName").getAsString()); + } + + @Test + @DisplayName("a response with a null payload should differ from one carrying a payload") + void responseWithNullPayload_shouldDifferFromPopulated() { + OutputResponse built = response("m"); + OutputResponse blank = response("m"); + blank.setResponse(null); + + assertNotEquals(built, blank); + assertNotEquals(blank, built); + assertDoesNotThrow(blank::hashCode); + assertDoesNotThrow(blank::toString); + } + } + + @Nested + @DisplayName("Error mapping for exception types raised by other AMRIT modules") + class ExternalExceptionMappingTests { + + // The mapping switches on getClass().getSimpleName(), so locally declared types + // with the same simple names reach the arms meant for Hibernate/JDBC exceptions. + private static class MissingMandatoryFieldsException extends Exception { + MissingMandatoryFieldsException(String message) { + super(message); + } + } + + private static class IllegalActionException extends Exception { + IllegalActionException(String message) { + super(message); + } + } + + private static class JDBCException extends Exception { + JDBCException(String message) { + super(message); + } + } + + private static class SQLGrammarException extends Exception { + SQLGrammarException(String message) { + super(message); + } + } + + private static class ConstraintViolationException extends Exception { + ConstraintViolationException(String message) { + super(message); + } + } + + @Test + @DisplayName("setErrorMessage should map a missing mandatory field to the params-missing code") + void setErrorMessage_shouldMapMissingMandatoryFields() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new MissingMandatoryFieldsException("benCount is required"))); + + assertEquals(OutputResponse.Builder.MANDATORY_PARAMS_MISSING, response.get("statusCode").getAsInt()); + assertEquals("Missing Mandatory Parameters.", response.get("statusMessage").getAsString()); + assertEquals("benCount is required", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("setErrorMessage should map an illegal action to the illegal-action code") + void setErrorMessage_shouldMapIllegalAction() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new IllegalActionException("not permitted"))); + + assertEquals(OutputResponse.Builder.ILLEGAL_ACTION, response.get("statusCode").getAsInt()); + assertTrue(response.get("statusMessage").getAsString().startsWith("Illegal Action performed")); + } + + @Test + @DisplayName("setErrorMessage should map a JDBC failure to the environment code") + void setErrorMessage_shouldMapJdbcFailure() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new JDBCException("pool exhausted"))); + + assertEquals(OutputResponse.Builder.ENVIRONMENT_EXCEPTION, response.get("statusCode").getAsInt()); + assertTrue(response.get("statusMessage").getAsString().startsWith("Failed with DB connection issues at ")); + } + + @Test + @DisplayName("setErrorMessage should map a SQL grammar failure to the code-exception code") + void setErrorMessage_shouldMapSqlGrammarFailure() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new SQLGrammarException("bad column"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should map a constraint violation to the code-exception code") + void setErrorMessage_shouldMapConstraintViolation() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new ConstraintViolationException("duplicate key"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + } } diff --git a/src/test/java/com/iemr/common/bengen/utils/VerhoeffTest.java b/src/test/java/com/iemr/common/bengen/utils/VerhoeffTest.java new file mode 100644 index 0000000..5c14ec4 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/VerhoeffTest.java @@ -0,0 +1,82 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.bengen.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("Verhoeff Test Suite") +class VerhoeffTest { + + @Test + @DisplayName("generateVerhoeff should produce the documented check digit for a known number") + void generateVerhoeff_shouldProduceCheckDigitForKnownNumber() { + assertEquals("3", Verhoeff.generateVerhoeff("236")); + } + + @Test + @DisplayName("generateVerhoeff should produce a single digit for a long number") + void generateVerhoeff_shouldProduceSingleDigitForLongNumber() { + String digit = Verhoeff.generateVerhoeff("75381272119"); + + assertEquals(1, digit.length()); + assertTrue(digit.charAt(0) >= '0' && digit.charAt(0) <= '9'); + } + + @Test + @DisplayName("validateVerhoeff should accept a number carrying its own generated check digit") + void validateVerhoeff_shouldAcceptNumberWithGeneratedCheckDigit() { + String base = "75381272119"; + + assertTrue(Verhoeff.validateVerhoeff(base + Verhoeff.generateVerhoeff(base))); + } + + @Test + @DisplayName("validateVerhoeff should reject a number whose check digit is wrong") + void validateVerhoeff_shouldRejectWrongCheckDigit() { + String base = "75381272119"; + int correct = Integer.parseInt(Verhoeff.generateVerhoeff(base)); + int wrong = (correct + 1) % 10; + + assertFalse(Verhoeff.validateVerhoeff(base + wrong)); + } + + @Test + @DisplayName("validateVerhoeff should reject a number with a transposed pair of digits") + void validateVerhoeff_shouldRejectTransposedDigits() { + String valid = "236" + Verhoeff.generateVerhoeff("236"); + String transposed = "" + valid.charAt(1) + valid.charAt(0) + valid.substring(2); + + assertFalse(Verhoeff.validateVerhoeff(transposed), + "Verhoeff must catch adjacent transposition, the error it exists to detect"); + } + + @Test + @DisplayName("generateVerhoeff should be stable across repeated calls") + void generateVerhoeff_shouldBeStableAcrossCalls() { + assertEquals(Verhoeff.generateVerhoeff("123456789"), Verhoeff.generateVerhoeff("123456789")); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java b/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java index 9718686..9b48648 100644 --- a/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java +++ b/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java @@ -245,4 +245,55 @@ void setError_shouldFallBackToGenericFailureForUnmappedException() { assertEquals("something odd", outputResponse.getErrorMessage()); } } + + @Nested + @DisplayName("Error mapping for exception types raised by other AMRIT modules") + class ExternalExceptionMappingTests { + + // setError switches on getClass().getSimpleName(), so locally declared types with + // the same simple names reach the arms meant for Hibernate/JDBC exceptions. + private static class JDBCException extends Exception { + JDBCException(String message) { + super(message); + } + } + + private static class SQLGrammarException extends Exception { + SQLGrammarException(String message) { + super(message); + } + } + + private static class ConstraintViolationException extends Exception { + ConstraintViolationException(String message) { + super(message); + } + } + + @Test + @DisplayName("setError should map a JDBC failure to a DB connection environment error") + void setError_shouldMapJdbcFailure() { + outputResponse.setError(new JDBCException("pool exhausted")); + + assertEquals(OutputResponse.ENVIRONMENT_EXCEPTION, outputResponse.getStatusCode()); + assertTrue(outputResponse.getStatus().startsWith("Failed with DB connection issues at ")); + assertEquals("pool exhausted", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map a SQL grammar failure to a code exception") + void setError_shouldMapSqlGrammarFailure() { + outputResponse.setError(new SQLGrammarException("bad column")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should map a constraint violation to a code exception") + void setError_shouldMapConstraintViolation() { + outputResponse.setError(new ConstraintViolationException("duplicate key")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + } }