diff --git a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/AbstractAllIrvEndpoint.java b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/AbstractAllIrvEndpoint.java index b11a84c0..aecc7008 100644 --- a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/AbstractAllIrvEndpoint.java +++ b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/AbstractAllIrvEndpoint.java @@ -55,11 +55,6 @@ public abstract class AbstractAllIrvEndpoint extends AbstractDoSDashboardEndpoin */ protected static final String RAIRE_URL = "raire_url"; - /** - * RAIRE error code key. - */ - protected static final String RAIRE_ERROR_CODE = "error_code"; - /** * RAIRE service endpoint name. */ 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 d183e2e1..13d347d2 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,6 +23,7 @@ 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; @@ -48,20 +49,20 @@ import java.util.stream.Collectors; /** - * The Generate Assertions endpoint. Takes a GenerateAssertionsRequest, and optional parameters specifying a contest - * and time limit. - * If no parameters are specified, it generates assertions for all IRV contests with a default time limit. + * The Generate Assertions endpoint. Takes a GenerateAssertionsRequest, and optional parameters + * specifying a contest and time limit. + * If no parameters are specified, it generates assertions for all IRV contests with a default time + * limit. * If a contest is specified, it generates assertions only for that contest. * If a time limit is specified, it uses that instead of the default. - * Returns a list of all responses, which are the contest name together with the winner (if one was returned) and an - * error (if there was one). + * Returns a list of all responses, which are the contest name together with the winner (if one was + * returned) or an error (if there was one). * For example, hitting /generate-assertions?contest="Boulder Mayoral",timeLimitSeconds=5 - * will, if successful, produce a singleton list with a GenerateAssertionsResponseWithErrors containing "Boulder Mayoral" - * and the winner. - * Hitting /generate-assertions with no parameters will produce a list of GenerateAssertionsResponseWithErrors, one for - * each IRV contest, each containing a nonempty winner or a nonempty error (in some cases, there may be both a winner - * and an error/warning). - * If the raire service endpoint returns a 4xx error, this throws a RuntimeException. + * will, if successful, produce a singleton list with a GenerateAssertionsResponseWithErrors + * containing "Boulder Mayoral" and the winner. + * Hitting /generate-assertions with no parameters will produce a list of + * GenerateAssertionsResponseWithErrors, one for each IRV contest, each containing a nonempty winner + * or a nonempty error. */ public class GenerateAssertions extends AbstractAllIrvEndpoint { @@ -88,7 +89,7 @@ public class GenerateAssertions extends AbstractAllIrvEndpoint { /** * Default winner to be used in the case where winner is unknown. */ - private static final String UNKNOWN_WINNER = "Unknown"; + protected static final String UNKNOWN_WINNER = "Unknown"; /** * {@inheritDoc} @@ -167,8 +168,7 @@ public String endpointBody(final Request the_request, final Response the_respons * Do the actual work of getting the assertions. * - 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 zip - * + * - 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. @@ -190,6 +190,23 @@ protected List generateAllAssertions(List< return responseData; } + /** + * 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. + * Other errors, such as a BAD_REQUEST response or a failure to parse raire's response, indicate + * programming or configuration errors. These are logged. + * @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. + */ protected GenerateAssertionsResponseWithErrors generateAssertionsUpdateWinners(List IRVContestResults, String contestName, double timeLimitSeconds, String raireUrl) { final String prefix = "[generateAssertions]"; @@ -221,8 +238,9 @@ 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) { + if (statusCode == HttpStatus.SC_OK && !gotRaireError) { // OK response. Update the stored winner and return it. LOGGER.debug(String.format("%s %s.", prefix, "OK response received from RAIRE for " @@ -236,11 +254,11 @@ protected GenerateAssertionsResponseWithErrors generateAssertionsUpdateWinners(L "Completed assertion generation for contest ", contestName)); return new GenerateAssertionsResponseWithErrors(contestName, responseFromRaire.winner, ""); - } else if (raireResponse.containsHeader(RAIRE_ERROR_CODE)) { + } else if (statusCode == HttpStatus.SC_INTERNAL_SERVER_ERROR && gotRaireError) { // Error response about a specific contest, e.g. "TIED_WINNERS". // Return the error, record it. - final String code = raireResponse.getFirstHeader(RAIRE_ERROR_CODE).getValue(); + 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)); @@ -251,7 +269,7 @@ protected GenerateAssertionsResponseWithErrors generateAssertionsUpdateWinners(L return new GenerateAssertionsResponseWithErrors(cr.getContestName(), UNKNOWN_WINNER, code); } else { - // Something went wrong with the connection, e.g. 404. Cannot continue. + // Something went wrong with the connection, e.g. 404 or a Bad Request. Cannot continue. final String msg = "Connection failure with Raire service. Http code " + statusCode + ". Check the configuration of Raire service url."; LOGGER.error(String.format("%s %s", prefix, msg)); diff --git a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/GetAssertions.java b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/GetAssertions.java index 06cae81f..09809348 100644 --- a/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/GetAssertions.java +++ b/server/eclipse-project/src/main/java/au/org/democracydevelopers/corla/endpoint/GetAssertions.java @@ -32,6 +32,7 @@ import java.util.zip.ZipOutputStream; import au.org.democracydevelopers.corla.communication.requestToRaire.GetAssertionsRequest; +import au.org.democracydevelopers.corla.communication.responseFromRaire.RaireServiceErrors; import org.apache.commons.io.IOUtils; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; @@ -200,11 +201,11 @@ public void getAssertions(final ZipOutputStream zos, final BigDecimal riskLimit, + getAssertionsRequest.contestName)); IOUtils.copy(raireResponse.getEntity().getContent(), zos); - } else if(raireResponse.containsHeader(RAIRE_ERROR_CODE)) { + } else if(raireResponse.containsHeader(RaireServiceErrors.ERROR_CODE_KEY)) { // Error response about a specific contest, e.g. "NO_ASSERTIONS_PRESENT". // Write the error into the zip file and continue. - final String code = raireResponse.getFirstHeader(RAIRE_ERROR_CODE).getValue(); + 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 " + getAssertionsRequest.contestName)); zos.write(code.getBytes(StandardCharsets.UTF_8)); 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 045841db..e8e29984 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,7 +23,10 @@ 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.*; import au.org.democracydevelopers.corla.util.testUtils; @@ -31,7 +34,6 @@ import org.apache.http.HttpStatus; import org.apache.log4j.LogManager; import org.apache.log4j.Logger; -import org.mockito.MockitoAnnotations; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; @@ -89,6 +91,13 @@ public class GenerateAssertionsTests { = new GenerateAssertionsRequest(tinyIRV, tinyIRVCount, 5, tinyIRVCandidates.stream().map(Choice::name).toList()); + /** + * Request for tiedIRV contest. This has the same candidates and ballot count as tinyIRV. + */ + private final static GenerateAssertionsRequest tiedIRVRequest + = new GenerateAssertionsRequest(tiedIRV, tinyIRVCount, 5, + tinyIRVCandidates.stream().map(Choice::name).toList()); + /** * Corla endpoint to be tested. */ @@ -138,7 +147,6 @@ public class GenerateAssertionsTests { */ @BeforeClass public void initMocks() { - MockitoAnnotations.openMocks(this); boulderIRVContestResult.setAuditReason(AuditReason.COUNTY_WIDE_CONTEST); boulderIRVContestResult.setBallotCount((long) bouldMayoralCount); @@ -150,6 +158,11 @@ public void initMocks() { tinyIRVContestResult.setWinners(Set.of("Alice")); tinyIRVContestResult.addContests(Set.of(tinyIRVExample)); + tiedIRVContestResult.setAuditReason(AuditReason.COUNTY_WIDE_CONTEST); + tiedIRVContestResult.setBallotCount((long) tinyIRVCount); + tiedIRVContestResult.setWinners(Set.of(UNKNOWN_WINNER)); + 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. wireMockRaireServer.start(); @@ -170,6 +183,14 @@ 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. + stubFor(post(urlEqualTo(raireGenerateAssertionsEndpoint)) + .withRequestBody(equalToJson(gson.toJson(tiedIRVRequest))) + .willReturn(aResponse() + .withStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .withHeader("Content-Type", "application/json") + .withHeader(RaireServiceErrors.ERROR_CODE_KEY, + RaireServiceErrors.RaireErrorCodes.TIED_WINNERS.toString()))); // Mock a 404 for badUrl. stubFor(post(urlEqualTo(badUrl)) .withRequestBody(equalToJson(gson.toJson(tinyIRVRequest))) @@ -198,7 +219,7 @@ public void closeMocks() { } /** - * Calls the single-contest version of the endpoint for the boulder Mayor '23 example , checks + * Calls the single-contest version of the endpoint for the boulder Mayor '23 example, checks * for the right winner. */ @Test @@ -206,13 +227,30 @@ public void rightBoulderIRVWinner() { testUtils.log(LOGGER, "rightBoulderIRVWinner"); GenerateAssertionsResponseWithErrors result = endpoint.generateAssertionsUpdateWinners( - mockedIRVContestResults, boulderRequest.contestName, boulderRequest.timeLimitSeconds, + mockedIRVContestResults, boulderRequest.contestName, tiedIRVRequest.timeLimitSeconds, baseUrl + raireGenerateAssertionsEndpoint); assertEquals(result.contestName, boulderMayoral); assertEquals(result.winner, "Aaron Brockett"); } + /** + * 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. + */ + @Test + public void tiedWinnersCorrectlyRecorded() { + testUtils.log(LOGGER, "tiedWinnersCorrectlyRecorded"); + + GenerateAssertionsResponseWithErrors 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()); + } + /** * Calls the generateAssertions main endpoint function and checks that the right winners are * returned, for the two example contests (Boulder and TinyIRV). @@ -221,7 +259,8 @@ public void rightBoulderIRVWinner() { public void rightWinners() { testUtils.log(LOGGER, "rightWinners"); - List results = endpoint.generateAllAssertions(mockedIRVContestResults, 5, + List results + = endpoint.generateAllAssertions(mockedIRVContestResults, boulderRequest.timeLimitSeconds, baseUrl + raireGenerateAssertionsEndpoint); assertEquals(results.size(), 2); diff --git a/server/eclipse-project/src/test/java/au/org/democracydevelopers/corla/util/testUtils.java b/server/eclipse-project/src/test/java/au/org/democracydevelopers/corla/util/testUtils.java index f580f218..f4d42590 100644 --- a/server/eclipse-project/src/test/java/au/org/democracydevelopers/corla/util/testUtils.java +++ b/server/eclipse-project/src/test/java/au/org/democracydevelopers/corla/util/testUtils.java @@ -49,6 +49,11 @@ public class testUtils { */ public final static String tinyIRV = "TinyExample1"; + /** + * Tied contest for mocking. + */ + public final static String tiedIRV = "TiedIRV"; + /** * Count of the universe size for TinyExample1 */ @@ -75,12 +80,27 @@ public class testUtils { new Choice("Paul Tweedlie", "", false, false) ); + /** + * Contest and ContestResult for tiny IRV example. + */ public final static Contest tinyIRVExample = new Contest(tinyIRV, new County("Arapahoe", 3L), ContestType.IRV.toString(), tinyIRVCandidates, 3, 1, 0); public final static ContestResult tinyIRVContestResult = new ContestResult(tinyIRV); + + /** + * Contest and ContestResult for Boulder Mayoral '23 example. + */ public final static Contest boulderMayoralContest = new Contest(boulderMayoral, new County("Boulder", 7L), ContestType.IRV.toString(), boulderMayoralCandidates, 4, 1, 0); public final static ContestResult boulderIRVContestResult = new ContestResult(boulderMayoral); + + /** + * Contest and ContestResult for tied example. + */ + public final static Contest tiedIRVContest = new Contest( tiedIRV, new County("Ouray", 46L), ContestType.IRV.toString(), + tinyIRVCandidates, 3, 1, 0); + public final static ContestResult tiedIRVContestResult = new ContestResult(tiedIRV); + public final static List mockedIRVContestResults = List.of(boulderIRVContestResult, tinyIRVContestResult); /**