Files
Matthias G cd3bf3f2b4 Manage entries at scale
- the admin uses the full window width, table cells stop wrapping
- columns sort by number, title, project, status, date and author
- filters sit in one row with search first and a page size next to them
- entries carry a real date: publish_at on create and change, publishing
  keeps that date, a date in the past publishes backdated
2026-08-01 15:43:05 +02:00

379 lines
13 KiB
TypeScript

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).toBeNull()
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).toBeNull()
})
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, ohne dem Entwurf eine Nummer zu geben', 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).toBeNull()
})
})
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('veröffentlicht rückwirkend, wenn der Termin in der Vergangenheit liegt', async () => {
const { entry } = await ready()
const earlier = new Date(Date.now() - 60 * 60 * 1000)
const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: earlier.toISOString() })
expect(state).toMatchObject({ ok: true })
expect(state.ok && state.entry.status).toBe('published')
expect(state.ok && state.entry.publishAt).toBe(earlier.toISOString())
})
it('übernimmt beim Veröffentlichen ein mitgegebenes Datum', async () => {
const { entry } = await ready()
const backdated = new Date('2026-01-15T09:00:00.000Z')
const state = await performPublishEntry(adminViewer(), { id: entry.id, publishAt: backdated.toISOString() })
expect(state.ok && state.entry.publishAt).toBe(backdated.toISOString())
})
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('lässt die Nummer des Entwurfs im Zielprojekt offen', 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).toBeNull()
})
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)
})
})