diff --git a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/communication/responseFromRaire/GenerateAssertionsResponse.java b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/communication/responseFromRaire/GenerateAssertionsResponse.java index 81fd93f3..5f667209 100644 --- a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/communication/responseFromRaire/GenerateAssertionsResponse.java +++ b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/communication/responseFromRaire/GenerateAssertionsResponse.java @@ -21,25 +21,29 @@ package au.org.democracydevelopers.corla.communication.responseFromRaire; -import java.util.Objects; /** - * The success response when a ContestRequest is sent to raire's generate-assertions endpoint. This - * simply returns the winner, as calculated by raire, along with the name of the contest for which - * the initial request was made. - * This record is identical to the record of the same name in raire-service. Used for + * The response when a ContestRequest is sent to raire's generate-assertions endpoint. + * This class is identical to the record of the same name in raire-service. Used for * deserialization. + * All four states of the two booleans are possible - for example, generation may succeed, but + * receive a TIME_OUT_TRIMMING_ASSERTIONS warning, in which case retry will be true. */ public final class GenerateAssertionsResponse { public String contestName; - public String winner; + public boolean succeeded; + public boolean retry; - /** - * @param contestName The name of the contest. - * @param winner The winner of the contest, as calculated by raire. - */ - public GenerateAssertionsResponse(String contestName, String winner) { + +/** + * All args constructor. + * @param contestName The name of the contest. + * @param succeeded Whether assertion generation succeeded. + * @param retry Whether it is worth retrying assertion generation. + */ + public GenerateAssertionsResponse(String contestName, boolean succeeded, boolean retry) { this.contestName = contestName; - this.winner = winner; + this.succeeded = succeeded; + this.retry = retry; } } \ No newline at end of file diff --git a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/communication/responseToColoradoRla/GenerateAssertionsResponseWithErrors.java b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/communication/responseToColoradoRla/GenerateAssertionsResponseWithErrors.java deleted file mode 100644 index c7334655..00000000 --- a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/communication/responseToColoradoRla/GenerateAssertionsResponseWithErrors.java +++ /dev/null @@ -1,50 +0,0 @@ -/* -Democracy Developers IRV extensions to colorado-rla. - -@copyright 2024 Colorado Department of State - -These IRV extensions are designed to connect to a running instance of the raire -service (https://github.com/DemocracyDevelopers/raire-service), in order to -generate assertions that can be audited using colorado-rla. - -The colorado-rla IRV extensions are free software: you can redistribute it and/or modify it under the terms -of the GNU Affero General Public License as published by the Free Software Foundation, either -version 3 of the License, or (at your option) any later version. - -The colorado-rla IRV extensions are distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; -without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -See the GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License along with -raire-service. If not, see . -*/ - -package au.org.democracydevelopers.corla.communication.responseToColoradoRla; - -import java.beans.ConstructorProperties; - -/** - * The response to be sent to colorado-rla after a GenerateAssertionsRequest. - * These are identified by contest name. - * The success response is simply a winner and an empty error string. - * Error responses have some non-empty error and usually a winner set to "UNKNOWN". - * Note that errors may sometimes have a real winner, though they usually don't. For example, if trimming assertions - * times out, there will be both a statement of that error and a real winner. - */ -public final class GenerateAssertionsResponseWithErrors { - public final String contestName; - public final String winner; - public final String raireError; - - /** - * @param contestName The name of the contest. - * @param winner The winner of the contest, as calculated by raire. - * @param raireError The error message returned from raire. Empty if there was no error. - */ - @ConstructorProperties({"contestName", "winner", "raireError"}) - public GenerateAssertionsResponseWithErrors(String contestName, String winner, String raireError) { - this.contestName = contestName; - this.winner = winner; - this.raireError = raireError; - } -} \ No newline at end of file diff --git a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/GenerateAssertions.java b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/GenerateAssertions.java index ba884292..9d2d4f69 100644 --- a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/GenerateAssertions.java +++ b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/GenerateAssertions.java @@ -23,8 +23,6 @@ import au.org.democracydevelopers.corla.communication.requestToRaire.GenerateAssertionsRequest; import au.org.democracydevelopers.corla.communication.responseFromRaire.GenerateAssertionsResponse; -import au.org.democracydevelopers.corla.communication.responseFromRaire.RaireServiceErrors; -import au.org.democracydevelopers.corla.communication.responseToColoradoRla.GenerateAssertionsResponseWithErrors; import com.google.gson.JsonSyntaxException; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; @@ -120,7 +118,7 @@ public String endpointBody(final Request the_request, final Response the_respons final String prefix = "[endpointBody]"; LOGGER.debug(String.format("%s %s.", prefix, "Received Generate Assertions request")); - final List responseData; + final List responseData; final String raireUrl = Main.properties().getProperty(RAIRE_URL, "") + RAIRE_ENDPOINT; @@ -177,22 +175,20 @@ public String endpointBody(final Request the_request, final Response the_respons * - Gather all the IRVContestResults * - For each IRV contest, make a request to the raire-service get-assertions endpoint of the right format type * - Collate all the results into a list. + * * @param IRVContestResults the collection of all IRV ContestResults. * @param timeLimitSeconds the time limit for raire assertion generation, per contest. * @param raireUrl the url where the raire-service is running. */ - protected List generateAllAssertions(List IRVContestResults, - double timeLimitSeconds, String raireUrl) { + protected List generateAllAssertions(final List IRVContestResults, + final double timeLimitSeconds, final String raireUrl) { final String prefix = "[generateAllAssertions]"; LOGGER.debug(String.format("%s %s.", prefix, "Generating assertions for all IRV contests")); - final List responseData = new ArrayList<>(); - - // Iterate through all IRV Contests, sending a request to the raire-service for each one's assertions and - for (final ContestResult cr : IRVContestResults) { - GenerateAssertionsResponseWithErrors response = generateAssertionsUpdateWinners(IRVContestResults, cr.getContestName(), timeLimitSeconds, raireUrl); - responseData.add(response); - } + // Iterate through all IRV Contests, sending a request to the raire-service for each one's assertions + final List responseData = IRVContestResults.stream().map( + r -> generateAssertionsUpdateWinners(IRVContestResults, r.getContestName(), timeLimitSeconds, raireUrl) + ).toList(); LOGGER.debug(String.format("%s %s.", prefix, "Completed assertion generation for all IRV contests")); return responseData; @@ -202,21 +198,23 @@ protected List generateAllAssertions(List< * The main work of this endpoint - sends the appropriate request for a single contest, and * updates stored data with the result. There are two expected kinds of responses from raire: * - a success response with a winner, or - * - an INTERNAL_SERVER_ERROR response with a reason, e.g. TIED_WINNERS or NO_VOTES_PRESENT. - * These are expected to happen occasionally because of the data. + * - an error response with a reason, e.g. TIED_WINNERS or NO_VOTES_PRESENT. + * These are expected to happen occasionally because of the data, and are saved in the + * GenerateAssertionsSummary table. * Other errors, such as a BAD_REQUEST response or a failure to parse raire's response, indicate - * programming or configuration errors. These are logged. + * programming or configuration errors. These are logged and an exception is thrown. + * * @param IRVContestResults The list of all ContestResults for IRV contests. Note that these do * not have the correct winners or losers for IRV. * @param contestName The name of the contest. * @param timeLimitSeconds The time limit allowed for raire to compute the assertions (not * counting time taken to retrieve vote data from the database). * @param raireUrl The url of the raire service. - * @return The GenerateAssertionsResponseWithErrors, which usually contains a - * winner but may instead be UNKNOWN_WINNER and an error message. + * @return The GenerateAssertionsResponseWithErrors, which usually contains a + * winner but may instead be UNKNOWN_WINNER and an error message. */ - protected GenerateAssertionsResponseWithErrors generateAssertionsUpdateWinners(List IRVContestResults, - String contestName, double timeLimitSeconds, String raireUrl) { + protected GenerateAssertionsResponse generateAssertionsUpdateWinners(final List IRVContestResults, + final String contestName, final double timeLimitSeconds, final String raireUrl) { final String prefix = "[generateAssertionsUpdateWinners]"; LOGGER.debug(String.format("%s %s %s.", prefix, "Generating assertions for contest ", contestName)); @@ -248,30 +246,17 @@ protected GenerateAssertionsResponseWithErrors generateAssertionsUpdateWinners(L // Interpret the response. final int statusCode = raireResponse.getStatusLine().getStatusCode(); - final boolean gotRaireError = raireResponse.containsHeader(RaireServiceErrors.ERROR_CODE_KEY); - - if (statusCode == HttpStatus.SC_OK && !gotRaireError) { - // OK response. Return the winner. - - LOGGER.debug(String.format("%s %s %s.", prefix, "OK response received from RAIRE for", - contestName)); - GenerateAssertionsResponse responseFromRaire = Main.GSON.fromJson(EntityUtils.toString(raireResponse.getEntity()), - GenerateAssertionsResponse.class); - - LOGGER.debug(String.format("%s %s %s.", prefix, - "Completed assertion generation for contest", contestName)); - return new GenerateAssertionsResponseWithErrors(contestName, responseFromRaire.winner, ""); - } else if (statusCode == HttpStatus.SC_INTERNAL_SERVER_ERROR && gotRaireError) { - // Error response about a specific contest, e.g. "TIED_WINNERS". Return the error. + if (statusCode == HttpStatus.SC_OK) { - final String code = raireResponse.getFirstHeader(RaireServiceErrors.ERROR_CODE_KEY).getValue(); - LOGGER.debug(String.format("%s %s %s.", prefix, "Error response " + code, - "received from RAIRE for " + contestName)); + // OK response, which may indicate either that assertion generation succeeded, or that it + // failed and raire generated a useful error. Return raire's response. + final GenerateAssertionsResponse responseFromRaire + = Main.GSON.fromJson(EntityUtils.toString(raireResponse.getEntity()), GenerateAssertionsResponse.class); - LOGGER.debug(String.format("%s %s %s.", prefix, - "Error response for assertion generation for contest ", contestName)); - return new GenerateAssertionsResponseWithErrors(cr.getContestName(), UNKNOWN_WINNER, code); + LOGGER.debug(String.format("%s %s %s %s.", prefix, responseFromRaire.succeeded ? "Success" : "Failure", + "response for raire assertion generation for contest", contestName)); + return responseFromRaire; } else { // Something went wrong with the connection, e.g. 404 or a Bad Request. Cannot continue. @@ -280,12 +265,12 @@ protected GenerateAssertionsResponseWithErrors generateAssertionsUpdateWinners(L LOGGER.error(String.format("%s %s", prefix, msg)); throw new RuntimeException(msg); } - } catch (URISyntaxException | MalformedURLException e) { + } catch (final URISyntaxException | MalformedURLException e) { // The raire service url is malformed, probably a config error. final String msg = "Bad configuration of Raire service url: " + raireUrl + ". Check your config file."; LOGGER.error(String.format("%s %s %s", prefix, msg, e.getMessage())); throw new RuntimeException(msg); - } catch (NoSuchElementException e ) { + } catch (final NoSuchElementException e) { // This happens if the contest name is not in the IRVContestResults, or if the Contest Result // does not actually contain any contests (the latter should never happen). LOGGER.error(String.format("%s %s %s.", prefix, e.getMessage(), contestName)); @@ -296,25 +281,25 @@ protected GenerateAssertionsResponseWithErrors generateAssertionsUpdateWinners(L final String msg = "Error interpreting Raire response for contest "; LOGGER.error(String.format("%s %s %s %s", prefix, msg, contestName, e.getMessage())); throw new RuntimeException(msg + contestName); - } catch (UnsupportedEncodingException e) { + } catch (final UnsupportedEncodingException e) { // This really shouldn't happen, but would happen if the effort to make the // generateAssertionsRequest as json failed. final String msg = "Error generating request to Raire for contest "; LOGGER.error(String.format("%s %s %s %s", prefix, msg, contestName, e.getMessage())); throw new RuntimeException(msg + contestName + e.getMessage()); - } catch (ClientProtocolException e) { + } catch (final ClientProtocolException e) { // This also really shouldn't happen, but would happen if the effort to use the httpClient // to send a message threw an exception. final String msg = "Error sending request to Raire for contest "; LOGGER.error(String.format("%s %s %s %s", prefix, msg, contestName, e.getMessage())); throw new RuntimeException(msg + contestName + e.getMessage()); - } catch (NullPointerException e) { + } catch (final NullPointerException e) { // This also shouldn't happen - it would indicate an unexpected problem such as the httpClient // returning a null response. final String msg = "Error requesting or receiving assertions for contest "; LOGGER.error(String.format("%s %s %s.", prefix, msg, contestName)); throw new RuntimeException(msg + contestName); - } catch (IOException e) { + } catch (final IOException e) { // Generic error that can be thrown by the httpClient if the connection attempt fails. final String msg = "I/O error during generate assertions attempt for contest "; LOGGER.error(String.format("%s %s %s %s", prefix, msg, contestName, e.getMessage())); @@ -338,11 +323,11 @@ protected boolean validateParameters(final Request the_request) { if (timeLimit != null && Double.parseDouble(timeLimit) <= 0) { return false; } - } catch (NumberFormatException e) { + } catch (final NumberFormatException e) { return false; } - // An absent contest name is fine, but a present null or blank one is invalid. + // An absent contest name is OK, but a present null or blank one is invalid. final String contestName = the_request.queryParams(CONTEST_NAME); return contestName == null || !contestName.isEmpty(); } diff --git a/server/eclipse-project/src/test/java/au/org/democracydevelopers/corla/endpoint/GenerateAssertionsTests.java b/server/eclipse-project/src/test/java/au/org/democracydevelopers/corla/endpoint/GenerateAssertionsTests.java index d14e4158..6ccdb684 100644 --- a/server/eclipse-project/src/test/java/au/org/democracydevelopers/corla/endpoint/GenerateAssertionsTests.java +++ b/server/eclipse-project/src/test/java/au/org/democracydevelopers/corla/endpoint/GenerateAssertionsTests.java @@ -23,8 +23,6 @@ import au.org.democracydevelopers.corla.communication.requestToRaire.GenerateAssertionsRequest; import au.org.democracydevelopers.corla.communication.responseFromRaire.GenerateAssertionsResponse; -import au.org.democracydevelopers.corla.communication.responseFromRaire.RaireServiceErrors; -import au.org.democracydevelopers.corla.communication.responseToColoradoRla.GenerateAssertionsResponseWithErrors; import static au.org.democracydevelopers.corla.endpoint.GenerateAssertions.UNKNOWN_WINNER; import static au.org.democracydevelopers.corla.util.testUtils.*; @@ -50,6 +48,8 @@ import static com.github.tomakehurst.wiremock.client.WireMock.*; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.assertFalse; /** * Test the GetAssertions endpoint, both CSV and JSON versions. The response is supposed to be a zip file containing @@ -81,13 +81,19 @@ public class GenerateAssertionsTests extends TestClassWithDatabase { * Mock response for Boulder Mayoral '23 */ private final static GenerateAssertionsResponse boulderResponse - = new GenerateAssertionsResponse(boulderMayoral, "Aaron Brockett"); + = new GenerateAssertionsResponse(boulderMayoral, true, false); /** * Mock response for tinyExample1 contest */ private final static GenerateAssertionsResponse tinyIRVResponse - = new GenerateAssertionsResponse(tinyIRV, "Alice"); + = new GenerateAssertionsResponse(tinyIRV, true, false); + + /** + * Mock response for tiedIRV contest + */ + private final static GenerateAssertionsResponse tiedIRVResponse + = new GenerateAssertionsResponse(tiedIRV, false, false); /** * Request for Boulder Mayoral '23 @@ -181,7 +187,8 @@ public void initMocks() { tiedIRVContestResult.addContests(Set.of(tiedIRVContest)); // Default raire server. You can instead run the real raire service and set baseUrl accordingly, - // though the tests of invalid/uninterpretable data will fail. + // though the tests of invalid/uninterpretable data will fail, and of course you have to have + // appropriate contests in the database. wireMockRaireServer.start(); baseUrl = wireMockRaireServer.baseUrl(); String badUrl = baseUrl + badEndpoint; @@ -200,14 +207,13 @@ public void initMocks() { .withStatus(HttpStatus.SC_OK) .withHeader("Content-Type", "application/json") .withBody(gson.toJson(tinyIRVResponse)))); - // Mock a TIED_WINNERS response to the tiedIRV contest. + // Mock failed, don't redo response to the tiedIRV contest. stubFor(post(urlEqualTo(raireGenerateAssertionsEndpoint)) .withRequestBody(equalToJson(gson.toJson(tiedIRVRequest))) .willReturn(aResponse() - .withStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .withStatus(HttpStatus.SC_OK) .withHeader("Content-Type", "application/json") - .withHeader(RaireServiceErrors.ERROR_CODE_KEY, - RaireServiceErrors.RaireErrorCodes.TIED_WINNERS.toString()))); + .withBody(gson.toJson(tiedIRVResponse)))); // Mock a 404 for badUrl. stubFor(post(urlEqualTo(badUrl)) .withRequestBody(equalToJson(gson.toJson(tinyIRVRequest))) @@ -221,7 +227,6 @@ public void initMocks() { .withStatus(HttpStatus.SC_OK) .withHeader("Content-Type", "application/json") .withBody(gson.toJson(tinyIRVCandidates)))); - // Mock an OK response with invalid json. stubFor(post(urlEqualTo(invalidResponseEndpoint)) .withRequestBody(equalToJson(gson.toJson(tinyIRVRequest))) @@ -239,57 +244,60 @@ public void closeMocks() { /** * Calls the single-contest version of the endpoint for the boulder Mayor '23 example, checks - * for the right winner. + * that generation succeeded and does not recommend retry. */ @Test public void rightBoulderIRVWinner() { testUtils.log(LOGGER, "rightBoulderIRVWinner"); GenerateAssertions endpoint = new GenerateAssertions(); - GenerateAssertionsResponseWithErrors result = endpoint.generateAssertionsUpdateWinners( + GenerateAssertionsResponse result = endpoint.generateAssertionsUpdateWinners( mockedIRVContestResults, boulderRequest.contestName, boulderRequest.timeLimitSeconds, baseUrl + raireGenerateAssertionsEndpoint); assertEquals(result.contestName, boulderMayoral); - assertEquals(result.winner, "Aaron Brockett"); + assertTrue(result.succeeded); + assertFalse(result.retry); } /** - * Calls the single-contest version of the endpoint for the tied contests, checks that the - * winner is unknown and the TIED_WINNERS error is returned. + * Calls the single-contest version of the endpoint for the tied contests, checks that + * assertion generation fails and does not recommend retry. */ @Test - public void tiedWinnersCorrectlyRecorded() { - testUtils.log(LOGGER, "tiedWinnersCorrectlyRecorded"); + public void tiedWinnersFailsNoRetry() { + testUtils.log(LOGGER, "tiedWinnersFailsNoRetry"); GenerateAssertions endpoint = new GenerateAssertions(); - GenerateAssertionsResponseWithErrors result = endpoint.generateAssertionsUpdateWinners( + GenerateAssertionsResponse result = endpoint.generateAssertionsUpdateWinners( List.of(tiedIRVContestResult), tiedIRV, tiedIRVRequest.timeLimitSeconds, baseUrl + raireGenerateAssertionsEndpoint); assertEquals(result.contestName, tiedIRV); - assertEquals(result.winner, UNKNOWN_WINNER); - assertEquals(result.raireError ,RaireServiceErrors.RaireErrorCodes.TIED_WINNERS.toString()); + assertFalse(result.succeeded); + assertFalse(result.retry); } /** - * Calls the generateAssertions main endpoint function and checks that the right winners are - * returned, for the two example contests (Boulder and TinyIRV). + * Calls the generateAssertions main endpoint function for the two example contests (Boulder and TinyIRV). + * and checks that both succeed and do not recommend retry. */ @Test - public void rightWinners() { - testUtils.log(LOGGER, "rightWinners"); + public void successAsExpected() { + testUtils.log(LOGGER, "successAsExpected"); GenerateAssertions endpoint = new GenerateAssertions(); - List results + List results = endpoint.generateAllAssertions(mockedIRVContestResults, boulderRequest.timeLimitSeconds, baseUrl + raireGenerateAssertionsEndpoint); assertEquals(results.size(), 2); assertEquals(results.get(0).contestName, boulderMayoral); - assertEquals(results.get(0).winner, "Aaron Brockett"); + assertTrue(results.get(0).succeeded); + assertFalse(results.get(0).retry); assertEquals(results.get(1).contestName, tinyIRV); - assertEquals(results.get(1).winner, "Alice"); + assertTrue(results.get(1).succeeded); + assertFalse(results.get(1).retry); } /**