cd3bf3f2b4
- 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
379 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|