Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"build:prod": "vite build && cp src/api/config.prod.php dist/api/config.php",
"preview": "vite preview",
"test": "vitest run && ./vendor/bin/phpunit",
"test:api-contract": "vitest run --config vitest.contract.config.js",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:setup-db": "node test/e2e/db.mjs setup",
Expand Down
2 changes: 1 addition & 1 deletion src/api/delete-votes.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

$ballotId = $_POST['id'];
$createdBy = $_POST['createdBy'];
$voteId = $_POST['voteId'];
$voteId = $_POST['voteId'] ?? null;

if(!empty($ballotId)) {
$query = "
Expand Down
2 changes: 1 addition & 1 deletion src/api/get-candidates.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
require_once("config.php");

$key = $_GET['key'];
$key = $_GET['key'] ?? null;
$edit = $_GET['edit'] ?? false;
if(!empty($key)) {
$sth = $dbh->prepare("SET time_zone = '+0:00'");
Expand Down
4 changes: 2 additions & 2 deletions src/api/get-votes.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
require_once("config.php");

$key = $_GET['key'];
$key = $_GET['key'] ?? null;

if(!empty($key)) {
$sth = $dbh->prepare("SET time_zone = '+0:00'");
Expand Down Expand Up @@ -42,7 +42,7 @@
echo "No one has voted yet on this ballot.";
} else {
// Fetch entries
$entrySth = $dbh->prepare("SELECT entry_id, name, image, color, hyperlink FROM entries WHERE ballotId = :ballotId");
$entrySth = $dbh->prepare("SELECT entry_id, name, image, color, hyperlink FROM entries WHERE ballotId = :ballotId ORDER BY entry_id ASC");
$entrySth->bindValue(':ballotId', $ballotId, PDO::PARAM_INT);
$entrySth->execute();
$entries = $entrySth->fetchAll(PDO::FETCH_ASSOC);
Expand Down
4 changes: 2 additions & 2 deletions src/api/new-ballot.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
$codeCount = min(intval($_POST['codeCount']), 500);

if (empty($_POST['maxVotes']))
$maxVotes = "NULL";
$maxVotes = 0;
else
$maxVotes = intval($_POST['maxVotes']);

Expand Down Expand Up @@ -125,7 +125,7 @@
$sth->bindValue(':hideNames', $hideNames, PDO::PARAM_INT);
$sth->bindValue(':hideDetails', $hideDetails, PDO::PARAM_INT);
$sth->bindValue(':showGraph', $showGraph, PDO::PARAM_INT);
$sth->bindValue(':maxVotes', $maxVotes, ($maxVotes === "NULL" ? PDO::PARAM_NULL : PDO::PARAM_INT));
$sth->bindValue(':maxVotes', $maxVotes, PDO::PARAM_INT);
$sth->bindValue(':kickbackUrl', $kickbackUrl, ($kickbackUrl === null ? PDO::PARAM_NULL : PDO::PARAM_STR));
$sth->bindValue(':iframeUrl', $iframeUrl, ($iframeUrl === null ? PDO::PARAM_NULL : PDO::PARAM_STR));
$sth->bindValue(':oneDeviceOneVote', $oneDeviceOneVote, PDO::PARAM_INT);
Expand Down
24 changes: 12 additions & 12 deletions src/api/vote.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
$data = array();
// Getting posted data and decoding json
$_POST = json_decode(file_get_contents('php://input'), true);
$key = $_POST['key'];
$id = $_POST['id'];
$key = $_POST['key'] ?? null;
$id = $_POST['id'] ?? null;

// checking for blank values.
if (empty($key))
Expand All @@ -23,16 +23,6 @@
$name = ",'" . substr(preg_replace(array('/[^a-zA-Z0-9-]/', '/ +/', '/^-|-$/'), array(' ', ' ', ''), $_POST['name']), 0, 40) . "'";
}

if (empty($id)) {
// Need to fetch ballot id from key using secure parameterized query
$subQuery = "SELECT id FROM ballots WHERE `key` = :key";
$subSth = $dbh->prepare($subQuery);
$subSth->bindValue(':key', $key, PDO::PARAM_STR);
$subSth->execute();
$result = $subSth->fetch(PDO::FETCH_ASSOC);
$id = $result['id'];
}

$fingerprint = isset($_POST['fingerprint']) ? substr($_POST['fingerprint'], 0, 64) : '';
$userId = isset($_POST['userId']) ? $_POST['userId'] : '';

Expand All @@ -41,6 +31,16 @@
$data['post'] = $_POST;
echo json_encode($data);
} else {
if (empty($id)) {
// Need to fetch ballot id from key using secure parameterized query
$subQuery = "SELECT id FROM ballots WHERE `key` = :key";
$subSth = $dbh->prepare($subQuery);
$subSth->bindValue(':key', $key, PDO::PARAM_STR);
$subSth->execute();
$result = $subSth->fetch(PDO::FETCH_ASSOC);
$id = $result['id'] ?? null;
}

// Fetch ballot settings
$ballotQuery = $dbh->prepare("SELECT oneDeviceOneVote, isSecure, createdBy, voteCutoff FROM ballots WHERE id = :id");
$ballotQuery->bindValue(':id', $id, PDO::PARAM_INT);
Expand Down
39 changes: 39 additions & 0 deletions test/contract/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Live API Contract Tests

These tests hit a real PHP dev server and real MySQL-backed API to catch
contract drift between the isolated PHPUnit SQLite coverage and the live local
stack.

## Run

1. Start the PHP backend:

```bash
cd src
php -S localhost:2461
```

2. In another terminal, run:

```bash
npm run test:api-contract
```

If your server is on a different URL, set `RCV_API_BASE_URL`, for example:

```bash
RCV_API_BASE_URL=http://127.0.0.1:3001/api npm run test:api-contract
```

## Scope

The suite currently verifies a small but high-signal flow:

- `new-ballot.php` success and validation errors
- `add-entries.php` success
- `get-candidates.php` success and missing-key plain-text error
- `vote.php` success and validation error envelope
- `get-votes.php` success after casting a real vote

Each test run creates a unique ballot through the public API and then attempts
to delete its votes and ballot during cleanup.
217 changes: 217 additions & 0 deletions test/contract/live-php-api.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// @vitest-environment node

import { afterEach, beforeAll, describe, expect, it } from 'vitest';

const apiBaseUrl = process.env.RCV_API_BASE_URL || 'http://127.0.0.1:2461/api';
const createdBallots = [];

function endpointUrl(endpoint, query = {}) {
const url = new URL(`${apiBaseUrl}/${endpoint}`);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
return url;
}

async function requestApi(method, endpoint, { query, body } = {}) {
const response = await fetch(endpointUrl(endpoint, query), {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});

const text = await response.text();
let json = null;

try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}

return {
status: response.status,
ok: response.ok,
text,
json
};
}

async function createBallot() {
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const key = `contract-${uniqueId}`;
const createdBy = `contract-test-${uniqueId}`;
const ballotName = `Contract Test ${uniqueId}`;

const newBallot = await requestApi('POST', 'new-ballot.php', {
body: {
name: ballotName,
key,
positions: '1',
createdBy,
sqlVoteCutoff: '2099-12-31 23:59:59',
sqlResultsRelease: '2099-12-31 23:59:59'
}
});

expect(newBallot.text).toMatch(/^\d+$/);

const ballotId = Number(newBallot.text);
createdBallots.push({ ballotId, createdBy });

const addEntries = await requestApi('POST', 'add-entries.php', {
body: {
ballotId,
entries: ['Alpha', 'Beta', 'Gamma'],
images: ['', '', ''],
hyperlinks: ['', '', ''],
colors: ['', '', '']
}
});

expect(addEntries.text).toBe('Success');

return {
ballotId,
ballotName,
createdBy,
key
};
}

async function cleanupBallot({ ballotId, createdBy }) {
await requestApi('POST', 'delete-votes.php', {
body: {
id: ballotId,
createdBy
}
});

await requestApi('POST', 'delete-ballot.php', {
body: {
id: ballotId,
createdBy
}
});
}

beforeAll(async () => {
try {
const response = await requestApi('GET', 'get-candidates.php');
if (response.status >= 500) {
throw new Error(`server responded with HTTP ${response.status}`);
}
} catch (error) {
throw new Error(
`Live API contract tests require a running PHP dev server at ${apiBaseUrl}. ` +
`Start it with "cd src && php -S localhost:2461" or set RCV_API_BASE_URL.\n` +
`Reachability check failed: ${error.message}`
);
}
});

afterEach(async () => {
while (createdBallots.length > 0) {
const ballot = createdBallots.pop();
await cleanupBallot(ballot);
}
});

describe('live PHP API contracts', () => {
it('creates a ballot, returns candidates, records a vote, and returns results', async () => {
const ballot = await createBallot();

const candidates = await requestApi('GET', 'get-candidates.php', {
query: { key: ballot.key }
});

expect(candidates.json).toMatchObject({
ballot: expect.objectContaining({
id: ballot.ballotId,
key: ballot.key,
name: ballot.ballotName,
positions: '1'
}),
candidates: expect.arrayContaining([
expect.objectContaining({ candidate: 'Alpha' }),
expect.objectContaining({ candidate: 'Beta' }),
expect.objectContaining({ candidate: 'Gamma' })
]),
groupFields: []
});

const candidateIds = candidates.json.candidates.map((candidate) => candidate.entry_id);

const vote = await requestApi('POST', 'vote.php', {
body: {
key: ballot.key,
id: ballot.ballotId,
vote: 'Alpha,Beta,Gamma',
voteIds: candidateIds.join(','),
name: 'Contract Voter'
}
});

expect(vote.text).toBe('');

const votes = await requestApi('GET', 'get-votes.php', {
query: { key: ballot.key }
});

expect(votes.json).toMatchObject({
ballot: expect.objectContaining({
id: ballot.ballotId,
ballotName: ballot.ballotName,
createdBy: ballot.createdBy
}),
entries: expect.arrayContaining([
expect.objectContaining({ name: 'Alpha' }),
expect.objectContaining({ name: 'Beta' }),
expect.objectContaining({ name: 'Gamma' })
]),
votes: expect.arrayContaining([
expect.objectContaining({
voteIds: candidateIds.join(','),
name: 'Contract Voter'
})
]),
groupFields: []
});
});

it('returns the expected validation envelope for new-ballot errors', async () => {
const response = await requestApi('POST', 'new-ballot.php', {
body: {}
});

expect(response.json).toEqual({
errors: {
name: 'Name is required.',
key: 'Key is required.',
positions: 'Positions is required.',
createdBy: 'Created By is required.'
},
post: []
});
});

it('returns the expected plain-text and JSON error contracts for common failures', async () => {
const missingShortcode = await requestApi('GET', 'get-candidates.php');
expect(missingShortcode.text).toBe('Failed to supply Shortcode');

const missingVote = await requestApi('POST', 'vote.php', {
body: { key: 'missing-vote-key' }
});

expect(missingVote.json).toEqual({
errors: {
vote: 'Vote is required.'
},
post: {
key: 'missing-vote-key'
}
});
});
});
3 changes: 2 additions & 1 deletion vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['test/setup.js'],
include: ['test/**/*.test.js']
include: ['test/**/*.test.js'],
exclude: ['test/contract/**/*.test.js']
},
resolve: {
alias: {
Expand Down
8 changes: 8 additions & 0 deletions vitest.contract.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'node',
include: ['test/contract/**/*.test.js']
}
});
Loading