import { describe, expect, it } from 'vitest' import { db } from '~/data/db' import { apiClients, brands, posts, projects } from '~/data/schema' import { hashToken } from '~/lib/api-auth' import { GET as getPosts } from '~/app/api/v1/posts/route' import { GET as getPost } from '~/app/api/v1/posts/[slug]/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' }).returning() await db.insert(posts).values([ { projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine', audience: 'customer', status: 'published', publishAt: new Date('2026-07-30T08:00:00Z'), }, { projectId: trakk!.id, number: 2, slug: 'interner-umbau', title: 'Interner Umbau', audience: 'internal', status: 'published', publishAt: new Date('2026-07-29T08:00:00Z'), }, ]) await db.insert(apiClients).values([ { name: 'Kundenclient', tokenHash: hashToken('lb_customer'), mode: 'read', scope: 'customer', projectId: trakk!.id }, { name: 'Interner Client', tokenHash: hashToken('lb_internal'), mode: 'read', scope: 'internal', projectId: trakk!.id }, ]) } async function seedForeignProject() { const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning() const [mta] = await db.insert(projects).values({ brandId: brand!.id, slug: 'mta360', name: 'MTA360' }).returning() await db.insert(posts).values([ { projectId: mta!.id, number: 1, slug: 'spaltenmenue', title: 'Spaltenmenü', audience: 'customer', status: 'published', publishAt: new Date('2026-07-31T08:00:00Z'), }, { projectId: mta!.id, number: 2, slug: 'ansichten', title: 'Ansichten', audience: 'public', status: 'published', publishAt: new Date('2026-07-28T08:00:00Z'), }, ]) } async function seedRevoked() { await db.insert(apiClients).values({ name: 'Widerrufener Client', tokenHash: hashToken('lb_revoked'), mode: 'read', scope: 'internal', revokedAt: new Date('2026-07-29T12:00:00Z'), }) } function request(url: string, token?: string) { return new Request(url, { headers: token ? { authorization: `Bearer ${token}` } : {} }) } describe('GET /api/v1/posts', () => { it('antwortet 401 ohne Token', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts')) expect(response.status).toBe(401) }) it('gibt einem Kundenclient keine internen Beiträge, auch nicht mit Parameter', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts?audience=internal', 'lb_customer')) const body = await response.json() expect(response.status).toBe(200) expect(body.items.map((item: { slug: string }) => item.slug)).toEqual(['regel-engine']) }) it('gibt einem internen Client alle Beiträge', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts', 'lb_internal')) const body = await response.json() expect(body.items).toHaveLength(2) expect(body.meta.total).toBe(2) }) it('lehnt ungültige Parameter mit 400 ab', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts?per_page=999', 'lb_internal')) expect(response.status).toBe(400) }) it('begrenzt einen projektgebundenen Client auf sein Projekt', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts?project=mta360', 'lb_customer')) const body = await response.json() expect(body.items).toHaveLength(0) }) it('zeigt einem projektgebundenen Client auch ohne Parameter kein fremdes Projekt', async () => { await seed() await seedForeignProject() const response = await getPosts(request('http://localhost/api/v1/posts', 'lb_internal')) const body = await response.json() expect(body.items.map((item: { projectSlug: string }) => item.projectSlug)).toEqual(['trakk', 'trakk']) expect(body.meta.total).toBe(2) }) it('liefert nichts, wenn ein projektgebundener Client ein fremdes vorhandenes Projekt anfragt', async () => { await seed() await seedForeignProject() const response = await getPosts(request('http://localhost/api/v1/posts?project=mta360', 'lb_internal')) const body = await response.json() expect(body.items).toHaveLength(0) expect(body.meta.total).toBe(0) }) it('antwortet 401 für ein widerrufenes Token', async () => { await seed() await seedRevoked() const response = await getPosts(request('http://localhost/api/v1/posts', 'lb_revoked')) expect(response.status).toBe(401) }) it('antwortet auf einen Fehler mit Problem-JSON in der vereinbarten Form', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts')) const body = await response.json() expect(response.headers.get('content-type')).toContain('application/problem+json') expect(body).toMatchObject({ type: 'unauthorized', status: 401 }) expect(body.title).toBeTypeOf('string') }) it('weist Parameter jenseits der Grenzen ab', async () => { await seed() const cases = ['page=0', 'page=-1', 'per_page=0', 'per_page=101', 'since=irgendwann', 'type=Quatsch!'] for (const query of cases) { const response = await getPosts(request(`http://localhost/api/v1/posts?${query}`, 'lb_internal')) expect(response.status, query).toBe(400) } }) it('liefert zu einer unbekannten Beitragsart eine leere Liste statt eines Fehlers', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts?type=gibtesnicht', 'lb_internal')) const body = await response.json() expect(response.status).toBe(200) expect(body.items).toEqual([]) expect(body.meta.total).toBe(0) }) it('nennt die Beitragsart mit Schlüssel, Bezeichnungen und Farbe', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts', 'lb_internal')) const body = await response.json() expect(body.items[0].type).toEqual({ id: expect.any(String), key: 'feature', labelDe: 'Feature', labelEn: 'Feature', color: '#2b4a9b', }) }) it('nimmt die Grenzwerte an, die erlaubt sind', async () => { await seed() for (const query of ['page=1', 'per_page=1', 'per_page=100']) { const response = await getPosts(request(`http://localhost/api/v1/posts?${query}`, 'lb_internal')) expect(response.status, query).toBe(200) } }) it('nimmt since als Gleichheitsgrenze mit', async () => { await seed() const response = await getPosts(request('http://localhost/api/v1/posts?since=2026-07-30T08:00:00Z', 'lb_internal')) const body = await response.json() expect(body.items.map((item: { slug: string }) => item.slug)).toEqual(['regel-engine']) }) it('gibt einem öffentlichen Client weder interne noch Kundenbeiträge', async () => { await seed() await db.insert(apiClients).values({ name: 'Öffentlich', tokenHash: hashToken('lb_public'), mode: 'read', scope: 'public', }) const response = await getPosts(request('http://localhost/api/v1/posts', 'lb_public')) const body = await response.json() expect(response.status).toBe(200) expect(body.items).toHaveLength(0) expect(body.meta.total).toBe(0) }) it('liefert keine Beiträge eines stillgelegten Projekts', async () => { const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning() const [alt] = await db.insert(projects).values({ brandId: brand!.id, slug: 'altprojekt', name: 'Altprojekt', isActive: false }).returning() await db.insert(posts).values({ projectId: alt!.id, number: 1, slug: 'altbeitrag', title: 'Altbeitrag', audience: 'public', status: 'published', publishAt: new Date('2026-07-30T08:00:00Z'), }) await db.insert(apiClients).values({ name: 'Intern ohne Projekt', tokenHash: hashToken('lb_all'), mode: 'read', scope: 'internal', }) const response = await getPosts(request('http://localhost/api/v1/posts', 'lb_all')) const body = await response.json() expect(body.items).toHaveLength(0) }) }) describe('GET /api/v1/posts/:slug', () => { it('liefert einem Kundenclient einen Kundenbeitrag', async () => { await seed() const response = await getPost( request('http://localhost/api/v1/posts/regel-engine', 'lb_customer'), { params: Promise.resolve({ slug: 'regel-engine' }) }, ) const body = await response.json() expect(response.status).toBe(200) expect(body.slug).toBe('regel-engine') }) it('antwortet einem Kundenclient bei einem internen Beitrag mit 404', async () => { await seed() const response = await getPost( request('http://localhost/api/v1/posts/interner-umbau', 'lb_customer'), { params: Promise.resolve({ slug: 'interner-umbau' }) }, ) expect(response.status).toBe(404) }) it('antwortet einem projektgebundenen Client bei einem fremden Projekt mit 404', async () => { const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning() const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning() const [mta] = await db.insert(projects).values({ brandId: brand!.id, slug: 'mta360', name: 'MTA360' }).returning() await db.insert(posts).values({ projectId: mta!.id, number: 1, slug: 'spaltenmenue', title: 'Spaltenmenü', audience: 'customer', status: 'published', publishAt: new Date('2026-07-28T08:00:00Z'), }) await db.insert(apiClients).values({ name: 'Kundenclient', tokenHash: hashToken('lb_customer'), mode: 'read', scope: 'customer', projectId: trakk!.id, }) const response = await getPost( request('http://localhost/api/v1/posts/spaltenmenue', 'lb_customer'), { params: Promise.resolve({ slug: 'spaltenmenue' }) }, ) expect(response.status).toBe(404) }) })