Files
Matthias G 2fd71fd406 Number entries on publish, delete media, read the cover
- drafts stay unnumbered, the number is handed out when an entry goes live
- DELETE /api/v1/media/:id and a delete control in the media library
- cover_media_id in the draft read, coverMediaId and coverUrl in the archive read
2026-08-01 12:25:01 +02:00

305 lines
9.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { db } from '~/data/db'
import { apiClients, brands, postTypes, posts, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
import { POST as createPost } from '~/app/api/v1/posts/route'
import { DELETE as deletePost, GET as readPost, PATCH as patchPost } from '~/app/api/v1/posts/[slug]/route'
import { PUT as putBlocks } from '~/app/api/v1/posts/[slug]/blocks/route'
import { GET as getTypes } from '~/app/api/v1/post-types/route'
async function seed() {
const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning()
const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk', code: 'TRK' }).returning()
const [orbit] = await db.insert(projects).values({ brandId: brand!.id, slug: 'orbit', name: 'Orbit', code: 'ORB' }).returning()
await db.insert(postTypes).values([
{ key: 'feature', labelDe: 'Feature', labelEn: 'Feature', color: '#2b4a9b', sort: 1 },
{ key: 'fix', labelDe: 'Fix', labelEn: 'Fix', color: '#b4761a', sort: 2 },
{ key: 'alt', labelDe: 'Alt', labelEn: 'Old', color: '#000000', sort: 9, isActive: false },
]).onConflictDoNothing()
await db.insert(apiClients).values([
{ name: 'Schreiber', tokenHash: hashToken('lb_w'), mode: 'write', scope: 'customer' },
{ name: 'Leser', tokenHash: hashToken('lb_r'), mode: 'read', scope: 'customer' },
{ name: 'Gebunden', tokenHash: hashToken('lb_bound'), mode: 'write', scope: 'customer', projectId: trakk!.id },
{ name: 'Nur intern', tokenHash: hashToken('lb_int'), mode: 'write', scope: 'internal' },
])
return { trakk: trakk!, orbit: orbit! }
}
function post(body: unknown, token = 'lb_w') {
return new Request('http://localhost/api/v1/posts', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
function withId(id: string, method: string, body?: unknown, token = 'lb_w') {
return new Request(`http://localhost/api/v1/posts/${id}`, {
method,
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
}
function params(id: string) {
return { params: Promise.resolve({ slug: id }) }
}
const base = { project: 'trakk', title: 'Regel-Engine', type: 'feature' }
async function created(body: Record<string, unknown> = {}, token = 'lb_w') {
const response = await createPost(post({ ...base, ...body }, token))
return { response, body: await response.json() }
}
describe('POST /api/v1/posts', () => {
it('legt einen Entwurf an, niemals etwas Veröffentlichtes', async () => {
await seed()
const { response, body } = await created({ audience: 'customer' })
expect(response.status).toBe(201)
expect(body.status).toBe('draft')
expect(body.number).toBeNull()
expect(body.slug).toBe('regel-engine')
})
it('antwortet 401 ohne Token', async () => {
await seed()
const response = await createPost(new Request('http://localhost/api/v1/posts', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(base),
}))
expect(response.status).toBe(401)
})
it('lässt einen Lesezugang nicht schreiben', async () => {
await seed()
const { response } = await created({}, 'lb_r')
expect(response.status).toBe(403)
})
it('lässt einen gebundenen Zugang nicht in ein fremdes Projekt schreiben', async () => {
await seed()
const { response } = await created({ project: 'orbit' }, 'lb_bound')
expect(response.status).toBe(403)
})
it('lehnt eine unbekannte Art ab', async () => {
await seed()
const { response } = await created({ type: 'gibtesnicht' })
expect(response.status).toBe(422)
})
it('lehnt eine abgeschaltete Art ab', async () => {
await seed()
const { response } = await created({ type: 'alt' })
expect(response.status).toBe(422)
})
it('lässt keine Zielgruppe zu, die offener ist als der Zugang', async () => {
await seed()
const offen = await created({ audience: 'public' })
const eng = await created({ audience: 'customer' }, 'lb_int')
expect(offen.response.status).toBe(403)
expect(eng.response.status).toBe(403)
})
it('verlangt einen Titel', async () => {
await seed()
const { response } = await created({ title: ' ' })
expect(response.status).toBe(400)
})
it('lehnt einen unbekannten Blocktyp ab', async () => {
await seed()
const { response } = await created({ blocks: [{ type: 'unbekannt', data: {} }] })
expect(response.status).toBe(400)
})
it('gibt bei gleichem Idempotenz-Schlüssel denselben Beitrag zurück', async () => {
await seed()
const first = await created({ idempotency_key: 'abc' })
const second = await created({ title: 'Anderer Titel', idempotency_key: 'abc' })
expect(second.body.id).toBe(first.body.id)
expect(second.body.title).toBe('Regel-Engine')
const rows = await db.select().from(posts)
expect(rows).toHaveLength(1)
})
})
describe('PATCH und DELETE /api/v1/posts/:id', () => {
it('ändert einen Entwurf', async () => {
await seed()
const { body } = await created()
const response = await patchPost(withId(body.id, 'PATCH', { title: 'Neuer Titel' }), params(body.id))
const updated = await response.json()
expect(response.status).toBe(200)
expect(updated.title).toBe('Neuer Titel')
})
it('lehnt eine Änderung an einem veröffentlichten Beitrag mit 409 ab', async () => {
await seed()
const { body } = await created()
await db.update(posts).set({ status: 'published', publishAt: new Date() }).where(eq(posts.id, body.id))
const response = await patchPost(withId(body.id, 'PATCH', { title: 'Zu spät' }), params(body.id))
expect(response.status).toBe(409)
})
it('verlangt die Kennung statt des Slugs', async () => {
await seed()
await created()
const response = await patchPost(withId('regel-engine', 'PATCH', { title: 'X' }), params('regel-engine'))
expect(response.status).toBe(400)
})
it('löscht einen Entwurf', async () => {
await seed()
const { body } = await created()
const response = await deletePost(withId(body.id, 'DELETE'), params(body.id))
expect(response.status).toBe(200)
expect(await db.select().from(posts)).toHaveLength(0)
})
it('löscht nichts, was veröffentlicht ist', async () => {
await seed()
const { body } = await created()
await db.update(posts).set({ status: 'published', publishAt: new Date() }).where(eq(posts.id, body.id))
const response = await deletePost(withId(body.id, 'DELETE'), params(body.id))
expect(response.status).toBe(409)
expect(await db.select().from(posts)).toHaveLength(1)
})
})
describe('PUT /api/v1/posts/:id/blocks', () => {
it('setzt die Blöcke in der übergebenen Reihenfolge', async () => {
await seed()
const { body } = await created()
const response = await putBlocks(
withId(body.id, 'PUT', {
blocks: [
{ type: 'quote', data: { text: 'Zitat', source: '' } },
{ type: 'text', data: { text: 'Text' } },
],
}),
params(body.id),
)
const updated = await response.json()
expect(response.status).toBe(200)
expect(updated.blocks.map((block: { type: string }) => block.type)).toEqual(['quote', 'text'])
})
it('lehnt zu viele Blöcke ab', async () => {
await seed()
const { body } = await created()
const blocks = Array.from({ length: 101 }, () => ({ type: 'text', data: { text: 'x' } }))
const response = await putBlocks(withId(body.id, 'PUT', { blocks }), params(body.id))
expect(response.status).toBe(400)
})
})
describe('GET /api/v1/post-types', () => {
it('liefert nur aktive Arten', async () => {
await seed()
const response = await getTypes(new Request('http://localhost/api/v1/post-types', {
headers: { authorization: 'Bearer lb_r' },
}))
const body = await response.json()
const keys = body.items.map((item: { key: string }) => item.key)
expect(keys).toContain('feature')
expect(keys).not.toContain('alt')
})
})
describe('GET /api/v1/posts/:id', () => {
it('gibt einem Schreibzugang seinen Entwurf zurück', async () => {
await seed()
const { body } = await created()
const response = await readPost(withId(body.id, 'GET'), params(body.id))
const read = await response.json()
expect(response.status).toBe(200)
expect(read.id).toBe(body.id)
expect(read.status).toBe('draft')
})
})
describe('GET /api/v1/posts/:id gibt nichts heraus, was nicht sichtbar ist', () => {
it('gibt einem Lesezugang keinen Entwurf', async () => {
await seed()
const { body } = await created()
const response = await readPost(withId(body.id, 'GET', undefined, 'lb_r'), params(body.id))
expect(response.status).toBe(404)
})
it('gibt einem Kundenzugang keinen internen Beitrag', async () => {
await seed()
const { body } = await created({ audience: 'internal' }, 'lb_int')
await db.update(posts).set({ status: 'published', publishAt: new Date() }).where(eq(posts.id, body.id))
const response = await readPost(withId(body.id, 'GET', undefined, 'lb_r'), params(body.id))
expect(response.status).toBe(404)
})
it('gibt einem Lesezugang einen veröffentlichten Beitrag seiner Zielgruppe', async () => {
await seed()
const { body } = await created({ audience: 'customer' })
await db.update(posts).set({ status: 'published', publishAt: new Date() }).where(eq(posts.id, body.id))
const response = await readPost(withId(body.id, 'GET', undefined, 'lb_r'), params(body.id))
expect(response.status).toBe(200)
})
})