Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions services/EmailNotificationService.java
Original file line number Diff line number Diff line change
@@ -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<NotificationLog> 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<NotificationLog> 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;
}
}
}
239 changes: 239 additions & 0 deletions tests/com/hpms/services/AlertServiceThresholdTest.java
Original file line number Diff line number Diff line change
@@ -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<Alert> alerts = service.getAllAlerts();
assertEquals(3, alerts.size());
}

@Test
void getAllAlerts_returnsEmptyListWhenNoAlerts() {
List<Alert> 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());
}
}
Loading
Loading