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