diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17292684a..84eb343f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: - cdot-release* pull_request: branches: - - "**" # Run on PRs to any branch + - '**' # Run on PRs to any branch jobs: test_change_limit: @@ -128,8 +128,8 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v4 with: - distribution: "adopt" - java-version: "21" + distribution: 'adopt' + java-version: '21' - name: Build and Test with Maven run: | @@ -190,6 +190,54 @@ jobs: run: | rm -rf $GITHUB_WORKSPACE/services/intersection-api/api/target + build_rsu_status_monitor: + needs: test_change_limit + if: ${{ always() && !failure() && !cancelled() }} + runs-on: ubuntu-latest + container: + image: maven:3.9.9-eclipse-temurin-22 + options: --user root + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}-${{ hashFiles('**/settings.xml') }}-${{ runner.os }}-java-22 + restore-keys: | + ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}-${{ runner.os }}-java-22 + ${{ runner.os }}-maven-${{ runner.os }}-java-22 + ${{ runner.os }}-maven + + - name: Install Dependencies + env: + MAVEN_GITHUB_TOKEN: ${{ secrets.MAVEN_GITHUB_TOKEN }} + MAVEN_GITHUB_ORG: ${{ github.repository_owner }} + run: | + cd $GITHUB_WORKSPACE/services/rsu-status-monitor/rsu-status-monitor + mvn clean install -DskipTests -s ./settings.xml + mvn dependency:go-offline -s ./settings.xml + + - name: Run tests + env: + MAVEN_GITHUB_TOKEN: ${{ secrets.MAVEN_GITHUB_TOKEN }} + MAVEN_GITHUB_ORG: ${{ github.repository_owner }} + run: | + cd $GITHUB_WORKSPACE/services/rsu-status-monitor/rsu-status-monitor + mvn verify -s ./settings.xml + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + with: + name: rsu-status-monitor-test-results + path: $GITHUB_WORKSPACE/services/rsu-status-monitor/rsu-status-monitor/target/surefire-reports + + - name: Clean up + run: | + rm -rf $GITHUB_WORKSPACE/services/rsu-status-monitor/rsu-status-monitor/target + sonar: if: github.event.pull_request.base.repo.owner.login == 'usdot-jpo-ode' needs: [build_api, webapp] @@ -224,8 +272,8 @@ jobs: - uses: actions/setup-java@v4 with: - distribution: "temurin" - java-version: "17" + distribution: 'temurin' + java-version: '17' - name: Setup SonarScanner uses: warchant/setup-sonar-scanner@v7 diff --git a/.gitmodules b/.gitmodules index fb7f2dfd7..27a80bfa6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,3 @@ [submodule "jpo-utils"] path = jpo-utils - url = https://github.com/usdot-jpo-ode/jpo-utils - branch = master + url = https://github.com/neaeraconsulting/jpo-utils diff --git a/docker-compose-intersection.yml b/docker-compose-intersection.yml index f727a8e21..e726cb53b 100644 --- a/docker-compose-intersection.yml +++ b/docker-compose-intersection.yml @@ -50,6 +50,16 @@ services: INTERSECTION_API_ENABLE_API: ${INTERSECTION_API_ENABLE_API} INTERSECTION_API_ENABLE_EMAILER: ${INTERSECTION_API_ENABLE_EMAILER} INTERSECTION_API_ENABLE_REPORTS: ${INTERSECTION_API_ENABLE_REPORTS} + INTERSECTION_API_ENABLE_REPORT_EMAIL: ${INTERSECTION_API_ENABLE_REPORT_EMAIL} + + INTERSECTION_EMAIL_BROKER: ${INTERSECTION_EMAIL_BROKER:-SMTP} + INTERSECTION_SENDER_EMAIL: ${INTERSECTION_SENDER_EMAIL:-donotreply@cvmanager.com} + UNSUBSCRIBE_SECRET_KEY: ${UNSUBSCRIBE_SECRET_KEY:-replace_me} + CV_MANAGER_FRONT_END_URI: ${CV_MANAGER_FRONT_END_URI:-http://localhost:${WEBAPP_PORT:-3000}} + INTERSECTION_SMTP_SERVER_IP: ${INTERSECTION_SMTP_SERVER_IP:-localhost} + INTERSECTION_SMTP_SERVER_PORT: ${INTERSECTION_SMTP_SERVER_PORT:-1025} + SENDGRID_API_KEY: ${SENDGRID_API_KEY} + POSTMARK_API_KEY: ${POSTMARK_API_KEY} BASE_ROUTE_PREFIX: ${INTERSECTION_API_ROUTE_PREFIX:-/} entrypoint: diff --git a/docker-compose-rsu-status-monitor.yml b/docker-compose-rsu-status-monitor.yml new file mode 100644 index 000000000..beca2f956 --- /dev/null +++ b/docker-compose-rsu-status-monitor.yml @@ -0,0 +1,32 @@ +services: + rsu_status_monitor: + profiles: + - rsu_status_monitor + image: rsu-status-monitor:latest + build: + context: ./services + dockerfile: Dockerfile.rsu_status_monitor + args: + MAVEN_GITHUB_TOKEN: ${MAVEN_GITHUB_TOKEN:?error} + MAVEN_GITHUB_ORG: ${MAVEN_GITHUB_ORG:?error} + ports: + - '8086:8080' + - '42300:42300/udp' + environment: + LOGGING_LEVEL: ${RSU_STATUS_LOGGING_LEVEL} + KAFKA_CONCURRENCY: ${KAFKA_CONCURRENCY:-1} + KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS} + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-default} + POSTGRES_SERVER_URL: jdbc:postgresql://${PG_DB_HOST} + POSTGRES_DB: ${PG_DB_NAME:-postgres} + POSTGRES_USER: ${PG_DB_USER:-postgres} + POSTGRES_PASSWORD: ${PG_DB_PASS:-postgres} + RSU_STATUS_MONITOR_ENABLE_MONITORING: ${RSU_STATUS_MONITOR_ENABLE_MONITORING:-true} + INTERSECTION_API_MONITOR_INTERVAL: ${INTERSECTION_API_MONITOR_INTERVAL:-300000} + depends_on: + cvmanager_postgres: + required: false + condition: service_started + kafka-setup: + required: false + condition: service_started diff --git a/docker-compose.yml b/docker-compose.yml index bd06227bc..853ba8779 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ include: - docker-compose-intersection.yml - docker-compose-conflictmonitor.yml - docker-compose-obu-ota-server.yml + - docker-compose-rsu-status-monitor.yml services: cvmanager_api: diff --git a/jpo-utils b/jpo-utils index d9298120d..a57e34628 160000 --- a/jpo-utils +++ b/jpo-utils @@ -1 +1 @@ -Subproject commit d9298120d603b9ff9ce3a5520ce885c8bd7ec23d +Subproject commit a57e346282e1a1f5196ceabd3f814812c1df02eb diff --git a/resources/sql_scripts/CVManager_SampleData.sql b/resources/sql_scripts/CVManager_SampleData.sql index c19999107..620e11426 100644 --- a/resources/sql_scripts/CVManager_SampleData.sql +++ b/resources/sql_scripts/CVManager_SampleData.sql @@ -72,7 +72,7 @@ INSERT INTO public.snmp_msgfwd_config( INSERT INTO public.email_type( email_type) - VALUES ('Support Requests'), ('Firmware Upgrade Failures'), ('Daily Message Counts'); + VALUES ('Support Requests'), ('Firmware Upgrade Failures'), ('Daily Message Counts'), ('Conflict Monitor Reports'); INSERT INTO public.intersections( intersection_number, ref_pt, intersection_name) @@ -84,4 +84,4 @@ INSERT INTO public.intersection_organization( INSERT INTO public.rsu_intersection( rsu_id, intersection_id) - VALUES (1, 1); \ No newline at end of file + VALUES (1, 1); diff --git a/sample.env b/sample.env index 1361ca207..07abfc0cb 100644 --- a/sample.env +++ b/sample.env @@ -209,6 +209,7 @@ INTERSECTION_API_ENABLE_API=true INTERSECTION_API_ENABLE_EMAILER=true INTERSECTION_API_ENABLE_REPORTS=true INTERSECTION_API_ENABLE_HAAS=true +INTERSECTION_API_ENABLE_REPORT_EMAIL=true # Base Path Prefix - Insert the following path into the base route of all REST endpoints in the Intersection API, used for simplifying proxy routing # Routes MUST start with a slash diff --git a/services/Dockerfile.rsu_status_monitor b/services/Dockerfile.rsu_status_monitor new file mode 100644 index 000000000..8bf3ae494 --- /dev/null +++ b/services/Dockerfile.rsu_status_monitor @@ -0,0 +1,37 @@ +FROM maven:3.9.6-eclipse-temurin-22-jammy AS builder + +WORKDIR /home/rsu-status-monitor + +COPY ./rsu-status-monitor/rsu-status-monitor/pom.xml . +COPY ./rsu-status-monitor/rsu-status-monitor/settings.xml . + +ARG MAVEN_GITHUB_TOKEN +ARG MAVEN_GITHUB_ORG +ENV MAVEN_GITHUB_TOKEN=$MAVEN_GITHUB_TOKEN +ENV MAVEN_GITHUB_ORG=$MAVEN_GITHUB_ORG + +RUN mvn -s settings.xml dependency:resolve + +# Copies source code after dependencies are resolved to leverage Docker caching +COPY ./rsu-status-monitor/rsu-status-monitor/src ./src +RUN mvn -s settings.xml install -DskipTests + +# Create a new stage for runtime +FROM eclipse-temurin:22-jre-noble + +WORKDIR /home + +COPY --from=builder /home/rsu-status-monitor/src/main/resources/application.yaml /home +COPY --from=builder /home/rsu-status-monitor/src/main/resources/application-confluent.yaml /home +COPY --from=builder /home/rsu-status-monitor/target/rsu-status-monitor.jar /home + +ENTRYPOINT ["java", \ + "-Djava.rmi.server.hostname=$DOCKER_HOST_IP", \ + "-Dcom.sun.management.jmxremote.port=9090", \ + "-Dcom.sun.management.jmxremote.rmi.port=9090", \ + "-Dcom.sun.management.jmxremote", \ + "-Dcom.sun.management.jmxremote.local.only=true", \ + "-Dcom.sun.management.jmxremote.authenticate=false", \ + "-Dcom.sun.management.jmxremote.ssl=false", \ + "-jar", \ + "/home/rsu-status-monitor.jar"] \ No newline at end of file diff --git a/services/intersection-api/api/pom.xml b/services/intersection-api/api/pom.xml index 523a89964..131d9fd72 100644 --- a/services/intersection-api/api/pom.xml +++ b/services/intersection-api/api/pom.xml @@ -294,6 +294,11 @@ json-unit-assertj 5.0.0 test + + + com.sun.mail + jakarta.mail + 2.0.1 diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepository.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepository.java index f17fbc58a..0185a1fe1 100644 --- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepository.java +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepository.java @@ -5,6 +5,8 @@ import us.dot.its.jpo.ode.api.models.DataLoader; import us.dot.its.jpo.ode.api.models.ReportDocument; +import java.util.List; + public interface ReportRepository extends DataLoader { long count(String reportName, Integer intersectionID, Long startTime, Long endTime); @@ -14,4 +16,6 @@ Page findLatest(String reportName, Integer intersectionID, Long Page find(String reportName, Integer intersectionID, Long startTime, Long endTime, boolean includeReportContents, Pageable pageable); + List findAll(String reportName, Integer intersectionID, Long startTime, Long endTime, + boolean includeReportContents); } \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepositoryImpl.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepositoryImpl.java index 52a4a24cb..bf9c3823d 100644 --- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepositoryImpl.java +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepositoryImpl.java @@ -129,4 +129,31 @@ public Page find( public void add(ReportDocument item) { mongoTemplate.insert(item, collectionName); } + + public List findAll( + String reportName, + Integer intersectionID, + Long startTime, + Long endTime, + boolean includeReportContents) { + Criteria criteria = new IntersectionCriteria() + .whereOptional(REPORT_NAME_FIELD, reportName) + .whereOptional(INTERSECTION_ID_FIELD, intersectionID); + + // Match exact start and stop times + criteria.and("reportStartTime").is(startTime); + criteria.and("reportStopTime").is(endTime); + + Sort sort = Sort.by(Sort.Direction.DESC, DATE_FIELD); + List excludedFields = new ArrayList<>(); + if (!includeReportContents) { + excludedFields.add("reportContents"); + } + Query query = Query.query(criteria).with(sort); + // Exclude fields if necessary + if (!excludedFields.isEmpty()) { + query.fields().exclude(excludedFields.toArray(new String[0])); + } + return mongoTemplate.find(query, ReportDocument.class, collectionName); + } } \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepository.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepository.java new file mode 100644 index 000000000..2dbbb3f51 --- /dev/null +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepository.java @@ -0,0 +1,14 @@ +package us.dot.its.jpo.ode.api.accessors.rsuState; + +import java.util.List; +import us.dot.its.jpo.ode.api.models.snmp.RsuState; + +public interface RsuStateRepository { + List retrieveRsuStateWithinTimeInterval(String rsuIP, long start, long end); + + RsuState findLatestByRsuIP(String rsuIP); + + List retrieveRsuStateWithinTimeInterval(String rsuIP, long start, long end, + int intervalMinutes); + +} \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepositoryImpl.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepositoryImpl.java new file mode 100644 index 000000000..6329fa464 --- /dev/null +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepositoryImpl.java @@ -0,0 +1,86 @@ +package us.dot.its.jpo.ode.api.accessors.rsuState; + +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Component; +import java.util.Date; +import org.bson.Document; + +import us.dot.its.jpo.ode.api.models.snmp.RsuState; + +import java.time.Instant; +import java.time.ZoneOffset; + +@Component +public class RsuStateRepositoryImpl implements RsuStateRepository { + + private final MongoTemplate mongoTemplate; + private final String collectionName = "RmMonitoringStatusRecords"; + + @Autowired + public RsuStateRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public List retrieveRsuStateWithinTimeInterval(String rsuIP, long start, long end) { + Criteria criteria = Criteria.where("rsuIP").is(rsuIP) + .and("timestamp").gte(new Date(start)).lte(new Date(end)) + .andOperator( + Criteria.where("uptime").ne(-1), + Criteria.where("temperature").ne(-1)); + Query query = Query.query(criteria).with(Sort.by(Sort.Direction.ASC, "timestamp")); + return mongoTemplate.find(query, RsuState.class, collectionName); + } + + @Override + public List retrieveRsuStateWithinTimeInterval(String rsuIP, long start, long end, int intervalMinutes) { + long intervalMs = intervalMinutes * 60 * 1000; // Convert minutes to milliseconds + + Criteria criteria = Criteria.where("rsuIP").is(rsuIP) + .and("timestamp").gte(new Date(start)).lte(new Date(end)) + .andOperator( + Criteria.where("uptime").ne(-1), + Criteria.where("temperature").ne(-1)); + + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(criteria), // filter by rsuIP and timestamp + Aggregation.project("timestamp", "rsuIP", "temperature", "uptime", "mode") + .andExpression("{$convert: {input: \"$timestamp\", to: \"long\"}}").as("timestampMillis"), + Aggregation.project("timestamp", "rsuIP", "temperature", "uptime", "mode", "timestampMillis") + .andExpression("timestampMillis - (timestampMillis % " + intervalMs + ")").as("interval"), + Aggregation.group("interval") // Group by interval + .avg("temperature").as("temperature") + .avg("uptime").as("uptime") + .first("rsuIP").as("rsuIP") + .first("mode").as("mode") + .first("timestamp").as("timestamp"), + Aggregation.sort(Sort.by(Sort.Direction.ASC, "timestamp")) // Sort by timestamp + ); + + List results = mongoTemplate.aggregate(aggregation, collectionName, RsuState.class) + .getMappedResults(); + + return results; + } + + @Override + public RsuState findLatestByRsuIP(String rsuIP) { + Criteria criteria = Criteria.where("rsuIP").is(rsuIP); + Query query = Query.query(criteria) + .with(Sort.by(Sort.Direction.DESC, "timestamp")) + .limit(1); // Limit the results to just one record + + return mongoTemplate.findOne(query, RsuState.class, collectionName); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/ReportsController.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/ReportsController.java index 3cf31efe0..a4134a083 100644 --- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/ReportsController.java +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/ReportsController.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -24,87 +25,91 @@ @RestController @ConditionalOnProperty(name = "enable.api", havingValue = "true", matchIfMissing = false) @ApiResponses(value = { - @ApiResponse(responseCode = "401", description = "Unauthorized"), - @ApiResponse(responseCode = "500", description = "Internal Server Error") + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") }) @RequestMapping("/reports") public class ReportsController { - private final ReportService reportService; - private final ReportRepository reportRepo; + private final ReportService reportService; + private final ReportRepository reportRepo; - @Autowired - public ReportsController( - ReportService reportService, - ReportRepository reportRepo) { - this.reportService = reportService; - this.reportRepo = reportRepo; - } + @Autowired + public ReportsController( + ReportService reportService, + ReportRepository reportRepo) { + this.reportService = reportService; + this.reportRepo = reportRepo; + } + + @Operation(summary = "Generate a Report", description = "Generates a new report for the intersection specified, within the start and end time. This can take upwards of 15 minutes to complete for longer reports") + @RequestMapping(value = "/intersection/generate", method = RequestMethod.GET, produces = "application/json") + @PreAuthorize("@PermissionService.isSuperUser() || (@PermissionService.hasIntersection(#intersectionID, 'USER') and @PermissionService.hasRole('USER')) ") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role with access to the intersection requested"), + }) + public ResponseEntity> generateReport( + @RequestParam(name = "intersection_id") int intersectionID, + @RequestParam(name = "start_time_utc_millis") long startTime, + @RequestParam(name = "end_time_utc_millis") long endTime) { + log.debug("Generating Report"); - @Operation(summary = "Generate a Report", description = "Generates a new report for the intersection specified, within the start and end time. This can take upwards of 15 minutes to complete for longer reports") - @RequestMapping(value = "/intersection/generate", method = RequestMethod.GET, produces = "application/octet-stream") - @PreAuthorize("@PermissionService.isSuperUser() || (@PermissionService.hasIntersection(#intersectionID, 'USER') and @PermissionService.hasRole('USER')) ") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Success"), - @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role with access to the intersection requested"), - }) - public byte[] generateReport( - @RequestParam(name = "intersection_id") int intersectionID, - @RequestParam(name = "start_time_utc_millis") long startTime, - @RequestParam(name = "end_time_utc_millis") long endTime) { - log.debug("Generating Report"); + ReportDocument document = reportService.buildReport(intersectionID, startTime, endTime); + PageRequest pageable = PageRequest.of(0, 1); - ReportDocument document = reportService.buildReport(intersectionID, startTime, endTime); + return ResponseEntity.ok(new PageImpl(java.util.Collections.singletonList(document), + pageable, 1)); - return document.getReportContents(); - } + } - @Operation(summary = "List Reports", description = "Returns a list of existing intersection reports, as aggregated data, filtered by name, intersection ID, start time, and end time. The latest parameter will return the most recent report.") - @RequestMapping(value = "/intersection", method = RequestMethod.GET, produces = "application/json") - @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Success"), - @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"), - }) - public ResponseEntity> listReports( - @RequestParam(name = "report_name", required = false) String reportName, - @RequestParam(name = "intersection_id") int intersectionID, - @RequestParam(name = "start_time_utc_millis") long startTime, - @RequestParam(name = "end_time_utc_millis") long endTime, - @RequestParam(name = "page", required = false, defaultValue = "0") int page, - @RequestParam(name = "size", required = false, defaultValue = "10000") int size, - @RequestParam(name = "latest") boolean latest) { + @Operation(summary = "List Reports", description = "Returns a list of existing intersection reports, as aggregated data, filtered by name, intersection ID, start time, and end time. The latest parameter will return the most recent report.") + @RequestMapping(value = "/intersection", method = RequestMethod.GET, produces = "application/json") + @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"), + }) + public ResponseEntity> listReports( + @RequestParam(name = "report_name", required = false) String reportName, + @RequestParam(name = "intersection_id") int intersectionID, + @RequestParam(name = "start_time_utc_millis") long startTime, + @RequestParam(name = "end_time_utc_millis") long endTime, + @RequestParam(name = "page", required = false, defaultValue = "0") int page, + @RequestParam(name = "size", required = false, defaultValue = "10000") int size, + @RequestParam(name = "latest") boolean latest) { - if (latest) { - return ResponseEntity.ok(reportRepo.findLatest(reportName, intersectionID, startTime, endTime, false)); - } else { - // Retrieve a paginated result from the repository - PageRequest pageable = PageRequest.of(page, size); - Page response = reportRepo.find(reportName, intersectionID, startTime, endTime, - false, pageable); - return ResponseEntity.ok(response); + if (latest) { + return ResponseEntity.ok( + reportRepo.findLatest(reportName, intersectionID, startTime, endTime, false)); + } else { + // Retrieve a paginated result from the repository + PageRequest pageable = PageRequest.of(page, size); + Page response = reportRepo.find(reportName, intersectionID, startTime, endTime, + false, pageable); + return ResponseEntity.ok(response); + } } - } - @Operation(summary = "Download a Report", description = "Returns the a report by name, as aggregated data") - @RequestMapping(value = "/intersection/download", method = RequestMethod.GET, produces = "application/octet-stream") - @PreAuthorize("@PermissionService.hasRole('USER')") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Success"), - @ApiResponse(responseCode = "403", description = "Forbidden - Requires USER role"), - @ApiResponse(responseCode = "404", description = "Report not found"), - }) - public ResponseEntity downloadReport( - @RequestParam(name = "report_name") String reportName) { + @Operation(summary = "Download a Report", description = "Returns the a report by name, as aggregated data") + @RequestMapping(value = "/intersection/download", method = RequestMethod.GET, produces = "application/octet-stream") + @PreAuthorize("@PermissionService.hasRole('USER')") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "403", description = "Forbidden - Requires USER role"), + @ApiResponse(responseCode = "404", description = "Report not found"), + }) + public ResponseEntity downloadReport( + @RequestParam(name = "report_name") String reportName) { - Page reports = reportRepo.findLatest(reportName, null, null, null, true); + Page reports = reportRepo.findLatest(reportName, null, null, null, true); - if (!reports.isEmpty()) { - return ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=\"" + reportName + "\"") - .body(reports.getContent().getFirst().getReportContents()); - } else { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + if (!reports.isEmpty()) { + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + reportName + "\"") + .body(reports.getContent().getFirst().getReportContents()); + } else { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + } } - } } \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/data/RsuController.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/data/RsuController.java new file mode 100644 index 000000000..57e19791c --- /dev/null +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/data/RsuController.java @@ -0,0 +1,89 @@ +package us.dot.its.jpo.ode.api.controllers.data; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import us.dot.its.jpo.ode.api.models.snmp.RsuState; +import us.dot.its.jpo.ode.api.accessors.rsuState.RsuStateRepository; + +import java.util.List; + +@RestController +@ConditionalOnProperty(name = "enable.api", havingValue = "true", matchIfMissing = false) +@ApiResponses(value = { + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") +}) +@RequestMapping("/data/rsu-status") +public class RsuController { + + private final RsuStateRepository rsuStateRepository; + + @Autowired + public RsuController(RsuStateRepository rsuStateRepository) { + this.rsuStateRepository = rsuStateRepository; + } + + @Operation(summary = "Get historical RSU Status", description = "Returns all RSU status records for the given RSU IP and time range (UTC ms)") + @GetMapping(value = "/historical", produces = "application/json") + @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"), + @ApiResponse(responseCode = "400", description = "Invalid parameters") + }) + public ResponseEntity> getHistoricalRsuStatus( + @RequestParam String rsuIp, + @RequestParam long startTime, + @RequestParam long endTime) { + if (startTime > endTime) { + return ResponseEntity.badRequest().build(); + } + List results = rsuStateRepository.retrieveRsuStateWithinTimeInterval(rsuIp, startTime, endTime); + return ResponseEntity.ok(results); + } + + @Operation(summary = "Get latest RSU Status", description = "Returns the most recent RSU status record for the given RSU IP") + @GetMapping(value = "/latest", produces = "application/json") + @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"), + @ApiResponse(responseCode = "404", description = "No RSU status found for this RSU IP") + }) + public ResponseEntity getLatestRsuStatus(@RequestParam String rsuIp) { + RsuState latest = rsuStateRepository.findLatestByRsuIP(rsuIp); + if (latest != null) { + return ResponseEntity.ok(latest); + } else { + return ResponseEntity.notFound().build(); + } + } + + @Operation(summary = "Get aggregated RSU Status", description = "Returns aggregated RSU status records for the given RSU IP and time range (UTC ms)") + @GetMapping(value = "/aggregated", produces = "application/json") + @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"), + @ApiResponse(responseCode = "400", description = "Invalid parameters") + }) + public ResponseEntity> getAggregatedRsuStatus( + @RequestParam String rsuIp, + @RequestParam long startTime, + @RequestParam long endTime, + @RequestParam int intervalMinutes) { + if (startTime > endTime || intervalMinutes <= 0) { + return ResponseEntity.badRequest().build(); + } + List results = rsuStateRepository.retrieveRsuStateWithinTimeInterval(rsuIp, startTime, + endTime, intervalMinutes); + return ResponseEntity.ok(results); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/EmailType.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/EmailType.java new file mode 100644 index 000000000..bff5af02c --- /dev/null +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/EmailType.java @@ -0,0 +1,23 @@ +package us.dot.its.jpo.ode.api.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@Table(name = "email_type") +public class EmailType { + + @Id + private int email_type_id; + private String email_type; + +} \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/UserEmailNotification.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/UserEmailNotification.java new file mode 100644 index 000000000..9a3e9e136 --- /dev/null +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/UserEmailNotification.java @@ -0,0 +1,24 @@ +package us.dot.its.jpo.ode.api.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@Table(name = "user_email_notification") +public class UserEmailNotification { + + @Id + private int user_email_notification_id; + private int user_id; + private int email_type_id; + +} \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/snmp/RsuState.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/snmp/RsuState.java new file mode 100644 index 000000000..7b998d493 --- /dev/null +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/snmp/RsuState.java @@ -0,0 +1,39 @@ +package us.dot.its.jpo.ode.api.models.snmp; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class RsuState { + /** + * Time in UTC Milliseconds that the RSU State was generated + */ + public long timestamp; + + /** + * IntersectionID of the intersection corresponding to the RSU + */ + public String intersectionID; + + /** + * IP Address of the RSU unit + */ + public String rsuIP; + + /** + * Internal Temperature of the RSU Unit + */ + public double temperature; + + /** + * Time in seconds since the RSU unit last rebooted + */ + public Long uptime; + + /** + * The Mode of the RSU. There are 3 common modes, Operational(4), Standby (2) + * and Off (16) + */ + public Long mode; +} diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/EmailService.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/EmailService.java index c550f02de..10e160e22 100644 --- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/EmailService.java +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/EmailService.java @@ -33,14 +33,17 @@ public class EmailService { private final SendGrid sendGrid; private final ApiClient postmark; private final ConflictMonitorApiProperties props; + private final PostgresService postgresService; @Autowired public EmailService(JavaMailSender mailSender, SendGrid sendGrid, ApiClient postmark, - ConflictMonitorApiProperties props) { + ConflictMonitorApiProperties props, PostgresService postgresService) { this.mailSender = mailSender; this.sendGrid = sendGrid; this.postmark = postmark; this.props = props; + this.postgresService = postgresService; + } public void sendEmailViaSendGrid(String to, String subject, String text) { @@ -79,6 +82,7 @@ public void sendEmailViaPostmark(String to, String subject, String text) { public void sendEmailViaSpringMail(String to, String subject, String text) { try { SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(props.getEmailFromAddress()); message.setTo(to); message.setSubject(subject); message.setText(text); @@ -112,4 +116,12 @@ public List getNotificationEmailList(EmailFrequency frequenc // TODO: Pull email list from Postgres return new ArrayList<>(); } + + public List getUsersForConflictMonitorReports() { + return postgresService.getUsersByNotificationType("Conflict Monitor Reports"); + } + + public List getAllowedIntersectionIdsByEmail(String email) { + return postgresService.getAllowedIntersectionIdsByEmail(email); + } } \ No newline at end of file diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/PostgresService.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/PostgresService.java index 9026c6a73..59b39ffdb 100644 --- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/PostgresService.java +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/PostgresService.java @@ -172,6 +172,18 @@ public boolean checkIntersectionWithOrg(String intersectionId, List orga return !result.isEmpty() && result.get(0).equals(intersectionId); } + private final String findUsersByNotificationTypeQuery = "SELECT u.email " + + "FROM UserEmailNotification uen " + + "JOIN Users u ON uen.user_id = u.user_id " + + "JOIN EmailType et ON uen.email_type_id = et.email_type_id " + + "WHERE et.email_type = :notification_type"; + + public List getUsersByNotificationType(String notificationType) { + TypedQuery query = entityManager.createQuery(findUsersByNotificationTypeQuery, String.class); + query.setParameter("notification_type", notificationType); + return query.getResultList(); + } + public List getQualifiedOrgList(String email, String requiredRole) { List organizationRoles = findUserOrgRoles(email); return organizationRoles.stream() diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTask.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTask.java new file mode 100644 index 000000000..2baea56ef --- /dev/null +++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTask.java @@ -0,0 +1,139 @@ +package us.dot.its.jpo.ode.api.tasks; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.data.domain.Pageable; +import us.dot.its.jpo.ode.api.models.IntersectionReferenceData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import us.dot.its.jpo.ode.api.models.ReportDocument; +import us.dot.its.jpo.ode.api.services.ReportService; +import us.dot.its.jpo.ode.api.services.EmailService; + +@Component +@ConditionalOnProperty(name = "enable.report-email", havingValue = "true", matchIfMissing = false) +@RequiredArgsConstructor +public class ReportEmailTask { + + private static final Logger log = LoggerFactory.getLogger(ReportEmailTask.class); + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss"); + + private final us.dot.its.jpo.ode.api.accessors.reports.ReportRepository reportRepo; + private final us.dot.its.jpo.ode.api.accessors.map.ProcessedMapRepository processedMapRepo; + private final EmailService emailService; + + private static final String WEEKLY_EMAIL_CRON = "0 0 1 ? * SUN"; // Runs weekly at 1 am on Sundays + + @Scheduled(cron = WEEKLY_EMAIL_CRON) + public void sendWeeklyReportEmails() { + log.info("Sending Weekly Report Emails at {}", dateFormat.format(new Date())); + ZonedDateTime now = ZonedDateTime.now().withHour(0).withMinute(0).withSecond(0).withNano(0); + ZonedDateTime expectedStartTime = now.minusWeeks(1); + ZonedDateTime expectedStopTime = now; + sendEmailsForReportsInRange(expectedStartTime.toInstant(), expectedStopTime.toInstant()); + } + + void sendEmailsForReportsInRange(Instant expectedStartTime, Instant expectedStopTime) { + log.info("Fetching users subscribed to Conflict Monitor Reports..."); + List users = emailService.getUsersForConflictMonitorReports(); + + Map reportCache = new HashMap<>(); + + for (String user : users) { + log.info("Processing user: {}", user); + + List allowedIntersections = emailService.getAllowedIntersectionIdsByEmail(user); + + List validIntersectionIds = fetchReportsForIntersections(allowedIntersections, expectedStartTime, + expectedStopTime, reportCache); + + if (validIntersectionIds.isEmpty()) { + continue; + } + + String emailBody = constructEmailBody(validIntersectionIds, reportCache, expectedStartTime, + expectedStopTime); + + String subject = String.format("Weekly Conflict Monitor Reports (%s)", + ZonedDateTime.now(ZoneOffset.UTC).toLocalDate()); + emailService.sendSimpleMessage(user, subject, emailBody); + } + } + + List fetchReportsForIntersections(List intersectionIds, Instant startTime, + Instant stopTime, Map reportCache) { + long startRange = startTime.toEpochMilli(); + long endRange = stopTime.toEpochMilli(); + + List processedIntersectionIds = new ArrayList<>(); + + for (Integer intersectionId : intersectionIds) { + if (reportCache.containsKey(intersectionId)) { + processedIntersectionIds.add(intersectionId); + continue; + } + + List reports = reportRepo.findAll( + null, + intersectionId, + startRange, + endRange, + true); + + if (!reports.isEmpty()) { + ReportDocument report = reports.get(0); + String serializedReport = serializeReportToJson(report); + reportCache.put(intersectionId, serializedReport); + processedIntersectionIds.add(intersectionId); + } + } + + return processedIntersectionIds; + } + + String constructEmailBody(List validIntersectionIds, Map reportCache, + Instant startTime, Instant stopTime) { + StringBuilder emailBody = new StringBuilder(); + + String startDate = ZonedDateTime.ofInstant(startTime, ZoneOffset.UTC).toLocalDate().toString(); + String endDate = ZonedDateTime.ofInstant(stopTime, ZoneOffset.UTC).toLocalDate().toString(); + emailBody.append( + String.format("The following Conflict Monitor Reports were generated for the week of %s to %s:\n\n", + startDate, endDate)); + + for (Integer intersectionId : validIntersectionIds) { + String serializedReport = reportCache.get(intersectionId); + + if (serializedReport != null) { + emailBody.append(String.format("Intersection %d:\n%s\n\n", intersectionId, serializedReport)); + } + } + + return emailBody.toString(); + } + + private String serializeReportToJson(ReportDocument report) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(report); + } catch (JsonProcessingException e) { + log.error("Error serializing report to JSON", e); + return "{}"; + } + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/main/resources/application.yaml b/services/intersection-api/api/src/main/resources/application.yaml index 68a0eea85..4d5a527fb 100644 --- a/services/intersection-api/api/src/main/resources/application.yaml +++ b/services/intersection-api/api/src/main/resources/application.yaml @@ -84,6 +84,7 @@ enable: report: ${INTERSECTION_API_ENABLE_REPORTS:true} haas: ${INTERSECTION_API_ENABLE_HAAS:true} asn1-decoder: ${INTERSECTION_API_ENABLE_ASN1_DECODER:true} + report-email: ${INTERSECTION_API_ENABLE_REPORT_EMAIL:true} cmServerURL: ${CM_SERVER_URL:http://localhost:8082} mongoTimeoutMs: ${CM_MONGO_TIMEOUT_MS:5000} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/ReportsControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/ReportsControllerTest.java index 7b0758d10..1a817b6b2 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/ReportsControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/ReportsControllerTest.java @@ -42,9 +42,10 @@ void testGenerateReport() { doc.setReportContents(new byte[] { 1, 2, 3 }); when(reportService.buildReport(anyInt(), anyLong(), anyLong())).thenReturn(doc); - byte[] result = controller.generateReport(1, 1000L, 2000L); + ResponseEntity> result = controller.generateReport(1, 1000L, 2000L); - assertThat(result).containsExactly(1, 2, 3); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).contains(doc); verify(reportService).buildReport(1, 1000L, 2000L); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java index a2ac7f9c4..175747179 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java @@ -42,6 +42,9 @@ class EmailServiceTest { @Mock private ConflictMonitorApiProperties props; + @Mock + private PostgresService postgresService; + @InjectMocks private EmailService emailService; @@ -237,4 +240,34 @@ void testGetNotificationEmailList() { // TODO: Test underlying logic when method is further implemented assertTrue(result.isEmpty()); } + + @Test + void testGetUsersForConflictMonitorReports() { + List mockUsers = List.of("user1@example.com", "user2@example.com"); + when(postgresService.getUsersByNotificationType("Conflict Monitor Reports")).thenReturn(mockUsers); + + List result = emailService.getUsersForConflictMonitorReports(); + + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains("user1@example.com")); + assertTrue(result.contains("user2@example.com")); + verify(postgresService, times(1)).getUsersByNotificationType("Conflict Monitor Reports"); + } + + @Test + void testGetAllowedIntersectionIdsByEmail() { + String email = "user@example.com"; + List mockIntersectionIds = List.of(1, 2, 3); + when(postgresService.getAllowedIntersectionIdsByEmail(email)).thenReturn(mockIntersectionIds); + + List result = emailService.getAllowedIntersectionIdsByEmail(email); + + assertNotNull(result); + assertEquals(3, result.size()); + assertTrue(result.contains(1)); + assertTrue(result.contains(2)); + assertTrue(result.contains(3)); + verify(postgresService, times(1)).getAllowedIntersectionIdsByEmail(email); + } } \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTaskTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTaskTest.java new file mode 100644 index 000000000..5140f2e08 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTaskTest.java @@ -0,0 +1,125 @@ +package us.dot.its.jpo.ode.api.tasks; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import us.dot.its.jpo.ode.api.services.EmailService; +import us.dot.its.jpo.ode.api.accessors.reports.ReportRepository; +import us.dot.its.jpo.ode.api.accessors.map.ProcessedMapRepository; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReportEmailTaskTest { + + @Mock + private EmailService emailService; + + @Mock + private ReportRepository reportRepo; + + @Mock + private ProcessedMapRepository processedMapRepo; + + @InjectMocks + private ReportEmailTask reportEmailTask; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testSendWeeklyReportEmails() { + // Arrange + List mockUsers = List.of("user1@example.com", "user2@example.com"); + when(emailService.getUsersForConflictMonitorReports()).thenReturn(mockUsers); + + List mockIntersectionIds = List.of(1, 2, 3); + when(emailService.getAllowedIntersectionIdsByEmail(anyString())).thenReturn(mockIntersectionIds); + + // Act + reportEmailTask.sendWeeklyReportEmails(); + + // Assert + verify(emailService, times(2)).getAllowedIntersectionIdsByEmail(anyString()); + verify(emailService, times(2)).sendSimpleMessage(anyString(), anyString(), anyString()); + } + + @Test + void testSendEmailsForReportsInRange() { + // Arrange + Instant startTime = Instant.now().minusSeconds(604800); // 1 week ago + Instant stopTime = Instant.now(); + + List mockUsers = List.of("user1@example.com"); + when(emailService.getUsersForConflictMonitorReports()).thenReturn(mockUsers); + + List mockIntersectionIds = List.of(1); + when(emailService.getAllowedIntersectionIdsByEmail("user1@example.com")).thenReturn(mockIntersectionIds); + + // Act + reportEmailTask.sendEmailsForReportsInRange(startTime, stopTime); + + // Assert + verify(emailService, times(1)).getAllowedIntersectionIdsByEmail("user1@example.com"); + verify(emailService, times(1)).sendSimpleMessage(anyString(), anyString(), anyString()); + } + + @Test + void testFetchReportsForIntersections() { + // Arrange + Instant startTime = Instant.now().minusSeconds(604800); // 1 week ago + Instant stopTime = Instant.now(); + Map reportCache = new HashMap<>(); + + List intersectionIds = List.of(1, 2); + + // Act + List result = reportEmailTask.fetchReportsForIntersections(intersectionIds, startTime, stopTime, + reportCache); + + // Assert + assertNotNull(result); + ArgumentCaptor intersectionIdCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor startTimeCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor endTimeCaptor = ArgumentCaptor.forClass(Long.class); + + verify(reportRepo, times(intersectionIds.size())).findAll( + eq(null), // Assuming reportName is null in this case + intersectionIdCaptor.capture(), + startTimeCaptor.capture(), + endTimeCaptor.capture(), + eq(true) // Assuming includeReportContents is true + ); + + // Verify captured arguments + assertEquals(intersectionIds, intersectionIdCaptor.getAllValues()); + assertTrue(startTimeCaptor.getAllValues().stream().allMatch(time -> time.equals(startTime.toEpochMilli()))); + assertTrue(endTimeCaptor.getAllValues().stream().allMatch(time -> time.equals(stopTime.toEpochMilli()))); + + } + + @Test + void testConstructEmailBody() { + // Arrange + Map reportCache = Map.of(1, "Report 1", 2, "Report 2"); + List validIntersectionIds = List.of(1, 2); + Instant startTime = Instant.now().minusSeconds(604800); // 1 week ago + Instant stopTime = Instant.now(); + + // Act + String emailBody = reportEmailTask.constructEmailBody(validIntersectionIds, reportCache, startTime, stopTime); + + // Assert + assertTrue(emailBody.contains("Report 1")); + assertTrue(emailBody.contains("Report 2")); + } +} \ No newline at end of file diff --git a/services/rsu-status-monitor/README.md b/services/rsu-status-monitor/README.md new file mode 100644 index 000000000..5b9762c0f --- /dev/null +++ b/services/rsu-status-monitor/README.md @@ -0,0 +1,50 @@ +# RSU Status Monitor + +## Overview + +The RSU Status Monitor is a service designed to track and report the operational status of Roadside Units (RSUs) within a connected vehicle environment. It collects status data, performs health checks, and provides alerts for any detected issues. + +## Features + +### Core Monitoring Capabilities + +- **Real-time RSU Status Monitoring**: Continuously monitors all accessible Roadside Units (RSUs) in the deployment detailed by the CV Manager PostgreSQL database +- **Scheduled Data Collection**: Configurable monitoring intervals with fixed rate execution +- **Parallel Processing**: Utilizes a fixed thread pool (10 threads) with CompletableFuture for concurrent RSU querying + +### SNMP Integration + +- **SNMP v2 & v3 Support**: Communicates with RSUs using both SNMP v2c (community-based) and SNMP v3 (username/password with authentication and encryption) +- **Security Protocols**: Implements SHA authentication and AES-128 encryption for secure SNMP v3 communications +- **Comprehensive OID Mapping**: Supports extensive NTCIP 1218 standard Object Identifiers (OIDs) including: + - System status and operational modes + - GPS/GNSS positioning data + - Temperature and environmental sensors + - Message forwarding configurations + - Firmware and MIB version information + - Clock source and synchronization status + +### Statistical Data Collection + +The application collects and reports the following RSU statistics: + +- **Uptime**: Time in seconds since the RSU last rebooted (`rsuTimeSincePowerOn`) +- **Temperature**: Internal temperature readings of the RSU hardware (`rsuIntTemp`) +- **Mode Status**: Operational state including Operational (4), Standby (2), and Off (16) modes (`rsuModeStatus`) +- **Timestamp**: UTC millisecond-precision timestamps for all collected data + +### Database and Event Streaming Integration + +- **PostgreSQL Integration**: Retrieves RSU credentials and configuration from a PostgreSQL database using JPA/Hibernate +- **Multi-RSU Support**: Queries all RSUs with their associated SNMP credentials, intersection mappings, and protocol configurations +- **Intersection Correlation**: Links RSU data with intersection identifiers for geographic context +- **Kafka Producer**: Publishes RSU status updates to Kafka topics for downstream processing and analytics + +## License Information + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this +file except in compliance with the License. +You may obtain a copy of the License at +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied. See the License for the specific language governing +permissions and limitations under the [License](http://www.apache.org/licenses/LICENSE-2.0). diff --git a/services/rsu-status-monitor/rsu-status-monitor/.gitignore b/services/rsu-status-monitor/rsu-status-monitor/.gitignore new file mode 100644 index 000000000..dc6805a22 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/.gitignore @@ -0,0 +1,40 @@ +HELP.md +target/ +bin/ +!**/src/main/**/target/ +!**/src/test/**/target/ + +### Application.yaml Profiles ### +application-dev.yaml + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +*.class + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/\ + +### Mvn Wrapper ### +.mvn/wrapper/* \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/mvnw b/services/rsu-status-monitor/rsu-status-monitor/mvnw new file mode 100644 index 000000000..a1ba1bf55 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/mvnw @@ -0,0 +1,233 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # + # Look for the Apple JDKs first to preserve the existing behaviour, and then look + # for the new JDKs provided by Oracle. + # + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Java/JavaVirtualMachines/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L "/Library/Java/JavaVirtualMachines/CurrentJDK" ] ; then + # + # Oracle JDKs + # + export JAVA_HOME=/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -x "/usr/libexec/java_home" ]; then + # + # Apple JDKs + # + export JAVA_HOME=`/usr/libexec/java_home` + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Migwn, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + local basedir=$(pwd) + local wdir=$(pwd) + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + wdir=$(cd "$wdir/.."; pwd) + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-$(find_maven_basedir)} +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} "$@" diff --git a/services/rsu-status-monitor/rsu-status-monitor/mvnw.cmd b/services/rsu-status-monitor/rsu-status-monitor/mvnw.cmd new file mode 100644 index 000000000..2b934e89d --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/mvnw.cmd @@ -0,0 +1,145 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +set MAVEN_CMD_LINE_ARGS=%* + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" + +set WRAPPER_JAR="".\.mvn\wrapper\maven-wrapper.jar"" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS% +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/pom.xml b/services/rsu-status-monitor/rsu-status-monitor/pom.xml new file mode 100644 index 000000000..c0cb7fd8f --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/pom.xml @@ -0,0 +1,273 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.4 + + + + usdot.jpo.ode + rsu-status-monitor + 0.1.0 + jar + rsu-status-monitor + RSU Status Monitor for better RSU management integration with the JPO CV Manager + + + 22 + 0.8.11 + jacoco + ${project.basedir}/target/site/jacoco/jacoco.xml + java + **/*Config.java,**/kafka/KafkaTopics.java,**/snmp/*Properties.java,**/utils/DateJsonMapper.java + usdot-jpo-ode + + + + + + org.springframework.boot + spring-boot-starter + + + org.apache.logging.log4j + log4j-to-slf4j + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin.external.google + android-json + + + + + org.springframework.boot + spring-boot-devtools + runtime + + + org.springframework.boot + spring-boot-starter-data-rest + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.kafka + spring-kafka + + + org.springframework.kafka + spring-kafka-test + test + + + + + org.projectlombok + lombok + 1.18.30 + provided + + + org.apache.commons + commons-lang3 + 3.14.0 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + 2.19.0 + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + test + + + org.apache.kafka + kafka-streams + + + org.snmp4j + snmp4j + 3.9.3 + + + org.postgresql + postgresql + runtime + + + org.locationtech.jts + jts-core + 1.19.0 + + + + + usdot.jpo.ode + j2735-2024-ffm-lib + 2.0.2 + + + + usdot.jpo.ode + jpo-geojsonconverter + 3.2.0 + + + + org.apache.kafka + kafka-streams + + + org.apache.kafka + kafka-streams-test-utils + + + + + + usdot.jpo.ode + jpo-conflictmonitor + 3.1.1 + + + + + ${project.artifactId} + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + 3.10.0.2594 + + + org.springframework.boot + spring-boot-maven-plugin + + + + build-info + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + all + exit + + + + @{argLine} + -XX:+EnableDynamicAgentLoading + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + default-prepare-agent + + prepare-agent + + + + default-report + + report + + + + **/*Config.class + **/kafka/KafkaTopics.class + **/snmp/*Properties.class + **/utils/DateJsonMapper.class + + + + + + + + + + + + package-jar + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + true + lib/ + us.dot.its.jpo.rsustatusmonitor.RsuStatusMonitorApplication + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + repackage + none + + + + + + + + \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/settings.xml b/services/rsu-status-monitor/rsu-status-monitor/settings.xml new file mode 100644 index 000000000..3d1158913 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/settings.xml @@ -0,0 +1,28 @@ + + + + default + + + + github + usdot-jpo-ode + ${env.MAVEN_GITHUB_TOKEN} + + + + + default + + + github + GitHub Apache Maven Packages + https://maven.pkg.github.com/${env.MAVEN_GITHUB_ORG}/* + + false + + + + + + \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorApplication.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorApplication.java new file mode 100644 index 000000000..9727dff52 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorApplication.java @@ -0,0 +1,33 @@ +package us.dot.its.jpo.rsustatusmonitor; + +import javax.management.InstanceAlreadyExistsException; +import javax.management.MBeanRegistrationException; +import javax.management.MalformedObjectNameException; +import javax.management.NotCompliantMBeanException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +@EnableKafka +@EnableScheduling +@EnableConfigurationProperties +@Slf4j +public class RsuStatusMonitorApplication { + + public static void main(String[] args) throws MalformedObjectNameException, InterruptedException, + InstanceAlreadyExistsException, MBeanRegistrationException, NotCompliantMBeanException { + SpringApplication.run(RsuStatusMonitorApplication.class, args); + } + + @Bean + CommandLineRunner init(RsuStatusMonitorProperties rsuStatusMonitorProperties) { + return args -> { + }; + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorProperties.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorProperties.java new file mode 100644 index 000000000..40e6323df --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorProperties.java @@ -0,0 +1,32 @@ +package us.dot.its.jpo.rsustatusmonitor; + +import jakarta.annotation.PostConstruct; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.info.BuildProperties; +import org.springframework.stereotype.Component; + +@Component +@Data +@Slf4j +public class RsuStatusMonitorProperties { + + final BuildProperties buildProperties; + + @Autowired + public RsuStatusMonitorProperties(BuildProperties buildProperties) { + this.buildProperties = buildProperties; + } + + @PostConstruct + void initialize() { + log.info("groupId: {}", buildProperties.getGroup()); + log.info("artifactId: {}", buildProperties.getArtifact()); + log.info("version: {}", buildProperties.getVersion()); + } + + public String getVersion() { + return buildProperties.getVersion(); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/AsyncConfig.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/AsyncConfig.java new file mode 100644 index 000000000..fad11074b --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/AsyncConfig.java @@ -0,0 +1,25 @@ +package us.dot.its.jpo.rsustatusmonitor.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +@EnableScheduling +public class AsyncConfig { + + @Bean + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); // number of core threads + executor.setMaxPoolSize(10); // maximum allowed threads + executor.setQueueCapacity(1000); // queue before rejecting + executor.setThreadNamePrefix("async-exec-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/KafkaProducerConfig.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/KafkaProducerConfig.java new file mode 100644 index 000000000..5202e7537 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/KafkaProducerConfig.java @@ -0,0 +1,38 @@ +package us.dot.its.jpo.rsustatusmonitor.config; + +import java.util.Map; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +@Configuration +public class KafkaProducerConfig { + + private final KafkaProperties kafkaProperties; + + public KafkaProducerConfig(KafkaProperties kafkaProperties) { + this.kafkaProperties = kafkaProperties; + } + + @Bean + public ProducerFactory producerFactory() { + // Get all producer properties from KafkaProperties (includes SASL, SSL, etc.) + Map configProps = kafkaProperties.buildProducerProperties(null); + + // Override serializers (these are required for our String producer) + configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + + return new DefaultKafkaProducerFactory<>(configProps); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/kafka/KafkaTopics.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/kafka/KafkaTopics.java new file mode 100644 index 000000000..44579dc15 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/kafka/KafkaTopics.java @@ -0,0 +1,15 @@ +package us.dot.its.jpo.rsustatusmonitor.kafka; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class for Kafka topics. + */ +@Configuration +@ConfigurationProperties(prefix = "rsu-status-monitor.kafka.topics") +@Data +public class KafkaTopics { + private String monitoringStatus; +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuData.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuData.java new file mode 100644 index 000000000..2138f8916 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuData.java @@ -0,0 +1,18 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.derived; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@AllArgsConstructor +public class RsuData { + private int rsu_id; + private String ipv4_address; + private String intersection_id; +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentials.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentials.java new file mode 100644 index 000000000..4d79121b6 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentials.java @@ -0,0 +1,22 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.derived; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@AllArgsConstructor +public class RsuSnmpCredentials { + private int rsu_id; + private String ipv4_address; + private String username; + private String password; + private String encrypt_password; + private String protocol_code; + private String intersection_id; +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigId.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigId.java new file mode 100644 index 000000000..db78b6f76 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigId.java @@ -0,0 +1,16 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.keys; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +public class SnmpMsgfwdConfigId implements Serializable { + // Composite primary key elements for the snmp_msgfwd_config table + private int rsu_id; + private int msgfwd_type; + private int snmp_index; +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Intersections.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Intersections.java new file mode 100644 index 000000000..d48d3286a --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Intersections.java @@ -0,0 +1,28 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.locationtech.jts.geom.Geometry; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@Table(name = "intersections") +public class Intersections { + + @Id + private int intersection_id; + private String intersection_number; + private Geometry ref_pt; + private Geometry bbox; + private String intersection_name; + private String origin_ip; + +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersection.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersection.java new file mode 100644 index 000000000..710de7a48 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersection.java @@ -0,0 +1,22 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@Table(name = "rsu_intersection") +public class RsuIntersection { + @Id + private int rsu_intersection_id; + private int rsu_id; + private int intersection_id; +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Rsus.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Rsus.java new file mode 100644 index 000000000..5639095b6 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Rsus.java @@ -0,0 +1,35 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.locationtech.jts.geom.Geometry; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@Table(name = "rsus") +public class Rsus { + + @Id + private int rsu_id; + private Geometry geography; + private float milepost; + private String ipv4_address; + private String serial_number; + private String iss_scms_id; + private String primary_route; + private int model; + private int credential_id; + private int snmp_credential_id; + private int snmp_protocol_id; + private int firmware_version; + private int target_firmware_version; + +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentials.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentials.java new file mode 100644 index 000000000..b8b8646e8 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentials.java @@ -0,0 +1,25 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@Table(name = "snmp_credentials") +public class SnmpCredentials { + @Id + private int snmp_credential_id; + private String username; + private String password; + private String encrypt_password; + private String nickname; + +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfig.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfig.java new file mode 100644 index 000000000..336a618ff --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfig.java @@ -0,0 +1,35 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.keys.SnmpMsgfwdConfigId; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@IdClass(SnmpMsgfwdConfigId.class) +@Table(name = "snmp_msgfwd_config") +public class SnmpMsgfwdConfig { + @Id + private int rsu_id; + @Id + private int msgfwd_type; + @Id + private int snmp_index; + private String message_type; + private String dest_ipv4; + private int dest_port; + private LocalDateTime start_datetime; + private LocalDateTime end_datetime; + private boolean active; + private boolean security; +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdType.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdType.java new file mode 100644 index 000000000..f8b7ac613 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdType.java @@ -0,0 +1,22 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@Table(name = "snmp_msgfwd_type") +public class SnmpMsgfwdType { + @Id + private int snmp_msgfwd_type_id; + private String name; + +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocols.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocols.java new file mode 100644 index 000000000..069b09a31 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocols.java @@ -0,0 +1,22 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@Entity +@Table(name = "snmp_protocols") +public class SnmpProtocols { + @Id + private int snmp_protocol_id; + private String protocol_code; + private String nickname; +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OID.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OID.java new file mode 100644 index 000000000..bf083b056 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OID.java @@ -0,0 +1,26 @@ +package us.dot.its.jpo.rsustatusmonitor.models.snmp; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@Setter +@EqualsAndHashCode +@Getter +@AllArgsConstructor +public class OID { + + private String name; + private OID_TYPE type; + private String oid; +} + +enum OID_TYPE { + SCALAR, + NODE, + TABLE, + ROW +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMap.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMap.java new file mode 100644 index 000000000..74d8ff320 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMap.java @@ -0,0 +1,150 @@ +package us.dot.its.jpo.rsustatusmonitor.models.snmp; + +import java.util.Map; + +public class OIDMap { + + // This is a map of existing OIDs supported by RSU SNMP Units. This list is not + // exhaustive and will need to be updated with additional codes as needs arrive. + // There is not currently a good library available for MIB parsing for this + // project, so this maps allows loading OIDS by name for now. + public static Map oids = Map.ofEntries( + Map.entry("rsuModeStatus", + new OID("rsuModeStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.3.0")), + Map.entry("rsuMode", new OID("rsuMode", OID_TYPE.NODE, "1.3.6.1.4.1.1.1206.4.2.18.16.2.0")), + Map.entry("rsuGnssStatus", + new OID("rsuGnssStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.2.1.0")), + Map.entry("rsuGnssAugmentation", + new OID("rsuGnssAugmentation", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.2.2.0")), + Map.entry("rsuGnssOutputString", + new OID("rsuGnssOutputString", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.6.5.0")), + Map.entry("rsuGnssLat", + new OID("rsuGnssLat", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.6.6.0")), + Map.entry("rsuGnssLon", + new OID("rsuGnssLon", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.6.7.0")), + Map.entry("rsuGnssElv", + new OID("rsuGnssElv", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.6.8.0")), + Map.entry("rsuLocationLat", + new OID("rsuLocationLat", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.5.0")), + Map.entry("rsuLocationLon", + new OID("rsuLocationLon", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.6.0")), + Map.entry("rsuLocationElv", + new OID("rsuLocationElv", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.7.0")), + Map.entry("rsuGnssMaxDeviation", + new OID("rsuGnssMaxDeviation", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.6.9.0")), + Map.entry("rsuLocationDeviation", + new OID("rsuLocationDeviation", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.6.10.0")), + Map.entry("rsuGnssPositionError", + new OID("rsuGnssPositionError", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1.1206.4.2.18.6.11.0")), + Map.entry("rsuStatus", + new OID("rsuStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.10.0")), + Map.entry("rsuFirmwareVersion", + new OID("rsuFirmwareVersion", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.13.2.0")), + Map.entry("rsuMibVersion", + new OID("rsuMibVersion", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.1.0")), + Map.entry("rsuID", new OID("rsuID", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.4.0")), + Map.entry("rsuReboot", + new OID("rsuReboot", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.4.0")), + Map.entry("rsuTimeSincePowerOn", + new OID("rsuTimeSincePowerOn", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.12.1.0")), + Map.entry("rsuIntTemp", + new OID("rsuIntTemp", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.12.2.0")), + Map.entry("rsuClockSource", + new OID("rsuClockSource", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.5.0")), + Map.entry("rsuClockSourceStatus", + new OID("rsuClockSourceStatus", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1.1206.4.2.18.16.6.0")), + Map.entry("rsuClockSourceTimeout", + new OID("rsuClockSourceTimeout", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1.1206.4.2.18.16.7.0")), + Map.entry("rsuClockDeviationTolerance", + new OID("rsuClockDeviationTolerance", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1.1206.4.2.18.16.9.0")), + Map.entry("maxRsuReceivedMsgs", + new OID("maxRsuReceivedMsgs", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.1")), + Map.entry("rsuReceivedMsgTable", + new OID("rsuReceivedMsgTable", OID_TYPE.TABLE, + "1.3.6.1.4.1.1206.4.2.18.5.2")), + Map.entry("rsuReceivedMsgEntry", + new OID("rsuReceivedMsgEntry", OID_TYPE.ROW, + "1.3.6.1.4.1.1206.4.2.18.5.2.1")), + Map.entry("rsuReceivedMsgPsid", + new OID("rsuReceivedMsgPsid", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.2")), + Map.entry("rsuReceivedMsgDestIpAddr", + new OID("rsuReceivedMsgDestIpAddr", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.3")), + Map.entry("rsuReceivedMsgDestPort", + new OID("rsuReceivedMsgDestPort", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.4")), + Map.entry("rsuReceivedMsgProtocol", + new OID("rsuReceivedMsgProtocol", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.5")), + Map.entry("rsuReceivedMsgRssi", + new OID("rsuReceivedMsgRssi", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.6")), + Map.entry("rsuReceivedMsgInterval", + new OID("rsuReceivedMsgInterval", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.7")), + Map.entry("rsuReceivedMsgDeliveryStart", + new OID("rsuReceivedMsgDeliveryStart", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.8")), + Map.entry("rsuReceivedMsgDeliveryStop", + new OID("rsuReceivedMsgDeliveryStop", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.9")), + Map.entry("rsuReceivedMsgStatus", + new OID("rsuReceivedMsgStatus", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.10")), + Map.entry("rsuReceivedMsgSecure", + new OID("rsuReceivedMsgSecure", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.11")), + Map.entry("rsuReceivedMsgAuthMsgInterval", + new OID("rsuReceivedMsgAuthMsgInterval", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.5.2.1.12")), + Map.entry("maxRsuMsgRepeat", + new OID("maxRsuMsgRepeat", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.3.1.0")), + Map.entry("rsuMsgRepeatStatusTable", + new OID("rsuMsgRepeatStatusTable", OID_TYPE.TABLE, + "1.3.6.1.4.1.1206.4.2.18.3.2.0")), + Map.entry("rsuMsgRepeatPsid", + new OID("rsuMsgRepeatPsid", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.2.0")), + Map.entry("rsuMsgRepeatTxChannel", + new OID("rsuMsgRepeatTxChannel", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.3.0")), + Map.entry("rsuMsgRepeatTxInterval", + new OID("rsuMsgRepeatTxInterval", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.4.0")), + Map.entry("rsuMsgRepeatDeliveryStart", + new OID("rsuMsgRepeatDeliveryStart", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.5.0")), + Map.entry("rsuMsgRepeatDeliveryStop", + new OID("rsuMsgRepeatDeliveryStop", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.6.0")), + Map.entry("rsuMsgRepeatPayload", + new OID("rsuMsgRepeatPayload", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.7.0")), + Map.entry("rsuMsgRepeatEnable", + new OID("rsuMsgRepeatEnable", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.8.0")), + Map.entry("rsuMsgRepeatStatus", + new OID("rsuMsgRepeatStatus", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.9.0")), + Map.entry("rsuMsgRepeatPriority", + new OID("rsuMsgRepeatPriority", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.10.0")), + Map.entry("rsuMsgRepeatOptions", + new OID("rsuMsgRepeatOptions", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.2.1.11.0")), + Map.entry("rsuMsgRepeatDeleteAll", + new OID("rsuMsgRepeatDeleteAll", OID_TYPE.SCALAR, + "1.3.6.1.4.1.1206.4.2.18.3.3.0"))); +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValues.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValues.java new file mode 100644 index 000000000..6bf0c014e --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValues.java @@ -0,0 +1,33 @@ +package us.dot.its.jpo.rsustatusmonitor.models.snmp; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Generated; +import lombok.NoArgsConstructor; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials; + +// NTCIP-1218 +@Data +@Generated +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class RsuMsgfwdValues { + private RsuSnmpCredentials rsuSnmpCredentials; + private String rsuReceivedMsgPsid; + private String rsuReceivedMsgDestIpAddr; + private Integer rsuReceivedMsgDestPort; + private Integer rsuReceivedMsgProtocol; + private Integer rsuReceivedMsgRssi; + private Integer rsuReceivedMsgInterval; + // Formatted in an 8-byte hex string: YYYYMMddHHmmssSS + // Example: '2025-01-01 00:00:00.00' would be formatted like '07e9010100000000' + private String rsuReceivedMsgDeliveryStart; + private String rsuReceivedMsgDeliveryStop; + private Integer rsuReceivedMsgStatus; + private Integer rsuReceivedMsgSecure; + private Integer rsuReceivedMsgAuthMsgInterval; +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuState.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuState.java new file mode 100644 index 000000000..06dfc70bc --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuState.java @@ -0,0 +1,39 @@ +package us.dot.its.jpo.rsustatusmonitor.models.snmp; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class RsuState { + /** + * Time in UTC Milliseconds that the RSU State was generated + */ + public long timestamp; + + /** + * IntersectionID of the intersection corresponding to the RSU + */ + public String intersectionID; + + /** + * IP Address of the RSU unit + */ + public String rsuIP; + + /** + * Internal Temperature of the RSU Unit + */ + public double temperature; + + /** + * Time in seconds since the RSU unit last rebooted + */ + public int uptime; + + /** + * The Mode of the RSU. There are 3 common modes, Operational(4), Standby (2) + * and Off (16) + */ + public int mode; +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerService.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerService.java new file mode 100644 index 000000000..2e08b24ed --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerService.java @@ -0,0 +1,45 @@ +package us.dot.its.jpo.rsustatusmonitor.services; + +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; +import us.dot.its.jpo.geojsonconverter.partitioner.RsuIntersectionKey; +import us.dot.its.jpo.rsustatusmonitor.kafka.KafkaTopics; +import us.dot.its.jpo.rsustatusmonitor.models.snmp.RsuState; + +@Service +@Slf4j +public class KafkaProducerService { + + private final KafkaTemplate kafkaTemplate; + + private ObjectMapper objectMapper; + + private KafkaTopics kafkaTopics; + + public KafkaProducerService(KafkaTemplate kafkaTemplate, ObjectMapper objectMapper, + KafkaTopics kafkaTopics) { + this.objectMapper = objectMapper; + this.kafkaTemplate = kafkaTemplate; + this.kafkaTopics = kafkaTopics; + } + + public void sendMessage(String topic, String key, String message) { + kafkaTemplate.send(topic, key, message); + } + + public void sendRsuStatus(RsuIntersectionKey key, RsuState message) { + try { + String jsonMessage = objectMapper.writeValueAsString(message); + String jsonKey = objectMapper.writeValueAsString(key); + kafkaTemplate.send(kafkaTopics.getMonitoringStatus(), jsonKey, jsonMessage); + } catch (JsonProcessingException e) { + log.error("Failed to serialize message" + message); + } + + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresService.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresService.java new file mode 100644 index 000000000..d4c7f996c --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresService.java @@ -0,0 +1,33 @@ +package us.dot.its.jpo.rsustatusmonitor.services; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import java.util.List; +import org.springframework.stereotype.Service; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials; + +@Service +public class PostgresService { + + @PersistenceContext + private EntityManager entityManager; + + // Finds the RSU SNMP credentials for all RSUs, includes the intersection ID if + // available + private final String findRsuSnmpCredentials = "SELECT new us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials( " + + + "rsu.rsu_id, rsu.ipv4_address, snmp_creds.username, snmp_creds.password, snmp_creds.encrypt_password, snmp_proto.protocol_code, i.intersection_number) " + + + "FROM Rsus rsu " + + "JOIN SnmpCredentials snmp_creds ON rsu.snmp_credential_id = snmp_creds.snmp_credential_id " + + "JOIN SnmpProtocols snmp_proto ON rsu.snmp_protocol_id = snmp_proto.snmp_protocol_id " + + "LEFT JOIN RsuIntersection ri ON rsu.rsu_id = ri.rsu_id " + + "LEFT JOIN Intersections i ON ri.intersection_id = i.intersection_id"; + + public List getRsusWithCredentials() { + TypedQuery query = entityManager.createQuery(findRsuSnmpCredentials, + RsuSnmpCredentials.class); + return query.getResultList(); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryService.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryService.java new file mode 100644 index 000000000..50feaa847 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryService.java @@ -0,0 +1,112 @@ +package us.dot.its.jpo.rsustatusmonitor.services; + +import java.io.IOException; +import java.time.Instant; + +import org.snmp4j.smi.Variable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; +import us.dot.its.jpo.geojsonconverter.partitioner.RsuIntersectionKey; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials; +import us.dot.its.jpo.rsustatusmonitor.models.snmp.OID; +import us.dot.its.jpo.rsustatusmonitor.models.snmp.OIDMap; +import us.dot.its.jpo.rsustatusmonitor.models.snmp.RsuState; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +@Service +@Slf4j +public class RsuQueryService { + + private SNMPService snmpService; + private KafkaProducerService kafkaService; + private MeterRegistry meterRegistry; + + @Autowired + public RsuQueryService(SNMPService snmpService, KafkaProducerService kafkaService, MeterRegistry meterRegistry) { + this.snmpService = snmpService; + this.kafkaService = kafkaService; + this.meterRegistry = meterRegistry; + } + + @Async + public void getRsuInformation(RsuSnmpCredentials cred) { + String username = cred.getUsername(); + String password = cred.getPassword(); + String encPass = cred.getEncrypt_password(); + String ip = cred.getIpv4_address(); + String intersectionId = cred.getIntersection_id() != null ? cred.getIntersection_id() : "-1"; + + log.info("Pulling SNMP Status for RSU: " + ip + " IntersectionID: " + intersectionId); + + Counter.builder("rsu.snmp.status") + .description("Number of snmp status queries by RSU") + .tags("rsu_ip", ip) + .register(meterRegistry) + .increment(); + + if (username == null || password == null || ip == null) { + log.warn("Cannot pull data from RSU unit. Missing Username, Password, or IP address. RSU ID: " + ip + + " Intersection ID: " + intersectionId); + + Counter.builder("rsu.snmp.status.error") + .description("Number of snmp status errors queries by RSU") + .tags("rsu_ip", ip, "error", "credential-error") + .register(meterRegistry) + .increment(); + return; + } + + // enc password is not defined for all RSU units. Try using normal password + if (encPass == null) { + encPass = password; + } + + RsuState state = new RsuState(); + state.timestamp = Instant.now().toEpochMilli(); + state.rsuIP = ip; + state.intersectionID = intersectionId; + + state.uptime = getIntOID(ip, username, password, encPass, OIDMap.oids.get("rsuTimeSincePowerOn")); + state.temperature = getIntOID(ip, username, password, encPass, OIDMap.oids.get("rsuIntTemp")); + state.mode = getIntOID(ip, username, password, encPass, OIDMap.oids.get("rsuModeStatus")); + + RsuIntersectionKey key = new RsuIntersectionKey(); + key.setIntersectionId(Integer.parseInt(intersectionId)); + key.setRsuId(ip); + key.setRegion(-1); + + kafkaService.sendRsuStatus(key, state); + + log.info("Retrieved RSU Information for RSU: " + ip + " IntersectionID: " + intersectionId + + " Uptime: " + state.uptime + " Temperature: " + state.temperature + " Mode: " + state.mode); + } + + public int getIntOID(String ip, String username, String password, String encPass, OID oid) { + try { + Variable var = snmpService.getSnmpV3Value(ip, username, password, encPass, oid.getOid()); + + if (var != null) { + return var.toInt(); + } else { + log.warn("Query of OID " + oid.getName() + " for Intersection" + ip + " returned no value"); + Counter.builder("rsu.snmp.status.error") + .description("Number of snmp status errors queries by RSU") + .tags("rsu_ip", ip, "error", "no-value", "oid", oid.getName()) + .register(meterRegistry) + .increment(); + } + } catch (IOException e) { + log.warn("Unable to Retrieve value for OID: " + oid.getName() + " for Intersection" + ip); + Counter.builder("rsu.snmp.status.error") + .description("Number of snmp status errors queries by RSU") + .tags("rsu_ip", ip, "error", "IO Exception", "oid", oid.getName()) + .register(meterRegistry) + .increment(); + } + return -1; + } +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPService.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPService.java new file mode 100644 index 000000000..804a0f8c4 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPService.java @@ -0,0 +1,232 @@ +package us.dot.its.jpo.rsustatusmonitor.services; + +import java.util.Map; +import org.snmp4j.*; +import org.snmp4j.event.ResponseEvent; +import org.snmp4j.mp.MPv3; +import org.snmp4j.mp.SnmpConstants; +import org.snmp4j.security.AuthSHA; +import org.snmp4j.security.PrivAES128; +import org.snmp4j.security.SecurityLevel; +import org.snmp4j.security.SecurityModels; +import org.snmp4j.security.SecurityProtocols; +import org.snmp4j.security.USM; +import org.snmp4j.security.UsmUser; +import org.snmp4j.smi.*; +import org.snmp4j.transport.DefaultUdpTransportMapping; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; +import us.dot.its.jpo.rsustatusmonitor.snmp.SnmpProperties; + +import java.io.IOException; + +@Service +@Slf4j +public class SNMPService { + + private SnmpProperties properties; + + @Autowired + public SNMPService(SnmpProperties properties) { + this.properties = properties; + } + + // Some RSUs do not require authentication to retrieve information. They may + // respond to SNMP V2 requests. + public String getSnmpV2Value(String ipAddress, String community, String oid) throws Exception { + + TransportMapping transport = new DefaultUdpTransportMapping(); + Snmp snmp = new Snmp(transport); + transport.listen(); + + CommunityTarget target = new CommunityTarget<>(); + target.setCommunity(new OctetString(community)); + target.setAddress(new UdpAddress(ipAddress + "/" + properties.getPort())); + target.setRetries(properties.getRetries()); + target.setTimeout(properties.getTimeout()); + target.setVersion(SnmpConstants.version2c); + + PDU pdu = new PDU(); + pdu.add(new VariableBinding(new OID(oid))); + pdu.setType(PDU.GET); + + ResponseEvent responseEvent = snmp.send(pdu, target); + snmp.close(); + + if (responseEvent != null && responseEvent.getResponse() != null) { + VariableBinding vb = responseEvent.getResponse().get(0); + return vb.getVariable().toString(); + } else { + throw new RuntimeException("SNMP GET timed out or returned null."); + } + + } + + public Variable getSnmpV3Value(String ip, String username, String authPass, String privPass, String oid) + throws IOException { + + TransportMapping transport = new DefaultUdpTransportMapping(); + Snmp snmp = new Snmp(transport); + transport.listen(); + + // Add USM and user + USM usm = new USM( + SecurityProtocols.getInstance(), + new OctetString(MPv3.createLocalEngineID()), + 0); + SecurityModels.getInstance().addSecurityModel(usm); + + // Add Required Security Protocols + SecurityProtocols.getInstance().addAuthenticationProtocol(new AuthSHA()); + SecurityProtocols.getInstance().addPrivacyProtocol(new PrivAES128()); + + snmp.getUSM().addUser( + new OctetString(username), + new UsmUser( + new OctetString(username), + AuthSHA.ID, new OctetString(authPass), + PrivAES128.ID, new OctetString(privPass))); + + // Configure the target + UserTarget target = new UserTarget<>(); + target.setAddress(new UdpAddress(ip + "/" + properties.getPort())); + target.setRetries(properties.getRetries()); + target.setTimeout(properties.getTimeout()); + target.setVersion(SnmpConstants.version3); + target.setSecurityLevel(SecurityLevel.AUTH_PRIV); + target.setSecurityName(new OctetString(username)); + + // Create a ScopedPDU for SET + ScopedPDU pdu = new ScopedPDU(); + pdu.setType(PDU.GET); + pdu.add(new VariableBinding(new OID(oid))); + + ResponseEvent response = snmp.send(pdu, target); + snmp.close(); + if (response != null && response.getResponse() != null) { + VariableBinding vb = response.getResponse().get(0); + return vb.getVariable(); + } + return null; + + } + + public void setSnmpV3Value( + String ipAddress, + String username, + String authPass, + String oid, + int intValue // directly passing integer for clarity + ) throws Exception { + + // Setup SNMP and transport + TransportMapping transport = new DefaultUdpTransportMapping(); + Snmp snmp = new Snmp(transport); + transport.listen(); + + // Add USM and user + USM usm = new USM( + SecurityProtocols.getInstance(), + new OctetString(MPv3.createLocalEngineID()), + 0); + SecurityModels.getInstance().addSecurityModel(usm); + + SecurityProtocols.getInstance().addAuthenticationProtocol(new AuthSHA()); + SecurityProtocols.getInstance().addPrivacyProtocol(new PrivAES128()); + + snmp.getUSM().addUser( + new OctetString(username), + new UsmUser( + new OctetString(username), + AuthSHA.ID, new OctetString(authPass), + PrivAES128.ID, new OctetString(authPass))); + + // Configure the target + UserTarget target = new UserTarget<>(); + target.setAddress(new UdpAddress(ipAddress + "/" + properties.getPort())); + target.setRetries(properties.getRetries()); + target.setTimeout(properties.getTimeout()); + target.setVersion(SnmpConstants.version3); + target.setSecurityLevel(SecurityLevel.AUTH_PRIV); + target.setSecurityName(new OctetString(username)); + + // Create a ScopedPDU for SET + ScopedPDU pdu = new ScopedPDU(); + pdu.setType(PDU.SET); + pdu.add(new VariableBinding(new OID(oid), new Integer32(intValue))); + + // Send and handle response + ResponseEvent response = snmp.send(pdu, target); + snmp.close(); + if (response == null || response.getResponse() == null) { + log.warn("Received Null Response from RSU unit " + ipAddress); + } + + if (response.getResponse().getErrorStatus() != PDU.noError) { + log.warn("Error while setting value on RSU unit " + ipAddress); + } + } + + // Used for setting multiple OID values at the same time. RSUs require this for + // many configurations + public void setSnmpV3Values( + String ipAddress, + String username, + String authPass, + Map oidValuePairs) throws Exception { + + // Setup SNMP and transport + TransportMapping transport = new DefaultUdpTransportMapping(); + Snmp snmp = new Snmp(transport); + transport.listen(); + + // Add USM and user + USM usm = new USM( + SecurityProtocols.getInstance(), + new OctetString(MPv3.createLocalEngineID()), + 0); + SecurityModels.getInstance().addSecurityModel(usm); + + SecurityProtocols.getInstance().addAuthenticationProtocol(new AuthSHA()); + SecurityProtocols.getInstance().addPrivacyProtocol(new PrivAES128()); + + snmp.getUSM().addUser( + new OctetString(username), + new UsmUser( + new OctetString(username), + AuthSHA.ID, new OctetString(authPass), + PrivAES128.ID, new OctetString(authPass))); + + // Configure the target + UserTarget target = new UserTarget<>(); + target.setAddress(new UdpAddress(ipAddress + "/" + properties.getPort())); + target.setRetries(properties.getRetries()); + target.setTimeout(properties.getTimeout()); + target.setVersion(SnmpConstants.version3); + target.setSecurityLevel(SecurityLevel.AUTH_PRIV); + target.setSecurityName(new OctetString(username)); + + // Create a ScopedPDU for SET with multiple OIDs + ScopedPDU pdu = new ScopedPDU(); + pdu.setType(PDU.SET); + + // Add all OID-value pairs to the same PDU + for (java.util.Map.Entry entry : oidValuePairs.entrySet()) { + pdu.add(new VariableBinding(new OID(entry.getKey()), entry.getValue())); + } + + // Send and handle response + ResponseEvent response = snmp.send(pdu, target); + snmp.close(); + if (response == null || response.getResponse() == null) { + log.warn("Received Null Response from RSU unit" + ipAddress); + } + + if (response.getResponse().getErrorStatus() != PDU.noError) { + log.warn("Error while setting values on RSU unit" + ipAddress + ". Error: " + + response.getResponse().getErrorStatusText()); + } + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/snmp/SnmpProperties.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/snmp/SnmpProperties.java new file mode 100644 index 000000000..b3f5f289f --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/snmp/SnmpProperties.java @@ -0,0 +1,17 @@ +package us.dot.its.jpo.rsustatusmonitor.snmp; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class for Kafka topics. + */ +@Configuration +@ConfigurationProperties(prefix = "rsu-status-monitor.snmp") +@Data +public class SnmpProperties { + private int retries; + private int timeout; + private int port; +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTask.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTask.java new file mode 100644 index 000000000..db98b65e0 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTask.java @@ -0,0 +1,63 @@ +package us.dot.its.jpo.rsustatusmonitor.tasks; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials; +import us.dot.its.jpo.rsustatusmonitor.services.PostgresService; +import us.dot.its.jpo.rsustatusmonitor.services.RsuQueryService; + +@Component +@ConditionalOnProperty(name = "enable.monitoring", havingValue = "true", matchIfMissing = false) +@Slf4j +public class RsuMonitoringTask { + + private PostgresService postgresService; + private RsuQueryService rsuQueryService; + private final Executor taskExecutor; + + @Autowired + public RsuMonitoringTask( + RsuQueryService rsuQueryService, + PostgresService postgresService) { + this.rsuQueryService = rsuQueryService; + this.postgresService = postgresService; + // Create a fixed thread pool with 10 threads for RSU monitoring + this.taskExecutor = Executors.newFixedThreadPool(10); + } + + @Scheduled(fixedRateString = "${monitor.interval}") + public void queryRSUStats() { + List credentials = postgresService.getRsusWithCredentials(); + + // Process all RSU credentials in parallel using CompletableFuture + List> futures = credentials.stream() + .map(cred -> CompletableFuture.runAsync(() -> { + try { + log.debug("Processing RSU: {}", cred.getIpv4_address()); + rsuQueryService.getRsuInformation(cred); + } catch (Exception e) { + log.error("Error processing status for RSU {}: {}", cred.getIpv4_address(), e.getMessage(), e); + } + }, taskExecutor)) + .toList(); + + // Wait for all tasks to complete before the method returns + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .exceptionally(throwable -> { + log.error("Error in RSU monitoring task execution: {}", throwable.getMessage(), throwable); + return null; + }) + .join(); + + log.debug("Completed RSU Monitoring Task for {} RSUs", credentials.size()); + } +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/DateJsonMapper.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/DateJsonMapper.java new file mode 100644 index 000000000..b0e18b92b --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/DateJsonMapper.java @@ -0,0 +1,28 @@ +package us.dot.its.jpo.rsustatusmonitor.utils; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Configuration; + +/** + * Provides a configured ObjectMapper instance for JSON date handling. + */ +@Configuration +public class DateJsonMapper { + private static final ObjectMapper objectMapper; + + static { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + objectMapper.setSerializationInclusion(Include.NON_NULL); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + public static ObjectMapper getInstance() { + return objectMapper; + } +} \ No newline at end of file diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtil.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtil.java new file mode 100644 index 000000000..a5151ea93 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtil.java @@ -0,0 +1,32 @@ +package us.dot.its.jpo.rsustatusmonitor.utils; + +import java.time.LocalDateTime; + +import org.snmp4j.smi.OctetString; + +public class SnmpHelperUtil { + public static String generateNtcip1218HexDateTimeString(LocalDateTime dateTime) { + String year = String.format("%04x", dateTime.getYear()); + String month = String.format("%02x", dateTime.getMonthValue()); + String day = String.format("%02x", dateTime.getDayOfMonth()); + String hour = String.format("%02x", dateTime.getHour()); + String minute = String.format("%02x", dateTime.getMinute()); + String second = String.format("%02x", dateTime.getSecond()); + return String.format("%s%s%s%s%s%s00", year, month, day, hour, minute, second); + } + + /** + * Helper method to convert hex string to OctetString for SNMP - add comment + * explaining nuance logic + * + * @param hexString The hex string to convert (e.g., "07e9010100000000") + * @return OctetString with the converted byte array + */ + public static OctetString hexStringToOctetString(String hexString) { + byte[] bytes = new byte[hexString.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) Integer.parseInt(hexString.substring(2 * i, 2 * i + 2), 16); + } + return new OctetString(bytes); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-confluent.yaml b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-confluent.yaml new file mode 100644 index 000000000..13d50f0db --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-confluent.yaml @@ -0,0 +1,13 @@ +# Confluent properties +spring: + config: + activate: + on-profile: 'confluent' + kafka: + properties: + ssl.endpoint.identification.algorithm: https + security.protocol: SASL_SSL + sasl.mechanism: PLAIN + sasl.jaas.config: > + org.apache.kafka.common.security.plain.PlainLoginModule required + username="${CONFLUENT_KEY:user}" password="${CONFLUENT_SECRET:pass}"; diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-local.yaml b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-local.yaml new file mode 100644 index 000000000..f1e32f813 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-local.yaml @@ -0,0 +1,6 @@ +# Local properties +spring: + config: + activate: + on-profile: 'local' + kafka.bootstrap-servers: localhost:9092 diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application.yaml b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application.yaml new file mode 100644 index 000000000..c52339229 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application.yaml @@ -0,0 +1,71 @@ +# RSU Status Monitor properties +rsu-status-monitor: + kafka: + topics: + monitoring-status: 'topic.RmMonitoringStatusRecords' + snmp: + retries: ${RSU_STATUS_MONITOR_SNMP_RETRY:2} # Number of times to retry + timeout: ${RSU_STATUS_MONITOR_SNMP_TIMEOUT_MS:5000} # Number of milliseconds before timeout + port: ${RSU_STATUS_MONITOR_SNMP_PORT:161} # Port to use for SNMP Operations + +logging: + level: + root: WARN + org.springframework.kafka: WARN + org.springframework.web: WARN + guru.springframework.controllers: WARN + us.dot.its.jpo.rsustatusmonitor: ${LOGGING_LEVEL:INFO} + org.apache.kafka: WARN + +# microservice actuator +management: + endpoints: + web: + exposure: + include: '*' + server: + port: 8082 + +# Spring boot properties +spring: + profiles: + default: 'none' + active: ${SPRING_PROFILES_ACTIVE:default} + # Postgres Configuration + datasource: + url: ${POSTGRES_SERVER_URL:jdbc:postgresql://localhost:5432}/${POSTGRES_DB:postgres} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + # Kafka Configuration + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + consumer: + group-id: rsu-status-monitor + fetch-min-size: 1 + fetch-max-wait: 10 + max-poll-records: 100 + auto-offset-reset: latest + enable-auto-commit: true + auto-commit-interval: 50 + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + heartbeat-interval: 3000 + listener: + ack-mode: BATCH + poll-timeout: 100 + idle-event-interval: 100 + idle-between-polls: 0 + concurrency: ${KAFKA_CONCURRENCY:1} + config: + import: + - classpath:application-confluent.yaml + +enable: + monitoring: ${RSU_STATUS_MONITOR_ENABLE_MONITORING:true} + +monitor: + interval: ${INTERSECTION_API_MONITOR_INTERVAL:300000} # Number of Milliseconds between RSU Status Checks diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorPropertiesTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorPropertiesTest.java new file mode 100644 index 000000000..b054f1ce7 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorPropertiesTest.java @@ -0,0 +1,45 @@ +package us.dot.its.jpo.rsustatusmonitor; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.info.BuildProperties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class RsuStatusMonitorPropertiesTest { + + @Mock + private BuildProperties buildProperties; + + @InjectMocks + private RsuStatusMonitorProperties rsuStatusMonitorProperties; + + @Test + void testInitialize() { + when(buildProperties.getGroup()).thenReturn("test-group"); + when(buildProperties.getArtifact()).thenReturn("test-artifact"); + when(buildProperties.getVersion()).thenReturn("1.0.0"); + + rsuStatusMonitorProperties.initialize(); + + verify(buildProperties).getGroup(); + verify(buildProperties).getArtifact(); + verify(buildProperties).getVersion(); + } + + @Test + void testGetVersion() { + String expectedVersion = "1.0.0"; + when(buildProperties.getVersion()).thenReturn(expectedVersion); + + String actualVersion = rsuStatusMonitorProperties.getVersion(); + + assertEquals(expectedVersion, actualVersion); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/TestConfig.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/TestConfig.java new file mode 100644 index 000000000..e9379d722 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/TestConfig.java @@ -0,0 +1,26 @@ +package us.dot.its.jpo.rsustatusmonitor; + +import java.util.Properties; +import org.springframework.boot.info.BuildProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestConfig { + + @Bean + public BuildProperties buildProperties() { + Properties properties = new Properties(); + properties.setProperty("group", "usdot.jpo.ode"); + properties.setProperty("artifact", "rsu-status-monitor"); + properties.setProperty("version", "0.1.0-TEST"); + properties.setProperty("name", "rsu-status-monitor"); + properties.setProperty("time", "2025-11-21T00:00:00Z"); + return new BuildProperties(properties); + } + + @Bean + public RsuStatusMonitorProperties rsuStatusMonitorProperties(BuildProperties buildProperties) { + return new RsuStatusMonitorProperties(buildProperties); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuDataTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuDataTest.java new file mode 100644 index 000000000..ce74b7320 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuDataTest.java @@ -0,0 +1,50 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.derived; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class RsuDataTest { + + @Test + public void testConstructor() { + RsuData rsuData = new RsuData(1, "192.168.1.1", "12345"); + + assertEquals(1, rsuData.getRsu_id()); + assertEquals("192.168.1.1", rsuData.getIpv4_address()); + assertEquals("12345", rsuData.getIntersection_id()); + } + + @Test + public void testGettersAndSetters() { + RsuData rsuData = new RsuData(1, "192.168.1.1", "12345"); + + rsuData.setRsu_id(2); + rsuData.setIpv4_address("10.0.0.1"); + rsuData.setIntersection_id("67890"); + + assertEquals(2, rsuData.getRsu_id()); + assertEquals("10.0.0.1", rsuData.getIpv4_address()); + assertEquals("67890", rsuData.getIntersection_id()); + } + + @Test + public void testEqualsAndHashCode() { + RsuData rsuData1 = new RsuData(1, "192.168.1.1", "12345"); + RsuData rsuData2 = new RsuData(1, "192.168.1.1", "12345"); + RsuData rsuData3 = new RsuData(2, "10.0.0.1", "67890"); + + assertEquals(rsuData1, rsuData2); + assertNotEquals(rsuData1, rsuData3); + assertEquals(rsuData1.hashCode(), rsuData2.hashCode()); + } + + @Test + public void testToString() { + RsuData rsuData = new RsuData(1, "192.168.1.1", "12345"); + String result = rsuData.toString(); + + assertNotNull(result); + assertTrue(result.contains("192.168.1.1")); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentialsTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentialsTest.java new file mode 100644 index 000000000..af3fdc8f7 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentialsTest.java @@ -0,0 +1,74 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.derived; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class RsuSnmpCredentialsTest { + + @Test + public void testConstructor() { + RsuSnmpCredentials credentials = new RsuSnmpCredentials( + 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345" + ); + + assertEquals(1, credentials.getRsu_id()); + assertEquals("192.168.1.1", credentials.getIpv4_address()); + assertEquals("user", credentials.getUsername()); + assertEquals("pass", credentials.getPassword()); + assertEquals("encryptPass", credentials.getEncrypt_password()); + assertEquals("SHA", credentials.getProtocol_code()); + assertEquals("12345", credentials.getIntersection_id()); + } + + @Test + public void testGettersAndSetters() { + RsuSnmpCredentials credentials = new RsuSnmpCredentials( + 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345" + ); + + credentials.setRsu_id(2); + credentials.setIpv4_address("10.0.0.1"); + credentials.setUsername("newUser"); + credentials.setPassword("newPass"); + credentials.setEncrypt_password("newEncrypt"); + credentials.setProtocol_code("MD5"); + credentials.setIntersection_id("67890"); + + assertEquals(2, credentials.getRsu_id()); + assertEquals("10.0.0.1", credentials.getIpv4_address()); + assertEquals("newUser", credentials.getUsername()); + assertEquals("newPass", credentials.getPassword()); + assertEquals("newEncrypt", credentials.getEncrypt_password()); + assertEquals("MD5", credentials.getProtocol_code()); + assertEquals("67890", credentials.getIntersection_id()); + } + + @Test + public void testEqualsAndHashCode() { + RsuSnmpCredentials credentials1 = new RsuSnmpCredentials( + 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345" + ); + RsuSnmpCredentials credentials2 = new RsuSnmpCredentials( + 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345" + ); + RsuSnmpCredentials credentials3 = new RsuSnmpCredentials( + 2, "10.0.0.1", "user2", "pass2", "encryptPass2", "MD5", "67890" + ); + + assertEquals(credentials1, credentials2); + assertNotEquals(credentials1, credentials3); + assertEquals(credentials1.hashCode(), credentials2.hashCode()); + } + + @Test + public void testToString() { + RsuSnmpCredentials credentials = new RsuSnmpCredentials( + 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345" + ); + String result = credentials.toString(); + + assertNotNull(result); + assertTrue(result.contains("192.168.1.1")); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigIdTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigIdTest.java new file mode 100644 index 000000000..5749a3ffc --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigIdTest.java @@ -0,0 +1,31 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.keys; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class SnmpMsgfwdConfigIdTest { + + @Test + public void testNoArgsConstructor() { + SnmpMsgfwdConfigId id = new SnmpMsgfwdConfigId(); + assertNotNull(id); + } + + @Test + public void testAllArgsConstructor() { + SnmpMsgfwdConfigId id = new SnmpMsgfwdConfigId(1, 2, 3); + assertNotNull(id); + } + + @Test + public void testEqualsAndHashCode() { + SnmpMsgfwdConfigId id1 = new SnmpMsgfwdConfigId(1, 2, 3); + SnmpMsgfwdConfigId id2 = new SnmpMsgfwdConfigId(1, 2, 3); + SnmpMsgfwdConfigId id3 = new SnmpMsgfwdConfigId(4, 5, 6); + + assertEquals(id1, id2); + assertNotEquals(id1, id3); + assertEquals(id1.hashCode(), id2.hashCode()); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/IntersectionsTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/IntersectionsTest.java new file mode 100644 index 000000000..d674e25e0 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/IntersectionsTest.java @@ -0,0 +1,55 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; + +import static org.junit.jupiter.api.Assertions.*; + +public class IntersectionsTest { + + @Test + public void testGettersAndSetters() { + Intersections intersection = new Intersections(); + GeometryFactory geometryFactory = new GeometryFactory(); + Point point = geometryFactory.createPoint(new Coordinate(1.0, 2.0)); + + intersection.setIntersection_id(123); + intersection.setIntersection_number("INT-001"); + intersection.setRef_pt(point); + intersection.setBbox(point); + intersection.setIntersection_name("Main St & 1st Ave"); + intersection.setOrigin_ip("192.168.1.1"); + + assertEquals(123, intersection.getIntersection_id()); + assertEquals("INT-001", intersection.getIntersection_number()); + assertEquals(point, intersection.getRef_pt()); + assertEquals(point, intersection.getBbox()); + assertEquals("Main St & 1st Ave", intersection.getIntersection_name()); + assertEquals("192.168.1.1", intersection.getOrigin_ip()); + } + + @Test + public void testEqualsAndHashCode() { + Intersections intersection1 = new Intersections(); + intersection1.setIntersection_id(123); + intersection1.setIntersection_name("Test"); + + Intersections intersection2 = new Intersections(); + intersection2.setIntersection_id(123); + intersection2.setIntersection_name("Test"); + + assertEquals(intersection1, intersection2); + assertEquals(intersection1.hashCode(), intersection2.hashCode()); + } + + @Test + public void testToString() { + Intersections intersection = new Intersections(); + intersection.setIntersection_id(123); + + String result = intersection.toString(); + assertNotNull(result); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersectionTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersectionTest.java new file mode 100644 index 000000000..8b60153d7 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersectionTest.java @@ -0,0 +1,44 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class RsuIntersectionTest { + + @Test + public void testGettersAndSetters() { + RsuIntersection rsuIntersection = new RsuIntersection(); + + rsuIntersection.setRsu_intersection_id(1); + rsuIntersection.setRsu_id(100); + rsuIntersection.setIntersection_id(200); + + assertEquals(1, rsuIntersection.getRsu_intersection_id()); + assertEquals(100, rsuIntersection.getRsu_id()); + assertEquals(200, rsuIntersection.getIntersection_id()); + } + + @Test + public void testEqualsAndHashCode() { + RsuIntersection rsuIntersection1 = new RsuIntersection(); + rsuIntersection1.setRsu_intersection_id(1); + rsuIntersection1.setRsu_id(100); + + RsuIntersection rsuIntersection2 = new RsuIntersection(); + rsuIntersection2.setRsu_intersection_id(1); + rsuIntersection2.setRsu_id(100); + + assertEquals(rsuIntersection1, rsuIntersection2); + assertEquals(rsuIntersection1.hashCode(), rsuIntersection2.hashCode()); + } + + @Test + public void testToString() { + RsuIntersection rsuIntersection = new RsuIntersection(); + rsuIntersection.setRsu_intersection_id(1); + + String result = rsuIntersection.toString(); + assertNotNull(result); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsusTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsusTest.java new file mode 100644 index 000000000..e9c40a521 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsusTest.java @@ -0,0 +1,69 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; + +import static org.junit.jupiter.api.Assertions.*; + +public class RsusTest { + + @Test + public void testGettersAndSetters() { + Rsus rsu = new Rsus(); + GeometryFactory geometryFactory = new GeometryFactory(); + Point point = geometryFactory.createPoint(new Coordinate(1.0, 2.0)); + + rsu.setRsu_id(1); + rsu.setGeography(point); + rsu.setMilepost(12.5f); + rsu.setIpv4_address("192.168.1.1"); + rsu.setSerial_number("SN12345"); + rsu.setIss_scms_id("SCMS001"); + rsu.setPrimary_route("Route 66"); + rsu.setModel(1); + rsu.setCredential_id(10); + rsu.setSnmp_credential_id(20); + rsu.setSnmp_protocol_id(30); + rsu.setFirmware_version(100); + rsu.setTarget_firmware_version(101); + + assertEquals(1, rsu.getRsu_id()); + assertEquals(point, rsu.getGeography()); + assertEquals(12.5f, rsu.getMilepost()); + assertEquals("192.168.1.1", rsu.getIpv4_address()); + assertEquals("SN12345", rsu.getSerial_number()); + assertEquals("SCMS001", rsu.getIss_scms_id()); + assertEquals("Route 66", rsu.getPrimary_route()); + assertEquals(1, rsu.getModel()); + assertEquals(10, rsu.getCredential_id()); + assertEquals(20, rsu.getSnmp_credential_id()); + assertEquals(30, rsu.getSnmp_protocol_id()); + assertEquals(100, rsu.getFirmware_version()); + assertEquals(101, rsu.getTarget_firmware_version()); + } + + @Test + public void testEqualsAndHashCode() { + Rsus rsu1 = new Rsus(); + rsu1.setRsu_id(1); + rsu1.setIpv4_address("192.168.1.1"); + + Rsus rsu2 = new Rsus(); + rsu2.setRsu_id(1); + rsu2.setIpv4_address("192.168.1.1"); + + assertEquals(rsu1, rsu2); + assertEquals(rsu1.hashCode(), rsu2.hashCode()); + } + + @Test + public void testToString() { + Rsus rsu = new Rsus(); + rsu.setRsu_id(1); + + String result = rsu.toString(); + assertNotNull(result); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentialsTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentialsTest.java new file mode 100644 index 000000000..c85fdb14b --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentialsTest.java @@ -0,0 +1,48 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class SnmpCredentialsTest { + + @Test + public void testGettersAndSetters() { + SnmpCredentials credentials = new SnmpCredentials(); + + credentials.setSnmp_credential_id(1); + credentials.setUsername("testUser"); + credentials.setPassword("testPass"); + credentials.setEncrypt_password("encryptedPass"); + credentials.setNickname("TestCred"); + + assertEquals(1, credentials.getSnmp_credential_id()); + assertEquals("testUser", credentials.getUsername()); + assertEquals("testPass", credentials.getPassword()); + assertEquals("encryptedPass", credentials.getEncrypt_password()); + assertEquals("TestCred", credentials.getNickname()); + } + + @Test + public void testEqualsAndHashCode() { + SnmpCredentials credentials1 = new SnmpCredentials(); + credentials1.setSnmp_credential_id(1); + credentials1.setUsername("testUser"); + + SnmpCredentials credentials2 = new SnmpCredentials(); + credentials2.setSnmp_credential_id(1); + credentials2.setUsername("testUser"); + + assertEquals(credentials1, credentials2); + assertEquals(credentials1.hashCode(), credentials2.hashCode()); + } + + @Test + public void testToString() { + SnmpCredentials credentials = new SnmpCredentials(); + credentials.setSnmp_credential_id(1); + + String result = credentials.toString(); + assertNotNull(result); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfigTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfigTest.java new file mode 100644 index 000000000..333d849eb --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfigTest.java @@ -0,0 +1,63 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +public class SnmpMsgfwdConfigTest { + + @Test + public void testGettersAndSetters() { + SnmpMsgfwdConfig config = new SnmpMsgfwdConfig(); + LocalDateTime startTime = LocalDateTime.of(2025, 11, 21, 10, 0); + LocalDateTime endTime = LocalDateTime.of(2025, 11, 21, 12, 0); + + config.setRsu_id(1); + config.setMsgfwd_type(2); + config.setSnmp_index(3); + config.setMessage_type("BSM"); + config.setDest_ipv4("192.168.1.1"); + config.setDest_port(8080); + config.setStart_datetime(startTime); + config.setEnd_datetime(endTime); + config.setActive(true); + config.setSecurity(false); + + assertEquals(1, config.getRsu_id()); + assertEquals(2, config.getMsgfwd_type()); + assertEquals(3, config.getSnmp_index()); + assertEquals("BSM", config.getMessage_type()); + assertEquals("192.168.1.1", config.getDest_ipv4()); + assertEquals(8080, config.getDest_port()); + assertEquals(startTime, config.getStart_datetime()); + assertEquals(endTime, config.getEnd_datetime()); + assertTrue(config.isActive()); + assertFalse(config.isSecurity()); + } + + @Test + public void testEqualsAndHashCode() { + SnmpMsgfwdConfig config1 = new SnmpMsgfwdConfig(); + config1.setRsu_id(1); + config1.setMsgfwd_type(2); + config1.setSnmp_index(3); + + SnmpMsgfwdConfig config2 = new SnmpMsgfwdConfig(); + config2.setRsu_id(1); + config2.setMsgfwd_type(2); + config2.setSnmp_index(3); + + assertEquals(config1, config2); + assertEquals(config1.hashCode(), config2.hashCode()); + } + + @Test + public void testToString() { + SnmpMsgfwdConfig config = new SnmpMsgfwdConfig(); + config.setRsu_id(1); + + String result = config.toString(); + assertNotNull(result); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdTypeTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdTypeTest.java new file mode 100644 index 000000000..479d95f60 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdTypeTest.java @@ -0,0 +1,42 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class SnmpMsgfwdTypeTest { + + @Test + public void testGettersAndSetters() { + SnmpMsgfwdType type = new SnmpMsgfwdType(); + + type.setSnmp_msgfwd_type_id(1); + type.setName("BSM"); + + assertEquals(1, type.getSnmp_msgfwd_type_id()); + assertEquals("BSM", type.getName()); + } + + @Test + public void testEqualsAndHashCode() { + SnmpMsgfwdType type1 = new SnmpMsgfwdType(); + type1.setSnmp_msgfwd_type_id(1); + type1.setName("BSM"); + + SnmpMsgfwdType type2 = new SnmpMsgfwdType(); + type2.setSnmp_msgfwd_type_id(1); + type2.setName("BSM"); + + assertEquals(type1, type2); + assertEquals(type1.hashCode(), type2.hashCode()); + } + + @Test + public void testToString() { + SnmpMsgfwdType type = new SnmpMsgfwdType(); + type.setSnmp_msgfwd_type_id(1); + + String result = type.toString(); + assertNotNull(result); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocolsTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocolsTest.java new file mode 100644 index 000000000..da5a14c93 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocolsTest.java @@ -0,0 +1,44 @@ +package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class SnmpProtocolsTest { + + @Test + public void testGettersAndSetters() { + SnmpProtocols protocol = new SnmpProtocols(); + + protocol.setSnmp_protocol_id(1); + protocol.setProtocol_code("SHA"); + protocol.setNickname("Secure Hash"); + + assertEquals(1, protocol.getSnmp_protocol_id()); + assertEquals("SHA", protocol.getProtocol_code()); + assertEquals("Secure Hash", protocol.getNickname()); + } + + @Test + public void testEqualsAndHashCode() { + SnmpProtocols protocol1 = new SnmpProtocols(); + protocol1.setSnmp_protocol_id(1); + protocol1.setProtocol_code("SHA"); + + SnmpProtocols protocol2 = new SnmpProtocols(); + protocol2.setSnmp_protocol_id(1); + protocol2.setProtocol_code("SHA"); + + assertEquals(protocol1, protocol2); + assertEquals(protocol1.hashCode(), protocol2.hashCode()); + } + + @Test + public void testToString() { + SnmpProtocols protocol = new SnmpProtocols(); + protocol.setSnmp_protocol_id(1); + + String result = protocol.toString(); + assertNotNull(result); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMapTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMapTest.java new file mode 100644 index 000000000..09d8c6e6e --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMapTest.java @@ -0,0 +1,49 @@ +package us.dot.its.jpo.rsustatusmonitor.models.snmp; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class OIDMapTest { + + @Test + public void testOIDMapContainsKnownOIDs() { + assertNotNull(OIDMap.oids); + assertFalse(OIDMap.oids.isEmpty()); + + // Test some known OIDs + assertTrue(OIDMap.oids.containsKey("rsuModeStatus")); + assertTrue(OIDMap.oids.containsKey("rsuMode")); + assertTrue(OIDMap.oids.containsKey("rsuGnssStatus")); + assertTrue(OIDMap.oids.containsKey("rsuStatus")); + } + + @Test + public void testOIDMapValues() { + OID rsuModeStatus = OIDMap.oids.get("rsuModeStatus"); + assertNotNull(rsuModeStatus); + assertEquals("rsuModeStatus", rsuModeStatus.getName()); + assertEquals(OID_TYPE.SCALAR, rsuModeStatus.getType()); + assertEquals("1.3.6.1.4.1.1.1206.4.2.18.16.3.0", rsuModeStatus.getOid()); + } + + @Test + public void testOIDMapContainsTableEntries() { + assertTrue(OIDMap.oids.containsKey("rsuReceivedMsgTable")); + assertTrue(OIDMap.oids.containsKey("rsuReceivedMsgEntry")); + + OID table = OIDMap.oids.get("rsuReceivedMsgTable"); + assertEquals(OID_TYPE.TABLE, table.getType()); + } + + @Test + public void testOIDMapIsImmutable() { + int originalSize = OIDMap.oids.size(); + + assertThrows(UnsupportedOperationException.class, () -> { + OIDMap.oids.put("newOID", new OID("test", OID_TYPE.SCALAR, "1.2.3")); + }); + + assertEquals(originalSize, OIDMap.oids.size()); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDTest.java new file mode 100644 index 000000000..0c0eada32 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDTest.java @@ -0,0 +1,58 @@ +package us.dot.its.jpo.rsustatusmonitor.models.snmp; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class OIDTest { + + @Test + public void testConstructor() { + OID oid = new OID("rsuModeStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.3.0"); + + assertEquals("rsuModeStatus", oid.getName()); + assertEquals(OID_TYPE.SCALAR, oid.getType()); + assertEquals("1.3.6.1.4.1.1.1206.4.2.18.16.3.0", oid.getOid()); + } + + @Test + public void testGettersAndSetters() { + OID oid = new OID("test", OID_TYPE.NODE, "1.2.3.4"); + + oid.setName("rsuStatus"); + oid.setType(OID_TYPE.TABLE); + oid.setOid("1.3.6.1.4.1"); + + assertEquals("rsuStatus", oid.getName()); + assertEquals(OID_TYPE.TABLE, oid.getType()); + assertEquals("1.3.6.1.4.1", oid.getOid()); + } + + @Test + public void testEqualsAndHashCode() { + OID oid1 = new OID("test", OID_TYPE.SCALAR, "1.2.3.4"); + OID oid2 = new OID("test", OID_TYPE.SCALAR, "1.2.3.4"); + OID oid3 = new OID("different", OID_TYPE.NODE, "5.6.7.8"); + + assertEquals(oid1, oid2); + assertNotEquals(oid1, oid3); + assertEquals(oid1.hashCode(), oid2.hashCode()); + } + + @Test + public void testToString() { + OID oid = new OID("rsuModeStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.3.0"); + String result = oid.toString(); + + assertNotNull(result); + assertTrue(result.contains("rsuModeStatus")); + } + + @Test + public void testOIDTypes() { + assertEquals(OID_TYPE.SCALAR, OID_TYPE.valueOf("SCALAR")); + assertEquals(OID_TYPE.NODE, OID_TYPE.valueOf("NODE")); + assertEquals(OID_TYPE.TABLE, OID_TYPE.valueOf("TABLE")); + assertEquals(OID_TYPE.ROW, OID_TYPE.valueOf("ROW")); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValuesTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValuesTest.java new file mode 100644 index 000000000..0ace74f87 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValuesTest.java @@ -0,0 +1,104 @@ +package us.dot.its.jpo.rsustatusmonitor.models.snmp; + +import org.junit.jupiter.api.Test; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials; + +import static org.junit.jupiter.api.Assertions.*; + +public class RsuMsgfwdValuesTest { + + @Test + public void testNoArgsConstructor() { + RsuMsgfwdValues values = new RsuMsgfwdValues(); + assertNotNull(values); + } + + @Test + public void testAllArgsConstructor() { + RsuSnmpCredentials credentials = new RsuSnmpCredentials( + 1, "192.168.1.1", "user", "pass", "encrypt", "SHA", "12345" + ); + + RsuMsgfwdValues values = new RsuMsgfwdValues( + credentials, "psid123", "10.0.0.1", 8080, 1, -50, 1000, + "07e9010100000000", "07e9123123595959", 1, 1, 500 + ); + + assertEquals(credentials, values.getRsuSnmpCredentials()); + assertEquals("psid123", values.getRsuReceivedMsgPsid()); + assertEquals("10.0.0.1", values.getRsuReceivedMsgDestIpAddr()); + assertEquals(8080, values.getRsuReceivedMsgDestPort()); + assertEquals(1, values.getRsuReceivedMsgProtocol()); + assertEquals(-50, values.getRsuReceivedMsgRssi()); + assertEquals(1000, values.getRsuReceivedMsgInterval()); + assertEquals("07e9010100000000", values.getRsuReceivedMsgDeliveryStart()); + assertEquals("07e9123123595959", values.getRsuReceivedMsgDeliveryStop()); + assertEquals(1, values.getRsuReceivedMsgStatus()); + assertEquals(1, values.getRsuReceivedMsgSecure()); + assertEquals(500, values.getRsuReceivedMsgAuthMsgInterval()); + } + + @Test + public void testGettersAndSetters() { + RsuMsgfwdValues values = new RsuMsgfwdValues(); + RsuSnmpCredentials credentials = new RsuSnmpCredentials( + 1, "192.168.1.1", "user", "pass", "encrypt", "SHA", "12345" + ); + + values.setRsuSnmpCredentials(credentials); + values.setRsuReceivedMsgPsid("psid456"); + values.setRsuReceivedMsgDestIpAddr("192.168.2.2"); + values.setRsuReceivedMsgDestPort(9090); + values.setRsuReceivedMsgProtocol(2); + values.setRsuReceivedMsgRssi(-60); + values.setRsuReceivedMsgInterval(2000); + values.setRsuReceivedMsgDeliveryStart("07e9020200000000"); + values.setRsuReceivedMsgDeliveryStop("07e9121231235959"); + values.setRsuReceivedMsgStatus(2); + values.setRsuReceivedMsgSecure(0); + values.setRsuReceivedMsgAuthMsgInterval(1000); + + assertEquals(credentials, values.getRsuSnmpCredentials()); + assertEquals("psid456", values.getRsuReceivedMsgPsid()); + assertEquals("192.168.2.2", values.getRsuReceivedMsgDestIpAddr()); + assertEquals(9090, values.getRsuReceivedMsgDestPort()); + assertEquals(2, values.getRsuReceivedMsgProtocol()); + assertEquals(-60, values.getRsuReceivedMsgRssi()); + assertEquals(2000, values.getRsuReceivedMsgInterval()); + assertEquals("07e9020200000000", values.getRsuReceivedMsgDeliveryStart()); + assertEquals("07e9121231235959", values.getRsuReceivedMsgDeliveryStop()); + assertEquals(2, values.getRsuReceivedMsgStatus()); + assertEquals(0, values.getRsuReceivedMsgSecure()); + assertEquals(1000, values.getRsuReceivedMsgAuthMsgInterval()); + } + + @Test + public void testEqualsAndHashCode() { + RsuSnmpCredentials credentials = new RsuSnmpCredentials( + 1, "192.168.1.1", "user", "pass", "encrypt", "SHA", "12345" + ); + + RsuMsgfwdValues values1 = new RsuMsgfwdValues( + credentials, "psid123", "10.0.0.1", 8080, 1, -50, 1000, + "07e9010100000000", "07e9123123595959", 1, 1, 500 + ); + + RsuMsgfwdValues values2 = new RsuMsgfwdValues( + credentials, "psid123", "10.0.0.1", 8080, 1, -50, 1000, + "07e9010100000000", "07e9123123595959", 1, 1, 500 + ); + + assertEquals(values1, values2); + assertEquals(values1.hashCode(), values2.hashCode()); + } + + @Test + public void testToString() { + RsuMsgfwdValues values = new RsuMsgfwdValues(); + values.setRsuReceivedMsgPsid("psid123"); + + String result = values.toString(); + assertNotNull(result); + assertTrue(result.contains("psid123")); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuStateTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuStateTest.java new file mode 100644 index 000000000..deb560fa4 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuStateTest.java @@ -0,0 +1,80 @@ +package us.dot.its.jpo.rsustatusmonitor.models.snmp; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class RsuStateTest { + + @Test + public void testGettersAndSetters() { + RsuState state = new RsuState(); + + state.setTimestamp(1700000000000L); + state.setIntersectionID("12345"); + state.setRsuIP("192.168.1.1"); + state.setTemperature(35.5); + state.setUptime(3600); + state.setMode(4); + + assertEquals(1700000000000L, state.getTimestamp()); + assertEquals("12345", state.getIntersectionID()); + assertEquals("192.168.1.1", state.getRsuIP()); + assertEquals(35.5, state.getTemperature()); + assertEquals(3600, state.getUptime()); + assertEquals(4, state.getMode()); + } + + @Test + public void testDefaultValues() { + RsuState state = new RsuState(); + + assertEquals(0L, state.getTimestamp()); + assertNull(state.getIntersectionID()); + assertNull(state.getRsuIP()); + assertEquals(0.0, state.getTemperature()); + assertEquals(0, state.getUptime()); + assertEquals(0, state.getMode()); + } + + @Test + public void testModeValues() { + RsuState state = new RsuState(); + + // Test common RSU modes + state.setMode(2); // Standby + assertEquals(2, state.getMode()); + + state.setMode(4); // Operational + assertEquals(4, state.getMode()); + + state.setMode(16); // Off + assertEquals(16, state.getMode()); + } + + @Test + public void testTemperatureRange() { + RsuState state = new RsuState(); + + state.setTemperature(-40.0); + assertEquals(-40.0, state.getTemperature()); + + state.setTemperature(85.0); + assertEquals(85.0, state.getTemperature()); + } + + @Test + public void testUptimeValues() { + RsuState state = new RsuState(); + + // Test various uptime values + state.setUptime(0); // Just booted + assertEquals(0, state.getUptime()); + + state.setUptime(86400); // 1 day + assertEquals(86400, state.getUptime()); + + state.setUptime(2592000); // 30 days + assertEquals(2592000, state.getUptime()); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerServiceTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerServiceTest.java new file mode 100644 index 000000000..19442f311 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerServiceTest.java @@ -0,0 +1,210 @@ +package us.dot.its.jpo.rsustatusmonitor.services; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +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.kafka.core.KafkaTemplate; +import us.dot.its.jpo.geojsonconverter.partitioner.RsuIntersectionKey; +import us.dot.its.jpo.rsustatusmonitor.kafka.KafkaTopics; +import us.dot.its.jpo.rsustatusmonitor.models.snmp.RsuState; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class KafkaProducerServiceTest { + + @Mock + private KafkaTemplate kafkaTemplate; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private KafkaTopics kafkaTopics; + + private KafkaProducerService service; + + @BeforeEach + public void setup() { + service = new KafkaProducerService(kafkaTemplate, objectMapper, kafkaTopics); + } + + @Test + public void testSendMessage_Success() { + String topic = "test-topic"; + String key = "test-key"; + String message = "test-message"; + + service.sendMessage(topic, key, message); + + verify(kafkaTemplate).send(topic, key, message); + } + + @Test + public void testSendMessage_WithNullValues() { + String topic = null; + String key = null; + String message = null; + + service.sendMessage(topic, key, message); + + verify(kafkaTemplate).send(topic, key, message); + } + + @Test + public void testSendMessage_WithEmptyStrings() { + String topic = ""; + String key = ""; + String message = ""; + + service.sendMessage(topic, key, message); + + verify(kafkaTemplate).send(topic, key, message); + } + + @Test + public void testSendRsuStatus_Success() throws JsonProcessingException { + RsuIntersectionKey key = new RsuIntersectionKey(); + key.setRsuId("rsu-123"); + key.setIntersectionId(12345); + key.setRegion(1); + + RsuState message = new RsuState(); + + String expectedTopic = "monitoring-status-topic"; + String serializedKey = "{\"rsuId\":\"rsu-123\",\"intersectionId\":12345,\"region\":1}"; + String serializedMessage = "{\"rsuState\":\"data\"}"; + + when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic); + when(objectMapper.writeValueAsString(key)).thenReturn(serializedKey); + when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage); + + service.sendRsuStatus(key, message); + + verify(objectMapper).writeValueAsString(key); + verify(objectMapper).writeValueAsString(message); + verify(kafkaTemplate).send(expectedTopic, serializedKey, serializedMessage); + } + + @Test + public void testSendRsuStatus_JsonProcessingException_OnMessage() throws JsonProcessingException { + RsuIntersectionKey key = new RsuIntersectionKey(); + key.setRsuId("rsu-123"); + RsuState message = new RsuState(); + + when(objectMapper.writeValueAsString(message)).thenThrow(new JsonProcessingException("Serialization error") { + }); + + service.sendRsuStatus(key, message); + + verify(objectMapper).writeValueAsString(message); + verify(objectMapper, never()).writeValueAsString(key); + verify(kafkaTemplate, never()).send(any(), any(), any()); + } + + @Test + public void testSendRsuStatus_JsonProcessingException_OnKey() throws JsonProcessingException { + RsuIntersectionKey key = new RsuIntersectionKey(); + RsuState message = new RsuState(); + + String serializedMessage = "{\"state\":\"data\"}"; + + when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage); + doThrow(new JsonProcessingException("Serialization error") { + }).when(objectMapper).writeValueAsString(any(RsuIntersectionKey.class)); + + service.sendRsuStatus(key, message); + + verify(objectMapper).writeValueAsString(message); + verify(objectMapper).writeValueAsString(key); + verify(kafkaTemplate, never()).send(any(), any(), any()); + } + + @Test + public void testSendRsuStatus_VerifyTopicFromKafkaTopics() throws JsonProcessingException { + RsuIntersectionKey key = new RsuIntersectionKey(); + RsuState message = new RsuState(); + + String expectedTopic = "custom-monitoring-topic"; + String serializedKey = "{}"; + String serializedMessage = "{}"; + + when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic); + when(objectMapper.writeValueAsString(any())).thenReturn(serializedKey, serializedMessage); + + service.sendRsuStatus(key, message); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(kafkaTemplate).send(topicCaptor.capture(), eq(serializedKey), eq(serializedMessage)); + assertEquals(expectedTopic, topicCaptor.getValue()); + } + + @Test + public void testSendRsuStatus_WithComplexRsuState() throws JsonProcessingException { + RsuIntersectionKey key = new RsuIntersectionKey(); + key.setRsuId("rsu-456"); + key.setIntersectionId(67890); + + RsuState message = new RsuState(); + + String expectedTopic = "monitoring-status-topic"; + String serializedKey = "{\"key\":\"data\"}"; + String serializedMessage = "{\"complex\":\"state\",\"nested\":{\"data\":true}}"; + + when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic); + when(objectMapper.writeValueAsString(key)).thenReturn(serializedKey); + when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage); + + service.sendRsuStatus(key, message); + + verify(kafkaTemplate).send(expectedTopic, serializedKey, serializedMessage); + } + + @Test + public void testSendRsuStatus_WithNullKey() throws JsonProcessingException { + RsuIntersectionKey key = null; + RsuState message = new RsuState(); + + String expectedTopic = "monitoring-status-topic"; + String serializedMessage = "{\"state\":\"data\"}"; + String serializedKey = "null"; + + when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic); + when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage); + when(objectMapper.writeValueAsString(key)).thenReturn(serializedKey); + + service.sendRsuStatus(key, message); + + verify(objectMapper).writeValueAsString(message); + verify(objectMapper).writeValueAsString(key); + verify(kafkaTemplate).send(expectedTopic, serializedKey, serializedMessage); + } + + @Test + public void testSendRsuStatus_WithNullMessage() throws JsonProcessingException { + RsuIntersectionKey key = new RsuIntersectionKey(); + RsuState message = null; + + String expectedTopic = "monitoring-status-topic"; + String serializedKey = "{}"; + String serializedMessage = "null"; + + when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic); + when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage); + when(objectMapper.writeValueAsString(key)).thenReturn(serializedKey); + + service.sendRsuStatus(key, message); + + verify(objectMapper).writeValueAsString(message); + verify(objectMapper).writeValueAsString(key); + verify(kafkaTemplate).send(expectedTopic, serializedKey, serializedMessage); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresServiceTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresServiceTest.java new file mode 100644 index 000000000..fc2cca81a --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresServiceTest.java @@ -0,0 +1,99 @@ +package us.dot.its.jpo.rsustatusmonitor.services; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuData; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class PostgresServiceTest { + + @Mock + private EntityManager entityManager; + + @Mock + private TypedQuery rsuCredentialsQuery; + + @Mock + private TypedQuery rsuDataQuery; + + @InjectMocks + private PostgresService service; + + @Test + public void testGetRsusWithCredentials_AllRsus_Success() { + RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3", + "12345"); + RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv2c", + "67890"); + List expectedCredentials = Arrays.asList(cred1, cred2); + + when(entityManager.createQuery(anyString(), eq(RsuSnmpCredentials.class))).thenReturn(rsuCredentialsQuery); + when(rsuCredentialsQuery.getResultList()).thenReturn(expectedCredentials); + + List result = service.getRsusWithCredentials(); + + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals(1, result.get(0).getRsu_id()); + assertEquals("192.168.1.1", result.get(0).getIpv4_address()); + assertEquals("12345", result.get(0).getIntersection_id()); + verify(entityManager).createQuery(anyString(), eq(RsuSnmpCredentials.class)); + verify(rsuCredentialsQuery).getResultList(); + } + + @Test + public void testGetRsusWithCredentials_AllRsus_EmptyResult() { + when(entityManager.createQuery(anyString(), eq(RsuSnmpCredentials.class))).thenReturn(rsuCredentialsQuery); + when(rsuCredentialsQuery.getResultList()).thenReturn(Collections.emptyList()); + + List result = service.getRsusWithCredentials(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + verify(entityManager).createQuery(anyString(), eq(RsuSnmpCredentials.class)); + verify(rsuCredentialsQuery).getResultList(); + } + + @Test + public void testGetRsusWithCredentials_AllRsus_WithNullIntersectionId() { + RsuSnmpCredentials credWithNullIntersection = new RsuSnmpCredentials(3, "192.168.1.3", "user3", "pass3", + "encPass3", "SNMPv3", null); + List expectedCredentials = Collections.singletonList(credWithNullIntersection); + + when(entityManager.createQuery(anyString(), eq(RsuSnmpCredentials.class))).thenReturn(rsuCredentialsQuery); + when(rsuCredentialsQuery.getResultList()).thenReturn(expectedCredentials); + + List result = service.getRsusWithCredentials(); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(3, result.get(0).getRsu_id()); + assertNull(result.get(0).getIntersection_id()); + verify(entityManager).createQuery(anyString(), eq(RsuSnmpCredentials.class)); + verify(rsuCredentialsQuery).getResultList(); + } + + @Test + public void testGetRsusWithCredentials_QueryExecutionException() { + when(entityManager.createQuery(anyString(), eq(RsuSnmpCredentials.class))).thenReturn(rsuCredentialsQuery); + when(rsuCredentialsQuery.getResultList()).thenThrow(new RuntimeException("Database error")); + + assertThrows(RuntimeException.class, () -> service.getRsusWithCredentials()); + verify(entityManager).createQuery(anyString(), eq(RsuSnmpCredentials.class)); + verify(rsuCredentialsQuery).getResultList(); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryServiceTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryServiceTest.java new file mode 100644 index 000000000..be260dea1 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryServiceTest.java @@ -0,0 +1,351 @@ +package us.dot.its.jpo.rsustatusmonitor.services; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.snmp4j.smi.Integer32; +import org.snmp4j.smi.Variable; + +import us.dot.its.jpo.geojsonconverter.partitioner.RsuIntersectionKey; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials; +import us.dot.its.jpo.rsustatusmonitor.models.snmp.OID; +import us.dot.its.jpo.rsustatusmonitor.models.snmp.RsuState; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class RsuQueryServiceTest { + + @Mock + private SNMPService snmpService; + + @Mock + private KafkaProducerService kafkaService; + + private MeterRegistry meterRegistry; + + @InjectMocks + private RsuQueryService service; + + private RsuSnmpCredentials credentials; + + @BeforeEach + public void setup() { + // Use a real SimpleMeterRegistry instead of mocking to avoid stubbing issues + meterRegistry = new SimpleMeterRegistry(); + service = new RsuQueryService(snmpService, kafkaService, meterRegistry); + + credentials = new RsuSnmpCredentials(1, "192.168.1.100", "testUser", "testPass", "encryptPass", "SNMPv3", + "12345"); + } + + @Test + public void testGetRsuInformation_Success() throws Exception { + Variable uptimeVar = new Integer32(3600); + Variable tempVar = new Integer32(45); + Variable modeVar = new Integer32(1); + + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(uptimeVar, tempVar, modeVar); + + doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + + service.getRsuInformation(credentials); + + // Allow async method to complete + Thread.sleep(100); + + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(RsuIntersectionKey.class); + ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(RsuState.class); + + verify(kafkaService).sendRsuStatus(keyCaptor.capture(), stateCaptor.capture()); + + RsuIntersectionKey capturedKey = keyCaptor.getValue(); + assertEquals(12345, capturedKey.getIntersectionId()); + assertEquals("192.168.1.100", capturedKey.getRsuId()); + assertEquals(-1, capturedKey.getRegion()); + + RsuState capturedState = stateCaptor.getValue(); + assertEquals("192.168.1.100", capturedState.rsuIP); + assertEquals("12345", capturedState.intersectionID); + assertEquals(3600, capturedState.uptime); + assertEquals(45, capturedState.temperature); + assertEquals(1, capturedState.mode); + assertTrue(capturedState.timestamp > 0); + + verify(snmpService, times(3)).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()); + } + + @Test + public void testGetRsuInformation_WithNullIntersectionId() throws Exception { + credentials = new RsuSnmpCredentials(2, "192.168.1.50", "user", "pass", "encPass", "SNMPv3", null); + + Variable uptimeVar = new Integer32(7200); + Variable tempVar = new Integer32(50); + Variable modeVar = new Integer32(2); + + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(uptimeVar, tempVar, modeVar); + + doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + + service.getRsuInformation(credentials); + + // Allow async method to complete + Thread.sleep(100); + + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(RsuIntersectionKey.class); + ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(RsuState.class); + + verify(kafkaService).sendRsuStatus(keyCaptor.capture(), stateCaptor.capture()); + + RsuIntersectionKey capturedKey = keyCaptor.getValue(); + assertEquals(-1, capturedKey.getIntersectionId()); + + RsuState capturedState = stateCaptor.getValue(); + assertEquals("-1", capturedState.intersectionID); + } + + @Test + public void testGetRsuInformation_MissingUsername() throws Exception { + credentials = new RsuSnmpCredentials(3, "192.168.1.75", null, "pass", "encPass", "SNMPv3", "54321"); + + service.getRsuInformation(credentials); + + // Allow async method to complete + Thread.sleep(100); + + verify(snmpService, never()).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()); + verify(kafkaService, never()).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + } + + @Test + public void testGetRsuInformation_MissingPassword() throws Exception { + credentials = new RsuSnmpCredentials(4, "192.168.1.80", "user", null, "encPass", "SNMPv3", "99999"); + + service.getRsuInformation(credentials); + + // Allow async method to complete + Thread.sleep(100); + + verify(snmpService, never()).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()); + verify(kafkaService, never()).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + } + + @Test + public void testGetRsuInformation_MissingIpAddress() throws Exception { + credentials = new RsuSnmpCredentials(5, null, "user", "pass", "encPass", "SNMPv3", "11111"); + + // The service will throw NullPointerException when trying to create a metric + // tag with null IP + // This happens before the null check in the service + assertThrows(NullPointerException.class, () -> { + service.getRsuInformation(credentials); + // Allow async method to complete + Thread.sleep(100); + }); + } + + @Test + public void testGetRsuInformation_NullEncryptPassword() throws Exception { + credentials = new RsuSnmpCredentials(6, "192.168.1.90", "user", "pass", null, "SNMPv3", "22222"); + + Variable uptimeVar = new Integer32(1800); + Variable tempVar = new Integer32(40); + Variable modeVar = new Integer32(0); + + when(snmpService.getSnmpV3Value(anyString(), anyString(), eq("pass"), eq("pass"), anyString())) + .thenReturn(uptimeVar, tempVar, modeVar); + + doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + + service.getRsuInformation(credentials); + + // Allow async method to complete + Thread.sleep(100); + + verify(snmpService, times(3)).getSnmpV3Value(anyString(), anyString(), eq("pass"), eq("pass"), anyString()); + verify(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + } + + @Test + public void testGetIntOID_Success() throws Exception { + OID testOid = new OID("testOid", null, "1.2.3.4.5"); + Variable var = new Integer32(100); + + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(var); + + int result = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid); + + assertEquals(100, result); + verify(snmpService).getSnmpV3Value("192.168.1.100", "user", "pass", "encPass", "1.2.3.4.5"); + } + + @Test + public void testGetIntOID_NullVariable() throws Exception { + OID testOid = new OID("testOid", null, "1.2.3.4.5"); + + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(null); + + int result = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid); + + assertEquals(-1, result); + verify(snmpService).getSnmpV3Value("192.168.1.100", "user", "pass", "encPass", "1.2.3.4.5"); + } + + @Test + public void testGetIntOID_IOException() throws Exception { + OID testOid = new OID("testOid", null, "1.2.3.4.5"); + + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException("Connection timeout")); + + int result = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid); + + assertEquals(-1, result); + verify(snmpService).getSnmpV3Value("192.168.1.100", "user", "pass", "encPass", "1.2.3.4.5"); + } + + @Test + public void testGetIntOID_DifferentValues() throws Exception { + OID testOid = new OID("testOid", null, "1.2.3.4.5"); + Variable var1 = new Integer32(0); + Variable var2 = new Integer32(-5); + Variable var3 = new Integer32(Integer.MAX_VALUE); + + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(var1, var2, var3); + + int result1 = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid); + int result2 = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid); + int result3 = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid); + + assertEquals(0, result1); + assertEquals(-5, result2); + assertEquals(Integer.MAX_VALUE, result3); + verify(snmpService, times(3)).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()); + } + + @Test + public void testGetRsuInformation_PartialSnmpFailures() throws Exception { + Variable uptimeVar = new Integer32(5000); + + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(uptimeVar, null, null); + + doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + + service.getRsuInformation(credentials); + + // Allow async method to complete + Thread.sleep(100); + + ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(RsuState.class); + verify(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), stateCaptor.capture()); + + RsuState capturedState = stateCaptor.getValue(); + assertEquals(5000, capturedState.uptime); + assertEquals(-1, capturedState.temperature); + assertEquals(-1, capturedState.mode); + } + + @Test + public void testGetRsuInformation_AllSnmpFailures() throws Exception { + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException("SNMP timeout")); + + doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + + service.getRsuInformation(credentials); + + // Allow async method to complete + Thread.sleep(100); + + ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(RsuState.class); + verify(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), stateCaptor.capture()); + + RsuState capturedState = stateCaptor.getValue(); + assertEquals(-1, capturedState.uptime); + assertEquals(-1, capturedState.temperature); + assertEquals(-1, capturedState.mode); + } + + @Test + public void testGetRsuInformation_AsyncExecution() throws Exception { + Variable var = new Integer32(100); + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(var); + doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + + long startTime = System.currentTimeMillis(); + service.getRsuInformation(credentials); + long endTime = System.currentTimeMillis(); + + assertTrue(endTime - startTime < 50, "Async method should return immediately"); + + // Allow async method to complete + Thread.sleep(100); + + verify(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + } + + @Test + public void testGetRsuInformation_MultipleRsusSequentially() throws Exception { + RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.10", "user1", "pass1", "enc1", "SNMPv3", + "1000"); + RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.20", "user2", "pass2", "enc2", "SNMPv3", + "2000"); + + Variable var = new Integer32(100); + when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(var); + doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + + service.getRsuInformation(cred1); + service.getRsuInformation(cred2); + + // Allow async methods to complete + Thread.sleep(100); + + verify(kafkaService, times(2)).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class)); + verify(snmpService, times(6)).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()); + } + + @Test + public void testGetIntOID_WithDifferentOIDs() throws Exception { + OID oid1 = new OID("uptime", null, "1.3.6.1.4.1.1206.4.2.3.5.3.0"); + OID oid2 = new OID("temperature", null, "1.3.6.1.4.1.1206.4.2.3.5.4.0"); + OID oid3 = new OID("mode", null, "1.3.6.1.4.1.1206.4.2.3.5.5.0"); + + Variable var1 = new Integer32(1000); + Variable var2 = new Integer32(35); + Variable var3 = new Integer32(1); + + when(snmpService.getSnmpV3Value(eq("192.168.1.100"), eq("user"), eq("pass"), eq("encPass"), eq(oid1.getOid()))) + .thenReturn(var1); + when(snmpService.getSnmpV3Value(eq("192.168.1.100"), eq("user"), eq("pass"), eq("encPass"), eq(oid2.getOid()))) + .thenReturn(var2); + when(snmpService.getSnmpV3Value(eq("192.168.1.100"), eq("user"), eq("pass"), eq("encPass"), eq(oid3.getOid()))) + .thenReturn(var3); + + int uptime = service.getIntOID("192.168.1.100", "user", "pass", "encPass", oid1); + int temp = service.getIntOID("192.168.1.100", "user", "pass", "encPass", oid2); + int mode = service.getIntOID("192.168.1.100", "user", "pass", "encPass", oid3); + + assertEquals(1000, uptime); + assertEquals(35, temp); + assertEquals(1, mode); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPServiceTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPServiceTest.java new file mode 100644 index 000000000..253adb62f --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPServiceTest.java @@ -0,0 +1,536 @@ +package us.dot.its.jpo.rsustatusmonitor.services; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.snmp4j.PDU; +import org.snmp4j.ScopedPDU; +import org.snmp4j.Snmp; +import org.snmp4j.Target; +import org.snmp4j.event.ResponseEvent; +import org.snmp4j.mp.MPv3; +import org.snmp4j.security.SecurityModels; +import org.snmp4j.security.SecurityProtocols; +import org.snmp4j.security.USM; +import org.snmp4j.smi.Integer32; +import org.snmp4j.smi.OID; +import org.snmp4j.smi.OctetString; +import org.snmp4j.smi.UdpAddress; +import org.snmp4j.smi.Variable; +import org.snmp4j.smi.VariableBinding; +import org.snmp4j.transport.DefaultUdpTransportMapping; +import us.dot.its.jpo.rsustatusmonitor.snmp.SnmpProperties; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SuppressWarnings({ "unchecked", "unused" }) +@ExtendWith(MockitoExtension.class) +public class SNMPServiceTest { + + @Mock + private SnmpProperties snmpProperties; + + @Mock + private Snmp snmp; + + @Mock + private DefaultUdpTransportMapping transport; + + @Mock + private ResponseEvent responseEvent; + + @Mock + private PDU responsePdu; + + @Mock + private ScopedPDU scopedResponsePdu; + + private SNMPService service; + + @BeforeEach + public void setup() { + lenient().when(snmpProperties.getPort()).thenReturn(161); + lenient().when(snmpProperties.getRetries()).thenReturn(2); + lenient().when(snmpProperties.getTimeout()).thenReturn(5000); + + service = new SNMPService(snmpProperties); + } + + @Test + public void testGetSnmpV2Value_Success() throws Exception { + String ipAddress = "192.168.1.100"; + String community = "public"; + String oid = "1.3.6.1.2.1.1.1.0"; + String expectedValue = "Test Device"; + + VariableBinding vb = new VariableBinding(new OID(oid), new OctetString(expectedValue)); + when(responsePdu.get(0)).thenReturn(vb); + when(responseEvent.getResponse()).thenReturn(responsePdu); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(PDU.class), any(Target.class))).thenReturn(responseEvent); + doNothing().when(mock).close(); + })) { + + String result = service.getSnmpV2Value(ipAddress, community, oid); + + assertEquals(expectedValue, result); + } + } + + @Test + public void testGetSnmpV2Value_NullResponse() throws Exception { + String ipAddress = "192.168.1.100"; + String community = "public"; + String oid = "1.3.6.1.2.1.1.1.0"; + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(PDU.class), any(Target.class))).thenReturn(null); + doNothing().when(mock).close(); + })) { + + assertThrows(RuntimeException.class, () -> { + service.getSnmpV2Value(ipAddress, community, oid); + }); + } + } + + @Test + public void testGetSnmpV2Value_NullResponsePdu() throws Exception { + String ipAddress = "192.168.1.100"; + String community = "public"; + String oid = "1.3.6.1.2.1.1.1.0"; + + when(responseEvent.getResponse()).thenReturn(null); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(PDU.class), any(Target.class))).thenReturn(responseEvent); + doNothing().when(mock).close(); + })) { + + assertThrows(RuntimeException.class, () -> { + service.getSnmpV2Value(ipAddress, community, oid); + }); + } + } + + @Test + public void testGetSnmpV3Value_Success() throws Exception { + String ip = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + String privPass = "privPassword"; + String oid = "1.3.6.1.4.1.1206.4.2.3.5.3.0"; + + Variable expectedVariable = new Integer32(12345); + VariableBinding vb = new VariableBinding(new OID(oid), expectedVariable); + when(scopedResponsePdu.get(0)).thenReturn(vb); + when(responseEvent.getResponse()).thenReturn(scopedResponsePdu); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + Variable result = service.getSnmpV3Value(ip, username, authPass, privPass, oid); + + assertNotNull(result); + assertEquals(12345, result.toInt()); + } + } + + @Test + public void testGetSnmpV3Value_NullResponse() throws Exception { + String ip = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + String privPass = "privPassword"; + String oid = "1.3.6.1.4.1.1206.4.2.3.5.3.0"; + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(null); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + Variable result = service.getSnmpV3Value(ip, username, authPass, privPass, oid); + + assertNull(result); + } + } + + @Test + public void testGetSnmpV3Value_NullResponsePdu() throws Exception { + String ip = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + String privPass = "privPassword"; + String oid = "1.3.6.1.4.1.1206.4.2.3.5.3.0"; + + when(responseEvent.getResponse()).thenReturn(null); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + Variable result = service.getSnmpV3Value(ip, username, authPass, privPass, oid); + + assertNull(result); + } + } + + @Test + public void testGetSnmpV3Value_IOException() throws Exception { + String ip = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + String privPass = "privPassword"; + String oid = "1.3.6.1.4.1.1206.4.2.3.5.3.0"; + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doThrow(new IOException("Network error")).when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + assertThrows(IOException.class, () -> { + service.getSnmpV3Value(ip, username, authPass, privPass, oid); + }); + } + } + + @Test + public void testSetSnmpV3Value_Success() throws Exception { + String ipAddress = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + String oid = "1.3.6.1.4.1.1206.4.2.3.5.5.0"; + int intValue = 42; + + when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.noError); + when(responseEvent.getResponse()).thenReturn(scopedResponsePdu); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + service.setSnmpV3Value(ipAddress, username, authPass, oid, intValue); + + assertTrue(true); + } + } + + @Test + public void testSetSnmpV3Value_NullResponse() throws Exception { + String ipAddress = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + String oid = "1.3.6.1.4.1.1206.4.2.3.5.5.0"; + int intValue = 42; + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(null); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + assertThrows(NullPointerException.class, () -> { + service.setSnmpV3Value(ipAddress, username, authPass, oid, intValue); + }); + } + } + + @Test + public void testSetSnmpV3Value_ErrorStatus() throws Exception { + String ipAddress = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + String oid = "1.3.6.1.4.1.1206.4.2.3.5.5.0"; + int intValue = 42; + + when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.genErr); + when(responseEvent.getResponse()).thenReturn(scopedResponsePdu); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + assertDoesNotThrow(() -> { + service.setSnmpV3Value(ipAddress, username, authPass, oid, intValue); + }); + } + } + + @Test + public void testSetSnmpV3Values_Success() throws Exception { + String ipAddress = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + Map oidValuePairs = new HashMap<>(); + oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.5.0", new Integer32(1)); + oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.6.0", new Integer32(2)); + oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.7.0", new OctetString("test")); + + when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.noError); + when(responseEvent.getResponse()).thenReturn(scopedResponsePdu); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + service.setSnmpV3Values(ipAddress, username, authPass, oidValuePairs); + + assertTrue(true); + } + } + + @Test + public void testSetSnmpV3Values_EmptyMap() throws Exception { + String ipAddress = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + Map oidValuePairs = new HashMap<>(); + + when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.noError); + when(responseEvent.getResponse()).thenReturn(scopedResponsePdu); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + service.setSnmpV3Values(ipAddress, username, authPass, oidValuePairs); + + assertTrue(true); + } + } + + @Test + public void testSetSnmpV3Values_NullResponse() throws Exception { + String ipAddress = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + Map oidValuePairs = new HashMap<>(); + oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.5.0", new Integer32(1)); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(null); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + assertThrows(NullPointerException.class, () -> { + service.setSnmpV3Values(ipAddress, username, authPass, oidValuePairs); + }); + } + } + + @Test + public void testSetSnmpV3Values_ErrorStatus() throws Exception { + String ipAddress = "192.168.1.100"; + String username = "testUser"; + String authPass = "authPassword"; + Map oidValuePairs = new HashMap<>(); + oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.5.0", new Integer32(1)); + + when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.badValue); + when(scopedResponsePdu.getErrorStatusText()).thenReturn("Bad value"); + when(responseEvent.getResponse()).thenReturn(scopedResponsePdu); + + try (MockedConstruction transportMock = mockConstruction( + DefaultUdpTransportMapping.class, + (mock, context) -> doNothing().when(mock).listen()); + MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> { + when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent); + when(mock.getUSM()).thenReturn(mock(USM.class)); + doNothing().when(mock).close(); + }); + MockedConstruction usmMock = mockConstruction(USM.class); + MockedStatic securityModelsMock = mockStatic(SecurityModels.class); + MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class); + MockedStatic mpv3Mock = mockStatic(MPv3.class)) { + + SecurityModels mockSecurityModels = mock(SecurityModels.class); + SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class); + + securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels); + securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols); + mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 }); + + // should log warning but not throw exception + assertDoesNotThrow(() -> { + service.setSnmpV3Values(ipAddress, username, authPass, oidValuePairs); + }); + } + } + + @Test + public void testConstructor() { + SNMPService newService = new SNMPService(snmpProperties); + assertNotNull(newService); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTaskTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTaskTest.java new file mode 100644 index 000000000..fb3bbdcbf --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTaskTest.java @@ -0,0 +1,315 @@ +package us.dot.its.jpo.rsustatusmonitor.tasks; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials; +import us.dot.its.jpo.rsustatusmonitor.services.PostgresService; +import us.dot.its.jpo.rsustatusmonitor.services.RsuQueryService; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class RsuMonitoringTaskTest { + + @Mock + private PostgresService postgresService; + + @Mock + private RsuQueryService rsuQueryService; + + private RsuMonitoringTask task; + + @BeforeEach + public void setup() { + task = new RsuMonitoringTask(rsuQueryService, postgresService); + } + + @Test + public void testQueryRSUStats_NoCredentials() { + when(postgresService.getRsusWithCredentials()).thenReturn(new ArrayList<>()); + + task.queryRSUStats(); + + verify(postgresService).getRsusWithCredentials(); + verify(rsuQueryService, never()).getRsuInformation(any()); + } + + @Test + public void testQueryRSUStats_SingleRsu() { + List credentials = new ArrayList<>(); + RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3", + "12345"); + credentials.add(cred1); + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + task.queryRSUStats(); + + verify(postgresService).getRsusWithCredentials(); + verify(rsuQueryService, times(1)).getRsuInformation(cred1); + } + + @Test + public void testQueryRSUStats_MultipleRsus() { + List credentials = new ArrayList<>(); + RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3", + "12345"); + RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3", + "12346"); + RsuSnmpCredentials cred3 = new RsuSnmpCredentials(3, "192.168.1.3", "user3", "pass3", "encPass3", "SNMPv3", + "12347"); + credentials.add(cred1); + credentials.add(cred2); + credentials.add(cred3); + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + task.queryRSUStats(); + + verify(postgresService).getRsusWithCredentials(); + verify(rsuQueryService, times(1)).getRsuInformation(cred1); + verify(rsuQueryService, times(1)).getRsuInformation(cred2); + verify(rsuQueryService, times(1)).getRsuInformation(cred3); + } + + @Test + public void testQueryRSUStats_ParallelProcessing() throws InterruptedException { + List credentials = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + credentials.add(new RsuSnmpCredentials(i, "192.168.1." + i, "user" + i, "pass" + i, "encPass" + i, "SNMPv3", + String.valueOf(10000 + i))); + } + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + CountDownLatch latch = new CountDownLatch(10); + AtomicInteger concurrentCalls = new AtomicInteger(0); + AtomicInteger maxConcurrent = new AtomicInteger(0); + + doAnswer(invocation -> { + int current = concurrentCalls.incrementAndGet(); + maxConcurrent.updateAndGet(max -> Math.max(max, current)); + + // Simulate some processing time + Thread.sleep(50); + + concurrentCalls.decrementAndGet(); + latch.countDown(); + return null; + }).when(rsuQueryService).getRsuInformation(any(RsuSnmpCredentials.class)); + + task.queryRSUStats(); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "All tasks should complete within timeout"); + verify(rsuQueryService, times(10)).getRsuInformation(any(RsuSnmpCredentials.class)); + + // Verify parallel execution (should have more than 1 concurrent call) + assertTrue(maxConcurrent.get() > 1, "Should have parallel execution with multiple concurrent calls"); + } + + @Test + public void testQueryRSUStats_ExceptionHandling_SingleRsuFailure() { + List credentials = new ArrayList<>(); + RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3", + "12345"); + RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3", + "12346"); + credentials.add(cred1); + credentials.add(cred2); + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + // First RSU throws exception, second succeeds + doThrow(new RuntimeException("SNMP connection failed")).when(rsuQueryService).getRsuInformation(cred1); + doNothing().when(rsuQueryService).getRsuInformation(cred2); + + task.queryRSUStats(); + + verify(postgresService).getRsusWithCredentials(); + verify(rsuQueryService, times(1)).getRsuInformation(cred1); + verify(rsuQueryService, times(1)).getRsuInformation(cred2); + } + + @Test + public void testQueryRSUStats_ExceptionHandling_AllRsusFailure() { + List credentials = new ArrayList<>(); + RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3", + "12345"); + RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3", + "12346"); + credentials.add(cred1); + credentials.add(cred2); + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + doThrow(new RuntimeException("Network error")).when(rsuQueryService) + .getRsuInformation(any(RsuSnmpCredentials.class)); + + task.queryRSUStats(); + + verify(postgresService).getRsusWithCredentials(); + verify(rsuQueryService, times(2)).getRsuInformation(any(RsuSnmpCredentials.class)); + } + + @Test + public void testQueryRSUStats_LargeNumberOfRsus() { + // Test with 50 RSUs to verify thread pool handling + List credentials = new ArrayList<>(); + for (int i = 1; i <= 50; i++) { + credentials.add(new RsuSnmpCredentials(i, "192.168.1." + i, "user" + i, "pass" + i, "encPass" + i, "SNMPv3", + String.valueOf(20000 + i))); + } + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + task.queryRSUStats(); + + verify(postgresService).getRsusWithCredentials(); + verify(rsuQueryService, times(50)).getRsuInformation(any(RsuSnmpCredentials.class)); + } + + @Test + public void testQueryRSUStats_WaitsForAllTasksToComplete() throws InterruptedException { + List credentials = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + credentials.add(new RsuSnmpCredentials(i, "192.168.1." + i, "user" + i, "pass" + i, "encPass" + i, "SNMPv3", + String.valueOf(30000 + i))); + } + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + AtomicInteger completedCount = new AtomicInteger(0); + + doAnswer(invocation -> { + Thread.sleep(100); // Simulate processing time + completedCount.incrementAndGet(); + return null; + }).when(rsuQueryService).getRsuInformation(any(RsuSnmpCredentials.class)); + + task.queryRSUStats(); + + assertEquals(5, completedCount.get(), "All tasks should be completed before method returns"); + } + + @Test + public void testQueryRSUStats_MixedSuccessAndFailure() { + List credentials = new ArrayList<>(); + RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3", + "12345"); + RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3", + "12346"); + RsuSnmpCredentials cred3 = new RsuSnmpCredentials(3, "192.168.1.3", "user3", "pass3", "encPass3", "SNMPv3", + "12347"); + RsuSnmpCredentials cred4 = new RsuSnmpCredentials(4, "192.168.1.4", "user4", "pass4", "encPass4", "SNMPv3", + "12348"); + credentials.add(cred1); + credentials.add(cred2); + credentials.add(cred3); + credentials.add(cred4); + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + // Mix of success and failure + doNothing().when(rsuQueryService).getRsuInformation(cred1); + doThrow(new RuntimeException("Connection timeout")).when(rsuQueryService).getRsuInformation(cred2); + doNothing().when(rsuQueryService).getRsuInformation(cred3); + doThrow(new RuntimeException("Authentication failed")).when(rsuQueryService).getRsuInformation(cred4); + + task.queryRSUStats(); + + verify(postgresService).getRsusWithCredentials(); + verify(rsuQueryService, times(1)).getRsuInformation(cred1); + verify(rsuQueryService, times(1)).getRsuInformation(cred2); + verify(rsuQueryService, times(1)).getRsuInformation(cred3); + verify(rsuQueryService, times(1)).getRsuInformation(cred4); + } + + @Test + public void testQueryRSUStats_VerifyCorrectCredentialsPassedToService() { + List credentials = new ArrayList<>(); + RsuSnmpCredentials expectedCred = new RsuSnmpCredentials(1, "192.168.100.50", "testuser", "testpass", + "testencpass", "SNMPv3", "99999"); + credentials.add(expectedCred); + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + task.queryRSUStats(); + + verify(rsuQueryService).getRsuInformation(expectedCred); + } + + @Test + public void testConstructor() { + RsuMonitoringTask newTask = new RsuMonitoringTask(rsuQueryService, postgresService); + assertNotNull(newTask); + } + + @Test + public void testQueryRSUStats_ThreadPoolHandles10ConcurrentRequests() throws InterruptedException { + // Exactly 10 RSUs (matches the thread pool size) + List credentials = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + credentials.add(new RsuSnmpCredentials(i, "192.168.1." + i, "user" + i, "pass" + i, "encPass" + i, "SNMPv3", + String.valueOf(40000 + i))); + } + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch completeLatch = new CountDownLatch(10); + + doAnswer(invocation -> { + startLatch.await(); // Wait for all threads to be ready + Thread.sleep(50); + completeLatch.countDown(); + return null; + }).when(rsuQueryService).getRsuInformation(any(RsuSnmpCredentials.class)); + + Thread taskThread = new Thread(() -> task.queryRSUStats()); + taskThread.start(); + + Thread.sleep(100); + startLatch.countDown(); // Release all threads at once + + taskThread.join(5000); // Wait for completion + + assertTrue(completeLatch.await(2, TimeUnit.SECONDS), "All 10 tasks should complete"); + verify(rsuQueryService, times(10)).getRsuInformation(any(RsuSnmpCredentials.class)); + } + + @Test + public void testQueryRSUStats_DifferentExceptionTypes() { + List credentials = new ArrayList<>(); + RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3", + "12345"); + RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3", + "12346"); + RsuSnmpCredentials cred3 = new RsuSnmpCredentials(3, "192.168.1.3", "user3", "pass3", "encPass3", "SNMPv3", + "12347"); + credentials.add(cred1); + credentials.add(cred2); + credentials.add(cred3); + + when(postgresService.getRsusWithCredentials()).thenReturn(credentials); + + // Different types of exceptions + doThrow(new RuntimeException("Network error")).when(rsuQueryService).getRsuInformation(cred1); + doThrow(new IllegalArgumentException("Invalid credentials")).when(rsuQueryService).getRsuInformation(cred2); + doThrow(new NullPointerException("Null response")).when(rsuQueryService).getRsuInformation(cred3); + + task.queryRSUStats(); + + // Should handle all exception types gracefully + verify(postgresService).getRsusWithCredentials(); + verify(rsuQueryService, times(3)).getRsuInformation(any(RsuSnmpCredentials.class)); + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtilTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtilTest.java new file mode 100644 index 000000000..97dd16990 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtilTest.java @@ -0,0 +1,294 @@ +package us.dot.its.jpo.rsustatusmonitor.utils; + +import org.junit.jupiter.api.Test; +import org.snmp4j.smi.OctetString; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +public class SnmpHelperUtilTest { + + @Test + public void testGenerateNtcip1218HexDateTimeString_BasicDateTime() { + LocalDateTime dateTime = LocalDateTime.of(2025, 11, 21, 14, 30, 45); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // Format: YYYYMMDDHHMMSS00 + // 2025 = 0x07e9, 11 = 0x0b, 21 = 0x15, 14 = 0x0e, 30 = 0x1e, 45 = 0x2d + assertEquals("07e90b150e1e2d00", result); + assertEquals(16, result.length()); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_Midnight() { + LocalDateTime dateTime = LocalDateTime.of(2025, 1, 1, 0, 0, 0); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // 2025 = 0x07e9, 01 = 0x01, 01 = 0x01, 00 = 0x00, 00 = 0x00, 00 = 0x00 + assertEquals("07e90101000000", result.substring(0, 14)); + assertEquals("00", result.substring(14)); + assertEquals(16, result.length()); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_MaxValues() { + LocalDateTime dateTime = LocalDateTime.of(2025, 12, 31, 23, 59, 59); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // 2025 = 0x07e9, 12 = 0x0c, 31 = 0x1f, 23 = 0x17, 59 = 0x3b, 59 = 0x3b + assertEquals("07e90c1f173b3b00", result); + assertEquals(16, result.length()); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_LeapYearDate() { + LocalDateTime dateTime = LocalDateTime.of(2024, 2, 29, 12, 0, 0); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // 2024 = 0x07e8, 02 = 0x02, 29 = 0x1d, 12 = 0x0c, 00 = 0x00, 00 = 0x00 + assertEquals("07e8021d0c0000", result.substring(0, 14)); + assertEquals(16, result.length()); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_Year2000() { + LocalDateTime dateTime = LocalDateTime.of(2000, 6, 15, 10, 20, 30); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // 2000 = 0x07d0, 06 = 0x06, 15 = 0x0f, 10 = 0x0a, 20 = 0x14, 30 = 0x1e + assertEquals("07d0060f0a141e00", result); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_FarFutureYear() { + LocalDateTime dateTime = LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // 9999 = 0x270f, 12 = 0x0c, 31 = 0x1f, 23 = 0x17, 59 = 0x3b, 59 = 0x3b + assertEquals("270f0c1f173b3b00", result); + assertEquals(16, result.length()); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_AlwaysEndsWithZeros() { + LocalDateTime dt1 = LocalDateTime.of(2025, 5, 10, 8, 15, 22); + LocalDateTime dt2 = LocalDateTime.of(2023, 1, 1, 0, 0, 1); + LocalDateTime dt3 = LocalDateTime.of(2030, 12, 25, 18, 45, 30); + + String result1 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dt1); + String result2 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dt2); + String result3 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dt3); + + assertTrue(result1.endsWith("00"), "Result should end with 00"); + assertTrue(result2.endsWith("00"), "Result should end with 00"); + assertTrue(result3.endsWith("00"), "Result should end with 00"); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_AllLowercaseHex() { + LocalDateTime dateTime = LocalDateTime.of(2025, 10, 31, 15, 45, 59); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + assertEquals(result.toLowerCase(), result, "Hex string should be lowercase"); + assertTrue(result.matches("[0-9a-f]+"), "Should only contain lowercase hex characters"); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_PaddingForSingleDigits() { + LocalDateTime dateTime = LocalDateTime.of(2025, 1, 5, 3, 7, 9); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // 2025 = 0x07e9, 01 = 0x01, 05 = 0x05, 03 = 0x03, 07 = 0x07, 09 = 0x09 + assertEquals("07e90105030709", result.substring(0, 14)); + assertEquals(16, result.length()); + } + + @Test + public void testHexStringToOctetString_BasicConversion() { + String hexString = "07e90b150e1e2d00"; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(result); + assertEquals(8, result.length()); // 16 hex chars = 8 bytes + + byte[] bytes = result.toByteArray(); + assertEquals((byte) 0x07, bytes[0]); + assertEquals((byte) 0xe9, bytes[1]); + assertEquals((byte) 0x0b, bytes[2]); + assertEquals((byte) 0x15, bytes[3]); + assertEquals((byte) 0x0e, bytes[4]); + assertEquals((byte) 0x1e, bytes[5]); + assertEquals((byte) 0x2d, bytes[6]); + assertEquals((byte) 0x00, bytes[7]); + } + + @Test + public void testHexStringToOctetString_EmptyString() { + String hexString = ""; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(result); + assertEquals(0, result.length()); + } + + @Test + public void testHexStringToOctetString_SingleByte() { + String hexString = "FF"; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(result); + assertEquals(1, result.length()); + assertEquals((byte) 0xFF, result.toByteArray()[0]); + } + + @Test + public void testHexStringToOctetString_AllZeros() { + String hexString = "00000000"; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(result); + assertEquals(4, result.length()); + byte[] bytes = result.toByteArray(); + for (byte b : bytes) { + assertEquals((byte) 0x00, b); + } + } + + @Test + public void testHexStringToOctetString_AllOnes() { + String hexString = "FFFFFFFF"; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(result); + assertEquals(4, result.length()); + byte[] bytes = result.toByteArray(); + for (byte b : bytes) { + assertEquals((byte) 0xFF, b); + } + } + + @Test + public void testHexStringToOctetString_MixedCase() { + String hexString = "AbCdEf01"; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(result); + assertEquals(4, result.length()); + byte[] bytes = result.toByteArray(); + assertEquals((byte) 0xAB, bytes[0]); + assertEquals((byte) 0xCD, bytes[1]); + assertEquals((byte) 0xEF, bytes[2]); + assertEquals((byte) 0x01, bytes[3]); + } + + @Test + public void testHexStringToOctetString_LongString() { + String hexString = "0123456789ABCDEF0123456789ABCDEF"; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(result); + assertEquals(16, result.length()); + } + + @Test + public void testRoundTrip_GenerateAndConvert() { + LocalDateTime dateTime = LocalDateTime.of(2025, 11, 21, 14, 30, 45); + + String hexString = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + OctetString octetString = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(hexString); + assertNotNull(octetString); + assertEquals(16, hexString.length()); + assertEquals(8, octetString.length()); + + byte[] bytes = octetString.toByteArray(); + assertEquals((byte) 0x07, bytes[0]); // Year high byte + assertEquals((byte) 0xe9, bytes[1]); // Year low byte (2025 = 0x07e9) + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_ConsistentFormat() { + LocalDateTime dateTime1 = LocalDateTime.of(2025, 6, 15, 10, 20, 30); + LocalDateTime dateTime2 = LocalDateTime.of(2025, 6, 15, 10, 20, 30); + + String result1 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime1); + String result2 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime2); + + assertEquals(result1, result2, "Same date/time should produce identical results"); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_DifferentTimes() { + LocalDateTime dateTime1 = LocalDateTime.of(2025, 6, 15, 10, 20, 30); + LocalDateTime dateTime2 = LocalDateTime.of(2025, 6, 15, 10, 20, 31); + + String result1 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime1); + String result2 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime2); + + assertNotEquals(result1, result2, "Different times should produce different results"); + } + + @Test + public void testHexStringToOctetString_WithLeadingZeros() { + String hexString = "00010203"; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + assertNotNull(result); + byte[] bytes = result.toByteArray(); + assertEquals((byte) 0x00, bytes[0]); + assertEquals((byte) 0x01, bytes[1]); + assertEquals((byte) 0x02, bytes[2]); + assertEquals((byte) 0x03, bytes[3]); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_EarlyMorningHour() { + LocalDateTime dateTime = LocalDateTime.of(2025, 3, 10, 1, 5, 8); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // 2025 = 0x07e9, 03 = 0x03, 10 = 0x0a, 01 = 0x01, 05 = 0x05, 08 = 0x08 + assertEquals("07e9030a01050800", result); + } + + @Test + public void testGenerateNtcip1218HexDateTimeString_NoonExactly() { + LocalDateTime dateTime = LocalDateTime.of(2025, 7, 4, 12, 0, 0); + + String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime); + + // 2025 = 0x07e9, 07 = 0x07, 04 = 0x04, 12 = 0x0c, 00 = 0x00, 00 = 0x00 + assertEquals("07e907040c000000", result); + } + + @Test + public void testHexStringToOctetString_ByteValueRange() { + String hexString = "00FF7F80"; + + OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString); + + byte[] bytes = result.toByteArray(); + assertEquals((byte) 0x00, bytes[0]); // Min unsigned + assertEquals((byte) 0xFF, bytes[1]); // Max unsigned + assertEquals((byte) 0x7F, bytes[2]); // Max positive signed + assertEquals((byte) 0x80, bytes[3]); // Min negative signed + } +} diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/resources/logback-test.xml b/services/rsu-status-monitor/rsu-status-monitor/src/test/resources/logback-test.xml new file mode 100644 index 000000000..1c136fc43 --- /dev/null +++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + %msg%n + + + + + + + \ No newline at end of file diff --git a/webapp/src/apis/intersections/rsu-api.test.ts b/webapp/src/apis/intersections/rsu-api.test.ts new file mode 100644 index 000000000..eb17afe7d --- /dev/null +++ b/webapp/src/apis/intersections/rsu-api.test.ts @@ -0,0 +1,227 @@ +import RsuApi from './rsu-api' +import { authApiHelper } from './api-helper-cviz' + +// Mock the api-helper-cviz module +jest.mock('./api-helper-cviz', () => ({ + authApiHelper: { + invokeApi: jest.fn(), + }, +})) + +const mockInvokeApi = authApiHelper.invokeApi as jest.MockedFunction + +beforeEach(() => { + mockInvokeApi.mockClear() +}) + +describe('RsuApi', () => { + describe('getHistoricalRsuStatus', () => { + it('should call invokeApi with correct parameters and return response', async () => { + const expectedResponse = [ + { + timestamp: 1717622387534, + intersectionID: '1234', + rsuIP: '10.0.0.1', + temperature: 37, + uptime: 1294615, + mode: 4, + }, + { + timestamp: 1717622447534, + intersectionID: '1234', + rsuIP: '10.0.0.1', + temperature: 38, + uptime: 1294675, + mode: 4, + }, + ] + + mockInvokeApi.mockResolvedValue(expectedResponse) + + const startTime = new Date('2025-06-05T21:00:00Z') + const endTime = new Date('2025-06-05T23:00:00Z') + + const result = await RsuApi.getHistoricalRsuStatus({ + token: 'testToken', + rsuIp: '10.0.0.1', + startTime, + endTime, + }) + + expect(result).toEqual(expectedResponse) + expect(mockInvokeApi).toHaveBeenCalledTimes(1) + expect(mockInvokeApi).toHaveBeenCalledWith({ + path: '/data/rsu-status/historical', + token: 'testToken', + queryParams: { + rsuIp: '10.0.0.1', + startTime: startTime.getTime().toString(), + endTime: endTime.getTime().toString(), + }, + abortController: undefined, + failureMessage: 'Failed to fetch historical RSU status', + tag: 'rsu', + }) + }) + + it('should pass abort controller when provided', async () => { + const expectedResponse = [] + mockInvokeApi.mockResolvedValue(expectedResponse) + + const abortController = new AbortController() + const startTime = new Date('2025-06-05T21:00:00Z') + const endTime = new Date('2025-06-05T23:00:00Z') + + await RsuApi.getHistoricalRsuStatus({ + token: 'testToken', + rsuIp: '10.0.0.1', + startTime, + endTime, + abortController, + }) + + expect(mockInvokeApi).toHaveBeenCalledWith( + expect.objectContaining({ + abortController, + }) + ) + }) + }) + + describe('getLatestRsuStatus', () => { + it('should call invokeApi with correct parameters and return response', async () => { + const expectedResponse = { + timestamp: 1717622387534, + intersectionID: '1234', + rsuIP: '10.0.0.1', + temperature: 37, + uptime: 1294615, + mode: 4, + } + + mockInvokeApi.mockResolvedValue(expectedResponse) + + const result = await RsuApi.getLatestRsuStatus({ + token: 'testToken', + rsuIp: '10.0.0.1', + }) + + expect(result).toEqual(expectedResponse) + expect(mockInvokeApi).toHaveBeenCalledTimes(1) + expect(mockInvokeApi).toHaveBeenCalledWith({ + path: '/data/rsu-status/latest', + token: 'testToken', + queryParams: { + rsuIp: '10.0.0.1', + }, + abortController: undefined, + failureMessage: 'Failed to fetch latest RSU status', + tag: 'rsu', + }) + }) + + it('should pass abort controller when provided', async () => { + const expectedResponse = { + timestamp: 1717622387534, + intersectionID: '1234', + rsuIP: '10.0.0.1', + temperature: 37, + uptime: 1294615, + mode: 4, + } + + mockInvokeApi.mockResolvedValue(expectedResponse) + + const abortController = new AbortController() + + await RsuApi.getLatestRsuStatus({ + token: 'testToken', + rsuIp: '10.0.0.1', + abortController, + }) + + expect(mockInvokeApi).toHaveBeenCalledWith( + expect.objectContaining({ + abortController, + }) + ) + }) + }) + + describe('getAggregatedRsuStatus', () => { + it('should call invokeApi with correct parameters and return response', async () => { + const expectedResponse = [ + { + timestamp: 1717622387534, + intersectionID: '1234', + rsuIP: '10.0.0.1', + temperature: 37, + uptime: 1294615, + mode: 4, + }, + { + timestamp: 1717626000000, + intersectionID: '1234', + rsuIP: '10.0.0.1', + temperature: 39, + uptime: 1298228, + mode: 4, + }, + ] + + mockInvokeApi.mockResolvedValue(expectedResponse) + + const startTime = new Date('2025-06-05T21:00:00Z') + const endTime = new Date('2025-06-06T21:00:00Z') + const intervalMinutes = 60 + + const result = await RsuApi.getAggregatedRsuStatus({ + token: 'testToken', + rsuIp: '10.0.0.1', + startTime, + endTime, + intervalMinutes, + }) + + expect(result).toEqual(expectedResponse) + expect(mockInvokeApi).toHaveBeenCalledTimes(1) + expect(mockInvokeApi).toHaveBeenCalledWith({ + path: '/data/rsu-status/aggregated', + token: 'testToken', + queryParams: { + rsuIp: '10.0.0.1', + startTime: startTime.getTime().toString(), + endTime: endTime.getTime().toString(), + intervalMinutes: intervalMinutes.toString(), + }, + abortController: undefined, + failureMessage: 'Failed to fetch aggregated RSU status', + tag: 'rsu', + }) + }) + + it('should pass abort controller when provided', async () => { + const expectedResponse = [] + mockInvokeApi.mockResolvedValue(expectedResponse) + + const abortController = new AbortController() + const startTime = new Date('2025-06-05T21:00:00Z') + const endTime = new Date('2025-06-06T21:00:00Z') + + await RsuApi.getAggregatedRsuStatus({ + token: 'testToken', + rsuIp: '10.0.0.1', + startTime, + endTime, + intervalMinutes: 60, + abortController, + }) + + expect(mockInvokeApi).toHaveBeenCalledWith( + expect.objectContaining({ + abortController, + }) + ) + }) + }) +}) diff --git a/webapp/src/apis/intersections/rsu-api.ts b/webapp/src/apis/intersections/rsu-api.ts new file mode 100644 index 000000000..6eeeed645 --- /dev/null +++ b/webapp/src/apis/intersections/rsu-api.ts @@ -0,0 +1,105 @@ +import { authApiHelper } from './api-helper-cviz' + +// TypeScript type for RSU State (should match your backend model) +export type RsuState = { + timestamp: number + intersectionID: string + rsuIP: string + temperature: number + uptime: number + mode: number +} + +class RsuApi { + // Fetch historical RSU states for a given RSU IP and time range + async getHistoricalRsuStatus({ + token, + rsuIp, + startTime, + endTime, + abortController, + }: { + token: string + rsuIp: string + startTime: Date + endTime: Date + abortController?: AbortController + }): Promise { + const queryParams: Record = { + rsuIp, + startTime: startTime.getTime().toString(), + endTime: endTime.getTime().toString(), + } + + const response = await authApiHelper.invokeApi({ + path: `/data/rsu-status/historical`, + token, + queryParams, + abortController, + failureMessage: 'Failed to fetch historical RSU status', + tag: 'rsu', + }) + + return response + } + + // Fetch the latest RSU state for a given RSU IP + async getLatestRsuStatus({ + token, + rsuIp, + abortController, + }: { + token: string + rsuIp: string + abortController?: AbortController + }): Promise { + const queryParams: Record = { rsuIp } + + const response = await authApiHelper.invokeApi({ + path: `/data/rsu-status/latest`, + token, + queryParams, + abortController, + failureMessage: 'Failed to fetch latest RSU status', + tag: 'rsu', + }) + + return response + } + + async getAggregatedRsuStatus({ + token, + rsuIp, + startTime, + endTime, + intervalMinutes, + abortController, + }: { + token: string + rsuIp: string + startTime: Date + endTime: Date + intervalMinutes: number + abortController?: AbortController + }): Promise { + const queryParams: Record = { + rsuIp, + startTime: startTime.getTime().toString(), + endTime: endTime.getTime().toString(), + intervalMinutes: intervalMinutes.toString(), + } + + const response = await authApiHelper.invokeApi({ + path: `/data/rsu-status/aggregated`, + token, + queryParams, + abortController, + failureMessage: 'Failed to fetch aggregated RSU status', + tag: 'rsu', + }) + + return response + } +} + +export default new RsuApi() diff --git a/webapp/src/components/AdminFormManager.test.tsx b/webapp/src/components/AdminFormManager.test.tsx index 2b29863fe..0e579fab6 100644 --- a/webapp/src/components/AdminFormManager.test.tsx +++ b/webapp/src/components/AdminFormManager.test.tsx @@ -11,7 +11,7 @@ import { BrowserRouter } from 'react-router-dom' it('snapshot rsu', () => { const { container } = render( - + @@ -25,7 +25,7 @@ it('snapshot rsu', () => { it('snapshot user', () => { const { container } = render( - + @@ -39,7 +39,7 @@ it('snapshot user', () => { it('snapshot organization', () => { const { container } = render( - + diff --git a/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap b/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap index 1edf556ec..202d413eb 100644 --- a/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap +++ b/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap @@ -814,7 +814,7 @@ exports[`snapshot rsu 1`] = ` { - Primary Route + + Primary Route + { - SSH Credential Group + + SSH Credential Group + { const selectedGroup = event.target.value as string dispatch(updateSelectedSnmpGroup(selectedGroup)) @@ -405,7 +414,9 @@ const AdminAddRsu = () => { - SNMP Protocol + + SNMP Protocol + { - Primary Route + + Primary Route + { - SSH Credential Group + + SSH Credential Group + { - SNMP Protocol + + SNMP Protocol + { const { container } = render( - + diff --git a/webapp/src/features/adminRsuTab/AdminRsuTab.tsx b/webapp/src/features/adminRsuTab/AdminRsuTab.tsx index 731ccd082..4c52193a0 100644 --- a/webapp/src/features/adminRsuTab/AdminRsuTab.tsx +++ b/webapp/src/features/adminRsuTab/AdminRsuTab.tsx @@ -15,7 +15,11 @@ import { setEditRsuRowData, } from './adminRsuTabSlice' import { clear, getRsuInfo } from '../adminEditRsu/adminEditRsuSlice' +import RsuStatusDialog from './RsuStatusDialog' +import RsuApi, { RsuState } from '../../apis/intersections/rsu-api' import { useSelector, useDispatch } from 'react-redux' +import { selectToken } from '../../generalSlices/userSlice' +import { debounce } from 'lodash' import './Admin.css' import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit' @@ -26,12 +30,20 @@ import { NotFound } from '../../pages/404' import toast from 'react-hot-toast' import { useTheme } from '@mui/material' import { DeleteOutline, ModeEditOutline } from '@mui/icons-material' +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' const AdminRsuTab = () => { const dispatch: ThunkDispatch = useDispatch() const navigate = useNavigate() const theme = useTheme() + const [statusDialogOpen, setStatusDialogOpen] = useState(false) + const [selectedRsuIp, setSelectedRsuIp] = useState(null) + const [latestRsuState, setLatestRsuState] = useState(null) + const [historicalRsuData, setHistoricalRsuData] = useState(null) + + const token = useSelector(selectToken) + const tableData = useSelector(selectTableData) const [columns] = useState([ { title: 'Milepost', field: 'milepost', id: 0 }, @@ -41,9 +53,65 @@ const AdminRsuTab = () => { { title: 'Serial Number', field: 'serial_number', id: 4 }, ]) + const handleStatusClick = (rowData: AdminEditRsuFormType) => { + setSelectedRsuIp(rowData.ip) + setStatusDialogOpen(true) + } + + const handleStatusDialogClose = () => { + setStatusDialogOpen(false) + setSelectedRsuIp(null) + } + + const debouncedQueryHistoricalData = debounce((selectedDate: string) => { + if (selectedRsuIp && selectedDate) { + const startTime = new Date(selectedDate).setHours(0, 0, 0, 0) + const endTime = new Date(selectedDate).setHours(23, 59, 59, 999) + + RsuApi.getHistoricalRsuStatus({ + token, + rsuIp: selectedRsuIp, + startTime: new Date(startTime), + endTime: new Date(endTime), + }) + .then((data) => { + setHistoricalRsuData(data ?? null) + }) + .catch((err) => { + setHistoricalRsuData(null) + }) + } + }, 300) // Debounce with 300ms delay + const loading = useSelector(selectLoading) + React.useEffect(() => { + if (statusDialogOpen && selectedRsuIp) { + RsuApi.getLatestRsuStatus({ token, rsuIp: selectedRsuIp }) + .then((data) => { + setLatestRsuState(data ?? null) + }) + .catch((err) => { + setLatestRsuState(null) + console.error('Failed to fetch RSU state:', err) + }) + } else { + setLatestRsuState(null) + } + }, [statusDialogOpen, selectedRsuIp, token]) + const tableActions: Action[] = [ + { + icon: () => , + tooltip: 'RSU Status', + position: 'row', + iconProps: { + itemType: 'rowAction', + }, + onClick: (event, rowData: AdminEditRsuFormType) => { + handleStatusClick(rowData) + }, + }, { icon: () => , tooltip: 'Edit RSU', @@ -155,6 +223,14 @@ const AdminRsuTab = () => { loading === false && (
+ {statusDialogOpen && ( + + )}
) } diff --git a/webapp/src/features/adminRsuTab/RsuStatusDialog.test.tsx b/webapp/src/features/adminRsuTab/RsuStatusDialog.test.tsx new file mode 100644 index 000000000..b8bb1ffdb --- /dev/null +++ b/webapp/src/features/adminRsuTab/RsuStatusDialog.test.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { render } from '@testing-library/react' +import RsuStatusDialog from './RsuStatusDialog' +import { Provider } from 'react-redux' +import { ThemeProvider } from '@mui/material' +import { testTheme } from '../../styles' +import { setupStore } from '../../store' +import { replaceChaoticIds } from '../../utils/test-utils' + +beforeAll(() => { + // Mock the Date object to always return a fixed date + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2025-12-05T00:00:00Z')) +}) + +afterAll(() => { + // Restore the Date object to its original behavior + jest.useRealTimers() +}) + +it('should take a snapshot', () => { + render( + + + {}} rsuIp="10.0.0.180" token="test-token" /> + + + ) + + expect(replaceChaoticIds(document.body)).toMatchSnapshot() +}) diff --git a/webapp/src/features/adminRsuTab/RsuStatusDialog.tsx b/webapp/src/features/adminRsuTab/RsuStatusDialog.tsx new file mode 100644 index 000000000..7f6cc3b5a --- /dev/null +++ b/webapp/src/features/adminRsuTab/RsuStatusDialog.tsx @@ -0,0 +1,566 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + Typography, + TextField, + IconButton, + Button, + MenuItem, + Select, + SelectChangeEvent, + FormControl, + InputLabel, +} from '@mui/material' +import { + ScatterChart, + Scatter, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + ReferenceArea, +} from 'recharts' +import { Close } from '@mui/icons-material' +import RsuApi, { type RsuState } from '../../apis/intersections/rsu-api' + +type RsuStatusDialogProps = { + open: boolean + onClose: () => void + rsuIp: string | null + token: string +} + +const RsuStatusDialog: React.FC = ({ open, onClose, rsuIp, token }) => { + const [latestRsuState, setLatestRsuState] = useState(null) + const [historicalData, setHistoricalData] = useState(null) + const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]) + const [endDate, setEndDate] = useState(new Date().toISOString().split('T')[0]) + const [showCharts, setShowCharts] = useState(false) + const [intervalMinutes, setIntervalMinutes] = useState(5) + const [chartStartDate, setChartStartDate] = useState(startDate) + const [chartEndDate, setChartEndDate] = useState(endDate) + // Track whether the generate button has been pressed + const [hasGenerated, setHasGenerated] = useState(false) + + useEffect(() => { + if (open && rsuIp) { + // Query latest RSU status + RsuApi.getLatestRsuStatus({ token, rsuIp }) + .then((data) => setLatestRsuState(data ?? null)) + .catch(() => setLatestRsuState(null)) + } + }, [open, rsuIp, token]) + + useEffect(() => { + if (!open) { + setShowCharts(false) + setHasGenerated(false) + } + }, [open]) + + const handleStartDateChange = (event: React.ChangeEvent) => { + setStartDate(event.target.value) + } + + const handleEndDateChange = (event: React.ChangeEvent) => { + setEndDate(event.target.value) + } + + const handleIntervalChange = (event: SelectChangeEvent) => { + setIntervalMinutes(Number(event.target.value)) + } + + const handleGenerateCharts = () => { + setHasGenerated(true) // Mark that the generate button has been pressed + const [earlierDate, laterDate] = + new Date(startDate) <= new Date(endDate) ? [startDate, endDate] : [endDate, startDate] + + const start = new Date(earlierDate).setUTCHours(0, 0, 0, 0) + const end = new Date(laterDate).setUTCHours(23, 59, 59, 999) + + RsuApi.getAggregatedRsuStatus({ + token, + rsuIp, + startTime: new Date(start), + endTime: new Date(end), + intervalMinutes, + }) + .then((data) => { + setHistoricalData(data ?? null) + setChartStartDate(earlierDate) + setChartEndDate(laterDate) + setShowCharts(true) + }) + .catch(() => { + setHistoricalData(null) + setChartStartDate(earlierDate) + setChartEndDate(laterDate) + setShowCharts(true) + }) + } + + const temperatureData = + historicalData?.map((data) => ({ + time: data.timestamp, + value: parseFloat((data.temperature * (9 / 5) + 32).toFixed(1)), + })) || [] + + const uptimeData = + historicalData?.map((data) => ({ + time: data.timestamp, + value: data.uptime, + })) || [] + + // Ensure data arrays are non-empty before calculating domains and offsets + const temperatureDomain = useMemo(() => { + if (temperatureData.length === 0) { + return [0, 0] // Default domain when no data is available + } + return [ + Math.floor(Math.min(...temperatureData.map((d) => d.value)) / 5) * 5, + Math.ceil(Math.max(...temperatureData.map((d) => d.value)) / 5) * 5, + ] + }, [temperatureData]) + + const uptimeDomain = useMemo(() => { + if (uptimeData.length === 0) { + return [0, 0] // Default domain when no data is available + } + return [ + Math.round(Math.min(...uptimeData.map((d) => d.value)) - 1), + Math.round(Math.max(...uptimeData.map((d) => d.value)) + 1), + ] + }, [uptimeData]) + + const timeDomain = useMemo(() => { + if (temperatureData.length === 0) { + return [0, 0] // Default domain when no data is available + } + return [Math.min(...temperatureData.map((d) => d.time)), Math.max(...temperatureData.map((d) => d.time))] + }, [temperatureData]) + + // Updated formatTimestamp to include hours and minutes for X-axis + const formatTimestamp = (unixTime: number) => { + const date = new Date(unixTime) + return `${date.getUTCMonth() + 1}/${date.getUTCDate()} ${date.getUTCHours()}:${date + .getUTCMinutes() + .toString() + .padStart(2, '0')}` // Format as MM/DD HH:mm in UTC + } + + // Define color gradients for charts + const redTempTarget = 140 + const yellowTempTarget = 120 + const greenTempTarget = 100 + + // Offset for red gradient stop based on temperature domain + const redOffset = 1 - (redTempTarget - temperatureDomain[0]) / (temperatureDomain[1] - temperatureDomain[0]) + // Offset for yellow gradient stop based on temperature domain + const yellowOffset = 1 - (yellowTempTarget - temperatureDomain[0]) / (temperatureDomain[1] - temperatureDomain[0]) + // Offset for green gradient stop based on temperature domain + const greenOffset = 1 - (greenTempTarget - temperatureDomain[0]) / (temperatureDomain[1] - temperatureDomain[0]) + + const yellowUptimeTarget = 7776000 // 90 days in seconds + const greenUptimeTarget = yellowUptimeTarget * 0.9 // 90% of yellow target + + // Offset for green uptime gradient stop + const greenUptimeOffset = 1 - (greenUptimeTarget - uptimeDomain[0]) / (uptimeDomain[1] - uptimeDomain[0]) + // Offset for yellow uptime gradient stop + const yellowUptimeOffset = 1 - (yellowUptimeTarget - uptimeDomain[0]) / (uptimeDomain[1] - uptimeDomain[0]) + + // Identify reboot points (local minima after a decrease) + const rebootPoints = uptimeData.reduce((acc, curr, index, arr) => { + if (index > 0 && arr[index - 1].value > curr.value) { + acc.push(curr.time) // Identify local minima after a decrease as reboot points + } + return acc + }, []) + + // Utility to get days, hours, minutes from a duration in milliseconds + function splitDurationIntoParts(durationMs: number) { + const durationSec = Math.floor(durationMs / 1000) + const days = Math.floor(durationSec / 86400) + const hours = Math.floor((durationSec % 86400) / 3600) + const minutes = Math.floor((durationSec % 3600) / 60) + return { days, hours, minutes } + } + + // Calculate reboot zones (from local max to subsequent local min) + const rebootZones = uptimeData.reduce((zones, curr, index, arr) => { + if (index > 0 && arr[index - 1].value > curr.value) { + const start = arr[index - 1].time + const end = curr.time - curr.value * 1000 // Subtract uptime (in milliseconds) from the current timestamp + const durationMs = end - start + + const { days, hours, minutes } = splitDurationIntoParts(durationMs) + let durationLabel = '' + if (days > 0) { + durationLabel = `${days} day${days > 1 ? 's' : ''}` + } else if (hours > 0) { + durationLabel = `${hours} hr ${minutes} min` + } else { + durationLabel = `${minutes} min` + } + + zones.push({ + start, + end, + label: `Offline ${durationLabel}`, + }) + } + return zones + }, []) + + // Formats uptime in days and hours + const formatUptime = (seconds: number, forTooltip = false) => { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + + if (days > 0 && hours > 0) { + return forTooltip ? `${days} days, ${hours} ${hours === 1 ? 'hour' : 'hours'}` : `${days}d ${hours}hr` + } + + if (days > 0 && hours === 0) { + return forTooltip ? `${days} days` : `${days}d` + } + + if (days === 0 && hours > 0) { + return forTooltip ? `${hours} ${hours === 1 ? 'hour' : 'hours'}` : `${hours}hr` + } + + return forTooltip ? `0 hours` : `0hr` + } + + // Updated formatTooltip to include hours and minutes for tooltips + const formatTooltip = (value: number, name: string, props: any) => { + if (name === 'Time') { + const date = new Date(value) + return [ + `${date.getUTCMonth() + 1}/${date.getUTCDate()}/${date.getUTCFullYear()} ${date.getUTCHours()}:${date + .getUTCMinutes() + .toString() + .padStart(2, '0')}`, // Format as MM/DD/YYYY HH:mm in UTC + name, + ] + } + if (name === 'Temperature') { + return [`${value}°F`, 'Temperature'] + } + if (name === 'Uptime') { + return [formatUptime(value, true), 'Uptime'] + } + return [value, name] + } + + // Clear and recalculate reboot points and zones whenever the historical data changes + useEffect(() => { + // Clear reboot points and zones when historical data changes + rebootPoints.splice(0, rebootPoints.length) + rebootZones.splice(0, rebootZones.length) + + // Recalculate reboot points + uptimeData.reduce((acc, curr, index, arr) => { + if (index > 0 && arr[index - 1].value > curr.value) { + acc.push(curr.time) + } + return acc + }, rebootPoints) + + // Recalculate reboot zones + uptimeData.reduce((zones, curr, index, arr) => { + if (index > 0 && arr[index - 1].value > curr.value) { + const start = arr[index - 1].time + const end = curr.time - curr.value * 1000 // Subtract uptime (in milliseconds) from the current timestamp + const durationMs = end - start + const { days, hours, minutes } = splitDurationIntoParts(durationMs) + let durationLabel = '' + if (days > 0) { + durationLabel = `${days} day${days > 1 ? 's' : ''}` + } else if (hours > 0) { + durationLabel = `${hours} hr ${minutes} min` + } else { + durationLabel = `${minutes} min` + } + + zones.push({ + start, + end, + label: `Offline ${durationLabel}`, + }) + } + return zones + }, rebootZones) + }, [historicalData]) + + // Calculate whether to position the box in the top right corner + const isEarlyReboot = rebootPoints.some((time) => time < timeDomain[0] + (timeDomain[1] - timeDomain[0]) * 0.4) + + // Set the warning box position dynamically to one of the top corners of the chart + const warningBoxWidth = 160 // Width of the warning box in pixels + const warningBoxHeight = 80 // Height of the warning box in pixels + const warningBoxX = isEarlyReboot ? `calc(98% - ${warningBoxWidth}px)` : 100 + const warningBoxY = 20 // Keep the Y position constant + + // Adjust the position to align the top-right corner of the box + + // Calculate ticks at 6-hour intervals and include start and end of the time domain + const sixHoursInMs = 6 * 60 * 60 * 1000 // 6 hours in milliseconds + const startOfFirstInterval = Math.ceil(timeDomain[0] / sixHoursInMs) * sixHoursInMs // Align to the next 6-hour mark + const xAxisTicks = [] + + // Add 6-hour interval ticks + for (let tick = startOfFirstInterval; tick <= timeDomain[1]; tick += sixHoursInMs) { + xAxisTicks.push(tick) + } + + // Ensure start and end of the time domain are included + if (!xAxisTicks.includes(timeDomain[0])) { + xAxisTicks.unshift(timeDomain[0]) + } + if (!xAxisTicks.includes(timeDomain[1])) { + xAxisTicks.push(timeDomain[1]) + } + + // Sort ticks to maintain order + xAxisTicks.sort((a, b) => a - b) + + const formatChartTitle = (startDate: string, endDate: string, label: string) => { + const [start, end] = new Date(startDate) <= new Date(endDate) ? [startDate, endDate] : [endDate, startDate] + if (start === end) { + return `${label} for ${start.split('-')[1]}/${start.split('-')[2]}/${start.split('-')[0]}` + } + return `${label} for ${start.split('-')[1]}/${start.split('-')[2]}/${start.split('-')[0]} - ${end.split('-')[1]}/${ + end.split('-')[2] + }/${end.split('-')[0]}` + } + + // Render the dialog with controls and conditional "no data" message + return ( + + + {`Status of RSU at ${rsuIp || 'unknown IP'}`} + + + + + + + {latestRsuState + ? `Last status update: ${new Date(latestRsuState.timestamp).toUTCString()} (${formatUptime( + Math.floor((Date.now() - latestRsuState.timestamp) / 1000), + true + )} ago)` + : 'No RSU status data.'} + + + {latestRsuState + ? `Last reboot: ${new Date( + latestRsuState.timestamp - latestRsuState.uptime * 1000 + ).toUTCString()} (${formatUptime( + Math.floor((Date.now() - (latestRsuState.timestamp - latestRsuState.uptime * 1000)) / 1000), + true + )} ago)` + : 'No RSU reboot data.'} + +
+
+
+ Start Date + +
+
+ End Date + +
+
+
+
+ Interval + + + +
+ +
+
+ {hasGenerated && (temperatureData.length === 0 || uptimeData.length === 0) ? ( + No data available for the selected time range. + ) : ( + showCharts && ( + <> + {formatChartTitle(chartStartDate, chartEndDate, 'Temperature')} + + + + + {yellowTempTarget < temperatureDomain[1] && } + + {yellowTempTarget > temperatureDomain[0] && ( + + )} + + + + + temperatureDomain[0] + i * 5 + )} + /> + + + + + + {formatChartTitle(chartStartDate, chartEndDate, 'Uptime')} + + + + + {yellowUptimeTarget <= uptimeDomain[1] && ( + + )} + {yellowUptimeTarget >= uptimeDomain[0] && ( + + )} + + + + + + formatUptime(value)} + ticks={Array.from( + { length: 3 }, + (_, i) => uptimeDomain[0] + (i * (uptimeDomain[1] - uptimeDomain[0])) / 2 + )} + width={85} + /> + + {/* Render shaded areas for reboot zones with labels starting at the right end */} + {rebootZones.map((zone, index) => ( + + ))} + + {/* Conditional textbox for warning */} + {yellowUptimeTarget < uptimeDomain[1] && ( + +
+ Yellow indicates more +
+ than 90 days since +
+ previous reboot. +
+
+ )} +
+
+ + ) + )} +
+
+ ) +} + +export default RsuStatusDialog diff --git a/webapp/src/features/adminRsuTab/__snapshots__/AdminRsuTab.test.tsx.snap b/webapp/src/features/adminRsuTab/__snapshots__/AdminRsuTab.test.tsx.snap index a4fd3a080..0727c94c9 100644 --- a/webapp/src/features/adminRsuTab/__snapshots__/AdminRsuTab.test.tsx.snap +++ b/webapp/src/features/adminRsuTab/__snapshots__/AdminRsuTab.test.tsx.snap @@ -415,7 +415,7 @@ exports[`should take a snapshot 1`] = ` +