project-bifrost-platform/tests/dispatches.test.ts
Jonathan Hvid 3b602a787b feat(component): DispatchesSection + reusable Avatar + tests
DispatchesSection (white-card, used on /pulse):
- Header row: LATEST FROM THE STUDIO + All dispatches → link
- Featured: 26px avatar byline (name · title · relative time + kind pill),
  serif italic title, two-paragraph excerpt (lead in body tone, trail in
  --secondary with ellipsis) cut on the nearest sentence boundary, then
  the terracotta uppercase 'Read the full dispatch →' link
- Earlier: 1px divider + EARLIER label + up to 3 rows with 22px avatar,
  serif italic title (single-line ellipsis), relative time
- Hidden entirely when zero published dispatches; divider + earlier list
  omitted when exactly one published dispatch exists

Avatar component (src/components/Avatar.astro) — pure presentational,
takes id + name + size, paints a deterministic-pigment circle with serif
italic initials. Reused by DispatchesSection now and by /members, /events,
and RecentlyFromTheCouncil in the next commits.

format.ts: adds dispatchKindPigment (decision→terracotta, update→indigo,
behind_the_scenes→ochre, note→heather) for pill backgrounds.

Tests: 9 cases covering create-as-draft vs published, publishDispatch
idempotency (re-publish preserves published_at), archive preserves
published_at, the published feed excludes drafts/archived, adjacent
prev/next, and slug round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:57:52 +02:00

102 lines
4.3 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import {
createDispatch, publishDispatch, archiveDispatch,
getDispatchById, getLatestPublishedDispatches, getAdjacentDispatches,
createUser,
} from '../src/lib/db.js';
import { dispatchSlug, parseDispatchSlug } from '../src/lib/format.js';
const db = (globalThis as typeof globalThis & { __bifrost_db?: Database.Database }).__bifrost_db!;
let admin: number;
beforeEach(() => {
db.exec(`
DELETE FROM votes;
DELETE FROM pulses;
DELETE FROM activity;
DELETE FROM dispatches;
DELETE FROM users;
`);
admin = createUser({ email: 'a@x.test', password_hash: 'x', name: 'Admin', organisation: 'Fenja', role: 'fenja' });
});
describe('Dispatch status transitions', () => {
it('draft create leaves published_at null', () => {
const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'draft' });
const d = getDispatchById(id)!;
expect(d.status).toBe('draft');
expect(d.published_at).toBeNull();
});
it('published create stamps published_at', () => {
const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'decision', author_id: admin, status: 'published' });
const d = getDispatchById(id)!;
expect(d.status).toBe('published');
expect(d.published_at).not.toBeNull();
});
it('publishDispatch on a draft stamps published_at', () => {
const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'draft' });
publishDispatch(id);
const d = getDispatchById(id)!;
expect(d.status).toBe('published');
expect(d.published_at).not.toBeNull();
});
it('publishDispatch is idempotent — re-publishing keeps the original published_at', () => {
const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'draft' });
publishDispatch(id);
const first = getDispatchById(id)!.published_at;
// Force a tick so a re-stamp would visibly differ
publishDispatch(id);
const second = getDispatchById(id)!.published_at;
expect(second).toBe(first);
});
it('archiveDispatch preserves published_at and only flips status', () => {
const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'update', author_id: admin, status: 'published' });
const before = getDispatchById(id)!.published_at;
archiveDispatch(id);
const d = getDispatchById(id)!;
expect(d.status).toBe('archived');
expect(d.published_at).toBe(before);
});
it('getLatestPublishedDispatches excludes drafts and archived', () => {
createDispatch({ title: 'A', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'draft' });
const pubId = createDispatch({ title: 'B', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'published' });
const archId = createDispatch({ title: 'C', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'published' });
archiveDispatch(archId);
const out = getLatestPublishedDispatches(10);
expect(out.map(d => d.id)).toEqual([pubId]);
});
it('getAdjacentDispatches returns prev/next in published order', () => {
const ids: number[] = [];
for (let i = 0; i < 3; i += 1) {
const id = createDispatch({ title: `T${i}`, body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'published' });
// Backdate so ordering is deterministic
db.prepare("UPDATE dispatches SET published_at = datetime('now', ?) WHERE id = ?").run(`-${(3 - i)} days`, id);
ids.push(id);
}
const { prev, next } = getAdjacentDispatches(ids[1]);
expect(prev?.id).toBe(ids[0]);
expect(next?.id).toBe(ids[2]);
});
});
describe('dispatch slug', () => {
it('produces {id}-kebab(title)', () => {
expect(dispatchSlug({ id: 7, title: 'A Decision About Telemetry' })).toBe('7-a-decision-about-telemetry');
expect(dispatchSlug({ id: 12, title: 'Behind the Scenes: April' })).toBe('12-behind-the-scenes-april');
});
it('parseDispatchSlug pulls the leading id', () => {
expect(parseDispatchSlug('7-a-decision-about-telemetry')).toBe(7);
expect(parseDispatchSlug('99')).toBe(99);
expect(parseDispatchSlug('not-a-slug')).toBeNull();
expect(parseDispatchSlug('')).toBeNull();
});
});