Game Features
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// Real-browser smoke test with Node's standard library and Chromium's CDP pipe.
|
||||
// Usage: node tests/browser.mjs build/workspace_debug/fpol_server
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawn, spawnSync} from 'node:child_process';
|
||||
import {mkdtempSync, mkdirSync, writeFileSync, rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
const root=path.resolve(import.meta.dirname,'..');
|
||||
const executable=path.resolve(process.argv[2]||'build/workspace_debug/fpol_server');
|
||||
const temporary=mkdtempSync(path.join(tmpdir(),'fpol-browser-'));
|
||||
const database=path.join(temporary,'fpol.sqlite');
|
||||
const password='browser test password';
|
||||
let server,browser;
|
||||
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
async function until(check,description){const deadline=Date.now()+15000;while(Date.now()<deadline){const result=await check();if(result)return result;await pause(50);}throw new Error(`Timed out: ${description}`);}
|
||||
function admin(args,input){const result=spawnSync(path.join(path.dirname(executable),'fpol_admin'),['--database',database,...args],{encoding:'utf8',input});assert.equal(result.status,0,result.stderr);return result.stdout;}
|
||||
try{
|
||||
admin(['import',path.join(root,'data/demo/catalog.json'),path.join(root,'data/demo/ratings.json')]);admin(['create-user','alice'],password+'\n');
|
||||
const listener=net.createServer();await new Promise((resolve,reject)=>{listener.once('error',reject);listener.listen(0,'127.0.0.1',resolve);});const port=listener.address().port;await new Promise(resolve=>listener.close(resolve));
|
||||
const origin=`http://127.0.0.1:${port}`;
|
||||
let serverLog='';server=spawn(executable,['--host','127.0.0.1','--port',String(port),'--database',database,'--origin',origin,'--allow-demo','--web-root',path.join(root,'frontend/dist')]);server.stderr.on('data',data=>{serverLog+=data;});server.stdout.on('data',data=>{serverLog+=data;});
|
||||
await until(()=>{if(server.exitCode!=null)throw new Error(serverLog);return serverLog.includes('Listening on');},'C++ server startup');
|
||||
const flags=['--headless','--disable-gpu','--disable-background-networking','--disable-component-update','--no-first-run','--remote-debugging-pipe',`--user-data-dir=${temporary}/chrome`];
|
||||
if(process.getuid?.()===0)flags.push('--no-sandbox');
|
||||
browser=spawn(process.env.FPOL_CHROMIUM||'chromium',flags,{stdio:['ignore','ignore','pipe','pipe','pipe']});let browserLog='';browser.stderr.on('data',data=>{browserLog+=data;});
|
||||
let counter=0,buffer='';const pending=new Map();
|
||||
browser.stdio[4].on('data',chunk=>{buffer+=chunk.toString();let index;while((index=buffer.indexOf('\0'))>=0){const value=JSON.parse(buffer.slice(0,index));buffer=buffer.slice(index+1);if(value.id&&pending.has(value.id)){const promise=pending.get(value.id);pending.delete(value.id);clearTimeout(promise.timer);value.error?promise.reject(new Error(JSON.stringify(value.error))):promise.resolve(value.result);}}});
|
||||
browser.on('exit',()=>{for(const promise of pending.values()){clearTimeout(promise.timer);promise.reject(new Error(`Chromium exited: ${browserLog}`));}pending.clear();});
|
||||
function cdp(method,params={},sessionId){return new Promise((resolve,reject)=>{const id=++counter;const timer=setTimeout(()=>{pending.delete(id);reject(new Error(`CDP timeout: ${method}. ${browserLog}`));},15000);pending.set(id,{resolve,reject,timer});browser.stdio[3].write(JSON.stringify({id,method,params,sessionId})+'\0');});}
|
||||
async function page(){const {browserContextId}=await cdp('Target.createBrowserContext');const {targetId}=await cdp('Target.createTarget',{url:'about:blank',browserContextId});const {sessionId}=await cdp('Target.attachToTarget',{targetId,flatten:true});await cdp('Page.enable',{},sessionId);await cdp('Runtime.enable',{},sessionId);
|
||||
async function evaluate(expression){const result=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true},sessionId);if(result.exceptionDetails)throw new Error(result.exceptionDetails.text+': '+result.exceptionDetails.exception?.description);return result.result.value;}
|
||||
return {sessionId,evaluate,go:async(hash='')=>{await cdp('Page.navigate',{url:origin+'/'+hash},sessionId);},wait:selector=>until(()=>evaluate(`!!document.querySelector(${JSON.stringify(selector)})`),selector),click:selector=>evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`),fill:(selector,value)=>evaluate(`document.querySelector(${JSON.stringify(selector)}).value=${JSON.stringify(value)}`),text:()=>evaluate('document.body.innerText')};
|
||||
}
|
||||
async function signIn(tab){await tab.wait('#signin');await tab.fill('#signin [name=username]','alice');await tab.fill('#signin [name=password]',password);await tab.click('#signin button');await tab.wait('a[href="#create"]');}
|
||||
const alice=await page();await alice.go();await signIn(alice);assert.match(await alice.text(),/My leagues/);
|
||||
await alice.click('a[href="#create"]');await alice.wait('#create');await alice.fill('[name=name]','Election friends');await alice.fill('[name=team_name]','The Night Owls');await alice.fill('[name=catalog_id]','demo-2026');await alice.click('#create button');await alice.wait('#invite');
|
||||
const league=await alice.evaluate('location.hash.split("/")[1]');await alice.click('#invite');await alice.wait('#invite-link input');const inviteUrl=await alice.evaluate('document.querySelector("#invite-link input").value');assert.match(inviteUrl,/#join\/[a-f0-9]+$/);
|
||||
const bob=await page();await bob.go(inviteUrl.split('/#')[1]? '#'+inviteUrl.split('/#')[1]:'');await bob.wait('#register');await bob.fill('#register [name=username]','bob');await bob.fill('#register [name=password]',password);await bob.fill('#register [name=team_name]','Late Calls');await bob.click('#register button');await bob.wait(`a[href="#league/${league}"]`);
|
||||
await alice.go(`#draft/${league}`);await alice.wait('#start');await until(()=>alice.evaluate('!document.querySelector("#start").disabled'),'draft readiness');await alice.click('#start');await alice.wait('#pause');await bob.go(`#draft/${league}`);await bob.wait('#clock');
|
||||
const active=await alice.evaluate('document.querySelector("#turn-label").innerText.includes("You’re on the clock")')?alice:bob;
|
||||
await until(()=>active.evaluate('!!document.querySelector(".pick-candidate:not([disabled])")'),'legal candidate buttons');await active.click('.queue-candidate:not([disabled])');await until(()=>active.evaluate('!!document.querySelector(".unqueue")'),'saved private queue');await active.click('.pick-candidate:not([disabled])');await until(()=>active.evaluate('document.querySelector("#recent").innerText.includes("house:")||document.querySelector("#recent").innerText.includes("governor:")||document.querySelector("#recent").innerText.includes("senate:")'),'recorded pick');
|
||||
const artifacts=path.join(root,'build/browser-artifacts');mkdirSync(artifacts,{recursive:true});await cdp('Emulation.setDeviceMetricsOverride',{width:1365,height:900,deviceScaleFactor:1,mobile:false},alice.sessionId);writeFileSync(path.join(artifacts,'draft-desktop.png'),Buffer.from((await cdp('Page.captureScreenshot',{format:'png'},alice.sessionId)).data,'base64'));
|
||||
await alice.go(`#standings/${league}`);await alice.wait('.standing');assert.match(await alice.text(),/Provisional/);await cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},alice.sessionId);assert.equal(await alice.evaluate('document.documentElement.scrollWidth <= 390'),true);writeFileSync(path.join(artifacts,'standings-mobile.png'),Buffer.from((await cdp('Page.captureScreenshot',{format:'png'},alice.sessionId)).data,'base64'));
|
||||
console.log('Browser smoke passed: accounts, invitations, leagues, queues, picks, standings, responsive layout.');
|
||||
}finally{browser?.kill('SIGTERM');server?.kill('SIGTERM');await pause(300);rmSync(temporary,{recursive:true,force:true});}
|
||||
+2
-2
@@ -5,7 +5,7 @@ import sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent
|
||||
status = 0
|
||||
for script, executable in (("test_core.py", sys.argv[1]), ("test_server.py", sys.argv[2])):
|
||||
result = subprocess.run([sys.executable, str(ROOT / script), executable], check=False)
|
||||
for script, arguments in (("test_core.py", [sys.argv[1]]), ("test_application.py", [sys.argv[1], sys.argv[3]]), ("test_server.py", [sys.argv[2]])):
|
||||
result = subprocess.run([sys.executable, str(ROOT / script), *arguments], check=False)
|
||||
status = status or result.returncode
|
||||
sys.exit(status)
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Real C++ service/SQLite integration without requiring a listening socket."""
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import json
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ADMIN = pathlib.Path(sys.argv.pop(1)).resolve()
|
||||
INGEST = pathlib.Path(sys.argv.pop(1)).resolve()
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
NOW = 2000000000
|
||||
PASSWORD = "a sufficiently long password"
|
||||
|
||||
class application_tests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.base = tempfile.TemporaryDirectory(prefix="fpol_accounts_")
|
||||
cls.base_database = pathlib.Path(cls.base.name) / "base.sqlite"
|
||||
cls.identities = {}
|
||||
def command(*args, stdin=None):
|
||||
run = subprocess.run([str(ADMIN), "--database", str(cls.base_database), "--allow-demo", *args], input=stdin, text=True, capture_output=True, timeout=30)
|
||||
if run.returncode:
|
||||
raise RuntimeError(run.stderr)
|
||||
return json.loads(run.stdout.splitlines()[-1])
|
||||
command("import", str(ROOT / "data/demo/catalog.json"), str(ROOT / "data/demo/ratings.json"), str(ROOT / "data/real/us-2026-10-04-partial.json"))
|
||||
for username in ("alice", "bob", "carol"):
|
||||
command("create-user", username, stdin=PASSWORD + "\n")
|
||||
cls.identities[username] = command("app-request", "-", stdin=json.dumps({"action": "auth/login", "input": {"username": username, "password": PASSWORD}, "clock": NOW}))
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.base.cleanup()
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="fpol_application_")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path = pathlib.Path(self.temp.name)
|
||||
self.database = self.path / "test.sqlite"
|
||||
with sqlite3.connect(self.base_database) as source, sqlite3.connect(self.database) as destination:
|
||||
source.backup(destination)
|
||||
self.identities = copy.deepcopy(type(self).identities)
|
||||
self.now = NOW
|
||||
def command(self, executable, *args, stdin=None):
|
||||
result = subprocess.run([str(executable), "--database", str(self.database), *map(str, args)], input=stdin, capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return json.loads(result.stdout.splitlines()[-1])
|
||||
def request(self, action, data=None, who="alice", status=200, csrf=True, raw=False):
|
||||
identity = self.identities.get(who, {})
|
||||
result = self.command(ADMIN, "--allow-demo", "app-request", "-", stdin=json.dumps({"action": action, "input": data or {}, "clock": self.now,
|
||||
"session": identity.get("session_token", ""), "csrf": identity.get("payload", {}).get("csrf", "") if csrf else ""}))
|
||||
self.assertEqual(result.get("status", 200), status, result)
|
||||
return result if raw or not result.get("ok") else result.get("payload", {})
|
||||
def league(self, players=("alice", "bob"), maximum=2, demo=True):
|
||||
data = {"name": "Friends", "team_name": "Alice team", "max_teams": maximum}
|
||||
if demo:
|
||||
data["catalog_id"] = "demo-2026"
|
||||
league = self.request("league/create", data)["league_id"]
|
||||
invite = self.request("league/invite", {"league_id": league})["invite"]
|
||||
for player in players[1:]:
|
||||
self.request("league/join", {"invite": invite, "team_name": player + " team"}, player)
|
||||
return league, invite
|
||||
def view(self, league, who="alice"):
|
||||
return self.request("league", {"league_id": league}, who)
|
||||
def start(self, players=("alice", "bob")):
|
||||
league, _ = self.league(players=players, maximum=len(players))
|
||||
self.request("draft/start", {"league_id": league})
|
||||
return league
|
||||
def turn(self, league):
|
||||
view = self.view(league)
|
||||
who = view["picker"]["username"]
|
||||
return who, self.view(league, who)
|
||||
def pick(self, league, candidate=None, request_id=None):
|
||||
who, view = self.turn(league)
|
||||
data = {"league_id": league, "candidate_id": candidate or view["legal_candidate_ids"][0], "pick_number": view["draft"]["pick_number"], "request_id": request_id or f"pick-{view['draft']['pick_number']}"}
|
||||
return who, data, self.request("draft/pick", data, who)
|
||||
def file(self, name, body):
|
||||
path = self.path / name
|
||||
path.write_text(json.dumps(body))
|
||||
return path
|
||||
def ingest(self, payload, configure=False):
|
||||
if configure:
|
||||
config = {"id": "fixture", "kind": "official-json", "url": "https://example.org/reviewed-test-fixture", "catalog_id": "demo-2026", "races": [{"race_id": "demo-house-01", "role": role} for role in ("counts", "outcome")]}
|
||||
self.command(INGEST, "configure", self.file("source.json", config))
|
||||
return self.command(INGEST, "replay", "fixture", self.file("payload.json", payload))
|
||||
def test_auth_and_csrf_and_logout(self):
|
||||
self.request("leagues", who="nobody", status=401)
|
||||
self.request("league/create", {}, csrf=False, status=403)
|
||||
self.request("auth/login", {"username": "alice", "password": "incorrect password"}, status=401)
|
||||
self.request("auth/logout")
|
||||
self.request("auth/me", status=401)
|
||||
def test_login_rate_limit(self):
|
||||
for _ in range(10):
|
||||
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=401)
|
||||
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=429)
|
||||
self.now += 901
|
||||
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=401)
|
||||
def test_invited_registration_capacity_and_atomicity(self):
|
||||
league, invite = self.league(players=("alice",), maximum=2)
|
||||
credentials = {"username": "david", "password": PASSWORD, "team_name": "David", "invite": invite}
|
||||
registered = self.request("auth/register", credentials, who="nobody", raw=True)
|
||||
self.assertEqual(registered["payload"]["admin"], 0)
|
||||
self.assertEqual(len(self.view(league)["teams"]), 2)
|
||||
credentials["username"] = "edward"
|
||||
self.request("auth/register", credentials, who="nobody", status=409)
|
||||
with sqlite3.connect(self.database) as database:
|
||||
self.assertEqual(database.execute("SELECT COUNT(*) FROM users WHERE username='edward'").fetchone()[0], 0)
|
||||
self.assertNotIn(invite, str(database.execute("SELECT * FROM invites").fetchall()))
|
||||
def test_invite_expiry_and_revocation(self):
|
||||
league, invite = self.league(players=("alice",))
|
||||
self.request("league/revoke-invites", {"league_id": league})
|
||||
self.request("league/join", {"invite": invite, "team_name": "Bob"}, "bob", status=404)
|
||||
invite = self.request("league/invite", {"league_id": league})["invite"]
|
||||
self.now += 7 * 86400
|
||||
self.request("league/join", {"invite": invite, "team_name": "Bob"}, "bob", status=404)
|
||||
def test_isolation_and_commissioner_permissions(self):
|
||||
league, _ = self.league()
|
||||
self.request("league", {"league_id": league}, "carol", status=403)
|
||||
self.request("standings", {"league_id": league}, "carol", status=403)
|
||||
self.request("candidates", {"league_id": league}, "carol", status=403)
|
||||
self.request("draft/start", {"league_id": league}, "bob", status=403)
|
||||
self.request("league/invite", {"league_id": league}, "bob", status=403)
|
||||
self.assertEqual(self.request("leagues", who="carol"), [])
|
||||
self.request("draft/queue", {"league_id": league}, status=400)
|
||||
def test_reset_single_use_expiry_and_session_revocation(self):
|
||||
reset = self.command(ADMIN, "reset-link", "alice")
|
||||
# Operator reset timestamps use wall time, not the diagnostic clock.
|
||||
self.now = reset["expires_at"] - 1
|
||||
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD + " new"}, who="nobody")
|
||||
self.now = NOW
|
||||
self.request("auth/me", status=401)
|
||||
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD}, who="nobody", status=400)
|
||||
reset = self.command(ADMIN, "reset-link", "bob")
|
||||
self.now = reset["expires_at"]
|
||||
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD}, who="nobody", status=400)
|
||||
def test_password_change_invalidates_old_session(self):
|
||||
result = self.request("auth/password", {"current_password": PASSWORD, "password": PASSWORD + " updated"}, raw=True)
|
||||
self.request("auth/me", status=401)
|
||||
self.identities["alice"] = result
|
||||
self.assertEqual(self.request("auth/me")["username"], "alice")
|
||||
def test_real_leagues_exist_before_data_ready(self):
|
||||
league, _ = self.league(demo=False)
|
||||
view = self.view(league)
|
||||
self.assertFalse(view["readiness"]["ready"])
|
||||
self.assertEqual(len(view["teams"]), 2)
|
||||
self.request("draft/start", {"league_id": league}, status=409)
|
||||
self.assertEqual(self.request("candidates", {"limit": 100})["total"], 115)
|
||||
self.assertEqual(len(self.request("candidates", {"offset": 100, "limit": 100})["items"]), 15)
|
||||
def test_private_queue_timeout_and_snake(self):
|
||||
league = self.start()
|
||||
who, view = self.turn(league)
|
||||
candidate = view["legal_candidate_ids"][-1]
|
||||
self.request("draft/queue", {"league_id": league, "candidates": [candidate]}, who)
|
||||
other = "alice" if who == "bob" else "bob"
|
||||
self.assertNotIn(candidate, json.dumps(self.view(league, other)["teams"]))
|
||||
self.now += 120
|
||||
self.request("tick")
|
||||
view = self.view(league)
|
||||
self.assertEqual(view["picks"][0]["candidate_id"], candidate)
|
||||
self.assertEqual(view["picks"][0]["automatic"], 1)
|
||||
second = view["picker"]["id"]
|
||||
self.pick(league)
|
||||
self.assertEqual(self.view(league)["picker"]["id"], second)
|
||||
def test_pause_restart_and_frozen_settings(self):
|
||||
league = self.start()
|
||||
self.now += 50
|
||||
self.request("draft/pause", {"league_id": league})
|
||||
self.assertEqual(self.view(league)["draft"]["remaining"], 70)
|
||||
self.now += 1000
|
||||
self.request("tick")
|
||||
self.assertEqual(len(self.view(league)["picks"]), 0)
|
||||
self.request("draft/resume", {"league_id": league})
|
||||
self.assertEqual(self.view(league)["draft"]["deadline"], self.now + 70)
|
||||
self.request("restart")
|
||||
view = self.view(league)
|
||||
self.assertEqual(view["league"]["status"], "paused")
|
||||
self.assertEqual(view["draft"]["remaining"], 120)
|
||||
self.assertEqual(json.loads(view["league"]["rules_json"])["version"], "v2-november-share")
|
||||
self.request("league/settings", {"league_id": league, "name": "Changed", "catalog_id": "demo-2026"}, status=409)
|
||||
def test_idempotent_concurrent_picks_and_timeout_boundary(self):
|
||||
league = self.start()
|
||||
who, view = self.turn(league)
|
||||
data = {"league_id": league, "candidate_id": view["legal_candidate_ids"][0], "pick_number": 0, "request_id": "same-request"}
|
||||
with concurrent.futures.ThreadPoolExecutor(2) as pool:
|
||||
results = list(pool.map(lambda _: self.request("draft/pick", data, who), range(2)))
|
||||
self.assertEqual(results[0], results[1])
|
||||
self.assertEqual(len(self.view(league)["picks"]), 1)
|
||||
data["candidate_id"] = view["legal_candidate_ids"][1]
|
||||
self.request("draft/pick", data, who, status=409)
|
||||
who, view = self.turn(league)
|
||||
self.now = view["draft"]["deadline"]
|
||||
self.request("draft/pick", {"league_id": league, "candidate_id": view["legal_candidate_ids"][0], "pick_number": 1, "request_id": "expired"}, who, status=409)
|
||||
self.request("tick")
|
||||
self.assertEqual(len(self.view(league)["picks"]), 2)
|
||||
def test_full_draft_capacity_and_league_exclusivity(self):
|
||||
league = self.start(players=("alice", "bob", "carol"))
|
||||
for _ in range(36):
|
||||
self.now += 120
|
||||
self.request("tick")
|
||||
view = self.view(league)
|
||||
self.assertEqual(view["league"]["status"], "drafted")
|
||||
self.assertEqual(len({p["candidate_id"] for p in view["picks"]}), 36)
|
||||
for team in view["teams"]:
|
||||
self.assertEqual(len({p["slot"] for p in view["picks"] if p["team_id"] == team["id"]}), 12)
|
||||
second = self.start()
|
||||
self.pick(second, view["picks"][0]["candidate_id"])
|
||||
self.assertEqual(len(self.view(second)["picks"]), 1)
|
||||
def test_ingest_corrections_runoff_retraction_and_outage(self):
|
||||
league = self.start()
|
||||
self.pick(league, "demo-house-01-1")
|
||||
counts = {"race_id": "demo-house-01", "kind": "counts", "round": "november_first", "source_time": "2026-11-04T01:00:00Z", "total_votes": 1000, "votes": [{"candidate_id": "demo-house-01-1", "votes": 400}]}
|
||||
outcome = {"race_id": "demo-house-01", "kind": "outcome", "round": "general", "source_time": "2026-11-04T01:00:00Z", "status": "pending_round", "winner_id": None}
|
||||
payload = {"schema_version": 1, "observations": [counts, outcome]}
|
||||
result = self.ingest(payload, configure=True)
|
||||
self.assertFalse(result["verified"])
|
||||
def score():
|
||||
return next(row["roster"][0]["points"] for row in self.request("standings", {"league_id": league})["teams"] if row["roster"])
|
||||
self.assertEqual(score(), 40)
|
||||
outcome.update(round="runoff", source_time="2026-12-06T01:00:00Z", status="called", winner_id="demo-house-01-1")
|
||||
self.ingest({"schema_version": 1, "observations": [outcome]})
|
||||
self.assertEqual(score(), 90)
|
||||
outcome.update(source_time="2026-12-06T02:00:00Z", status="reporting", winner_id=None)
|
||||
self.ingest({"schema_version": 1, "observations": [outcome]})
|
||||
self.assertEqual(score(), 40)
|
||||
counts.update(source_time="2026-11-04T03:00:00Z", votes=[{"candidate_id": "demo-house-01-1", "votes": 450}])
|
||||
self.ingest({"schema_version": 1, "observations": [counts]})
|
||||
self.assertEqual(score(), 45)
|
||||
counts["round"] = "runoff"
|
||||
self.assertTrue(self.ingest({"schema_version": 1, "observations": [counts]})["quarantined"])
|
||||
self.assertEqual(score(), 45)
|
||||
counts["round"] = "november_first"
|
||||
counts["source_time"] = "2026-11-04T00:00:00Z"
|
||||
counts["votes"][0]["votes"] = 100
|
||||
self.ingest({"schema_version": 1, "observations": [counts]})
|
||||
self.assertEqual(score(), 45)
|
||||
def test_twelve_teams_preserve_party_capacity_and_finish(self):
|
||||
league, invite = self.league(players=("alice", "bob", "carol"), maximum=12)
|
||||
for index in range(9):
|
||||
name = f"player_{index}"
|
||||
self.identities[name] = self.request("auth/register", {"username": name, "password": PASSWORD, "team_name": name, "invite": invite}, who="nobody", raw=True)
|
||||
self.request("draft/start", {"league_id": league})
|
||||
self.pick(league, "demo-house-01-1")
|
||||
# Once someone has their D slot, a second D would consume another team's only required supply.
|
||||
with sqlite3.connect(self.database) as database:
|
||||
first = database.execute("SELECT team_id FROM picks WHERE number=0 AND league_id=?", (league,)).fetchone()[0]
|
||||
database.execute("UPDATE drafts SET pick_number=23 WHERE league_id=?", (league,))
|
||||
who, view = self.turn(league)
|
||||
self.assertEqual(view["picker"]["id"], first)
|
||||
self.assertNotIn("demo-house-02-1", view["legal_candidate_ids"])
|
||||
with sqlite3.connect(self.database) as database:
|
||||
database.execute("UPDATE drafts SET pick_number=1 WHERE league_id=?", (league,))
|
||||
for _ in range(143):
|
||||
self.now += 120
|
||||
self.request("tick")
|
||||
view = self.view(league)
|
||||
self.assertEqual(view["league"]["status"], "drafted")
|
||||
self.assertEqual(len(view["picks"]), 144)
|
||||
self.assertEqual(len({row["candidate_id"] for row in view["picks"]}), 144)
|
||||
def test_openamerica_mapping_and_fusion_lines(self):
|
||||
config = {"id": "openamerica-test", "kind": "openamerica", "url": "https://openamerica.io/elections/results.json?year=2026&office=House", "office": "House", "catalog_id": "demo-2026", "counts_until": "2099-01-01T00:00:00Z", "races": [{"race_id": "demo-house-01", "role": "counts", "provider_id": "MI-01", "candidates": {"Test Candidate|D": "demo-house-01-1", "Test Candidate|W": "demo-house-01-1", "Write-in|": None}}]}
|
||||
self.command(INGEST, "configure", self.file("oa-config.json", config))
|
||||
# Synthetic transport fixture. It cannot verify a real feed.
|
||||
payload = {"api": 1, "year": 2026, "office": "House", "stale": False, "as_of": "2026-11-04T01:00:00+00:00", "races": {"MI-01": {"total": 100, "cands": [{"n": "Test Candidate", "p": "D", "v": 60}, {"n": "Test Candidate", "p": "W", "v": 10}]}}}
|
||||
result = self.command(INGEST, "replay", "openamerica-test", self.file("oa-payload.json", payload))
|
||||
self.assertEqual(result["changed"], 1)
|
||||
self.assertFalse(result["verified"])
|
||||
with sqlite3.connect(self.database) as database:
|
||||
normalized = json.loads(database.execute("SELECT body FROM observations").fetchone()[0])
|
||||
self.assertEqual(normalized["votes"], [{"candidate_id": "demo-house-01-1", "votes": 70}])
|
||||
payload["races"]["MI-01"]["cands"].append({"n": "Unknown", "p": "R", "v": 20})
|
||||
self.assertTrue(self.command(INGEST, "replay", "openamerica-test", self.file("oa-payload.json", payload))["quarantined"])
|
||||
activation = subprocess.run([str(INGEST), "--database", str(self.database), "activate", "openamerica-test"], capture_output=True, text=True)
|
||||
self.assertNotEqual(activation.returncode, 0)
|
||||
def test_ratings_import_permission_immutability_and_freeze(self):
|
||||
league, _ = self.league()
|
||||
ratings = json.loads((ROOT / "data/demo/ratings.json").read_text())
|
||||
ratings["id"] = "commissioner-ratings"
|
||||
data = {"league_id": league, "document": ratings}
|
||||
self.request("league/ratings", data, "bob", status=403)
|
||||
self.request("league/ratings", data)
|
||||
self.request("league/ratings", data)
|
||||
self.assertEqual(self.view(league)["league"]["ratings_id"], ratings["id"])
|
||||
ratings["entries"][0]["rating"] = "toss_up"
|
||||
self.request("league/ratings", data, status=409)
|
||||
self.request("draft/start", {"league_id": league})
|
||||
ratings["id"] = "too-late"
|
||||
self.request("league/ratings", data, status=409)
|
||||
self.request("league/name", {"league_id": league, "name": "Renamed after start"})
|
||||
self.assertEqual(self.view(league)["league"]["name"], "Renamed after start")
|
||||
def test_one_verified_race_cannot_activate_whole_feed(self):
|
||||
self.ingest({"schema_version": 1, "observations": []}, configure=True)
|
||||
# Fixture-only network evidence, to exercise the coverage gate without sockets.
|
||||
with sqlite3.connect(self.database) as database:
|
||||
database.execute("UPDATE sources SET verified=1,last_error=NULL WHERE id='fixture'")
|
||||
database.execute("INSERT INTO source_checks VALUES('fixture','demo-house-01','counts',?)", (self.now,))
|
||||
result = subprocess.run([str(INGEST), "--database", str(self.database), "activate", "fixture"], capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
with sqlite3.connect(self.database) as database:
|
||||
self.assertEqual(database.execute("SELECT COUNT(*) FROM coverage").fetchone()[0], 0)
|
||||
def test_candidate_watch_deduplicates_and_detects_return_to_previous_payload(self):
|
||||
config = {"id": "watch", "kind": "candidate-watch", "url": "https://example.org/official-candidates", "catalog_id": "demo-2026", "races": []}
|
||||
self.command(INGEST, "configure", self.file("watch.json", config))
|
||||
first = self.file("first.json", {"version": 1})
|
||||
second = self.file("second.json", {"version": 2})
|
||||
self.command(INGEST, "replay", "watch", first)
|
||||
with sqlite3.connect(self.database) as database:
|
||||
database.execute("UPDATE sources SET last_changed=123,etag='external-etag' WHERE id='watch'")
|
||||
self.command(INGEST, "replay", "watch", first)
|
||||
with sqlite3.connect(self.database) as database:
|
||||
self.assertEqual(database.execute("SELECT last_changed FROM sources WHERE id='watch'").fetchone()[0], 123)
|
||||
self.command(INGEST, "replay", "watch", second)
|
||||
with sqlite3.connect(self.database) as database:
|
||||
database.execute("UPDATE sources SET last_changed=456 WHERE id='watch'")
|
||||
self.command(INGEST, "replay", "watch", first)
|
||||
with sqlite3.connect(self.database) as database:
|
||||
self.assertGreater(database.execute("SELECT last_changed FROM sources WHERE id='watch'").fetchone()[0], 456)
|
||||
self.assertEqual(database.execute("SELECT COUNT(*) FROM source_payloads").fetchone()[0], 2)
|
||||
self.assertEqual(database.execute("SELECT verified FROM sources WHERE id='watch'").fetchone()[0], 0)
|
||||
def test_catalog_documents_survive_migration_and_backup(self):
|
||||
backup = self.path / "backup.sqlite"
|
||||
self.command(ADMIN, "backup", backup)
|
||||
with sqlite3.connect(backup) as database:
|
||||
self.assertEqual(database.execute("PRAGMA integrity_check").fetchone()[0], "ok")
|
||||
self.assertEqual(database.execute("SELECT COUNT(*) FROM documents").fetchone()[0], 3)
|
||||
hashes = database.execute("SELECT password_hash FROM users").fetchall()
|
||||
self.assertEqual(len(set(row[0] for row in hashes)), 3)
|
||||
self.assertTrue(all(row[0].startswith("scrypt$131072$8$1$") for row in hashes))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-1
@@ -165,7 +165,7 @@ class core_tests(unittest.TestCase):
|
||||
self.assertEqual(self.preview(), original)
|
||||
with contextlib.closing(sqlite3.connect(self.database)) as database:
|
||||
self.assertEqual(database.execute("SELECT COUNT(*) FROM documents").fetchone()[0], 7)
|
||||
self.assertEqual(database.execute("SELECT COUNT(*) FROM _noto_schema_migrations").fetchone()[0], 1)
|
||||
self.assertEqual(database.execute("SELECT COUNT(*) FROM _noto_schema_migrations").fetchone()[0], 2)
|
||||
def test_idempotency_and_frozen_ratings(self):
|
||||
self.assertFalse(self.run_admin("import", DEMO / "ratings.json")["imported"])
|
||||
self.ratings["entries"][0]["rating"] = "toss_up"
|
||||
|
||||
+37
-2
@@ -67,7 +67,7 @@ class server_tests(unittest.TestCase):
|
||||
self.assertEqual(json.loads(body)["code"], "not_found")
|
||||
self.assertEqual(request(port, "/api/health", "POST")[0], 404)
|
||||
def test_catalog_rules_and_validation_routes(self):
|
||||
with running_server() as port:
|
||||
with running_server("--allow-demo") as port:
|
||||
self.assertEqual(json.loads(request(port, "/api/catalog")[2])["payload"], {"documents": []})
|
||||
self.assertEqual(len(json.loads(request(port, "/api/rules")[2])["payload"]["slots"]), 12)
|
||||
status, _, body = request(port, "/api/capacity", "POST", {"catalog_id": "missing", "players": 12})
|
||||
@@ -81,7 +81,7 @@ class server_tests(unittest.TestCase):
|
||||
database = pathlib.Path(directory) / "league.sqlite"
|
||||
result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "import", str(demo / "catalog.json"), str(demo / "ratings.json"), str(demo / "results-3.json")], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
with running_server("--database", database) as port:
|
||||
with running_server("--database", database, "--allow-demo") as port:
|
||||
catalog = json.loads(request(port, "/api/catalog")[2])["payload"]
|
||||
self.assertEqual(len(catalog["documents"]), 3)
|
||||
status, _, body = request(port, "/api/capacity", "POST", json.loads((demo / "capacity.json").read_text()))
|
||||
@@ -90,6 +90,41 @@ class server_tests(unittest.TestCase):
|
||||
status, _, body = request(port, "/api/score-preview", "POST", json.loads((demo / "preview.json").read_text()))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(json.loads(body)["payload"]["total"], 528.24)
|
||||
def test_authenticated_cookie_headers_origin_and_query(self):
|
||||
with tempfile.TemporaryDirectory(prefix="fpol_auth_http_") as directory:
|
||||
database = pathlib.Path(directory) / "league.sqlite"
|
||||
result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "create-user", "alice"], input="a strong test password\n", capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
with running_server("--database", database, "--origin", "https://game.example", "--secure-cookies") as port:
|
||||
def call(path, method="GET", body=None, headers=None):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
try:
|
||||
connection.request(method, path, body=json.dumps(body) if body is not None else None, headers={"Content-Type": "application/json", **(headers or {})})
|
||||
response = connection.getresponse()
|
||||
return response.status, dict(response.getheaders()), json.loads(response.read())
|
||||
finally:
|
||||
connection.close()
|
||||
login = {"username": "alice", "password": "a strong test password"}
|
||||
self.assertEqual(call("/api/auth/login", "POST", login)[0], 403)
|
||||
headers = {"Origin": "https://game.example", "X-Fpol-Request": "1"}
|
||||
status, response, body = call("/api/auth/login", "POST", login, headers)
|
||||
self.assertEqual(status, 200)
|
||||
cookie = response["Set-Cookie"]
|
||||
self.assertIn("HttpOnly", cookie)
|
||||
self.assertIn("SameSite=Lax", cookie)
|
||||
self.assertIn("Secure", cookie)
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
self.assertNotIn("session_token", body)
|
||||
headers["Cookie"] = cookie.split(";", 1)[0]
|
||||
self.assertEqual(call("/api/auth/me", headers=headers)[2]["payload"]["username"], "alice")
|
||||
self.assertEqual(call("/api/candidates?search=two+words&limit=1", headers=headers)[0], 200)
|
||||
self.assertEqual(call("/api/candidates?search=%ZZ", headers=headers)[0], 400)
|
||||
self.assertEqual(call("/api/candidates?limit=1&limit=2", headers=headers)[0], 400)
|
||||
self.assertEqual(call("/api/auth/logout", "POST", {}, headers)[0], 403)
|
||||
headers["X-CSRF-Token"] = body["payload"]["csrf"]
|
||||
self.assertEqual(call("/api/auth/logout", "POST", {}, headers)[0], 200)
|
||||
self.assertEqual(call("/api/auth/me", headers=headers)[0], 401)
|
||||
self.assertEqual(call("/api/catalog", headers=headers)[0], 404)
|
||||
def test_static_assets_and_route_boundaries(self):
|
||||
with tempfile.TemporaryDirectory(prefix="fpol_web_") as directory:
|
||||
root = pathlib.Path(directory)
|
||||
|
||||
Reference in New Issue
Block a user