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() }) })