From a8352ecbb89be2dca642be3983104d7a6649e2c5 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 29 Sep 2026 14:53:12 +0300 Subject: [PATCH] Add election round and monitoring NGO stats endpoints for platform admins. Provide platform-admin views of NGO-style statistics for an entire election round and for a specific monitoring NGO. Co-authored-by: Cursor --- .../GetElectionRoundStatistics/Endpoint.cs | 491 +++++++++++++++++ .../GetElectionRoundStatistics/NgosStats.cs | 7 + .../GetElectionRoundStatistics/Request.cs | 6 + .../GetElectionRoundStatistics/Response.cs | 22 + .../GetElectionRoundStatistics/Validator.cs | 9 + .../GetMonitoringNgoStatistics/Endpoint.cs | 510 ++++++++++++++++++ .../GetMonitoringNgoStatistics/Request.cs | 7 + .../GetMonitoringNgoStatistics/Response.cs | 22 + .../GetMonitoringNgoStatistics/Validator.cs | 10 + ...etElectionRoundStatisticsValidatorTests.cs | 34 ++ ...etMonitoringNgoStatisticsValidatorTests.cs | 59 ++ 11 files changed, 1177 insertions(+) create mode 100644 api/src/Feature.Statistics/GetElectionRoundStatistics/Endpoint.cs create mode 100644 api/src/Feature.Statistics/GetElectionRoundStatistics/NgosStats.cs create mode 100644 api/src/Feature.Statistics/GetElectionRoundStatistics/Request.cs create mode 100644 api/src/Feature.Statistics/GetElectionRoundStatistics/Response.cs create mode 100644 api/src/Feature.Statistics/GetElectionRoundStatistics/Validator.cs create mode 100644 api/src/Feature.Statistics/GetMonitoringNgoStatistics/Endpoint.cs create mode 100644 api/src/Feature.Statistics/GetMonitoringNgoStatistics/Request.cs create mode 100644 api/src/Feature.Statistics/GetMonitoringNgoStatistics/Response.cs create mode 100644 api/src/Feature.Statistics/GetMonitoringNgoStatistics/Validator.cs create mode 100644 api/tests/Feature.Statistics.UnitTests/Validators/GetElectionRoundStatisticsValidatorTests.cs create mode 100644 api/tests/Feature.Statistics.UnitTests/Validators/GetMonitoringNgoStatisticsValidatorTests.cs diff --git a/api/src/Feature.Statistics/GetElectionRoundStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetElectionRoundStatistics/Endpoint.cs new file mode 100644 index 000000000..9b9c3b660 --- /dev/null +++ b/api/src/Feature.Statistics/GetElectionRoundStatistics/Endpoint.cs @@ -0,0 +1,491 @@ +using Authorization.Policies; +using Dapper; +using Feature.Statistics.GetNgoAdminStatistics.Models; +using Feature.Statistics.Options; +using Microsoft.Extensions.Options; +using Vote.Monitor.Domain.ConnectionFactory; +using ZiggyCreatures.Caching.Fusion; + +namespace Feature.Statistics.GetElectionRoundStatistics; + +public class Endpoint( + INpgsqlConnectionFactory dbConnectionFactory, + IFusionCache cache, + IOptions options) : Endpoint +{ + private readonly StatisticsFeatureOptions _options = options.Value; + + public override void Configure() + { + Get("/api/election-rounds/{electionRoundId}:stats"); + DontAutoTag(); + Options(x => x.WithTags("statistics")); + Policies(PolicyNames.PlatformAdminsOnly); + Summary(s => + { + s.Summary = "Statistics for an election round across all monitoring NGOs"; + }); + } + + public override async Task ExecuteAsync(Request req, CancellationToken ct) + { + var cacheKey = $"statistics-election-round-{req.ElectionRoundId}"; + + return await cache.GetOrSetAsync( + cacheKey, + async _ => await GetStatisticsAsync(req, ct), + cacheOptions => cacheOptions.SetDuration(TimeSpan.FromMinutes(_options.CacheDurationInMinutes)), + token: ct); + } + + private async Task GetStatisticsAsync(Request req, CancellationToken ct) + { + const string sql = + """ + SELECT + COUNT(*) FILTER (WHERE "Status" = 'Active') AS "ActiveNgos", + COUNT(*) FILTER (WHERE "Status" = 'Suspended') AS "InactiveNgos" + FROM "MonitoringNgos" + WHERE "ElectionRoundId" = @electionRoundId; + ------------------------------ + + SELECT + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Active' + AND U."Status" = 'Active' + ) AS "ActiveObservers", + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Pending' + OR U."Status" = 'Pending' + ) AS "PendingObservers", + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Suspended' + OR U."Status" = 'Deactivated' + ) AS "SuspendedObservers" + FROM "MonitoringObservers" MO + INNER JOIN "AspNetUsers" U ON U."Id" = MO."ObserverId" + WHERE MO."ElectionRoundId" = @electionRoundId; + ------------------------------ + + WITH + "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + ), + "ActiveObservers" AS ( + SELECT + FS."PollingStationId", + FS."MonitoringObserverId" + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + UNION + SELECT + PSI."PollingStationId", + PSI."MonitoringObserverId" + FROM "PollingStationInformation" PSI + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = PSI."MonitoringObserverId" + UNION + SELECT + QR."PollingStationId", + QR."MonitoringObserverId" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + WHERE QR."PollingStationId" IS NOT NULL + UNION + SELECT + IR."PollingStationId", + IR."MonitoringObserverId" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + WHERE IR."PollingStationId" IS NOT NULL + ), + "PollingStationsStatsRaw" AS ( + SELECT + FS."PollingStationId", + 0 AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + COUNT(1) AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + SUM(FS."NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM(FS."NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + GROUP BY FS."PollingStationId" + UNION ALL + SELECT + PSI."PollingStationId", + 0 AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + COUNT(1) AS "NumberOfFormSubmissions", + SUM("ComputeMinutesMonitoring"("ArrivalTime", "DepartureTime", "Breaks")) AS "MinutesMonitoring", + SUM(PSI."NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM(PSI."NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "PollingStationInformation" PSI + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = PSI."MonitoringObserverId" + GROUP BY PSI."PollingStationId" + UNION ALL + SELECT + QR."PollingStationId", + 0 AS "NumberOfIncidentReports", + COUNT(1) AS "NumberOfQuickReports", + 0 AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + 0 AS "NumberOfFlaggedAnswers", + 0 AS "NumberOfQuestionsAnswered" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + WHERE QR."PollingStationId" IS NOT NULL + GROUP BY QR."PollingStationId" + UNION ALL + SELECT + IR."PollingStationId", + COUNT(1) AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + 0 AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + 0 AS "NumberOfFlaggedAnswers", + 0 AS "NumberOfQuestionsAnswered" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + WHERE IR."PollingStationId" IS NOT NULL + GROUP BY IR."PollingStationId" + ), + "PollingStationsStats" AS ( + SELECT + "PollingStationId", + SUM("NumberOfIncidentReports") AS "NumberOfIncidentReports", + SUM("NumberOfQuickReports") AS "NumberOfQuickReports", + SUM("NumberOfFormSubmissions") AS "NumberOfFormSubmissions", + SUM("MinutesMonitoring") AS "MinutesMonitoring", + SUM("NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM("NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "PollingStationsStatsRaw" + GROUP BY "PollingStationId" + ), + "ElectionPollingStations" AS ( + SELECT + PS."Id", + NULLIF(PS."Level1", '') AS "Level1", + NULLIF(PS."Level2", '') AS "Level2", + NULLIF(PS."Level3", '') AS "Level3", + NULLIF(PS."Level4", '') AS "Level4", + NULLIF(PS."Level5", '') AS "Level5" + FROM "PollingStations" PS + WHERE PS."ElectionRoundId" = @electionRoundId + AND PS."Level1" != '' + ), + "PollingStationsPerLevel" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(PS."Id") AS "NumberOfPollingStations" + FROM "ElectionPollingStations" PS + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ), + "PollingStationLevelsStats" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(PSV."PollingStationId") AS "NumberOfVisitedPollingStations", + COALESCE(SUM(PSV."NumberOfIncidentReports"), 0) AS "NumberOfIncidentReports", + COALESCE(SUM(PSV."NumberOfQuickReports"), 0) AS "NumberOfQuickReports", + COALESCE(SUM(PSV."NumberOfFormSubmissions"), 0) AS "NumberOfFormSubmissions", + COALESCE(SUM(PSV."MinutesMonitoring"), 0) AS "MinutesMonitoring", + COALESCE(SUM(PSV."NumberOfFlaggedAnswers"), 0) AS "NumberOfFlaggedAnswers", + COALESCE(SUM(PSV."NumberOfQuestionsAnswered"), 0) AS "NumberOfQuestionsAnswered" + FROM "PollingStationsStats" PSV + INNER JOIN "ElectionPollingStations" PS ON PS."Id" = PSV."PollingStationId" + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ), + "ActiveObserversPerLevel" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(DISTINCT AO."MonitoringObserverId") AS "ActiveObservers" + FROM "ActiveObservers" AO + INNER JOIN "ElectionPollingStations" PS ON PS."Id" = AO."PollingStationId" + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ) + SELECT + S."Path", + S."Level", + S."NumberOfVisitedPollingStations", + PS."NumberOfPollingStations", + S."NumberOfIncidentReports", + S."NumberOfQuickReports", + S."NumberOfFormSubmissions", + S."MinutesMonitoring", + S."NumberOfFlaggedAnswers", + S."NumberOfQuestionsAnswered", + AOL."ActiveObservers", + ( + S."NumberOfVisitedPollingStations" * 100.0 / NULLIF(PS."NumberOfPollingStations", 0) + ) AS "CoveragePercentage" + FROM "PollingStationLevelsStats" S + INNER JOIN "PollingStationsPerLevel" PS ON S."Level" = PS."Level" AND S."Path" = PS."Path" + INNER JOIN "ActiveObserversPerLevel" AOL ON S."Level" = AOL."Level" AND S."Path" = AOL."Path"; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + ), + submission_histogram AS ( + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', FS."LastUpdatedAt") + )::timestamptz AS bucket, + 1 AS forms_submitted, + FS."NumberOfQuestionsAnswered" AS number_of_questions_answered, + FS."NumberOfFlaggedAnswers" AS number_of_flagged_answers + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + + UNION ALL + + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', qr."LastUpdatedAt") + )::timestamptz AS bucket, + 1 AS forms_submitted, + qr."NumberOfQuestionsAnswered" AS number_of_questions_answered, + qr."NumberOfFlaggedAnswers" AS number_of_flagged_answers + FROM "PollingStationInformation" qr + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = qr."MonitoringObserverId" + ) + SELECT + bucket AS "Bucket", + COUNT(*) AS "FormsSubmitted", + SUM(number_of_questions_answered) AS "NumberOfQuestionsAnswered", + SUM(number_of_flagged_answers) AS "NumberOfFlaggedAnswers" + FROM submission_histogram + GROUP BY bucket + ORDER BY bucket; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + ) + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', QR."LastUpdatedAt") + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + GROUP BY 1; + + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE( + 'utc', + COALESCE(CR."LastModifiedOn", CR."CreatedOn") + ) + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "CitizenReports" CR + WHERE CR."ElectionRoundId" = @electionRoundId + GROUP BY 1; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + ) + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', IR."LastUpdatedAt") + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + GROUP BY 1; + """; + + var queryArgs = new { electionRoundId = req.ElectionRoundId }; + + NgosStats ngosStats; + ObserversStats observersStats; + List visitedPollingStationsStats; + List formSubmissionsHistogram; + List quickReportsHistogram; + List citizenReportsHistogram; + List incidentReportsHistogram; + + using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct)) + { + using var multi = await dbConnection.QueryMultipleAsync(sql, queryArgs); + + ngosStats = multi.ReadSingle(); + observersStats = multi.ReadSingle(); + visitedPollingStationsStats = multi.Read().ToList(); + formSubmissionsHistogram = multi.Read().ToList(); + quickReportsHistogram = multi.Read().ToList(); + citizenReportsHistogram = multi.Read().ToList(); + incidentReportsHistogram = multi.Read().ToList(); + } + + return new Response + { + NgosStats = ngosStats, + ObserversStats = observersStats, + TotalStats = visitedPollingStationsStats.FirstOrDefault(x => x.Level == 0), + Level1Stats = visitedPollingStationsStats.Where(x => x.Level == 1).ToList(), + Level2Stats = visitedPollingStationsStats.Where(x => x.Level == 2).ToList(), + Level3Stats = visitedPollingStationsStats.Where(x => x.Level == 3).ToList(), + Level4Stats = visitedPollingStationsStats.Where(x => x.Level == 4).ToList(), + Level5Stats = visitedPollingStationsStats.Where(x => x.Level == 5).ToList(), + FormsHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.FormsSubmitted + }).ToArray(), + QuestionsHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.NumberOfQuestionsAnswered + }).ToArray(), + FlaggedAnswersHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.NumberOfFlaggedAnswers + }).ToArray(), + QuickReportsHistogram = quickReportsHistogram.ToArray(), + IncidentReportsHistogram = incidentReportsHistogram.ToArray(), + CitizenReportsHistogram = citizenReportsHistogram.ToArray() + }; + } +} diff --git a/api/src/Feature.Statistics/GetElectionRoundStatistics/NgosStats.cs b/api/src/Feature.Statistics/GetElectionRoundStatistics/NgosStats.cs new file mode 100644 index 000000000..e2a388df2 --- /dev/null +++ b/api/src/Feature.Statistics/GetElectionRoundStatistics/NgosStats.cs @@ -0,0 +1,7 @@ +namespace Feature.Statistics.GetElectionRoundStatistics; + +public class NgosStats +{ + public int ActiveNgos { get; set; } + public int InactiveNgos { get; set; } +} diff --git a/api/src/Feature.Statistics/GetElectionRoundStatistics/Request.cs b/api/src/Feature.Statistics/GetElectionRoundStatistics/Request.cs new file mode 100644 index 000000000..9c29fbedd --- /dev/null +++ b/api/src/Feature.Statistics/GetElectionRoundStatistics/Request.cs @@ -0,0 +1,6 @@ +namespace Feature.Statistics.GetElectionRoundStatistics; + +public class Request +{ + public Guid ElectionRoundId { get; set; } +} diff --git a/api/src/Feature.Statistics/GetElectionRoundStatistics/Response.cs b/api/src/Feature.Statistics/GetElectionRoundStatistics/Response.cs new file mode 100644 index 000000000..05244daa9 --- /dev/null +++ b/api/src/Feature.Statistics/GetElectionRoundStatistics/Response.cs @@ -0,0 +1,22 @@ +using Feature.Statistics.GetNgoAdminStatistics.Models; + +namespace Feature.Statistics.GetElectionRoundStatistics; + +public class Response +{ + public NgosStats NgosStats { get; set; } = new(); + public ObserversStats ObserversStats { get; set; } = new(); + + public VisitedPollingStationLevelStats? TotalStats { get; set; } + public List Level1Stats { get; set; } = []; + public List Level2Stats { get; set; } = []; + public List Level3Stats { get; set; } = []; + public List Level4Stats { get; set; } = []; + public List Level5Stats { get; set; } = []; + public HistogramPoint[] FormsHistogram { get; set; } = []; + public HistogramPoint[] QuestionsHistogram { get; set; } = []; + public HistogramPoint[] FlaggedAnswersHistogram { get; set; } = []; + public HistogramPoint[] QuickReportsHistogram { get; set; } = []; + public HistogramPoint[] IncidentReportsHistogram { get; set; } = []; + public HistogramPoint[] CitizenReportsHistogram { get; set; } = []; +} diff --git a/api/src/Feature.Statistics/GetElectionRoundStatistics/Validator.cs b/api/src/Feature.Statistics/GetElectionRoundStatistics/Validator.cs new file mode 100644 index 000000000..bd31159e4 --- /dev/null +++ b/api/src/Feature.Statistics/GetElectionRoundStatistics/Validator.cs @@ -0,0 +1,9 @@ +namespace Feature.Statistics.GetElectionRoundStatistics; + +public class Validator : Validator +{ + public Validator() + { + RuleFor(x => x.ElectionRoundId).NotEmpty(); + } +} diff --git a/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Endpoint.cs new file mode 100644 index 000000000..bef13902c --- /dev/null +++ b/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Endpoint.cs @@ -0,0 +1,510 @@ +using Authorization.Policies; +using Dapper; +using Feature.Statistics.GetNgoAdminStatistics.Models; +using Feature.Statistics.Options; +using Microsoft.Extensions.Options; +using Vote.Monitor.Domain.ConnectionFactory; +using ZiggyCreatures.Caching.Fusion; + +namespace Feature.Statistics.GetMonitoringNgoStatistics; + +public class Endpoint( + INpgsqlConnectionFactory dbConnectionFactory, + IFusionCache cache, + IOptions options) : Endpoint, NotFound>> +{ + private readonly StatisticsFeatureOptions _options = options.Value; + + public override void Configure() + { + Get("/api/election-rounds/{electionRoundId}/monitoring-ngos/{monitoringNgoId}:stats"); + DontAutoTag(); + Options(x => x.WithTags("statistics")); + Policies(PolicyNames.PlatformAdminsOnly); + Summary(s => + { + s.Summary = "Statistics for a monitoring NGO in an election round"; + }); + } + + public override async Task, NotFound>> ExecuteAsync(Request req, CancellationToken ct) + { + var cacheKey = $"statistics-monitoring-ngo-{req.ElectionRoundId}-{req.MonitoringNgoId}"; + + var cached = await cache.TryGetAsync(cacheKey, token: ct); + if (cached.HasValue) + { + return TypedResults.Ok(cached.Value); + } + + var result = await GetStatisticsAsync(req, ct); + if (result is null) + { + return TypedResults.NotFound(); + } + + await cache.SetAsync( + cacheKey, + result, + options => options.SetDuration(TimeSpan.FromMinutes(_options.CacheDurationInMinutes)), + token: ct); + + return TypedResults.Ok(result); + } + + private async Task GetStatisticsAsync(Request req, CancellationToken ct) + { + const string sql = + """ + SELECT "Status" + FROM "MonitoringNgos" + WHERE "Id" = @monitoringNgoId + AND "ElectionRoundId" = @electionRoundId; + ------------------------------ + + SELECT + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Active' + AND U."Status" = 'Active' + ) AS "ActiveObservers", + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Pending' + OR U."Status" = 'Pending' + ) AS "PendingObservers", + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Suspended' + OR U."Status" = 'Deactivated' + ) AS "SuspendedObservers" + FROM "MonitoringObservers" MO + INNER JOIN "AspNetUsers" U ON U."Id" = MO."ObserverId" + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" = @monitoringNgoId; + ------------------------------ + + WITH + "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" = @monitoringNgoId + ), + "ActiveObservers" AS ( + SELECT + FS."PollingStationId", + FS."MonitoringObserverId" + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + UNION + SELECT + PSI."PollingStationId", + PSI."MonitoringObserverId" + FROM "PollingStationInformation" PSI + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = PSI."MonitoringObserverId" + UNION + SELECT + QR."PollingStationId", + QR."MonitoringObserverId" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + WHERE QR."PollingStationId" IS NOT NULL + UNION + SELECT + IR."PollingStationId", + IR."MonitoringObserverId" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + WHERE IR."PollingStationId" IS NOT NULL + ), + "PollingStationsStatsRaw" AS ( + SELECT + FS."PollingStationId", + 0 AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + COUNT(1) AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + SUM(FS."NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM(FS."NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + GROUP BY FS."PollingStationId" + UNION ALL + SELECT + PSI."PollingStationId", + 0 AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + COUNT(1) AS "NumberOfFormSubmissions", + SUM("ComputeMinutesMonitoring"("ArrivalTime", "DepartureTime", "Breaks")) AS "MinutesMonitoring", + SUM(PSI."NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM(PSI."NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "PollingStationInformation" PSI + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = PSI."MonitoringObserverId" + GROUP BY PSI."PollingStationId" + UNION ALL + SELECT + QR."PollingStationId", + 0 AS "NumberOfIncidentReports", + COUNT(1) AS "NumberOfQuickReports", + 0 AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + 0 AS "NumberOfFlaggedAnswers", + 0 AS "NumberOfQuestionsAnswered" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + WHERE QR."PollingStationId" IS NOT NULL + GROUP BY QR."PollingStationId" + UNION ALL + SELECT + IR."PollingStationId", + COUNT(1) AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + 0 AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + 0 AS "NumberOfFlaggedAnswers", + 0 AS "NumberOfQuestionsAnswered" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + WHERE IR."PollingStationId" IS NOT NULL + GROUP BY IR."PollingStationId" + ), + "PollingStationsStats" AS ( + SELECT + "PollingStationId", + SUM("NumberOfIncidentReports") AS "NumberOfIncidentReports", + SUM("NumberOfQuickReports") AS "NumberOfQuickReports", + SUM("NumberOfFormSubmissions") AS "NumberOfFormSubmissions", + SUM("MinutesMonitoring") AS "MinutesMonitoring", + SUM("NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM("NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "PollingStationsStatsRaw" + GROUP BY "PollingStationId" + ), + "ElectionPollingStations" AS ( + SELECT + PS."Id", + NULLIF(PS."Level1", '') AS "Level1", + NULLIF(PS."Level2", '') AS "Level2", + NULLIF(PS."Level3", '') AS "Level3", + NULLIF(PS."Level4", '') AS "Level4", + NULLIF(PS."Level5", '') AS "Level5" + FROM "PollingStations" PS + WHERE PS."ElectionRoundId" = @electionRoundId + AND PS."Level1" != '' + ), + "PollingStationsPerLevel" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(PS."Id") AS "NumberOfPollingStations" + FROM "ElectionPollingStations" PS + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ), + "PollingStationLevelsStats" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(PSV."PollingStationId") AS "NumberOfVisitedPollingStations", + COALESCE(SUM(PSV."NumberOfIncidentReports"), 0) AS "NumberOfIncidentReports", + COALESCE(SUM(PSV."NumberOfQuickReports"), 0) AS "NumberOfQuickReports", + COALESCE(SUM(PSV."NumberOfFormSubmissions"), 0) AS "NumberOfFormSubmissions", + COALESCE(SUM(PSV."MinutesMonitoring"), 0) AS "MinutesMonitoring", + COALESCE(SUM(PSV."NumberOfFlaggedAnswers"), 0) AS "NumberOfFlaggedAnswers", + COALESCE(SUM(PSV."NumberOfQuestionsAnswered"), 0) AS "NumberOfQuestionsAnswered" + FROM "PollingStationsStats" PSV + INNER JOIN "ElectionPollingStations" PS ON PS."Id" = PSV."PollingStationId" + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ), + "ActiveObserversPerLevel" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(DISTINCT AO."MonitoringObserverId") AS "ActiveObservers" + FROM "ActiveObservers" AO + INNER JOIN "ElectionPollingStations" PS ON PS."Id" = AO."PollingStationId" + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ) + SELECT + S."Path", + S."Level", + S."NumberOfVisitedPollingStations", + PS."NumberOfPollingStations", + S."NumberOfIncidentReports", + S."NumberOfQuickReports", + S."NumberOfFormSubmissions", + S."MinutesMonitoring", + S."NumberOfFlaggedAnswers", + S."NumberOfQuestionsAnswered", + AOL."ActiveObservers", + ( + S."NumberOfVisitedPollingStations" * 100.0 / NULLIF(PS."NumberOfPollingStations", 0) + ) AS "CoveragePercentage" + FROM "PollingStationLevelsStats" S + INNER JOIN "PollingStationsPerLevel" PS ON S."Level" = PS."Level" AND S."Path" = PS."Path" + INNER JOIN "ActiveObserversPerLevel" AOL ON S."Level" = AOL."Level" AND S."Path" = AOL."Path"; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" = @monitoringNgoId + ), + submission_histogram AS ( + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', FS."LastUpdatedAt") + )::timestamptz AS bucket, + 1 AS forms_submitted, + FS."NumberOfQuestionsAnswered" AS number_of_questions_answered, + FS."NumberOfFlaggedAnswers" AS number_of_flagged_answers + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + + UNION ALL + + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', qr."LastUpdatedAt") + )::timestamptz AS bucket, + 1 AS forms_submitted, + qr."NumberOfQuestionsAnswered" AS number_of_questions_answered, + qr."NumberOfFlaggedAnswers" AS number_of_flagged_answers + FROM "PollingStationInformation" qr + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = qr."MonitoringObserverId" + ) + SELECT + bucket AS "Bucket", + COUNT(*) AS "FormsSubmitted", + SUM(number_of_questions_answered) AS "NumberOfQuestionsAnswered", + SUM(number_of_flagged_answers) AS "NumberOfFlaggedAnswers" + FROM submission_histogram + GROUP BY bucket + ORDER BY bucket; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" = @monitoringNgoId + ) + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', QR."LastUpdatedAt") + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + GROUP BY 1; + + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE( + 'utc', + COALESCE(CR."LastModifiedOn", CR."CreatedOn") + ) + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "CitizenReports" CR + INNER JOIN "ElectionRounds" ER ON ER."Id" = CR."ElectionRoundId" + WHERE CR."ElectionRoundId" = @electionRoundId + AND ER."MonitoringNgoForCitizenReportingId" = @monitoringNgoId + GROUP BY 1; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" = @monitoringNgoId + ) + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', IR."LastUpdatedAt") + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + GROUP BY 1; + """; + + var queryArgs = new + { + electionRoundId = req.ElectionRoundId, + monitoringNgoId = req.MonitoringNgoId + }; + + using var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct); + using var multi = await dbConnection.QueryMultipleAsync(sql, queryArgs); + + var status = multi.Read().SingleOrDefault(); + if (status is null) + { + return null; + } + + var observersStats = multi.ReadSingle(); + var visitedPollingStationsStats = multi.Read().ToList(); + var formSubmissionsHistogram = multi.Read().ToList(); + var quickReportsHistogram = multi.Read().ToList(); + var citizenReportsHistogram = multi.Read().ToList(); + var incidentReportsHistogram = multi.Read().ToList(); + + return new Response + { + Status = status, + ObserversStats = observersStats, + TotalStats = visitedPollingStationsStats.FirstOrDefault(x => x.Level == 0), + Level1Stats = visitedPollingStationsStats.Where(x => x.Level == 1).ToList(), + Level2Stats = visitedPollingStationsStats.Where(x => x.Level == 2).ToList(), + Level3Stats = visitedPollingStationsStats.Where(x => x.Level == 3).ToList(), + Level4Stats = visitedPollingStationsStats.Where(x => x.Level == 4).ToList(), + Level5Stats = visitedPollingStationsStats.Where(x => x.Level == 5).ToList(), + FormsHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.FormsSubmitted + }).ToArray(), + QuestionsHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.NumberOfQuestionsAnswered + }).ToArray(), + FlaggedAnswersHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.NumberOfFlaggedAnswers + }).ToArray(), + QuickReportsHistogram = quickReportsHistogram.ToArray(), + IncidentReportsHistogram = incidentReportsHistogram.ToArray(), + CitizenReportsHistogram = citizenReportsHistogram.ToArray() + }; + } +} diff --git a/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Request.cs b/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Request.cs new file mode 100644 index 000000000..667154d7a --- /dev/null +++ b/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Request.cs @@ -0,0 +1,7 @@ +namespace Feature.Statistics.GetMonitoringNgoStatistics; + +public class Request +{ + public Guid ElectionRoundId { get; set; } + public Guid MonitoringNgoId { get; set; } +} diff --git a/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Response.cs b/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Response.cs new file mode 100644 index 000000000..1498ca401 --- /dev/null +++ b/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Response.cs @@ -0,0 +1,22 @@ +using Feature.Statistics.GetNgoAdminStatistics.Models; + +namespace Feature.Statistics.GetMonitoringNgoStatistics; + +public class Response +{ + public string Status { get; set; } = string.Empty; + public ObserversStats ObserversStats { get; set; } = new(); + + public VisitedPollingStationLevelStats? TotalStats { get; set; } + public List Level1Stats { get; set; } = []; + public List Level2Stats { get; set; } = []; + public List Level3Stats { get; set; } = []; + public List Level4Stats { get; set; } = []; + public List Level5Stats { get; set; } = []; + public HistogramPoint[] FormsHistogram { get; set; } = []; + public HistogramPoint[] QuestionsHistogram { get; set; } = []; + public HistogramPoint[] FlaggedAnswersHistogram { get; set; } = []; + public HistogramPoint[] QuickReportsHistogram { get; set; } = []; + public HistogramPoint[] IncidentReportsHistogram { get; set; } = []; + public HistogramPoint[] CitizenReportsHistogram { get; set; } = []; +} diff --git a/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Validator.cs b/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Validator.cs new file mode 100644 index 000000000..b86934897 --- /dev/null +++ b/api/src/Feature.Statistics/GetMonitoringNgoStatistics/Validator.cs @@ -0,0 +1,10 @@ +namespace Feature.Statistics.GetMonitoringNgoStatistics; + +public class Validator : Validator +{ + public Validator() + { + RuleFor(x => x.ElectionRoundId).NotEmpty(); + RuleFor(x => x.MonitoringNgoId).NotEmpty(); + } +} diff --git a/api/tests/Feature.Statistics.UnitTests/Validators/GetElectionRoundStatisticsValidatorTests.cs b/api/tests/Feature.Statistics.UnitTests/Validators/GetElectionRoundStatisticsValidatorTests.cs new file mode 100644 index 000000000..e3fc654b1 --- /dev/null +++ b/api/tests/Feature.Statistics.UnitTests/Validators/GetElectionRoundStatisticsValidatorTests.cs @@ -0,0 +1,34 @@ +using Feature.Statistics.GetElectionRoundStatistics; + +namespace Feature.Statistics.UnitTests.Validators; + +public class GetElectionRoundStatisticsValidatorTests +{ + private readonly Validator _validator = new(); + + [Fact] + public void Validation_ShouldPass_When_ElectionRoundId_NotEmpty() + { + // Arrange + var request = new Request { ElectionRoundId = Guid.NewGuid() }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.ElectionRoundId); + } + + [Fact] + public void Validation_ShouldFail_When_ElectionRoundId_Empty() + { + // Arrange + var request = new Request { ElectionRoundId = Guid.Empty }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.ElectionRoundId); + } +} diff --git a/api/tests/Feature.Statistics.UnitTests/Validators/GetMonitoringNgoStatisticsValidatorTests.cs b/api/tests/Feature.Statistics.UnitTests/Validators/GetMonitoringNgoStatisticsValidatorTests.cs new file mode 100644 index 000000000..82545348e --- /dev/null +++ b/api/tests/Feature.Statistics.UnitTests/Validators/GetMonitoringNgoStatisticsValidatorTests.cs @@ -0,0 +1,59 @@ +using Feature.Statistics.GetMonitoringNgoStatistics; + +namespace Feature.Statistics.UnitTests.Validators; + +public class GetMonitoringNgoStatisticsValidatorTests +{ + private readonly Validator _validator = new(); + + [Fact] + public void Validation_ShouldPass_When_Request_Valid() + { + // Arrange + var request = new Request + { + ElectionRoundId = Guid.NewGuid(), + MonitoringNgoId = Guid.NewGuid() + }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validation_ShouldFail_When_ElectionRoundId_Empty() + { + // Arrange + var request = new Request + { + ElectionRoundId = Guid.Empty, + MonitoringNgoId = Guid.NewGuid() + }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.ElectionRoundId); + } + + [Fact] + public void Validation_ShouldFail_When_MonitoringNgoId_Empty() + { + // Arrange + var request = new Request + { + ElectionRoundId = Guid.NewGuid(), + MonitoringNgoId = Guid.Empty + }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.MonitoringNgoId); + } +}