Skip to content

Commit 8b925a3

Browse files
Implement data entry abort modal behaviour (#256)
Co-authored-by: Joep Schuurkes <[email protected]>
1 parent 52d81f6 commit 8b925a3

File tree

19 files changed

+490
-198
lines changed

19 files changed

+490
-198
lines changed

backend/openapi.json

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@
207207
"tags": [
208208
"polling_station"
209209
],
210-
"summary": "Save or update the data entry for a polling station",
210+
"summary": "Save or update a data entry for a polling station",
211211
"operationId": "polling_station_data_entry",
212212
"parameters": [
213213
{
@@ -295,6 +295,62 @@
295295
}
296296
}
297297
}
298+
},
299+
"delete": {
300+
"tags": [
301+
"polling_station"
302+
],
303+
"summary": "Delete an in-progress (not finalised) data entry for a polling station",
304+
"operationId": "polling_station_data_entry_delete",
305+
"parameters": [
306+
{
307+
"name": "polling_station_id",
308+
"in": "path",
309+
"description": "Polling station database id",
310+
"required": true,
311+
"schema": {
312+
"type": "integer",
313+
"format": "int32",
314+
"minimum": 0
315+
}
316+
},
317+
{
318+
"name": "entry_number",
319+
"in": "path",
320+
"description": "Data entry number (first or second data entry)",
321+
"required": true,
322+
"schema": {
323+
"type": "integer",
324+
"format": "int32",
325+
"minimum": 0
326+
}
327+
}
328+
],
329+
"responses": {
330+
"204": {
331+
"description": "Data entry deleted successfully"
332+
},
333+
"404": {
334+
"description": "Not found",
335+
"content": {
336+
"application/json": {
337+
"schema": {
338+
"$ref": "#/components/schemas/ErrorResponse"
339+
}
340+
}
341+
}
342+
},
343+
"500": {
344+
"description": "Internal server error",
345+
"content": {
346+
"application/json": {
347+
"schema": {
348+
"$ref": "#/components/schemas/ErrorResponse"
349+
}
350+
}
351+
}
352+
}
353+
}
298354
}
299355
},
300356
"/api/polling_stations/{polling_station_id}/data_entries/{entry_number}/finalise": {

backend/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use axum::extract::rejection::JsonRejection;
55
use axum::extract::FromRef;
66
use axum::http::StatusCode;
77
use axum::response::{IntoResponse, Response};
8-
use axum::routing::{get, post};
8+
use axum::routing::{delete, get, post};
99
use axum::{Json, Router};
1010
use serde::{Deserialize, Serialize};
1111
use sqlx::Error::RowNotFound;
@@ -32,6 +32,10 @@ pub fn router(pool: SqlitePool) -> Result<Router, Box<dyn Error>> {
3232
"/:polling_station_id/data_entries/:entry_number",
3333
post(polling_station::polling_station_data_entry),
3434
)
35+
.route(
36+
"/:polling_station_id/data_entries/:entry_number",
37+
delete(polling_station::polling_station_data_entry_delete),
38+
)
3539
.route(
3640
"/:polling_station_id/data_entries/:entry_number/finalise",
3741
post(polling_station::polling_station_data_entry_finalise),
@@ -73,6 +77,7 @@ pub fn create_openapi() -> utoipa::openapi::OpenApi {
7377
election::election_status,
7478
polling_station::polling_station_list,
7579
polling_station::polling_station_data_entry,
80+
polling_station::polling_station_data_entry_delete,
7681
polling_station::polling_station_data_entry_finalise,
7782
),
7883
components(

backend/src/polling_station/mod.rs

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use axum::extract::{FromRequest, Path, State};
2+
use axum::http::StatusCode;
23
use axum::response::{IntoResponse, Response};
34
use axum::Json;
45
use serde::{Deserialize, Serialize};
@@ -35,7 +36,7 @@ impl IntoResponse for DataEntryResponse {
3536
}
3637
}
3738

38-
/// Save or update the data entry for a polling station
39+
/// Save or update a data entry for a polling station
3940
#[utoipa::path(
4041
post,
4142
path = "/api/polling_stations/{polling_station_id}/data_entries/{entry_number}",
@@ -84,6 +85,38 @@ pub async fn polling_station_data_entry(
8485
Ok(DataEntryResponse { validation_results })
8586
}
8687

88+
/// Delete an in-progress (not finalised) data entry for a polling station
89+
#[utoipa::path(
90+
delete,
91+
path = "/api/polling_stations/{polling_station_id}/data_entries/{entry_number}",
92+
responses(
93+
(status = 204, description = "Data entry deleted successfully"),
94+
(status = 404, description = "Not found", body = ErrorResponse),
95+
(status = 500, description = "Internal server error", body = ErrorResponse),
96+
),
97+
params(
98+
("polling_station_id" = u32, description = "Polling station database id"),
99+
("entry_number" = u8, description = "Data entry number (first or second data entry)"),
100+
),
101+
)]
102+
pub async fn polling_station_data_entry_delete(
103+
State(polling_station_data_entries): State<PollingStationDataEntries>,
104+
Path((id, entry_number)): Path<(u32, u8)>,
105+
) -> Result<StatusCode, APIError> {
106+
// only the first data entry is supported for now
107+
if entry_number != 1 {
108+
return Err(APIError::NotFound(
109+
"Only the first data entry is supported".to_string(),
110+
));
111+
}
112+
113+
polling_station_data_entries
114+
.delete(id, entry_number)
115+
.await?;
116+
117+
Ok(StatusCode::NO_CONTENT)
118+
}
119+
87120
/// Finalise the data entry for a polling station
88121
#[utoipa::path(
89122
post,
@@ -169,13 +202,13 @@ pub async fn polling_station_list(
169202

170203
#[cfg(test)]
171204
mod tests {
205+
use axum::http::StatusCode;
172206
use sqlx::{query, SqlitePool};
173207

174208
use super::*;
175209

176-
#[sqlx::test(fixtures("../../fixtures/elections.sql", "../../fixtures/polling_stations.sql"))]
177-
async fn test_polling_station_data_entry_valid(pool: SqlitePool) {
178-
let mut request_body = DataEntryRequest {
210+
fn example_data_entry() -> DataEntryRequest {
211+
DataEntryRequest {
179212
data: PollingStationResults {
180213
recounted: false,
181214
voters_counts: VotersCounts {
@@ -215,7 +248,12 @@ mod tests {
215248
],
216249
}],
217250
},
218-
};
251+
}
252+
}
253+
254+
#[sqlx::test(fixtures("../../fixtures/elections.sql", "../../fixtures/polling_stations.sql"))]
255+
async fn test_polling_station_data_entry_valid(pool: SqlitePool) {
256+
let mut request_body = example_data_entry();
219257

220258
async fn save(pool: SqlitePool, request_body: DataEntryRequest) -> Response {
221259
polling_station_data_entry(
@@ -242,7 +280,7 @@ mod tests {
242280
}
243281

244282
let response = save(pool.clone(), request_body.clone()).await;
245-
assert_eq!(response.status(), 200);
283+
assert_eq!(response.status(), StatusCode::OK);
246284

247285
// Check if a row was created
248286
let row_count = query!("SELECT COUNT(*) AS count FROM polling_station_data_entries")
@@ -253,15 +291,15 @@ mod tests {
253291

254292
// Check that we cannot finalise with errors
255293
let response = save(pool.clone(), request_body.clone()).await;
256-
assert_eq!(response.status(), 200);
294+
assert_eq!(response.status(), StatusCode::OK);
257295
let response = finalise(pool.clone()).await;
258-
assert_eq!(response.status(), 409);
296+
assert_eq!(response.status(), StatusCode::CONFLICT);
259297

260298
// Test updating the data entry
261299
let poll_card_count = 98;
262300
request_body.data.voters_counts.poll_card_count = poll_card_count; // correct value
263301
let response = save(pool.clone(), request_body.clone()).await;
264-
assert_eq!(response.status(), 200);
302+
assert_eq!(response.status(), StatusCode::OK);
265303

266304
// Check if there is still only one row
267305
let row_count = query!("SELECT COUNT(*) AS count FROM polling_station_data_entries")
@@ -280,9 +318,9 @@ mod tests {
280318

281319
// Finalise data entry after correcting the error
282320
let response = save(pool.clone(), request_body.clone()).await;
283-
assert_eq!(response.status(), 200);
321+
assert_eq!(response.status(), StatusCode::OK);
284322
let response = finalise(pool.clone()).await;
285-
assert_eq!(response.status(), 200);
323+
assert_eq!(response.status(), StatusCode::OK);
286324

287325
// Check if the data entry was finalised:
288326
// removed from polling_station_data_entries...
@@ -299,6 +337,46 @@ mod tests {
299337
assert_eq!(row_count.count, 1);
300338
}
301339

340+
#[sqlx::test(fixtures("../../fixtures/elections.sql", "../../fixtures/polling_stations.sql"))]
341+
async fn test_polling_station_data_entry_delete(pool: SqlitePool) {
342+
// create data entry
343+
let response = polling_station_data_entry(
344+
State(PollingStationDataEntries::new(pool.clone())),
345+
State(PollingStations::new(pool.clone())),
346+
State(Elections::new(pool.clone())),
347+
Path((1, 1)),
348+
example_data_entry(),
349+
)
350+
.await
351+
.into_response();
352+
assert_eq!(response.status(), StatusCode::OK);
353+
354+
// delete data entry
355+
let response = polling_station_data_entry_delete(
356+
State(PollingStationDataEntries::new(pool.clone())),
357+
Path((1, 1)),
358+
)
359+
.await
360+
.into_response();
361+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
362+
363+
// check if data entry was deleted
364+
let row_count = query!("SELECT COUNT(*) AS count FROM polling_station_data_entries")
365+
.fetch_one(&pool)
366+
.await
367+
.unwrap();
368+
assert_eq!(row_count.count, 0);
369+
370+
// check that deleting a non-existing data entry returns 404
371+
let response = polling_station_data_entry_delete(
372+
State(PollingStationDataEntries::new(pool.clone())),
373+
Path((1, 1)),
374+
)
375+
.await
376+
.into_response();
377+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
378+
}
379+
302380
#[sqlx::test(fixtures("../../fixtures/elections.sql"))]
303381
async fn test_polling_station_number_unique_per_election(pool: SqlitePool) {
304382
// Insert two unique polling stations

backend/src/polling_station/repository.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ impl PollingStationDataEntries {
118118
res.data.ok_or_else(|| sqlx::Error::RowNotFound)
119119
}
120120

121+
pub async fn delete(&self, id: u32, entry_number: u8) -> Result<(), sqlx::Error> {
122+
let res = query!(
123+
"DELETE FROM polling_station_data_entries WHERE polling_station_id = ? AND entry_number = ?",
124+
id,
125+
entry_number
126+
)
127+
.execute(&self.0)
128+
.await?;
129+
if res.rows_affected() == 0 {
130+
Err(sqlx::Error::RowNotFound)
131+
} else {
132+
Ok(())
133+
}
134+
}
135+
121136
pub async fn finalise(
122137
&self,
123138
tx: &mut Transaction<'_, Sqlite>,

backend/tests/data_entries_integration_test.rs

Lines changed: 33 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
#![cfg(test)]
22

3-
use reqwest::StatusCode;
3+
use reqwest::{Response, StatusCode};
44
use serde_json::json;
55
use sqlx::SqlitePool;
6+
use std::net::SocketAddr;
67

7-
use backend::polling_station::{
8-
CandidateVotes, DataEntryRequest, DataEntryResponse, DifferencesCounts, PoliticalGroupVotes,
9-
PollingStationResults, VotersCounts, VotesCounts,
10-
};
8+
use backend::polling_station::DataEntryResponse;
119
use backend::validation::ValidationResultCode;
1210
use backend::ErrorResponse;
1311

@@ -156,48 +154,7 @@ async fn test_polling_station_data_entry_invalid(pool: SqlitePool) {
156154
async fn test_polling_station_data_entry_only_for_existing(pool: SqlitePool) {
157155
let addr = serve_api(pool).await;
158156

159-
let request_body = DataEntryRequest {
160-
data: PollingStationResults {
161-
recounted: false,
162-
voters_counts: VotersCounts {
163-
poll_card_count: 100,
164-
proxy_certificate_count: 2,
165-
voter_card_count: 2,
166-
total_admitted_voters_count: 104,
167-
},
168-
votes_counts: VotesCounts {
169-
votes_candidates_counts: 102,
170-
blank_votes_count: 1,
171-
invalid_votes_count: 1,
172-
total_votes_cast_count: 104,
173-
},
174-
voters_recounts: None,
175-
differences_counts: DifferencesCounts {
176-
more_ballots_count: 0,
177-
fewer_ballots_count: 0,
178-
unreturned_ballots_count: 0,
179-
too_few_ballots_handed_out_count: 0,
180-
too_many_ballots_handed_out_count: 0,
181-
other_explanation_count: 0,
182-
no_explanation_count: 0,
183-
},
184-
political_group_votes: vec![PoliticalGroupVotes {
185-
number: 1,
186-
total: 102,
187-
candidate_votes: vec![
188-
CandidateVotes {
189-
number: 1,
190-
votes: 54,
191-
},
192-
CandidateVotes {
193-
number: 2,
194-
votes: 48,
195-
},
196-
],
197-
}],
198-
},
199-
};
200-
157+
let request_body = shared::example_data_entry();
201158
let invalid_id = 123_456_789;
202159

203160
let url = format!("http://{addr}/api/polling_stations/{invalid_id}/data_entries/1");
@@ -219,3 +176,32 @@ async fn test_polling_station_data_entry_only_for_existing(pool: SqlitePool) {
219176
// Ensure the response is what we expect
220177
assert_eq!(response.status(), StatusCode::NOT_FOUND);
221178
}
179+
180+
#[sqlx::test(fixtures("../fixtures/elections.sql", "../fixtures/polling_stations.sql"))]
181+
async fn test_polling_station_data_entry_deletion(pool: SqlitePool) {
182+
let addr = serve_api(pool).await;
183+
184+
let request_body = shared::example_data_entry();
185+
186+
// create a data entry
187+
let url = format!("http://{addr}/api/polling_stations/1/data_entries/1");
188+
let response = reqwest::Client::new()
189+
.post(&url)
190+
.json(&request_body)
191+
.send()
192+
.await
193+
.unwrap();
194+
assert_eq!(response.status(), StatusCode::OK);
195+
196+
// delete the data entry
197+
async fn delete_data_entry(addr: SocketAddr) -> Response {
198+
let url = format!("http://{addr}/api/polling_stations/1/data_entries/1");
199+
reqwest::Client::new().delete(&url).send().await.unwrap()
200+
}
201+
let response = delete_data_entry(addr).await;
202+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
203+
204+
// delete a non-existing data entry
205+
let response = delete_data_entry(addr).await;
206+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
207+
}

0 commit comments

Comments
 (0)