Lands End Technology

Technical Architecture

Swansea Meeting Vote — internal reference for senior engineers

Runtime: Next.js 16 / React 19 Database: Neon PostgreSQL + Prisma 7 Deploy: Vercel (Fluid Compute) Updated: May 2026

Contents

  1. Technology Stack
  2. Data Model
  3. API Surface
  4. Authentication & Authorization
  5. Vote Integrity Pipeline
  6. Real-Time & Polling
  7. Security Mechanisms
  8. Infrastructure & Deployment
  9. AI Integration
  10. Testing
1

Technology Stack

Framework
Next.js 16.2 (App Router)
React 19 · Server Components · Turbopack in dev · TypeScript throughout
Database
Neon PostgreSQL
Serverless Postgres · Two endpoints: pooled (app) + unpooled (migrations). Prisma 7 with @prisma/adapter-pg
ORM
Prisma 7.8 (driver adapter)
PrismaPg adapter injects the connection at runtime. Schema in prisma/schema.prisma. Migrations via raw SQL on Neon (no Prisma migration tracking in prod)
Hosting
Vercel (Fluid Compute)
Full-stack serverless. Each API route = one function. Production domain: municipalvote.com
Rate Limiting
Upstash Redis
Sliding-window limiter via @upstash/ratelimit. In-memory fallback for local dev. Applied only to /api/join
Styling
Tailwind CSS 4
Utility-first, dark navy + gold design system. No component library — custom components throughout
Charts
Recharts 3.8
Bar charts + pie charts on results and report pages. React-rendered, no canvas fallback needed
QR Generation
qrcode 1.5
Server-side QR generation for voter pass print pages. Returns PNG data URIs embedded in the HTML
AI
Anthropic Claude (SDK 0.95)
Used exclusively in /api/meetings/[id]/import-ballot to parse warrant article PDFs into structured JSON
Testing
Vitest 4 + Playwright
Unit/integration tests mock Prisma client. Playwright for E2E. 707 passing tests across 50 test files
Icons
lucide-react 1.14
All UI icons. Tree-shaken per import
No real-time push infrastructure (Pusher/WebSockets). The voter client polls /api/active/[roomCode] every few seconds to detect when a vote opens. The moderator dashboard polls /api/meetings/[id]/sessions. This was a deliberate simplicity choice — the polling interval is short enough for a meeting context and eliminates a stateful WebSocket dependency.
2

Data Model

Entity overview

Seven models. The core voting path is Meeting → VotingSession → Vote. The check-in path is RegisteredVoter ↔ CheckIn ↔ Meeting. Voter token issuance is Meeting → VoterToken ← deviceToken.

// Core voting path Meeting // one per town meeting; anchor for all other records ├── VotingSession // one per warrant article; status PENDING | OPEN | CLOSED │ └── Vote // one per ballot cast; unique(votingSessionId, deviceToken) ├── VoterToken // pre-generated QR pass; claimed by a deviceToken at /api/join ├── Heartbeat // last-seen timestamp per deviceToken; used for attendance count └── CheckIn // links a RegisteredVoter to a Meeting (one per person per meeting) RegisteredVoter // imported voter roll (CSV); has many CheckIns GlobalSetting // key/value store for app-wide config (e.g. admin password hash)

Key fields and design decisions

Meeting.roomCode 6-character uppercase code (e.g. LK4R2A) — unique, cryptographically random, excludes ambiguous characters (0/O, 1/I, S/5). Displayed on projector screen so voters can join. Unique constraint in DB.
Meeting.status PENDING | ACTIVE | CLOSED. CLOSED is terminal — no new sessions can be opened and voter passes are deactivated.
Meeting.geofence* geofenceEnabled (bool), geofenceLat/Lng (float), geofenceMeters (int, default 300). All checked at vote-submission time, not at join time.
VotingSession.parentSessionId Self-referential string FK (no Prisma @relation). Set when the session is an amendment to another article. Used to compute word-level diffs in the moderator UI.
VotingSession.position Integer ordering field. When inserting an amendment, all sessions after the parent are shifted down by 1 and the amendment takes parent.position + 1.
Vote.deviceToken Client-generated UUID stored in localStorage. Combined with votingSessionId forms a unique constraint — the primary duplicate-vote prevention mechanism at the database level.
VoterToken.deviceToken Null until claimed at /api/join. Claimed via conditional update (WHERE deviceToken IS NULL) inside a try/catch — prevents a race condition where two devices claim the same pass simultaneously.
Heartbeat Upsert on every active-session poll. unique(meetingId, deviceToken). Used by the moderator report to count unique devices seen during the meeting.
CheckIn Created by the check-in volunteer tablet when a voter is found in the RegisteredVoter table. unique(meetingId, registeredVoterId) prevents double check-in of the same voter.

ID strategy

All primary keys use cuid() — collision-resistant IDs generated by Prisma. Room codes are separate cryptographically random strings (see Section 4). deviceToken is a UUID generated client-side with crypto.randomUUID() and persisted in localStorage.

3

API Surface

All routes are Next.js App Router Route Handlers (route.ts files). Every route returns JSON. Auth is enforced per-route — there is no middleware layer doing blanket auth.

Public voter routes (no auth required)

MethodPathDescriptionKey behaviour
POST /api/join Join a meeting by room code Rate-limited 2,000 req/60s per IP. Claims voter pass (conditional update on deviceToken IS NULL). Returns joinToken (HMAC, 8h TTL) binding (meetingId, deviceToken). Returns active session + all sessions.
POST /api/vote Submit a vote Validates joinToken, voter pass binding, geofence, session status, and duplicate vote. Single DB write. DB unique constraint is final guard. See Section 5 for full pipeline.
POST /api/active/[roomCode] Heartbeat + active session poll Upserts Heartbeat row. Returns currently OPEN session or null. Called every ~3s by voter clients.
GET /api/room/[roomCode] Meeting metadata for voter join screen Returns title, geofence config, requireVoterToken flag. No votes or device data.
GET /api/meetings/[meetingId]/sessions Session list for results/display pages Returns sessions with votes. If caller has valid mod token: includes deviceToken per vote (for unique-voter count). Otherwise: choice only.

Moderator routes (mod token required)

MethodPathDescriptionAuth scope
POST /api/moderator/login Exchange password for mod token Password compared via SHA-256 + timingSafeEqual. Sets mod_auth HttpOnly cookie + returns token in body (for localStorage fallback).
POST /api/moderator/logout Clear mod session Clears cookie. Client clears localStorage.
GET POST /api/meetings List / create meetings Global mod token only. POST generates unique room code (crypto random, retry loop up to 10).
GET PATCH DELETE /api/meetings/[meetingId] Get / update / delete a meeting Meeting-scoped mod token. PATCH handles settings updates including geofence config and meeting status (adjourn).
GET POST /api/meetings/[meetingId]/sessions List / create voting sessions POST handles position shifting for amendments. Auto-assigns article number as {parent}.{N} when afterSessionId is provided.
PATCH DELETE /api/sessions/[sessionId] Open/close/reconsider/edit/delete a session Serializable transaction enforces one-open-at-a-time. Reconsider clears votes and reopens. Edit only allowed on PENDING sessions.
GET POST DELETE /api/meetings/[meetingId]/tokens Generate / list / delete voter passes POST bulk-inserts up to 10,000 VoterToken rows in a single transaction. DELETE removes all tokens and clears requireVoterToken.
POST /api/meetings/[meetingId]/import-ballot AI warrant article import Accepts PDF via multipart form. Passes base64-encoded PDF to Claude claude-sonnet-4-6 with a structured extraction prompt. Returns article array for moderator review before bulk insert.
GET /api/meetings/[meetingId]/export CSV export of meeting results Returns RFC-4180 CSV. All string values are CSV-safe (double-quoted, formula-injection-prefixed).
GET /api/meetings/[meetingId]/attendees Live attendee count Counts distinct Heartbeat rows for the meeting. Used by the moderator dashboard header.
GET PATCH /api/meetings/[meetingId]/settings Meeting settings read/write Separate from the main meeting route. Handles geofence, PIN, and voter token settings in one endpoint.

Check-in routes (PIN-authenticated)

MethodPathDescription
POST /api/checkin/[roomCode]/verify-pin Validate the volunteer check-in PIN against the meeting's stored PIN.
GET /api/checkin/[roomCode]/search Full-text search of RegisteredVoter by last name or street address. Returns paginated results with check-in status.
POST /api/checkin/[roomCode]/checkin Mark a registered voter as checked in. Creates CheckIn row. Idempotent (returns existing if already checked in).
GET /api/checkin/[roomCode]/stats Checked-in count vs total registered voters for the meeting.
GET /api/checkin/[roomCode]/roster Full alphabetical roster with check-in status. Used for print-ready check-in sheets.

Admin routes (global mod + admin password)

MethodPathDescription
GET PATCH /api/admin/settings Read/update global app settings (admin password hash, etc.) stored in GlobalSetting.
POST /api/admin/voters/import Bulk import voter roll from CSV. Upserts on voterId.
GET /api/admin/voters/search Search registered voter database by name, address, or precinct.
GET /api/admin/voters/stats Total registered voters, breakdown by precinct and status.
GET /api/cron/deactivate-tokens Vercel cron job — deactivates voter tokens for closed meetings. Runs on a schedule.
4

Authentication & Authorization

The system has three distinct auth contexts: moderator auth, voter pass auth, and check-in PIN auth. None of them use a third-party auth provider — everything is implemented with Node.js crypto.

Moderator token (HMAC-SHA256, 12h TTL)

// Token format: {meetingId}.{base36-timestamp}.{base64url-HMAC} // Secret: MOD_SECRET env var (required in production) // Example: global.lk4r2a.ABC123== signToken("global") // issued at login — valid for any meeting signToken("meetingId") // valid for one specific meeting only // Verification uses timingSafeEqual — prevents timing oracle attacks // TTL is checked against embedded base36 timestamp (12 hours)

The token is transported two ways simultaneously: an HttpOnly; Secure; SameSite=Lax cookie (mod_auth) set at login, and in the response body for the client to persist in localStorage. API routes accept either. The dual transport exists because Safari ITP can block third-party cookies; the header fallback (x-mod-auth) via localStorage ensures moderators on Safari aren't silently logged out mid-meeting.

// requireModAuth() — checks cookie OR x-mod-auth header, either is sufficient function requireModAuth(req, meetingId): cookie = req.cookies.get("mod_auth") header = req.headers.get("x-mod-auth") if (cookie && isValid(cookie, meetingId)) return null // authorized if (header && isValid(header, meetingId)) return null // authorized return 401

Join token (HMAC-SHA256, 8h TTL)

Issued by /api/join after a voter successfully enters the room. Proves that a specific (meetingId, deviceToken) pair went through the join flow. The vote endpoint requires it — preventing a script from generating random deviceToken values and submitting votes without ever joining.

// Token format: {base36-timestamp}.{base64url-HMAC} // HMAC payload: "{meetingId}:{deviceToken}:{timestamp}" — NOT embedded in token // At verification: meetingId comes from DB (session lookup), deviceToken from request body // This keeps the token short while binding it cryptographically to both values signJoinToken(meetingId, deviceToken) // called at /api/join, returned to client verifyJoinToken(token, meetingId, deviceToken) // called at /api/vote

Moderator login (SHA-256 password hash)

The stored credential is a SHA-256 hex digest of the plaintext password, set via the MOD_PASSWORD environment variable. At login, the supplied password is hashed and compared using timingSafeEqual. On success, a signToken("global") token is issued with a 12-hour TTL and set as an HttpOnly cookie.

// MOD_PASSWORD = SHA-256 hex of the real password // Generate: node -e "require('crypto').createHash('sha256').update('pass').digest('hex')" // At login: suppliedHash = SHA256(suppliedPassword) storedHash = Buffer.from(MOD_PASSWORD, "hex") if (timingSafeEqual(suppliedHash, storedHash)) → issue token

Check-in PIN

A plain numeric PIN stored on the Meeting record (checkInPin field). Volunteers enter it on the check-in tablet. Verified at /api/checkin/[roomCode]/verify-pin. The PIN grants access only to check-in operations — not to moderator controls.

Authorization matrix

CallerCredentialAccess
VoterNone (public)/api/join, /api/vote, /api/active/*, /api/room/*
Meeting moderatorHMAC token (meeting-scoped)All /api/meetings/[meetingId]/* and /api/sessions/* routes
Global moderatorHMAC token (global)All of the above + /api/meetings (create/list)
Check-in volunteerMeeting PIN/api/checkin/[roomCode]/* only
AdminGlobal mod token + admin password/api/admin/*
5

Vote Integrity Pipeline

The POST /api/vote handler is the most security-critical path in the application. Every vote passes through eight sequential checks before a DB write is attempted. A failure at any step returns a 4xx and logs the event.

POST /api/vote 1. Input validation – votingSessionId: string, 1–128 chars (rejects oversized payloads) – deviceToken: string, 1–128 chars – choice: "YES" | "NO" | "ABSTAIN" (enum, nothing else) – joinToken: non-empty string (HMAC verified in step 3) 2. Session lookup + meeting state check – Session must exist (404 if not) – session.status must be "OPEN" (409 if not) – meeting.status must not be "CLOSED" (409 if so) 3. Join token verification – verifyJoinToken(joinToken, session.meetingId, deviceToken) – Proves the device went through /api/join for THIS meeting – HMAC-SHA256 + timingSafeEqual + 8h TTL – Failure: 403 4. Voter pass check (when meeting.requireVoterToken === true) – voterToken must be present in request (403 if missing) – VoterToken row must exist in DB (403 if not) – token.meetingId must match session.meetingId (403 if mismatch) – token.deviceToken must match request deviceToken (403 if mismatch) 5. Geofence check (when meeting.geofenceEnabled === true) – lat, lng, accuracy must all be numbers (403 if missing/wrong type) – accuracy must be ≤ 150 m — hard cap regardless of geofence radius – haversineMeters(anchor, reported) must be ≤ meeting.geofenceMeters – Failure: 403 6. Duplicate check (pre-write) – findUnique({ votingSessionId, deviceToken }) — returns existing vote if any – Returns 409 "Already voted" if found 7. Vote write – prisma.vote.create({ votingSessionId, deviceToken, choice }) 8. Race condition guard – If create() throws P2002 (unique constraint violation), two concurrent requests beat the pre-write check simultaneously → returns 409 instead of 500 DB unique constraint is the final authoritative guard
The DB unique constraint is the real guarantee. The pre-write duplicate check (step 6) is an optimisation to return a friendly error faster. Even if two requests pass step 6 simultaneously, the @@unique([votingSessionId, deviceToken]) constraint on the Vote model ensures only one row is ever inserted. The P2002 catch in step 8 surfaces this as a 409 rather than a 500.

One-open-at-a-time enforcement

When a moderator opens a session, PATCH /api/sessions/[id] runs a Serializable-isolation transaction:

// Serializable isolation prevents the phantom read where two concurrent PATCH // requests both see zero open sessions and both proceed to open their session. prisma.$transaction(async (tx) => { alreadyOpen = await tx.votingSession.findFirst({ where: { meetingId, status: "OPEN", id: { not: sessionId } } }); if (alreadyOpen) return { conflict: true } // → 409 session = await tx.votingSession.update({ where: { id: sessionId }, data: { status: "OPEN", openedAt: new Date() } }); return { conflict: false, session }; }, { isolationLevel: "Serializable" });
6

Real-Time & Polling

The application uses HTTP polling rather than WebSockets or SSE. This was a deliberate trade-off: polling is stateless, works through all proxy/CDN layers, and eliminates persistent connection management on a serverless platform.

Voter client polling (/api/active/[roomCode])

  • The voter page calls POST /api/active/[roomCode] with the deviceToken in the body every ~3 seconds while a vote is not open
  • The endpoint upserts a Heartbeat row (last-seen timestamp) and returns the currently OPEN session or null
  • When an OPEN session is returned, the voter UI transitions to the ballot screen
  • When the voter is on the ballot screen, polling pauses — the vote is submitted directly and the result is shown from the response
  • Heartbeats stop being recorded for CLOSED meetings (no point counting attendees post-adjourn)

Moderator dashboard polling (/api/meetings/[meetingId]/sessions)

  • The moderator control panel re-fetches the full session list periodically to pick up vote counts as ballots come in
  • The sessions endpoint returns votes with deviceToken for authorized callers — the client computes the tally from the raw votes array (no separate tally field is stored)
  • The tally is derived from tallyFromVotes(session.votes) in types/voting.ts — a pure function, no server-side aggregation stored

Display page

The public display page (/display/[roomCode]) polls /api/meetings/[meetingId]/sessions and renders live tallies with Recharts bar and pie charts. Intended to be projected on the auditorium screen during a vote.

No WebSocket / Pusher: The original architecture included Pusher for push notifications to voter clients. This was removed in favour of polling after evaluating the operational complexity. If sub-second latency becomes a requirement (e.g., large concurrent audiences), re-introducing SSE or Pusher to the /api/active path is the natural upgrade path.
7

Security Mechanisms

HMAC tokens — crypto.createHmac

Two independent HMAC token types. Both use the same MOD_SECRET env var, SHA-256 digest, and timingSafeEqual for constant-time comparison.

Mod token format{meetingId}.{base36-ts}.{base64url-sig} — meetingId embedded so scope is verifiable without DB lookup
Join token format{base36-ts}.{base64url-sig} — meetingId/deviceToken NOT embedded; provided externally at verify time, binding checked via HMAC payload
Timing attack preventiontimingSafeEqual used for all HMAC comparisons and password hash comparison
TTLMod tokens: 12h. Join tokens: 8h (covers a full town meeting). Embedded base36 timestamp checked at every verification.

Password storage — SHA-256 hex digest

The moderator password is stored as a SHA-256 hex digest in the MOD_PASSWORD env var (not in the database). This is not bcrypt — the trade-off is that SHA-256 is fast (no salting, no stretch), acceptable here because the password gates a single-operator system behind an additional HMAC token layer, and the token is the actual credential for all API calls after login.

GPS geofencing — Haversine formula

// lib/geo.ts — Haversine great-circle distance, accurate to ~0.3% under 500 km function haversineMeters(lat1, lng1, lat2, lng2): R = 6371000 // Earth mean radius in metres φ1, φ2 = lat1 * π/180, lat2 * π/180 Δφ = (lat2 - lat1) * π/180 Δλ = (lng2 - lng1) * π/180 a = sin²(Δφ/2) + cos(φ1) · cos(φ2) · sin²(Δλ/2) return R · 2 · atan2(√a, √(1-a)) // Additional guard: hard accuracy cap of 150 m // A device reporting accuracy > 150 m is rejected regardless of its coordinates // This prevents a device with a completely unreliable GPS fix from passing // the geofence check by reporting coordinates that happen to be within radius

Voter pass token claiming — race condition prevention

// Conditional update: only succeeds if deviceToken is still null // Prevents two devices claiming the same pass in a narrow concurrency window await prisma.voterToken.update({ where: { id: voterToken, deviceToken: null }, // conditional data: { deviceToken, claimedAt: new Date() } }); // If update matches 0 rows → throws → caught → 403 "already used on another device"

Rate limiting — Upstash Redis sliding window

// lib/ratelimit.ts // Applied ONLY to /api/join — not to /api/vote // Why: all attendees in a hall share one public IP (building WiFi) // A low vote-endpoint rate limit would block legitimate voters // Duplicate vote prevention is handled by the DB unique constraint instead JOIN: 2,000 req / 60s per IP // lets 1,000-person hall all join within the window VOTE: no IP limit // DB constraint is the authoritative guard // Local dev: in-memory sliding window (Map<string, {count, resetAt}>) // Production: Upstash Redis via @upstash/ratelimit (Vercel Marketplace integration)

Input validation

  • All ID fields capped at 128 characters — prevents oversized-payload DoS
  • choice field validated as strict enum — no other values accepted
  • Room codes normalized to uppercase before DB lookup
  • Voter token count capped at 10,000 — prevents mass DB row insertion via the token generation endpoint
  • Article question text capped at 2,000 characters
  • Meeting title capped at 200 characters
  • CSV export sanitized against formula injection (=, +, -, @, \t, \r prefixes are escaped)

Cookie security

response.cookies.set("mod_auth", token, { httpOnly: true, // not accessible to JS — XSS cannot steal it sameSite: "lax", // CSRF protection for same-site navigations secure: true, // HTTPS only maxAge: 60 * 60 * 8, // 8 hours path: "/" });

Vote secrecy

The GET /api/meetings/[meetingId]/sessions endpoint conditionally includes or excludes deviceToken per vote based on the caller's auth status. Unauthorized callers (voters, public display) receive choice only. Moderators receive deviceToken as well, enabling the unique-voter count in the report. The individual choice → deviceToken mapping is never exposed publicly.

8

Infrastructure & Deployment

Vercel — Fluid Compute

Each Next.js API route handler deploys as an independent Vercel Function. Fluid Compute reuses function instances across concurrent requests — reducing cold starts compared to traditional one-request-per-instance serverless. The production domain is municipalvote.com aliased to the latest Vercel deployment.

Database — Neon PostgreSQL (two endpoints)

Pooled endpointep-silent-cloud-aqfjewjk-pooler.* — used by the running application via DATABASE_URL. Connection pooling handles concurrent serverless function connections.
Unpooled endpointep-silent-cloud-aqfjewjk.* — used for migrations via DATABASE_URL_UNPOOLED. Direct connection required for DDL statements.
Migration strategyProduction DB was not initialized with Prisma migration tracking. Schema changes are applied as raw SQL via DATABASE_URL_UNPOOLED npx prisma db execute --stdin. Local dev uses the local Neon endpoint and prisma migrate dev.
Client initializationPrismaPg adapter initialized with connectionString at runtime. Singleton on globalThis in dev to survive hot-reloads without exhausting the connection limit.

Environment variables

VariableRequiredPurpose
DATABASE_URLYesNeon pooled connection string (used by Prisma at runtime)
DATABASE_URL_UNPOOLEDMigrations onlyDirect Neon connection for DDL execution
MOD_SECRETYes (prod)HMAC signing key for mod tokens and join tokens
MOD_PASSWORDYes (prod)SHA-256 hex digest of the moderator password
ANTHROPIC_API_KEYFor AI importClaude API key for warrant article PDF parsing
KV_REST_API_URLFor rate limitingUpstash Redis endpoint (Vercel Marketplace KV format)
KV_REST_API_TOKENFor rate limitingUpstash Redis auth token

Room code generation

// lib/roomCode.ts // Excludes visually ambiguous chars: 0/O, 1/I, S/5 // Characters shown on a projector screen must be readable at a distance CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" function generateRoomCode(): return Array.from({ length: 6 }, () => CHARS[randomInt(CHARS.length)]).join("") // At meeting creation: retry loop up to 10 times to ensure uniqueness // crypto.randomInt() — not Math.random() — cryptographically secure

Cron job

GET /api/cron/deactivate-tokens is a Vercel cron route that runs on a schedule to deactivate voter tokens for meetings that have been closed. This prevents tokens from old meetings remaining claimable indefinitely.

9

AI Integration

Claude is used in exactly one place: parsing a town meeting warrant PDF into structured article data so the moderator doesn't have to enter each article manually.

Endpoint: POST /api/meetings/[meetingId]/import-ballot

// Flow: 1. Moderator uploads PDF via multipart form 2. Server reads the file as ArrayBuffer, encodes to base64 3. Sends to Claude claude-sonnet-4-6 as a document content block: { type: "document", source: { type: "base64", media_type: "application/pdf", data: ... } } 4. Prompt instructs Claude to return a JSON array: [{ number: "1", question: "...", description: "..." }, ...] 5. Response is returned to the moderator for review 6. Moderator confirms → second POST with action="import" bulk-inserts the articles

The AI result is always shown to the moderator before anything is written to the database — it's a review step, not an autonomous import. The moderator can edit, reorder, or discard articles before confirming.

Two-step import. The first POST (with the PDF) returns the extracted articles as JSON. The second POST (with action="import" and the confirmed articles array) does the actual DB writes. This prevents a malformed Claude response from silently polluting the meeting's article list.
10

Testing

Test setup

  • Vitest 4 — test runner and assertion library
  • Prisma client is fully mockedvi.mock("@/lib/prisma") replaces all DB calls with jest-style mock functions. Tests never hit a real database.
  • Next.js NextRequest constructed inline — route handlers are called directly as async functions; no HTTP server is spun up
  • 50 test files, 707 tests — all passing
  • Playwright configured for E2E against the running dev server

Key test patterns

// Constructing a test request with mod auth function makeReq(url, init): return new NextRequest(new Request(url, init)) function globalModHeaders(): return { "x-mod-auth": signToken("global") } // Calling a route handler directly const res = await sessionsPATCH(req, ctx({ sessionId: "s1" })) expect(res.status).toBe(200) // Mocking Prisma mockPrisma.votingSession.findUnique.mockResolvedValue({ id: "s1", status: "OPEN", ... }) mockPrisma.vote.findUnique.mockResolvedValue(null) // no duplicate mockPrisma.vote.create.mockResolvedValue({ id: "v1" })

Coverage areas

  • Vote integrity pipeline — all 8 checks tested individually (join token, voter pass binding, geofence, duplicate, etc.)
  • HMAC token sign/verify — valid tokens, expired tokens, tampered signatures, wrong meeting scope
  • Geofence accuracy cap — boundary conditions at 150m accuracy, rejection above cap
  • Amendment position shifting — sibling count, article number inheritance, parent-not-found path
  • Reconsider flow — closed sessions cleared and reopened, conflict detection, meeting-adjourned guard
  • Serializable transaction conflict paths — simulated via mock return values
  • CSV export — formula injection escaping, special character handling
  • Rate limiter — mock Redis responses, in-memory fallback behaviour