Skip to content

Commit

Permalink
Test for, and mock of, TIED_WINNERS error.
Browse files Browse the repository at this point in the history
  • Loading branch information
vteague committed Jun 28, 2024
1 parent 578ccbb commit 997de6e
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Expand All @@ -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}
Expand Down Expand Up @@ -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.
Expand All @@ -190,6 +190,23 @@ protected List<GenerateAssertionsResponseWithErrors> 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<ContestResult> IRVContestResults,
String contestName, double timeLimitSeconds, String raireUrl) {
final String prefix = "[generateAssertions]";
Expand Down Expand Up @@ -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 "
Expand All @@ -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));

Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,17 @@

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;

import com.google.gson.Gson;
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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -138,7 +147,6 @@ public class GenerateAssertionsTests {
*/
@BeforeClass
public void initMocks() {
MockitoAnnotations.openMocks(this);

boulderIRVContestResult.setAuditReason(AuditReason.COUNTY_WIDE_CONTEST);
boulderIRVContestResult.setBallotCount((long) bouldMayoralCount);
Expand All @@ -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();
Expand All @@ -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)))
Expand Down Expand Up @@ -198,21 +219,38 @@ 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
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).
Expand All @@ -221,7 +259,8 @@ public void rightBoulderIRVWinner() {
public void rightWinners() {
testUtils.log(LOGGER, "rightWinners");

List<GenerateAssertionsResponseWithErrors> results = endpoint.generateAllAssertions(mockedIRVContestResults, 5,
List<GenerateAssertionsResponseWithErrors> results
= endpoint.generateAllAssertions(mockedIRVContestResults, boulderRequest.timeLimitSeconds,
baseUrl + raireGenerateAssertionsEndpoint);

assertEquals(results.size(), 2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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<ContestResult> mockedIRVContestResults = List.of(boulderIRVContestResult, tinyIRVContestResult);

/**
Expand Down

0 comments on commit 997de6e

Please sign in to comment.