Files
logbuch/tests/api/write.test.ts
T
Matthias Giesselmann b90ff252d1 Add Logbuch: project update blog with admin, media and API
Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
2026-07-31 21:33:42 +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).toBe(1)
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)
})
})