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:
parent
fba369a36d
commit
20209db2d8
6 changed files with 183 additions and 0 deletions
14
src/lib/routing.ts
Normal file
14
src/lib/routing.ts
Normal 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
16
tests/home-route.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
tests/pulse-status.test.ts
Normal file
34
tests/pulse-status.test.ts
Normal 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
21
tests/setup.ts
Normal 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
87
tests/votes.test.ts
Normal 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
11
vitest.config.ts
Normal 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 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue