test: vitest suite — pulse status, vote uniqueness, home route

Three test files covering the Phase 1 invariants:
- derivePulseStatus: draft/closed are sticky; open auto-closes by date.
- vote UNIQUE(pulse_id, user_id): castVote (OR IGNORE) keeps the first vote
  silently, a raw second INSERT raises a constraint error, and uniqueness is
  per-pulse (same user on a different pulse is fine).
- homeRouteForRole: cab/fenja → /pulse, pilot → null (render existing home).

tests/setup.ts opens BIFROST_DB_PATH=':memory:' and applies all migrations
before tests run, so the in-memory DB has the live schema. Each vitest fork
gets its own globalThis → its own fresh in-memory DB.

The homeRouteForRole helper extraction makes the / role-redirect testable
without booting Astro. Step 6 will use it from /index.astro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 14:45:55 +02:00
parent fba369a36d
commit 20209db2d8
6 changed files with 183 additions and 0 deletions

14
src/lib/routing.ts Normal file
View file

@ -0,0 +1,14 @@
import type { Role } from './db.js';
/**
* The path the `/` route should redirect to for a given role, or `null` to
* render the existing editorial home content in place.
*
* - pilot null (keep existing /index.astro content right audience for it)
* - cab /pulse (the member portal is the council's landing)
* - fenja /pulse (admins see the member view by default; /admin is one click away)
*/
export function homeRouteForRole(role: Role): string | null {
if (role === 'pilot') return null;
return '/pulse';
}

16
tests/home-route.test.ts Normal file
View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { homeRouteForRole } from '../src/lib/routing.js';
describe('homeRouteForRole — what / does per user role', () => {
it('cab → redirect to /pulse', () => {
expect(homeRouteForRole('cab')).toBe('/pulse');
});
it('fenja → redirect to /pulse (admins see the member view by default)', () => {
expect(homeRouteForRole('fenja')).toBe('/pulse');
});
it('pilot → null (render existing editorial home in place)', () => {
expect(homeRouteForRole('pilot')).toBeNull();
});
});

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { derivePulseStatus } from '../src/lib/db.js';
const ago = (mins: number) => new Date(Date.now() - mins * 60_000).toISOString();
const ahead = (mins: number) => new Date(Date.now() + mins * 60_000).toISOString();
describe('derivePulseStatus', () => {
it('a draft stays a draft regardless of dates', () => {
expect(derivePulseStatus('draft', ago(60))).toBe('draft');
expect(derivePulseStatus('draft', ahead(60))).toBe('draft');
});
it('a closed pulse stays closed', () => {
expect(derivePulseStatus('closed', ahead(60))).toBe('closed');
expect(derivePulseStatus('closed', ago(60))).toBe('closed');
});
it('an open pulse stays open while now < closes_at', () => {
expect(derivePulseStatus('open', ahead(1))).toBe('open');
expect(derivePulseStatus('open', ahead(60 * 24))).toBe('open');
});
it('an open pulse becomes closed once now ≥ closes_at', () => {
expect(derivePulseStatus('open', ago(1))).toBe('closed');
expect(derivePulseStatus('open', ago(60 * 24))).toBe('closed');
});
it('respects an injected anchor time (deterministic)', () => {
const closes = '2026-05-11T12:00:00Z';
expect(derivePulseStatus('open', closes, new Date('2026-05-11T11:59:59Z'))).toBe('open');
expect(derivePulseStatus('open', closes, new Date('2026-05-11T12:00:00Z'))).toBe('closed');
expect(derivePulseStatus('open', closes, new Date('2026-05-11T12:00:01Z'))).toBe('closed');
});
});

21
tests/setup.ts Normal file
View file

@ -0,0 +1,21 @@
// Test setup: opens an in-memory SQLite database, applies all migrations.
// Each vitest worker fork gets its own globalThis, so each test file gets a
// fresh DB. db.ts reads BIFROST_DB_PATH at import time, so this must run first.
process.env.BIFROST_DB_PATH = ':memory:';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import type Database from 'better-sqlite3';
// Importing db.ts triggers the singleton against ':memory:'.
await import('../src/lib/db.js');
const db = (globalThis as typeof globalThis & { __bifrost_db?: Database.Database }).__bifrost_db;
if (!db) throw new Error('db singleton did not initialise against :memory:');
const migrationsDir = join(process.cwd(), 'migrations');
const files = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const file of files) {
db.exec(readFileSync(join(migrationsDir, file), 'utf8'));
}

87
tests/votes.test.ts Normal file
View file

@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createPulse, castVote, getUserVote, countPulseParticipants } from '../src/lib/db.js';
const db = (globalThis as typeof globalThis & { __bifrost_db?: Database.Database }).__bifrost_db!;
function seedUser(name: string, role: 'pilot' | 'cab' | 'fenja' = 'cab'): number {
const r = db.prepare(
'INSERT INTO users (email, password_hash, name, organisation, role, slug) VALUES (?,?,?,?,?,?)'
).run(`${name}@example.test`, 'x', name, 'TestOrg', role, name.toLowerCase());
return Number(r.lastInsertRowid);
}
function seedPulse(createdBy: number): number {
return createPulse({
question: 'Should we ship X?',
context: null,
options: ['Yes', 'No'],
opens_at: new Date(Date.now() - 60_000).toISOString(),
closes_at: new Date(Date.now() + 60 * 60_000).toISOString(),
status: 'open',
created_by: createdBy,
});
}
beforeEach(() => {
db.exec(`
DELETE FROM votes;
DELETE FROM pulses;
DELETE FROM activity;
DELETE FROM users;
`);
});
describe('vote uniqueness (UNIQUE(pulse_id, user_id))', () => {
it('rejects a second vote by the same user on the same pulse (first wins)', () => {
const admin = seedUser('admin', 'fenja');
const voter = seedUser('voter', 'cab');
const pulseId = seedPulse(admin);
castVote(pulseId, voter, 0);
// INSERT OR IGNORE → silent no-op for the duplicate.
castVote(pulseId, voter, 1);
expect(getUserVote(pulseId, voter)).toBe(0);
expect(countPulseParticipants(pulseId)).toBe(1);
});
it('raises a SqliteError when bypassing OR IGNORE with a raw INSERT', () => {
const admin = seedUser('admin', 'fenja');
const voter = seedUser('voter', 'cab');
const pulseId = seedPulse(admin);
castVote(pulseId, voter, 0);
expect(() =>
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index) VALUES (?,?,?)')
.run(pulseId, voter, 1)
).toThrow(/UNIQUE constraint failed/);
});
it('allows two different users to vote on the same pulse', () => {
const admin = seedUser('admin', 'fenja');
const a = seedUser('alice', 'cab');
const b = seedUser('bob', 'cab');
const pulseId = seedPulse(admin);
castVote(pulseId, a, 0);
castVote(pulseId, b, 1);
expect(countPulseParticipants(pulseId)).toBe(2);
expect(getUserVote(pulseId, a)).toBe(0);
expect(getUserVote(pulseId, b)).toBe(1);
});
it('allows the same user to vote on two different pulses', () => {
const admin = seedUser('admin', 'fenja');
const voter = seedUser('voter', 'cab');
const p1 = seedPulse(admin);
const p2 = seedPulse(admin);
castVote(p1, voter, 0);
castVote(p2, voter, 1);
expect(getUserVote(p1, voter)).toBe(0);
expect(getUserVote(p2, voter)).toBe(1);
});
});

11
vitest.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
setupFiles: ['./tests/setup.ts'],
// Each test file gets its own worker → its own globalThis → its own in-memory DB.
pool: 'forks',
poolOptions: { forks: { singleFork: false } },
},
});