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.
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user