diff --git a/src/lib/routing.ts b/src/lib/routing.ts new file mode 100644 index 0000000..1437152 --- /dev/null +++ b/src/lib/routing.ts @@ -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'; +} diff --git a/tests/home-route.test.ts b/tests/home-route.test.ts new file mode 100644 index 0000000..f3281cf --- /dev/null +++ b/tests/home-route.test.ts @@ -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(); + }); +}); diff --git a/tests/pulse-status.test.ts b/tests/pulse-status.test.ts new file mode 100644 index 0000000..8499e0b --- /dev/null +++ b/tests/pulse-status.test.ts @@ -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'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..421556d --- /dev/null +++ b/tests/setup.ts @@ -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')); +} diff --git a/tests/votes.test.ts b/tests/votes.test.ts new file mode 100644 index 0000000..3af0377 --- /dev/null +++ b/tests/votes.test.ts @@ -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); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fe0120b --- /dev/null +++ b/vitest.config.ts @@ -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 } }, + }, +});