ed5be8dd93
Rows carry a checkbox, the bar above publishes, withdraws, resets to draft, sets the audience or deletes drafts for the whole selection and reports what went through.
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
import { findEntry, listEntryBlocks } from '~/data/repositories/entries'
|
|
import {
|
|
performArchiveEntry,
|
|
performBulkEntries,
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('performBulkEntries', () => {
|
|
it('veröffentlicht mehrere Entwürfe auf einmal', async () => {
|
|
const first = await ready()
|
|
const second = await performSaveEntry(adminViewer(), entryInput(first.project.id, { title: 'Zweiter Beitrag' }))
|
|
|
|
if (!second.ok) {
|
|
throw new Error('Anlegen fehlgeschlagen')
|
|
}
|
|
|
|
const outcome = await performBulkEntries(adminViewer(), [first.entry.id, second.entry.id], 'publish')
|
|
|
|
expect(outcome).toMatchObject({ done: 2, failed: 0, blocked: 0 })
|
|
expect((await findEntry(first.entry.id))?.status).toBe('published')
|
|
expect((await findEntry(second.entry.id))?.status).toBe('published')
|
|
})
|
|
|
|
it('setzt die Zielgruppe für eine Auswahl', async () => {
|
|
const { entry } = await ready()
|
|
|
|
const outcome = await performBulkEntries(adminViewer(), [entry.id], 'audience', 'public')
|
|
|
|
expect(outcome).toMatchObject({ done: 1 })
|
|
expect((await findEntry(entry.id))?.audience).toBe('public')
|
|
})
|
|
|
|
it('löscht nur Entwürfe', async () => {
|
|
const { entry } = await ready()
|
|
|
|
await performPublishEntry(adminViewer(), { id: entry.id })
|
|
|
|
const outcome = await performBulkEntries(adminViewer(), [entry.id], 'delete')
|
|
|
|
expect(outcome).toMatchObject({ done: 0, failed: 1 })
|
|
expect(await findEntry(entry.id)).toBeDefined()
|
|
})
|
|
})
|