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>
102 lines
4.3 KiB
TypeScript
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();
|
|
});
|
|
});
|