diff --git a/services/EmailNotificationService.java b/services/EmailNotificationService.java new file mode 100644 index 0000000..e9478c8 --- /dev/null +++ b/services/EmailNotificationService.java @@ -0,0 +1,135 @@ +package com.hpms.services; + +import com.hpms.domain.Alert; +import com.hpms.services.exceptions.BusinessRuleException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.logging.Logger; + +/** + * EmailNotificationService — sends email notifications for critical alerts. + * Issue #7 — Email notifications for critical alerts (FR-06) + * + * NOTE: Not annotated with @Service to avoid Spring requiring an EmailSender bean. + * Instantiate manually or wire with a concrete EmailSender in your config. + */ +public class EmailNotificationService { + + private static final Logger LOGGER = Logger.getLogger(EmailNotificationService.class.getName()); + private static final String CRITICAL_SEVERITY = "CRITICAL"; + + private final EmailSender emailSender; + private final List deliveryLog = new ArrayList<>(); + + public EmailNotificationService(EmailSender emailSender) { + this.emailSender = emailSender; + } + + public void notifyDoctor(Alert alert, String patientName, String doctorEmail) { + validateRequired(patientName, "patientName"); + validateRequired(doctorEmail, "doctorEmail"); + if (alert == null) { + throw new BusinessRuleException("alert must not be null."); + } + if (!CRITICAL_SEVERITY.equalsIgnoreCase(alert.getSeverity())) { + throw new BusinessRuleException( + "Email notifications are only sent for CRITICAL alerts. " + + "Received severity: " + alert.getSeverity() + ); + } + + String subject = buildSubject(patientName, alert); + String body = buildBody(patientName, alert); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + boolean success = false; + try { + emailSender.send(doctorEmail, subject, body); + success = true; + LOGGER.info(String.format( + "[NOTIFICATION] Critical alert email sent to %s for patient %s at %s", + doctorEmail, patientName, timestamp + )); + } catch (Exception e) { + LOGGER.severe(String.format( + "[NOTIFICATION] Failed to send critical alert email to %s: %s", + doctorEmail, e.getMessage() + )); + } + + deliveryLog.add(new NotificationLog( + alert.getAlertId().toString(), + patientName, + doctorEmail, + timestamp, + success + )); + } + + public List getDeliveryLog() { + return Collections.unmodifiableList(deliveryLog); + } + + private String buildSubject(String patientName, Alert alert) { + return String.format( + "[CRITICAL ALERT] Patient: %s — Vital: %s", + patientName, alert.getVitalType() + ); + } + + private String buildBody(String patientName, Alert alert) { + String timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + return String.format( + "CRITICAL ALERT NOTIFICATION\n" + + "===========================\n" + + "Patient Name : %s\n" + + "Vital Type : %s\n" + + "Triggered Value: %.2f\n" + + "Severity : %s\n" + + "Timestamp : %s\n" + + "===========================\n" + + "Please log in to the Hospital Patient Monitoring System immediately.\n", + patientName, + alert.getVitalType(), + alert.getTriggeredValue(), + alert.getSeverity(), + timestamp + ); + } + + private void validateRequired(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new BusinessRuleException(fieldName + " is required."); + } + } + + // ── Pluggable sender interface ───────────────────────────────────────── + + public interface EmailSender { + void send(String to, String subject, String body); + } + + // ── Delivery log entry ──────────────────────────────────────────────── + + public static class NotificationLog { + public final String alertId; + public final String patientName; + public final String doctorEmail; + public final String timestamp; + public final boolean success; + + public NotificationLog(String alertId, String patientName, + String doctorEmail, String timestamp, boolean success) { + this.alertId = alertId; + this.patientName = patientName; + this.doctorEmail = doctorEmail; + this.timestamp = timestamp; + this.success = success; + } + } +} \ No newline at end of file diff --git a/tests/com/hpms/services/AlertServiceThresholdTest.java b/tests/com/hpms/services/AlertServiceThresholdTest.java new file mode 100644 index 0000000..cc8b2af --- /dev/null +++ b/tests/com/hpms/services/AlertServiceThresholdTest.java @@ -0,0 +1,239 @@ +package com.hpms.services; + +import com.hpms.domain.Alert; +import com.hpms.repositories.inmemory.InMemoryAlertRepository; +import com.hpms.services.exceptions.BusinessRuleException; +import com.hpms.services.exceptions.ResourceNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * AlertServiceThresholdTest — Integration Tests for AlertService + * + * Tests threshold validation, state transitions, and business rules. + * Relates to: Issue #33 — Write integration test for AlertService threshold + */ +class AlertServiceThresholdTest { + + private AlertService service; + + @BeforeEach + void setUp() { + service = new AlertService(new InMemoryAlertRepository()); + } + + // ── 1. Threshold / triggeredValue validation ────────────────────────── + + @Test + void createAlert_throwsWhenTriggeredValueIsZero() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "heartRate", 0, "WARNING") + ); + assertTrue(ex.getMessage().contains("triggeredValue must be greater than 0")); + } + + @Test + void createAlert_throwsWhenTriggeredValueIsNegative() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "bloodPressure", -10.0, "CRITICAL") + ); + assertTrue(ex.getMessage().contains("triggeredValue must be greater than 0")); + } + + @Test + void createAlert_succeedsWithValidThreshold() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING" + ); + assertNotNull(alert); + assertEquals(140.0, alert.getTriggeredValue()); + assertEquals("WARNING", alert.getSeverity()); + } + + @Test + void createAlert_succeedsWithMinimalPositiveThreshold() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "oxygenLevel", 0.1, "WARNING" + ); + assertEquals(0.1, alert.getTriggeredValue()); + } + + @Test + void createAlert_succeedsWithHighThresholdValue() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "bloodPressure", 999.9, "CRITICAL" + ); + assertEquals(999.9, alert.getTriggeredValue()); + } + + // ── 2. Required field validation ────────────────────────────────────── + + @Test + void createAlert_throwsWhenVitalTypeIsNull() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), null, 120.0, "WARNING") + ); + assertTrue(ex.getMessage().contains("vitalType is required")); + } + + @Test + void createAlert_throwsWhenVitalTypeIsBlank() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), " ", 120.0, "WARNING") + ); + assertTrue(ex.getMessage().contains("vitalType is required")); + } + + @Test + void createAlert_throwsWhenSeverityIsNull() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "heartRate", 120.0, null) + ); + assertTrue(ex.getMessage().contains("severity is required")); + } + + @Test + void createAlert_throwsWhenSeverityIsBlank() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "heartRate", 120.0, "") + ); + assertTrue(ex.getMessage().contains("severity is required")); + } + + // ── 3. State transitions ────────────────────────────────────────────── + + @Test + void triggerAlert_changesStatusToTriggered() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 150.0, "CRITICAL" + ); + Alert triggered = service.triggerAlert(alert.getAlertId()); + assertEquals("TRIGGERED", triggered.getStatus()); + } + + @Test + void acknowledgeAlert_afterTrigger_changesStatusToAcknowledged() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING" + ); + service.triggerAlert(alert.getAlertId()); + Alert acknowledged = service.acknowledgeAlert(alert.getAlertId(), UUID.randomUUID()); + assertEquals("ACKNOWLEDGED", acknowledged.getStatus()); + } + + @Test + void closeAlert_changesStatusToClosed() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "oxygenLevel", 85.0, "CRITICAL" + ); + service.triggerAlert(alert.getAlertId()); + service.acknowledgeAlert(alert.getAlertId(), UUID.randomUUID()); + Alert closed = service.closeAlert(alert.getAlertId()); + assertEquals("CLOSED", closed.getStatus()); + } + + @Test + void fullAlertLifecycle_createdToClosedSuccessfully() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "temperature", 39.5, "WARNING" + ); + assertNotNull(alert.getAlertId()); + + Alert triggered = service.triggerAlert(alert.getAlertId()); + assertEquals("TRIGGERED", triggered.getStatus()); + + Alert acknowledged = service.acknowledgeAlert(alert.getAlertId(), UUID.randomUUID()); + assertEquals("ACKNOWLEDGED", acknowledged.getStatus()); + + Alert closed = service.closeAlert(alert.getAlertId()); + assertEquals("CLOSED", closed.getStatus()); + } + + // ── 4. Retrieval ────────────────────────────────────────────────────── + + @Test + void getAlertById_returnsCorrectAlert() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 130.0, "WARNING" + ); + Alert fetched = service.getAlertById(alert.getAlertId()); + assertEquals(alert.getAlertId(), fetched.getAlertId()); + } + + @Test + void getAlertById_throwsWhenNotFound() { + assertThrows(ResourceNotFoundException.class, () -> + service.getAlertById(UUID.randomUUID()) + ); + } + + @Test + void getAllAlerts_returnsAllCreatedAlerts() { + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING"); + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "bloodPressure", 180.0, "CRITICAL"); + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "oxygenLevel", 88.0, "WARNING"); + + List alerts = service.getAllAlerts(); + assertEquals(3, alerts.size()); + } + + @Test + void getAllAlerts_returnsEmptyListWhenNoAlerts() { + List alerts = service.getAllAlerts(); + assertTrue(alerts.isEmpty()); + } + + // ── 5. Delete ───────────────────────────────────────────────────────── + + @Test + void deleteAlert_removesAlertFromRepository() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING" + ); + service.deleteAlert(alert.getAlertId()); + assertThrows(ResourceNotFoundException.class, () -> + service.getAlertById(alert.getAlertId()) + ); + } + + @Test + void deleteAlert_throwsWhenAlertDoesNotExist() { + assertThrows(ResourceNotFoundException.class, () -> + service.deleteAlert(UUID.randomUUID()) + ); + } + + // ── 6. Multiple patients / isolation ───────────────────────────────── + + @Test + void multipleAlerts_forDifferentPatients_areStoredIndependently() { + UUID patient1 = UUID.randomUUID(); + UUID patient2 = UUID.randomUUID(); + + Alert alert1 = service.createAlert(patient1, UUID.randomUUID(), "heartRate", 140.0, "WARNING"); + Alert alert2 = service.createAlert(patient2, UUID.randomUUID(), "bloodPressure", 190.0, "CRITICAL"); + + assertNotEquals(alert1.getAlertId(), alert2.getAlertId()); + assertEquals(2, service.getAllAlerts().size()); + } + + @Test + void triggeringOneAlert_doesNotAffectOtherAlerts() { + Alert alert1 = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING" + ); + Alert alert2 = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "oxygenLevel", 85.0, "CRITICAL" + ); + + service.triggerAlert(alert1.getAlertId()); + + Alert fetched2 = service.getAlertById(alert2.getAlertId()); + assertNotEquals("TRIGGERED", fetched2.getStatus()); + } +} \ No newline at end of file diff --git a/tests/com/hpms/services/EmailNotificationServiceTest.java b/tests/com/hpms/services/EmailNotificationServiceTest.java new file mode 100644 index 0000000..7ff706a --- /dev/null +++ b/tests/com/hpms/services/EmailNotificationServiceTest.java @@ -0,0 +1,242 @@ +package com.hpms.services; + +import com.hpms.domain.Alert; +import com.hpms.repositories.inmemory.InMemoryAlertRepository; +import com.hpms.services.exceptions.BusinessRuleException; +import com.hpms.services.exceptions.ResourceNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * AlertServiceThresholdTest — Integration Tests for AlertService + * Relates to: Issue #33 — Write integration test for AlertService threshold + */ +class AlertServiceThresholdTest { + + private AlertService service; + + @BeforeEach + void setUp() { + service = new AlertService(new InMemoryAlertRepository()); + } + + // ── 1. Threshold / triggeredValue validation ────────────────────────── + + @Test + void createAlert_throwsWhenTriggeredValueIsZero() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "heartRate", 0, "WARNING") + ); + assertTrue(ex.getMessage().contains("triggeredValue must be greater than 0")); + } + + @Test + void createAlert_throwsWhenTriggeredValueIsNegative() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "bloodPressure", -10.0, "CRITICAL") + ); + assertTrue(ex.getMessage().contains("triggeredValue must be greater than 0")); + } + + @Test + void createAlert_succeedsWithValidThreshold() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING" + ); + assertNotNull(alert); + assertEquals(140.0, alert.getTriggeredValue()); + assertEquals("WARNING", alert.getSeverity()); + } + + @Test + void createAlert_succeedsWithMinimalPositiveThreshold() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "oxygenLevel", 0.1, "WARNING" + ); + assertEquals(0.1, alert.getTriggeredValue()); + } + + @Test + void createAlert_succeedsWithHighThresholdValue() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "bloodPressure", 999.9, "CRITICAL" + ); + assertEquals(999.9, alert.getTriggeredValue()); + } + + // ── 2. Required field validation ────────────────────────────────────── + + @Test + void createAlert_throwsWhenVitalTypeIsNull() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), null, 120.0, "WARNING") + ); + assertTrue(ex.getMessage().contains("vitalType is required")); + } + + @Test + void createAlert_throwsWhenVitalTypeIsBlank() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), " ", 120.0, "WARNING") + ); + assertTrue(ex.getMessage().contains("vitalType is required")); + } + + @Test + void createAlert_throwsWhenSeverityIsNull() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "heartRate", 120.0, null) + ); + assertTrue(ex.getMessage().contains("severity is required")); + } + + @Test + void createAlert_throwsWhenSeverityIsBlank() { + BusinessRuleException ex = assertThrows(BusinessRuleException.class, () -> + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "heartRate", 120.0, "") + ); + assertTrue(ex.getMessage().contains("severity is required")); + } + + // ── 3. State transitions ────────────────────────────────────────────── + + @Test + void triggerAlert_changesStatusAwayFromOpen() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 150.0, "CRITICAL" + ); + Alert triggered = service.triggerAlert(alert.getAlertId()); + // Status should no longer be OPEN after triggering + assertNotEquals("OPEN", triggered.getStatus()); + } + + @Test + void acknowledgeAlert_afterTrigger_changesStatusToAcknowledged() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING" + ); + service.triggerAlert(alert.getAlertId()); + Alert acknowledged = service.acknowledgeAlert(alert.getAlertId(), UUID.randomUUID()); + assertEquals("ACKNOWLEDGED", acknowledged.getStatus()); + } + + @Test + void closeAlert_changesStatusToClosed() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "oxygenLevel", 85.0, "CRITICAL" + ); + service.triggerAlert(alert.getAlertId()); + service.acknowledgeAlert(alert.getAlertId(), UUID.randomUUID()); + Alert closed = service.closeAlert(alert.getAlertId()); + assertEquals("CLOSED", closed.getStatus()); + } + + @Test + void fullAlertLifecycle_createdToClosedSuccessfully() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "temperature", 39.5, "WARNING" + ); + assertNotNull(alert.getAlertId()); + + // Trigger — status changes away from OPEN + Alert triggered = service.triggerAlert(alert.getAlertId()); + assertNotEquals("OPEN", triggered.getStatus()); + + // Acknowledge + Alert acknowledged = service.acknowledgeAlert(alert.getAlertId(), UUID.randomUUID()); + assertEquals("ACKNOWLEDGED", acknowledged.getStatus()); + + // Close + Alert closed = service.closeAlert(alert.getAlertId()); + assertEquals("CLOSED", closed.getStatus()); + } + + // ── 4. Retrieval ────────────────────────────────────────────────────── + + @Test + void getAlertById_returnsCorrectAlert() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 130.0, "WARNING" + ); + Alert fetched = service.getAlertById(alert.getAlertId()); + assertEquals(alert.getAlertId(), fetched.getAlertId()); + } + + @Test + void getAlertById_throwsWhenNotFound() { + assertThrows(ResourceNotFoundException.class, () -> + service.getAlertById(UUID.randomUUID()) + ); + } + + @Test + void getAllAlerts_returnsAllCreatedAlerts() { + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING"); + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "bloodPressure", 180.0, "CRITICAL"); + service.createAlert(UUID.randomUUID(), UUID.randomUUID(), "oxygenLevel", 88.0, "WARNING"); + + List alerts = service.getAllAlerts(); + assertEquals(3, alerts.size()); + } + + @Test + void getAllAlerts_returnsEmptyListWhenNoAlerts() { + List alerts = service.getAllAlerts(); + assertTrue(alerts.isEmpty()); + } + + // ── 5. Delete ───────────────────────────────────────────────────────── + + @Test + void deleteAlert_removesAlertFromRepository() { + Alert alert = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING" + ); + service.deleteAlert(alert.getAlertId()); + assertThrows(ResourceNotFoundException.class, () -> + service.getAlertById(alert.getAlertId()) + ); + } + + @Test + void deleteAlert_throwsWhenAlertDoesNotExist() { + assertThrows(ResourceNotFoundException.class, () -> + service.deleteAlert(UUID.randomUUID()) + ); + } + + // ── 6. Multiple patients / isolation ───────────────────────────────── + + @Test + void multipleAlerts_forDifferentPatients_areStoredIndependently() { + UUID patient1 = UUID.randomUUID(); + UUID patient2 = UUID.randomUUID(); + + Alert alert1 = service.createAlert(patient1, UUID.randomUUID(), "heartRate", 140.0, "WARNING"); + Alert alert2 = service.createAlert(patient2, UUID.randomUUID(), "bloodPressure", 190.0, "CRITICAL"); + + assertNotEquals(alert1.getAlertId(), alert2.getAlertId()); + assertEquals(2, service.getAllAlerts().size()); + } + + @Test + void triggeringOneAlert_doesNotAffectOtherAlerts() { + Alert alert1 = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "heartRate", 140.0, "WARNING" + ); + Alert alert2 = service.createAlert( + UUID.randomUUID(), UUID.randomUUID(), "oxygenLevel", 85.0, "CRITICAL" + ); + + service.triggerAlert(alert1.getAlertId()); + + Alert fetched2 = service.getAlertById(alert2.getAlertId()); + assertNotEquals("ACKNOWLEDGED", fetched2.getStatus()); + assertNotEquals("CLOSED", fetched2.getStatus()); + } +} \ No newline at end of file