Add Logbuch: project update blog with admin, media and API

Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
This commit is contained in:
Matthias Giesselmann
2026-07-31 21:33:42 +02:00
commit b90ff252d1
291 changed files with 43671 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { findEntry, listEntryBlocks } from '~/data/repositories/entries'
import {
performArchiveEntry,
performPublishEntry,
performResumeEntry,
performSaveEntry,
performScheduleEntry,
performUnscheduleEntry,
} from '~/lib/entries'
import { adminViewer, auditEntries, makeBrand, makeProject, moderatorViewer, resetAccounts } from '../support/admin'
import { entryInput, makeMedia } from '../support/entries'
beforeEach(resetAccounts)
async function ready(audience: 'internal' | 'customer' | 'public' = 'internal') {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { audience }))
if (!state.ok) {
throw new Error(`Anlegen fehlgeschlagen: ${state.error}`)
}
return { project, entry: state.entry }
}
describe('performSaveEntry legt an', () => {
it('speichert Beitrag, Blöcke und Autor', async () => {
const { entry } = await ready()
expect(entry.number).toBe(1)
expect(entry.slug).toBe('regel-engine')
expect(entry.status).toBe('draft')
const saved = await findEntry(entry.id)
const blocks = await listEntryBlocks(entry.id)
expect(saved?.title).toBe('Regel-Engine')
expect(saved?.authorName).toBe('Admin')
expect(blocks).toHaveLength(1)
expect(blocks[0]?.type).toBe('text')
const entries = await auditEntries()
expect(entries[0]).toMatchObject({ action: 'post.create', entity: 'post', entityId: entry.id })
})
it('verlangt einen Titel', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { title: ' ' }))
expect(state).toMatchObject({ ok: false, error: 'titleMissing' })
})
it('lehnt einen unbekannten Blocktyp ab', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performSaveEntry(
adminViewer(),
entryInput(project.id, { blocks: [{ type: 'unbekannt', data: {} }] as never }),
)
expect(state).toMatchObject({ ok: false, error: 'blocksInvalid' })
})
it('lässt einen Moderator nur in seinem Projekt schreiben', async () => {
const brand = await makeBrand()
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
const allowed = await performSaveEntry(moderatorViewer([trakk.id]), entryInput(trakk.id))
const denied = await performSaveEntry(moderatorViewer([trakk.id]), entryInput(orbit.id))
expect(allowed.ok).toBe(true)
expect(denied).toMatchObject({ ok: false, error: 'forbidden' })
})
})
describe('performSaveEntry ändert', () => {
it('behält Nummer und Slug, wenn sich der Titel ändert', async () => {
const { project, entry } = await ready()
const state = await performSaveEntry(
adminViewer(),
entryInput(project.id, { id: entry.id, title: 'Regel-Engine, zweiter Anlauf' }),
)
expect(state).toMatchObject({ ok: true })
expect(state.ok && state.entry.slug).toBe('regel-engine')
expect(state.ok && state.entry.number).toBe(1)
})
it('schreibt Blöcke in neuer Reihenfolge', async () => {
const { project, entry } = await ready()
await performSaveEntry(adminViewer(), entryInput(project.id, {
id: entry.id,
blocks: [
{ type: 'quote', data: { text: 'Erst das Zitat', source: '' } },
{ type: 'text', data: { text: 'Dann der Text' } },
],
}))
const blocks = await listEntryBlocks(entry.id)
expect(blocks.map(block => block.type)).toEqual(['quote', 'text'])
})
it('lehnt einen vergebenen Slug ab', async () => {
const { project, entry } = await ready()
await performSaveEntry(adminViewer(), entryInput(project.id, { title: 'SLA-Uhr', slug: 'sla-uhr' }))
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { id: entry.id, slug: 'sla-uhr' }))
expect(state).toMatchObject({ ok: false, error: 'slugTaken' })
})
it('lässt das Projekt wechseln und nummeriert im Zielprojekt neu', async () => {
const brand = await makeBrand()
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
const created = await performSaveEntry(adminViewer(), entryInput(trakk.id))
const state = await performSaveEntry(
adminViewer(),
entryInput(orbit.id, { id: created.ok ? created.entry.id : '' }),
)
expect(state.ok).toBe(true)
const saved = await findEntry(created.ok ? created.entry.id : '')
expect(saved?.projectId).toBe(orbit.id)
expect(saved?.number).toBe(1)
})
})
describe('Statusübergänge', () => {
it('veröffentlicht einen Entwurf und setzt den Zeitpunkt', async () => {
const { entry } = await ready()
const state = await performPublishEntry(adminViewer(), { id: entry.id })
expect(state).toMatchObject({ ok: true })
expect(state.ok && state.entry.status).toBe('published')
expect(state.ok && state.entry.publishAt).not.toBeNull()
const entries = await auditEntries()
expect(entries[0]).toMatchObject({ action: 'post.publish', entityId: entry.id })
})
it('zieht einen veröffentlichten Beitrag zurück und holt ihn wieder', async () => {
const { entry } = await ready()
await performPublishEntry(adminViewer(), { id: entry.id })
const archived = await performArchiveEntry(adminViewer(), { id: entry.id })
const resumed = await performResumeEntry(adminViewer(), { id: entry.id })
expect(archived.ok && archived.entry.status).toBe('archived')
expect(resumed.ok && resumed.entry.status).toBe('draft')
})
it('setzt einen Termin und nimmt ihn zurück', async () => {
const { entry } = await ready()
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
const scheduled = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: later })
const dropped = await performUnscheduleEntry(adminViewer(), { id: entry.id })
expect(scheduled.ok && scheduled.entry.status).toBe('scheduled')
expect(scheduled.ok && scheduled.entry.publishAt).toBe(new Date(later).toISOString())
expect(dropped.ok && dropped.entry.status).toBe('draft')
expect(dropped.ok && dropped.entry.publishAt).toBeNull()
})
it('lehnt einen Termin in der Vergangenheit ab', async () => {
const { entry } = await ready()
const earlier = new Date(Date.now() - 60 * 1000).toISOString()
const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: earlier })
expect(state).toMatchObject({ ok: false, error: 'schedulePast' })
})
it('lehnt einen Übergang ab, den der Status nicht hergibt', async () => {
const { entry } = await ready()
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
await performPublishEntry(adminViewer(), { id: entry.id })
const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: later })
expect(state).toMatchObject({ ok: false, error: 'statusInvalid' })
})
it('lässt einen fremden Moderator nichts veröffentlichen', async () => {
const { entry } = await ready()
const state = await performPublishEntry(moderatorViewer([]), { id: entry.id })
expect(state).toMatchObject({ ok: false, error: 'notFound' })
const saved = await findEntry(entry.id)
expect(saved?.status).toBe('draft')
})
})
describe('Veröffentlichungsprüfungen', () => {
it('sperrt einen öffentlichen Beitrag mit Bild ohne Alt-Text', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const item = await makeMedia(project.id, null)
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
audience: 'public',
blocks: [
{ type: 'text', data: { text: 'Dazu ein Bild.' } },
{ type: 'image', data: { mediaId: item.id } },
],
}))
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
expect(state).toMatchObject({ ok: false, error: 'blocked' })
expect(!state.ok && state.blockers).toContain('alt_text_missing')
const saved = await findEntry(created.ok ? created.entry.id : '')
expect(saved?.status).toBe('draft')
})
it('lässt denselben Beitrag intern durch', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const item = await makeMedia(project.id, null)
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
audience: 'internal',
blocks: [{ type: 'image', data: { mediaId: item.id } }],
}))
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
expect(state).toMatchObject({ ok: true })
})
it('sperrt einen Beitrag ohne Inhalt', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
blocks: [{ type: 'text', data: { text: ' ' } }],
}))
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
expect(state).toMatchObject({ ok: false, error: 'blocked' })
expect(!state.ok && state.blockers).toContain('content_empty')
})
it('sperrt einen Termin, solange ein Sperrgrund besteht', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const created = await performSaveEntry(adminViewer(), entryInput(project.id, { blocks: [] }))
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
const state = await performScheduleEntry(adminViewer(), {
id: created.ok ? created.entry.id : '',
publishAt: later,
})
expect(state).toMatchObject({ ok: false, error: 'blocked' })
})
})
describe('performSaveEntry wechselt das Projekt', () => {
it('vergibt im Zielprojekt die nächste freie Nummer', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
const first = await performSaveEntry(adminViewer(), entryInput(to.id, { slug: 'schon-da' }))
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
if (!first.ok || !created.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
const moved = await performSaveEntry(adminViewer(), {
...entryInput(to.id),
id: created.entry.id,
})
if (!moved.ok) {
throw new Error(`Wechsel fehlgeschlagen: ${moved.error}`)
}
const saved = await findEntry(created.entry.id)
expect(saved?.projectId).toBe(to.id)
expect(moved.entry.number).toBe(2)
})
it('macht den Slug im Zielprojekt eindeutig', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
const blocking = await performSaveEntry(adminViewer(), entryInput(to.id))
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
if (!blocking.ok || !created.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
const moved = await performSaveEntry(adminViewer(), {
...entryInput(to.id),
id: created.entry.id,
})
if (!moved.ok) {
throw new Error(`Wechsel fehlgeschlagen: ${moved.error}`)
}
expect(moved.entry.slug).not.toBe(blocking.entry.slug)
})
it('schreibt den Wechsel ins Protokoll', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
if (!created.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
await performSaveEntry(adminViewer(), { ...entryInput(to.id), id: created.entry.id })
const log = await auditEntries()
expect(log.some(row => row.action === 'post.move')).toBe(true)
})
it('lässt einen Moderator ohne Recht am Zielprojekt nicht wechseln', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
if (!created.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
const result = await performSaveEntry(moderatorViewer([from.id]), {
...entryInput(to.id),
id: created.entry.id,
})
expect(result.ok).toBe(false)
})
})