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>
87 lines
2.8 KiB
TypeScript
87 lines
2.8 KiB
TypeScript
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);
|
|
});
|
|
});
|