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:
Matthias Giesselmann
2026-07-31 21:33:42 +02:00
commit b90ff252d1
291 changed files with 43671 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import sharp from 'sharp'
import { db } from '~/data/db'
import { apiClients, brands, media, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
import { sanitiseFilename } from '~/lib/media-upload'
import { POST as postMedia } from '~/app/api/v1/media/route'
import { GET as getFile } from '~/app/api/v1/media/file/[...path]/route'
let root: string
let previous: string | undefined
beforeAll(async () => {
previous = process.env.STORAGE_DIR
root = await mkdtemp(join(tmpdir(), 'logbuch-media-'))
process.env.STORAGE_DIR = root
})
afterAll(async () => {
if (previous === undefined) {
delete process.env.STORAGE_DIR
} else {
process.env.STORAGE_DIR = previous
}
await rm(root, { recursive: true, force: true })
})
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()
const [orbit] = await db.insert(projects).values({ brandId: brand!.id, slug: 'orbit', name: 'Orbit' }).returning()
await db.insert(apiClients).values([
{ name: 'Schreibclient', tokenHash: hashToken('lb_write'), mode: 'write', scope: 'internal', projectId: trakk!.id },
{ name: 'Leseclient', tokenHash: hashToken('lb_read'), mode: 'read', scope: 'internal', projectId: trakk!.id },
{ name: 'Freier Schreibclient', tokenHash: hashToken('lb_write_all'), mode: 'write', scope: 'internal' },
])
return { trakk: trakk!, orbit: orbit! }
}
async function png(width: number, height: number): Promise<Buffer> {
return sharp({ create: { width, height, channels: 3, background: '#2e7d5b' } }).png().toBuffer()
}
function upload(body: Buffer, options: { name?: string, token?: string, project?: string, alt?: string, type?: string } = {}) {
const form = new FormData()
const file = new File([new Uint8Array(body)], options.name ?? 'Aufmacher Bild.png', {
type: options.type ?? 'image/png',
})
form.set('file', file)
if (options.project) {
form.set('project', options.project)
}
if (options.alt) {
form.set('alt', options.alt)
}
const token = options.token ?? 'lb_write'
return new Request('http://localhost/api/v1/media', {
method: 'POST',
headers: { authorization: `Bearer ${token}` },
body: form,
})
}
function fileRequest(path: string[]) {
return getFile(new Request(`http://localhost/api/v1/media/file/${path.join('/')}`), {
params: Promise.resolve({ path }),
})
}
describe('POST /api/v1/media', () => {
it('legt einen Datensatz mit Fassungen in den erwarteten Breiten an', async () => {
await seed()
const response = await postMedia(upload(await png(2000, 1125), { alt: 'Ein ruhiges Schaubild' }))
const body = await response.json()
expect(response.status).toBe(201)
expect(body.width).toBe(2000)
expect(body.height).toBe(1125)
expect(body.mime).toBe('image/png')
expect(body.alt).toBe('Ein ruhiges Schaubild')
expect(body.variants.map((variant: { width: number }) => variant.width)).toEqual([480, 960, 1600])
expect(body.variants.every((variant: { format: string }) => variant.format === 'webp')).toBe(true)
expect(body.url).toMatch(/^\/api\/v1\/media\/file\//u)
const rows = await db.select().from(media)
expect(rows).toHaveLength(1)
expect(rows[0]!.variants).toHaveLength(3)
expect(rows[0]!.variantError).toBeNull()
})
it('bereinigt den Dateinamen', async () => {
await seed()
const response = await postMedia(upload(await png(600, 400), { name: '../Über uns/Größe & Maß.png' }))
const body = await response.json()
expect(body.originalFilename).toBe('Groesse-Mass.png')
})
it('liefert die abgelegten Fassungen über die Auslieferung aus', async () => {
await seed()
const created = await (await postMedia(upload(await png(2000, 1125)))).json()
const variant = created.variants.at(-1)
const path = String(variant.url).replace('/api/v1/media/file/', '').split('/')
const response = await fileRequest(path)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/webp')
expect(response.headers.get('cache-control')).toContain('immutable')
expect(Number(response.headers.get('content-length'))).toBeGreaterThan(0)
})
it('weist eine zu grosse Datei ab', async () => {
await seed()
const oversized = Buffer.alloc(15 * 1024 * 1024 + 1, 7)
const response = await postMedia(upload(oversized))
expect(response.status).toBe(413)
expect(await db.select().from(media)).toHaveLength(0)
})
it('weist eine Datei ab, die kein Bild ist', async () => {
await seed()
const response = await postMedia(upload(Buffer.from('nur text, als bild deklariert')))
expect(response.status).toBe(422)
expect(await db.select().from(media)).toHaveLength(0)
})
it('weist ein Bildformat ab, das nicht angenommen wird', async () => {
await seed()
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>')
const response = await postMedia(upload(svg, { name: 'zeichnung.svg', type: 'image/svg+xml' }))
expect(response.status).toBe(415)
expect(await db.select().from(media)).toHaveLength(0)
})
it('antwortet ohne Token mit 401', async () => {
await seed()
const request = new Request('http://localhost/api/v1/media', { method: 'POST', body: new FormData() })
expect((await postMedia(request)).status).toBe(401)
})
it('lässt ein Lesetoken nicht hochladen', async () => {
await seed()
const response = await postMedia(upload(await png(600, 400), { token: 'lb_read' }))
expect(response.status).toBe(403)
})
it('lässt ein projektgebundenes Token nicht in ein fremdes Projekt schreiben', async () => {
await seed()
const response = await postMedia(upload(await png(600, 400), { project: 'orbit' }))
expect(response.status).toBe(403)
})
it('verlangt von einem freien Token die Angabe des Projekts', async () => {
await seed()
const response = await postMedia(upload(await png(600, 400), { token: 'lb_write_all' }))
expect(response.status).toBe(400)
})
it('nimmt von einem freien Token ein benanntes Projekt an', async () => {
const { orbit } = await seed()
const response = await postMedia(upload(await png(600, 400), { token: 'lb_write_all', project: 'orbit' }))
const body = await response.json()
expect(response.status).toBe(201)
expect(body.projectId).toBe(orbit.id)
})
})
describe('GET /api/v1/media/file', () => {
it('weist jeden Pfad-Ausbruch mit 400 ab', async () => {
const escapes = [
['..', '..', 'etc', 'passwd'],
['..', '.env'],
['projekt', '..', '..', 'secret.txt'],
['', 'etc', 'passwd'],
['.', 'a.webp'],
]
for (const path of escapes) {
const response = await fileRequest(path)
expect(response.status, path.join('/')).toBe(400)
}
})
it('antwortet für ein unbekanntes Objekt mit 404', async () => {
const response = await fileRequest(['projekt', 'bild', 'w960.webp'])
expect(response.status).toBe(404)
})
})
describe('sanitiseFilename', () => {
it('nimmt Pfadanteile heraus und ersetzt Umlaute', () => {
expect(sanitiseFilename('/etc/passwd')).toBe('passwd')
expect(sanitiseFilename('..\\..\\Größe.png')).toBe('Groesse.png')
expect(sanitiseFilename('mein bild (1).PNG')).toBe('mein-bild-1.PNG')
})
it('fällt auf einen Ersatznamen zurück, wenn nichts übrig bleibt', () => {
expect(sanitiseFilename('...')).toBe('bild')
expect(sanitiseFilename('')).toBe('bild')
})
})
+284
View File
@@ -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)
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { db } from '~/data/db'
import { apiClients, brands, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
import { GET as getProjects } from '~/app/api/v1/projects/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', sort: 1 }).returning()
await db.insert(projects).values([
{ brandId: brand!.id, slug: 'mta360', name: 'MTA360', sort: 2 },
{ brandId: brand!.id, slug: 'altprojekt', name: 'Altprojekt', isActive: false, sort: 3 },
])
await db.insert(apiClients).values([
{ name: 'Trakk', tokenHash: hashToken('lb_trakk'), mode: 'read', scope: 'customer', projectId: trakk!.id },
{ name: 'Intern', tokenHash: hashToken('lb_intern'), mode: 'read', scope: 'internal' },
])
}
function request(url: string, token?: string) {
return new Request(url, { headers: token ? { authorization: `Bearer ${token}` } : {} })
}
describe('GET /api/v1/projects', () => {
it('antwortet 401 ohne Token', async () => {
await seed()
const response = await getProjects(request('http://localhost/api/v1/projects'))
expect(response.status).toBe(401)
})
it('gibt einem projektgebundenen Client nur sein eigenes Projekt', async () => {
await seed()
const response = await getProjects(request('http://localhost/api/v1/projects', 'lb_trakk'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.items.map((item: { slug: string }) => item.slug)).toEqual(['trakk'])
})
it('gibt einem Client ohne Projektbindung alle aktiven Projekte', async () => {
await seed()
const response = await getProjects(request('http://localhost/api/v1/projects', 'lb_intern'))
const body = await response.json()
expect(body.items.map((item: { slug: string }) => item.slug)).toEqual(['trakk', 'mta360'])
})
})
+224
View File
@@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { db } from '~/data/db'
import { apiClients, brands, postReads, posts, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
import { GET as getUnread } from '~/app/api/v1/unread/route'
import { POST as postRead } from '~/app/api/v1/read/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()
const [mta] = await db.insert(projects).values({ brandId: brand!.id, slug: 'mta360', name: 'MTA360' }).returning()
const inserted = 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: 'sla-uhr', title: 'SLA-Uhr',
audience: 'customer', status: 'published', publishAt: new Date('2026-07-25T08:00:00Z'),
},
]).returning()
const [internal] = await db.insert(posts).values({
projectId: trakk!.id, number: 3, slug: 'interner-umbau', title: 'Interner Umbau',
audience: 'internal', status: 'published', publishAt: new Date('2026-07-24T08:00:00Z'),
}).returning()
const [foreign] = 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'),
}).returning()
await db.insert(apiClients).values([
{ name: 'Trakk', tokenHash: hashToken('lb_trakk'), mode: 'read', scope: 'customer', projectId: trakk!.id },
{ name: 'MTA360', tokenHash: hashToken('lb_mta'), mode: 'read', scope: 'customer', projectId: mta!.id },
])
return {
postIds: inserted.map(post => post.id),
internalId: internal!.id,
foreignId: foreign!.id,
}
}
function get(url: string, token = 'lb_trakk') {
return new Request(url, { headers: { authorization: `Bearer ${token}` } })
}
function post(body: unknown, token = 'lb_trakk') {
return new Request('http://localhost/api/v1/read', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('gelesen-Status', () => {
it('zählt zuerst alle Beiträge als ungelesen', async () => {
await seed()
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(body.count).toBe(2)
expect(body.items).toHaveLength(2)
})
it('meldet Beiträge als gelesen und zählt danach weniger', async () => {
const { postIds } = await seed()
const marked = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(marked.status).toBe(200)
expect(body.count).toBe(1)
expect(body.items[0].slug).toBe('sla-uhr')
})
it('bleibt bei doppelter Meldung stabil', async () => {
const { postIds } = await seed()
await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
const second = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
expect(second.status).toBe(200)
})
it('trennt Nutzer voneinander', async () => {
const { postIds } = await seed()
await postRead(post({ external_user_id: 'u-42', post_ids: postIds }))
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-99'))
const body = await response.json()
expect(body.count).toBe(2)
})
it('verlangt eine Nutzerkennung', async () => {
await seed()
const response = await getUnread(get('http://localhost/api/v1/unread'))
expect(response.status).toBe(400)
})
it('zählt interne Beiträge für einen Kundenclient nicht als ungelesen', async () => {
await seed()
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(body.items.map((item: { slug: string }) => item.slug)).not.toContain('interner-umbau')
})
})
describe('POST /api/v1/read weist ab, was dem Client nicht gehört', () => {
it('antwortet auf eine unbekannte Beitrags-Kennung mit 200 und markiert nichts', async () => {
await seed()
const response = await postRead(post({ external_user_id: 'u-42', post_ids: ['00000000-0000-4000-8000-000000000000'] }))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.marked).toBe(0)
})
it('markiert keinen Beitrag eines fremden Projekts und speichert dazu keine Zeile', async () => {
const { foreignId } = await seed()
const response = await postRead(post({ external_user_id: 'u-42', post_ids: [foreignId] }))
const body = await response.json()
const rows = await db.select().from(postReads)
expect(response.status).toBe(200)
expect(body.marked).toBe(0)
expect(rows).toHaveLength(0)
})
it('lässt den gelesen-Status des fremden Projekts unberührt', async () => {
const { foreignId } = await seed()
await postRead(post({ external_user_id: 'u-42', post_ids: [foreignId] }))
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42', 'lb_mta'))
const body = await response.json()
expect(body.count).toBe(1)
expect(body.items[0].slug).toBe('spaltenmenue')
})
it('markiert für einen Kundenclient keinen internen Beitrag', async () => {
const { internalId } = await seed()
const response = await postRead(post({ external_user_id: 'u-42', post_ids: [internalId] }))
const body = await response.json()
expect(body.marked).toBe(0)
})
it('markiert erlaubte Beiträge und meldet bei Wiederholung null neue', async () => {
const { postIds } = await seed()
const first = await postRead(post({ external_user_id: 'u-42', post_ids: postIds }))
const second = await postRead(post({ external_user_id: 'u-42', post_ids: postIds }))
expect((await first.json()).marked).toBe(2)
expect((await second.json()).marked).toBe(0)
})
it('weist einen Client ohne Projektbindung mit 422 ab', async () => {
await seed()
await db.insert(apiClients).values({
name: 'Ohne Projekt', tokenHash: hashToken('lb_ohne'), mode: 'read', scope: 'internal',
})
const readResponse = await postRead(post({ external_user_id: 'u-42', post_ids: ['00000000-0000-4000-8000-000000000000'] }, 'lb_ohne'))
const unreadResponse = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42', 'lb_ohne'))
expect(readResponse.status).toBe(422)
expect(unreadResponse.status).toBe(422)
})
it('ignoriert eine gelesen-Zeile, die einem anderen Projekt zugeordnet ist', async () => {
const { postIds } = await seed()
const [mta] = await db.select().from(projects).where(eq(projects.slug, 'mta360'))
await db.insert(postReads).values({
postId: postIds[0]!,
projectId: mta!.id,
externalUserId: 'u-42',
})
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(body.count).toBe(2)
})
it('weist einen ungültigen Rumpf mit 400 und Problem-JSON ab', async () => {
await seed()
const response = await postRead(post({ external_user_id: 'u-42', post_ids: ['keine-uuid'] }))
const body = await response.json()
expect(response.status).toBe(400)
expect(response.headers.get('content-type')).toContain('application/problem+json')
expect(body.type).toBe('invalid_body')
})
it('verwirft unbekannte Kennungen im Stapel und markiert den Rest', async () => {
const { postIds } = await seed()
const response = await postRead(post({
external_user_id: 'u-42',
post_ids: [postIds[0], '00000000-0000-4000-8000-000000000000'],
}))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.marked).toBe(1)
})
})
+304
View File
@@ -0,0 +1,304 @@
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)
})
})