diff --git a/package.json b/package.json index a1cc6a6..26c5b25 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/delete-votes.php b/src/api/delete-votes.php index f9a7165..a3e27d0 100644 --- a/src/api/delete-votes.php +++ b/src/api/delete-votes.php @@ -5,7 +5,7 @@ $ballotId = $_POST['id']; $createdBy = $_POST['createdBy']; -$voteId = $_POST['voteId']; +$voteId = $_POST['voteId'] ?? null; if(!empty($ballotId)) { $query = " diff --git a/src/api/get-candidates.php b/src/api/get-candidates.php index 6583b54..dad42f3 100644 --- a/src/api/get-candidates.php +++ b/src/api/get-candidates.php @@ -1,7 +1,7 @@ prepare("SET time_zone = '+0:00'"); diff --git a/src/api/get-votes.php b/src/api/get-votes.php index 9b1d6e3..7e3b321 100644 --- a/src/api/get-votes.php +++ b/src/api/get-votes.php @@ -1,7 +1,7 @@ prepare("SET time_zone = '+0:00'"); @@ -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); diff --git a/src/api/new-ballot.php b/src/api/new-ballot.php index e00c035..da867f6 100644 --- a/src/api/new-ballot.php +++ b/src/api/new-ballot.php @@ -91,7 +91,7 @@ $codeCount = min(intval($_POST['codeCount']), 500); if (empty($_POST['maxVotes'])) - $maxVotes = "NULL"; + $maxVotes = 0; else $maxVotes = intval($_POST['maxVotes']); @@ -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); diff --git a/src/api/vote.php b/src/api/vote.php index f2c86a8..17052dc 100644 --- a/src/api/vote.php +++ b/src/api/vote.php @@ -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)) @@ -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'] : ''; @@ -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); diff --git a/test/contract/README.md b/test/contract/README.md new file mode 100644 index 0000000..db4b156 --- /dev/null +++ b/test/contract/README.md @@ -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. diff --git a/test/contract/live-php-api.test.js b/test/contract/live-php-api.test.js new file mode 100644 index 0000000..36456e4 --- /dev/null +++ b/test/contract/live-php-api.test.js @@ -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' + } + }); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 521dcaa..42d4756 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -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: { diff --git a/vitest.contract.config.js b/vitest.contract.config.js new file mode 100644 index 0000000..ae60b17 --- /dev/null +++ b/vitest.contract.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/contract/**/*.test.js'] + } +});