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 = {}, 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) }) })