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,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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ImageLoaderProps } from 'next/image'
|
||||
import type { Media } from '~/data/schema'
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}))
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ loader, src, alt, sizes }: {
|
||||
loader?: (args: ImageLoaderProps) => string
|
||||
src: string
|
||||
alt: string
|
||||
sizes?: string
|
||||
}) => <img src={loader ? loader({ src, width: 960, quality: 75 }) : src} alt={alt} data-sizes={sizes} />,
|
||||
}))
|
||||
|
||||
const { blockRegistry } = await import('~/components/blocks/registry')
|
||||
|
||||
const base: Media = {
|
||||
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00001',
|
||||
projectId: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00009',
|
||||
kind: 'image',
|
||||
originalFilename: 'schema.png',
|
||||
path: 'projekt/bild/original.png',
|
||||
mime: 'image/png',
|
||||
width: 2000,
|
||||
height: 1125,
|
||||
byteSize: 12345,
|
||||
variants: [
|
||||
{ width: 480, format: 'webp', path: 'projekt/bild/w480.webp' },
|
||||
{ width: 960, format: 'webp', path: 'projekt/bild/w960.webp' },
|
||||
],
|
||||
variantError: null,
|
||||
alt: 'Ein Schema',
|
||||
caption: 'Bildunterschrift',
|
||||
uploadedBy: null,
|
||||
createdAt: new Date('2026-07-30T07:00:00Z'),
|
||||
}
|
||||
|
||||
function media(...items: Media[]): Map<string, Media> {
|
||||
return new Map(items.map(item => [item.id, item]))
|
||||
}
|
||||
|
||||
function render(type: string, data: Record<string, unknown>, items: Map<string, Media>): string {
|
||||
const Component = blockRegistry[type]
|
||||
|
||||
if (!Component) {
|
||||
throw new Error(`kein Block für ${type}`)
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(<Component data={data} media={items} />)
|
||||
}
|
||||
|
||||
describe('Blöcke ohne Medium', () => {
|
||||
it('bleibt bei jedem Typ ohne Daten leer statt kaputt', () => {
|
||||
for (const type of Object.keys(blockRegistry)) {
|
||||
expect(render(type, {}, new Map()), type).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('verliert kein Bild, wenn die Kennung ins Leere zeigt', () => {
|
||||
const missing = { mediaId: base.id, mediaIds: [base.id], beforeMediaId: base.id, afterMediaId: base.id }
|
||||
|
||||
for (const type of ['image', 'gallery', 'before_after', 'video']) {
|
||||
expect(render(type, missing, new Map()), type).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('zeigt den Videoverweis auch ohne Medium', () => {
|
||||
const markup = render('video', { url: 'https://example.test/film' }, new Map())
|
||||
|
||||
expect(markup).toContain('https://example.test/film')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Blöcke mit Medium', () => {
|
||||
it('holt die passende Fassung über den Medienweg', () => {
|
||||
const markup = render('image', { mediaId: base.id }, media(base))
|
||||
|
||||
expect(markup).toContain('/api/v1/media/file/projekt/bild/w960.webp')
|
||||
expect(markup).toContain('Ein Schema')
|
||||
})
|
||||
|
||||
it('nimmt das Original, wenn keine Fassung erzeugt wurde', () => {
|
||||
const original: Media = { ...base, variants: [] }
|
||||
const markup = render('image', { mediaId: original.id }, media(original))
|
||||
|
||||
expect(markup).toContain('/api/v1/media/file/projekt/bild/original.png')
|
||||
})
|
||||
|
||||
it('stellt die Galerie mit Vorschaubildern auf', () => {
|
||||
const second: Media = { ...base, id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00002', caption: null }
|
||||
const markup = render('gallery', { mediaIds: [base.id, second.id] }, media(base, second))
|
||||
|
||||
expect(markup.match(/<img/g)).toHaveLength(2)
|
||||
expect(markup).toContain('position')
|
||||
})
|
||||
|
||||
it('vergleicht vorher und nachher mit einem Schieberegler', () => {
|
||||
const after: Media = { ...base, id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00003' }
|
||||
const markup = render(
|
||||
'before_after',
|
||||
{ beforeMediaId: base.id, afterMediaId: after.id },
|
||||
media(base, after),
|
||||
)
|
||||
|
||||
expect(markup).toContain('type="range"')
|
||||
expect(markup).toContain('clip-path')
|
||||
})
|
||||
|
||||
it('zeigt nur eine Seite, wenn das Gegenstück fehlt', () => {
|
||||
const markup = render('before_after', { beforeMediaId: base.id }, media(base))
|
||||
|
||||
expect(markup).not.toContain('type="range"')
|
||||
expect(markup).toContain('before')
|
||||
})
|
||||
|
||||
it('spielt ein Video als Video aus', () => {
|
||||
const film: Media = { ...base, kind: 'video', mime: 'video/mp4', variants: [] }
|
||||
const markup = render('video', { mediaId: film.id }, media(film))
|
||||
|
||||
expect(markup).toContain('<video')
|
||||
expect(markup).toContain('/api/v1/media/file/projekt/bild/original.png')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,373 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '~/data/db'
|
||||
import { blocks, brands, media, postTags, posts, projects, tags } from '~/data/schema'
|
||||
import {
|
||||
findPostWithBlocks,
|
||||
listArchiveMonths,
|
||||
listBrandsWithProjects,
|
||||
listPostsForArchive,
|
||||
listRecentPosts,
|
||||
loadArchiveStats,
|
||||
} from '~/data/repositories/archive'
|
||||
import { postTypeId } from '../support/post-types'
|
||||
|
||||
async function seed() {
|
||||
const [pocket] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket', sort: 2 }).returning()
|
||||
const [nyo] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO', sort: 1 }).returning()
|
||||
|
||||
const [trakk] = await db.insert(projects).values({ brandId: pocket!.id, slug: 'trakk', name: 'Trakk', code: 'TRK', color: '#2e7d5b', sort: 1 }).returning()
|
||||
const [mta] = await db.insert(projects).values({ brandId: nyo!.id, slug: 'mta360', name: 'MTA360', description: 'Werkstatt', sort: 1 }).returning()
|
||||
const [pulsar] = await db.insert(projects).values({ brandId: nyo!.id, slug: 'pulsar', name: 'Pulsar', sort: 2 }).returning()
|
||||
const [orbit] = await db.insert(projects).values({ brandId: nyo!.id, slug: 'orbit', name: 'Orbit', sort: 3, isActive: false }).returning()
|
||||
|
||||
await db.insert(posts).values([
|
||||
{
|
||||
projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine',
|
||||
teaser: 'Bedingung trifft Aktion', typeId: postTypeId('feature'), audience: 'customer', status: 'published',
|
||||
publishAt: new Date('2026-07-20T10:00:00Z'),
|
||||
},
|
||||
{
|
||||
projectId: trakk!.id, number: 2, slug: 'sla-uhr', title: 'SLA-Uhr',
|
||||
teaser: 'Feiertage zählen nicht mehr mit', typeId: postTypeId('fix'), audience: 'public', status: 'published',
|
||||
publishAt: new Date('2026-07-10T10:00:00Z'),
|
||||
},
|
||||
{
|
||||
projectId: trakk!.id, number: 3, slug: 'interner-umbau', title: 'Interner Umbau',
|
||||
teaser: 'Nur für uns', typeId: postTypeId('improvement'), audience: 'internal', status: 'published',
|
||||
publishAt: new Date('2026-06-15T10:00:00Z'),
|
||||
},
|
||||
{
|
||||
projectId: trakk!.id, number: 4, slug: 'noch-entwurf', title: 'Noch Entwurf',
|
||||
typeId: postTypeId('info'), audience: 'public', status: 'draft',
|
||||
},
|
||||
{
|
||||
projectId: mta!.id, number: 1, slug: 'spaltenmenue', title: 'Spaltenmenü',
|
||||
teaser: 'Rechtsklick auf die Kopfzeile', typeId: postTypeId('improvement'), audience: 'public', status: 'published',
|
||||
publishAt: new Date('2026-06-12T10:00:00Z'),
|
||||
},
|
||||
{
|
||||
projectId: mta!.id, number: 2, slug: 'lokale-ansichten', title: 'Lokale Ansichten entfallen',
|
||||
teaser: 'Ansichten liegen in der Datenbank', typeId: postTypeId('breaking'), audience: 'customer', status: 'published',
|
||||
publishAt: new Date('2025-12-05T10:00:00Z'),
|
||||
},
|
||||
{
|
||||
projectId: orbit!.id, number: 1, slug: 'ein-eingang', title: 'Ein Eingang für alle Kommentare',
|
||||
teaser: 'Stillgelegtes Projekt', typeId: postTypeId('feature'), audience: 'public', status: 'published',
|
||||
publishAt: new Date('2026-07-22T10:00:00Z'),
|
||||
},
|
||||
])
|
||||
|
||||
return { pocket: pocket!, nyo: nyo!, trakk: trakk!, mta: mta!, pulsar: pulsar!, orbit: orbit! }
|
||||
}
|
||||
|
||||
async function postId(slug: string): Promise<string> {
|
||||
const [row] = await db.select({ id: posts.id }).from(posts).where(eq(posts.slug, slug))
|
||||
return row!.id
|
||||
}
|
||||
|
||||
describe('listBrandsWithProjects', () => {
|
||||
it('gruppiert nach Marke, sortiert nach sort und zählt veröffentlichte Beiträge', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listBrandsWithProjects({ scope: 'internal' })
|
||||
|
||||
expect(result.map(brand => brand.slug)).toEqual(['nyo', 'pocket-rocket'])
|
||||
expect(result[0]?.projects.map(project => project.slug)).toEqual(['mta360', 'pulsar'])
|
||||
expect(result[0]?.projects.map(project => project.postCount)).toEqual([2, 0])
|
||||
expect(result[1]?.projects.map(project => project.postCount)).toEqual([3])
|
||||
expect(result[0]?.projects[0]?.description).toBe('Werkstatt')
|
||||
})
|
||||
|
||||
it('liefert Kürzel und Farbe je Projekt mit', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listBrandsWithProjects({ scope: 'internal' })
|
||||
const projectList = result.flatMap(brand => brand.projects)
|
||||
const trakk = projectList.find(project => project.slug === 'trakk')
|
||||
const mta = projectList.find(project => project.slug === 'mta360')
|
||||
|
||||
expect(trakk?.code).toBe('TRK')
|
||||
expect(trakk?.color).toBe('#2e7d5b')
|
||||
expect(mta?.code).toBeNull()
|
||||
})
|
||||
|
||||
it('zählt für Kunden ohne interne Beiträge', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listBrandsWithProjects({ scope: 'customer' })
|
||||
const trakk = result.flatMap(brand => brand.projects).find(project => project.slug === 'trakk')
|
||||
|
||||
expect(trakk?.postCount).toBe(2)
|
||||
})
|
||||
|
||||
it('führt ein stillgelegtes Projekt nicht auf', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listBrandsWithProjects({ scope: 'internal' })
|
||||
const slugs = result.flatMap(brand => brand.projects).map(project => project.slug)
|
||||
|
||||
expect(slugs).not.toContain('orbit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listArchiveMonths', () => {
|
||||
it('zählt Beiträge je Monat, neueste zuerst', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listArchiveMonths({ scope: 'internal' })
|
||||
|
||||
expect(result).toEqual([
|
||||
{ year: 2026, month: 7, postCount: 2 },
|
||||
{ year: 2026, month: 6, postCount: 2 },
|
||||
{ year: 2025, month: 12, postCount: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('grenzt auf ein Projekt ein', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listArchiveMonths({ scope: 'internal', projectSlug: 'trakk' })
|
||||
|
||||
expect(result).toEqual([
|
||||
{ year: 2026, month: 7, postCount: 2 },
|
||||
{ year: 2026, month: 6, postCount: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('lässt interne Beiträge für Kunden aus der Zählung', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listArchiveMonths({ scope: 'customer', projectSlug: 'trakk' })
|
||||
|
||||
expect(result).toEqual([{ year: 2026, month: 7, postCount: 2 }])
|
||||
})
|
||||
|
||||
it('zählt Beiträge stillgelegter Projekte nicht mit', async () => {
|
||||
await seed()
|
||||
|
||||
const withOrbit = await listArchiveMonths({ scope: 'internal', projectSlug: 'orbit' })
|
||||
const all = await listArchiveMonths({ scope: 'internal' })
|
||||
|
||||
expect(withOrbit).toEqual([])
|
||||
expect(all[0]?.postCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findPostWithBlocks', () => {
|
||||
it('liefert Blöcke in sort-Reihenfolge mit Projekt, Marke und Medien', async () => {
|
||||
const { trakk } = await seed()
|
||||
const id = await postId('regel-engine')
|
||||
const [bild] = await db.insert(media).values({
|
||||
projectId: trakk.id, kind: 'image', originalFilename: 'regel.png', path: 'trakk/regel/original.png',
|
||||
mime: 'image/png', byteSize: 1024, alt: 'Regel',
|
||||
}).returning()
|
||||
|
||||
await db.insert(blocks).values([
|
||||
{ postId: id, sort: 2, type: 'text', data: { text: 'Drittens' } },
|
||||
{ postId: id, sort: 0, type: 'text', data: { text: 'Erstens' } },
|
||||
{ postId: id, sort: 1, type: 'image', data: { mediaId: bild!.id } },
|
||||
])
|
||||
|
||||
const result = await findPostWithBlocks({ slug: 'regel-engine', scope: 'internal' })
|
||||
|
||||
expect(result?.blocks.map(block => block.sort)).toEqual([0, 1, 2])
|
||||
expect(result?.blocks.map(block => block.data.text ?? block.type)).toEqual(['Erstens', 'image', 'Drittens'])
|
||||
expect(result?.project.slug).toBe('trakk')
|
||||
expect(result?.brand.slug).toBe('pocket-rocket')
|
||||
expect(result?.media.map(entry => entry.id)).toEqual([bild!.id])
|
||||
expect(result?.cover).toBeUndefined()
|
||||
})
|
||||
|
||||
it('liefert das Aufmacherbild getrennt mit', async () => {
|
||||
const { trakk } = await seed()
|
||||
const id = await postId('regel-engine')
|
||||
const [cover] = await db.insert(media).values({
|
||||
projectId: trakk.id, kind: 'image', originalFilename: 'cover.png', path: 'trakk/cover/original.png',
|
||||
mime: 'image/png', byteSize: 2048,
|
||||
}).returning()
|
||||
await db.update(posts).set({ coverMediaId: cover!.id }).where(eq(posts.id, id))
|
||||
|
||||
const result = await findPostWithBlocks({ slug: 'regel-engine', scope: 'internal' })
|
||||
|
||||
expect(result?.cover?.id).toBe(cover!.id)
|
||||
expect(result?.media.map(entry => entry.id)).toEqual([cover!.id])
|
||||
})
|
||||
|
||||
it('liefert einen Beitrag ohne Blöcke mit leerer Liste', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await findPostWithBlocks({ slug: 'sla-uhr', scope: 'internal' })
|
||||
|
||||
expect(result?.blocks).toEqual([])
|
||||
expect(result?.media).toEqual([])
|
||||
})
|
||||
|
||||
it('verbirgt einen internen Beitrag vor Kunden', async () => {
|
||||
await seed()
|
||||
|
||||
await expect(findPostWithBlocks({ slug: 'interner-umbau', scope: 'customer' })).resolves.toBeUndefined()
|
||||
await expect(findPostWithBlocks({ slug: 'interner-umbau', scope: 'internal' })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('verbirgt Beiträge stillgelegter Projekte und Entwürfe', async () => {
|
||||
await seed()
|
||||
|
||||
await expect(findPostWithBlocks({ slug: 'ein-eingang', scope: 'internal' })).resolves.toBeUndefined()
|
||||
await expect(findPostWithBlocks({ slug: 'noch-entwurf', scope: 'internal' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('grenzt auf das Projekt ein', async () => {
|
||||
await seed()
|
||||
|
||||
await expect(findPostWithBlocks({ slug: 'regel-engine', scope: 'internal', projectSlug: 'mta360' })).resolves.toBeUndefined()
|
||||
await expect(findPostWithBlocks({ slug: 'regel-engine', scope: 'internal', projectSlug: 'trakk' })).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listPostsForArchive', () => {
|
||||
it('liefert veröffentlichte Beiträge, neueste zuerst, mit Gesamtzahl', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listPostsForArchive({ scope: 'internal', page: 1, perPage: 25 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).toEqual(['regel-engine', 'sla-uhr', 'interner-umbau', 'spaltenmenue', 'lokale-ansichten'])
|
||||
expect(result.total).toBe(5)
|
||||
})
|
||||
|
||||
it('grenzt auf Jahr und Monat ein', async () => {
|
||||
await seed()
|
||||
|
||||
const juli = await listPostsForArchive({ scope: 'internal', year: 2026, month: 7, page: 1, perPage: 25 })
|
||||
const juni = await listPostsForArchive({ scope: 'internal', year: 2026, month: 6, page: 1, perPage: 25 })
|
||||
const jahr = await listPostsForArchive({ scope: 'internal', year: 2025, page: 1, perPage: 25 })
|
||||
|
||||
expect(juli.items.map(item => item.slug)).toEqual(['regel-engine', 'sla-uhr'])
|
||||
expect(juli.total).toBe(2)
|
||||
expect(juni.items.map(item => item.slug)).toEqual(['interner-umbau', 'spaltenmenue'])
|
||||
expect(jahr.items.map(item => item.slug)).toEqual(['lokale-ansichten'])
|
||||
})
|
||||
|
||||
it('verbindet Monatsfilter mit Projektfilter', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listPostsForArchive({ scope: 'internal', projectSlug: 'mta360', year: 2026, month: 6, page: 1, perPage: 25 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).toEqual(['spaltenmenue'])
|
||||
expect(result.total).toBe(1)
|
||||
})
|
||||
|
||||
it('findet über Titel und Teaser, unabhängig von Groß- und Kleinschreibung', async () => {
|
||||
await seed()
|
||||
|
||||
const überTitel = await listPostsForArchive({ scope: 'internal', query: 'REGEL', page: 1, perPage: 25 })
|
||||
const überTeaser = await listPostsForArchive({ scope: 'internal', query: 'feiertage', page: 1, perPage: 25 })
|
||||
const ohneTreffer = await listPostsForArchive({ scope: 'internal', query: 'gibtesnicht', page: 1, perPage: 25 })
|
||||
|
||||
expect(überTitel.items.map(item => item.slug)).toEqual(['regel-engine'])
|
||||
expect(überTitel.total).toBe(1)
|
||||
expect(überTeaser.items.map(item => item.slug)).toEqual(['sla-uhr'])
|
||||
expect(ohneTreffer.items).toEqual([])
|
||||
expect(ohneTreffer.total).toBe(0)
|
||||
})
|
||||
|
||||
it('behandelt Platzhalterzeichen in der Suche als Text', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listPostsForArchive({ scope: 'internal', query: '%', page: 1, perPage: 25 })
|
||||
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
|
||||
it('verbirgt interne Beiträge und stillgelegte Projekte', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listPostsForArchive({ scope: 'customer', page: 1, perPage: 25 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).not.toContain('interner-umbau')
|
||||
expect(result.items.map(item => item.slug)).not.toContain('ein-eingang')
|
||||
expect(result.total).toBe(4)
|
||||
})
|
||||
|
||||
it('filtert nach Schlagwort und meldet die Gesamtzahl unabhängig von der Seitengröße', async () => {
|
||||
const { trakk } = await seed()
|
||||
const [tag] = await db.insert(tags).values({ projectId: trakk.id, slug: 'engine', label: 'Engine' }).returning()
|
||||
await db.insert(postTags).values([
|
||||
{ postId: await postId('regel-engine'), tagId: tag!.id },
|
||||
{ postId: await postId('sla-uhr'), tagId: tag!.id },
|
||||
])
|
||||
|
||||
const result = await listPostsForArchive({ scope: 'internal', tagSlug: 'engine', page: 1, perPage: 1 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).toEqual(['regel-engine'])
|
||||
expect(result.total).toBe(2)
|
||||
})
|
||||
|
||||
it('teilt in Seiten', async () => {
|
||||
await seed()
|
||||
|
||||
const zweite = await listPostsForArchive({ scope: 'internal', page: 2, perPage: 2 })
|
||||
|
||||
expect(zweite.items.map(item => item.slug)).toEqual(['interner-umbau', 'spaltenmenue'])
|
||||
expect(zweite.total).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listRecentPosts', () => {
|
||||
it('liefert die neuesten Beiträge über alle Projekte, begrenzt', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listRecentPosts({ scope: 'internal', limit: 3 })
|
||||
|
||||
expect(result.map(item => item.slug)).toEqual(['regel-engine', 'sla-uhr', 'interner-umbau'])
|
||||
expect(result[0]?.projectName).toBe('Trakk')
|
||||
expect(result[0]?.projectCode).toBe('TRK')
|
||||
expect(result[0]?.projectColor).toBe('#2e7d5b')
|
||||
})
|
||||
|
||||
it('verbirgt interne Beiträge vor Kunden und lässt stillgelegte Projekte aus', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listRecentPosts({ scope: 'customer', limit: 10 })
|
||||
|
||||
expect(result.map(item => item.slug)).toEqual(['regel-engine', 'sla-uhr', 'spaltenmenue', 'lokale-ansichten'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadArchiveStats', () => {
|
||||
it('liefert Zeitraum, Gesamtzahl und die Zahl der jungen Beiträge', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await loadArchiveStats({
|
||||
scope: 'internal',
|
||||
recentDays: 14,
|
||||
now: new Date('2026-07-24T00:00:00Z'),
|
||||
})
|
||||
|
||||
expect(result.total).toBe(5)
|
||||
expect(result.recent).toBe(2)
|
||||
expect(result.from?.toISOString()).toBe('2025-12-05T10:00:00.000Z')
|
||||
expect(result.to?.toISOString()).toBe('2026-07-20T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('grenzt auf ein Projekt ein und lässt interne Beiträge für Kunden aus', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await loadArchiveStats({
|
||||
scope: 'customer',
|
||||
projectSlug: 'trakk',
|
||||
recentDays: 14,
|
||||
now: new Date('2026-07-24T00:00:00Z'),
|
||||
})
|
||||
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.from?.toISOString()).toBe('2026-07-10T10:00:00.000Z')
|
||||
expect(result.to?.toISOString()).toBe('2026-07-20T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('liefert ohne Beiträge einen leeren Zeitraum', async () => {
|
||||
const result = await loadArchiveStats({ scope: 'internal', recentDays: 14 })
|
||||
|
||||
expect(result).toEqual({ from: undefined, to: undefined, total: 0, recent: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { asc, eq } from 'drizzle-orm'
|
||||
import { db } from '~/data/db'
|
||||
import { posts } from '~/data/schema'
|
||||
import {
|
||||
createEntry,
|
||||
listEntries,
|
||||
listEntryBlocks,
|
||||
replaceEntryBlocks,
|
||||
type EntryCreateValues,
|
||||
} from '~/data/repositories/entries'
|
||||
import { makeBrand, makeProject } from '../support/admin'
|
||||
import { makePost } from '../support/entries'
|
||||
import { postTypeId } from '../support/post-types'
|
||||
|
||||
function values(projectId: string, slug: string, title = 'Beitrag'): EntryCreateValues {
|
||||
return {
|
||||
projectId,
|
||||
title,
|
||||
teaser: null,
|
||||
slug,
|
||||
typeId: postTypeId('feature'),
|
||||
audience: 'internal',
|
||||
coverMediaId: null,
|
||||
authorName: 'Paul',
|
||||
}
|
||||
}
|
||||
|
||||
async function numbersOf(projectId: string): Promise<number[]> {
|
||||
const rows = await db
|
||||
.select({ number: posts.number })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, projectId))
|
||||
.orderBy(asc(posts.number))
|
||||
|
||||
return rows.map(row => row.number)
|
||||
}
|
||||
|
||||
describe('createEntry vergibt Beitragsnummern', () => {
|
||||
it('zählt je Projekt getrennt hoch', async () => {
|
||||
const brand = await makeBrand()
|
||||
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
|
||||
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
|
||||
|
||||
await createEntry(values(trakk.id, 'eins'))
|
||||
await createEntry(values(trakk.id, 'zwei'))
|
||||
await createEntry(values(orbit.id, 'eins'))
|
||||
|
||||
expect(await numbersOf(trakk.id)).toEqual([1, 2])
|
||||
expect(await numbersOf(orbit.id)).toEqual([1])
|
||||
})
|
||||
|
||||
it('vergibt bei gleichzeitigem Anlegen lückenlose Nummern ohne Doppelung', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, index) => createEntry(values(project.id, `beitrag-${index}`))),
|
||||
)
|
||||
|
||||
expect(await numbersOf(project.id)).toEqual(Array.from({ length: 20 }, (_, index) => index + 1))
|
||||
})
|
||||
|
||||
it('setzt hinter bereits vergebenen Nummern fort', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
await makePost(project.id, { number: 142, slug: 'alter-beitrag' })
|
||||
|
||||
const created = await createEntry(values(project.id, 'neuer-beitrag'))
|
||||
|
||||
expect(created.number).toBe(143)
|
||||
})
|
||||
|
||||
it('hängt die Nummer an, wenn der Slug im Projekt schon vergeben ist', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const first = await createEntry(values(project.id, 'regel-engine'))
|
||||
const second = await createEntry(values(project.id, 'regel-engine'))
|
||||
|
||||
expect(first.slug).toBe('regel-engine')
|
||||
expect(second.slug).toBe(`regel-engine-${second.number}`)
|
||||
})
|
||||
|
||||
it('übernimmt Titel, Autor und Zielgruppe', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const created = await createEntry({ ...values(project.id, 'regel-engine', 'Regel-Engine'), audience: 'customer' })
|
||||
|
||||
expect(created).toMatchObject({
|
||||
title: 'Regel-Engine',
|
||||
authorName: 'Paul',
|
||||
audience: 'customer',
|
||||
status: 'draft',
|
||||
number: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceEntryBlocks', () => {
|
||||
it('speichert Blöcke in der übergebenen Reihenfolge', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const entry = await createEntry(values(project.id, 'mit-bloecken'))
|
||||
|
||||
await replaceEntryBlocks(entry.id, [
|
||||
{ type: 'text', data: { text: 'Erster' } },
|
||||
{ type: 'quote', data: { text: 'Zweiter', source: 'Paul' } },
|
||||
{ type: 'text', data: { text: 'Dritter' } },
|
||||
])
|
||||
|
||||
const rows = await listEntryBlocks(entry.id)
|
||||
|
||||
expect(rows.map(row => row.type)).toEqual(['text', 'quote', 'text'])
|
||||
expect(rows.map(row => row.sort)).toEqual([0, 1, 2])
|
||||
expect(rows[1]?.data).toMatchObject({ text: 'Zweiter', source: 'Paul' })
|
||||
})
|
||||
|
||||
it('ersetzt vorhandene Blöcke vollständig', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const entry = await createEntry(values(project.id, 'ersetzt'))
|
||||
|
||||
await replaceEntryBlocks(entry.id, [{ type: 'text', data: { text: 'Alt' } }])
|
||||
await replaceEntryBlocks(entry.id, [{ type: 'callout', data: { text: 'Neu', title: '', tone: 'info' } }])
|
||||
|
||||
const rows = await listEntryBlocks(entry.id)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]?.type).toBe('callout')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listEntries', () => {
|
||||
it('grenzt auf Projekte, Status und Suche ein', async () => {
|
||||
const brand = await makeBrand()
|
||||
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
|
||||
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
|
||||
|
||||
await makePost(trakk.id, { number: 1, slug: 'regel-engine', title: 'Regel-Engine', status: 'draft' })
|
||||
await makePost(trakk.id, { number: 2, slug: 'sla-uhr', title: 'SLA-Uhr', status: 'published' })
|
||||
await makePost(orbit.id, { number: 1, slug: 'eingang', title: 'Ein Eingang', status: 'draft' })
|
||||
|
||||
const mine = await listEntries({ projectIds: [trakk.id], page: 1, perPage: 25 })
|
||||
const drafts = await listEntries({ status: 'draft', page: 1, perPage: 25 })
|
||||
const found = await listEntries({ search: 'sla', page: 1, perPage: 25 })
|
||||
const none = await listEntries({ projectIds: [], page: 1, perPage: 25 })
|
||||
|
||||
expect(mine.total).toBe(2)
|
||||
expect(drafts.items.map(item => item.slug).sort()).toEqual(['eingang', 'regel-engine'])
|
||||
expect(found.items.map(item => item.slug)).toEqual(['sla-uhr'])
|
||||
expect(none.total).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '~/data/db'
|
||||
import { brands, postTags, posts, projects, tags } from '~/data/schema'
|
||||
import { findPost, listPosts } from '~/data/repositories/posts'
|
||||
import { postTypeId } from '../support/post-types'
|
||||
|
||||
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', color: '#2e7d5b' }).returning()
|
||||
const [mta] = await db.insert(projects).values({ brandId: brand!.id, slug: 'mta360', name: 'MTA360' }).returning()
|
||||
|
||||
await db.insert(posts).values([
|
||||
{
|
||||
projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine',
|
||||
typeId: postTypeId('feature'), audience: 'customer', status: 'published',
|
||||
publishAt: new Date('2026-07-30T08:00:00Z'),
|
||||
},
|
||||
{
|
||||
projectId: trakk!.id, number: 2, slug: 'interner-umbau', title: 'Interner Umbau',
|
||||
typeId: postTypeId('improvement'), audience: 'internal', status: 'published',
|
||||
publishAt: new Date('2026-07-29T08:00:00Z'),
|
||||
},
|
||||
{
|
||||
projectId: trakk!.id, number: 3, slug: 'noch-entwurf', title: 'Noch Entwurf',
|
||||
typeId: postTypeId('fix'), audience: 'public', status: 'draft',
|
||||
},
|
||||
{
|
||||
projectId: mta!.id, number: 1, slug: 'spaltenmenue', title: 'Spaltenmenü',
|
||||
typeId: postTypeId('improvement'), audience: 'public', status: 'published',
|
||||
publishAt: new Date('2026-07-28T08:00:00Z'),
|
||||
},
|
||||
])
|
||||
|
||||
return { trakk: trakk!, mta: mta! }
|
||||
}
|
||||
|
||||
describe('listPosts', () => {
|
||||
it('liefert nur veröffentlichte Beiträge, neueste zuerst', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listPosts({ scope: 'internal', page: 1, perPage: 25 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).toEqual(['regel-engine', 'interner-umbau', 'spaltenmenue'])
|
||||
expect(result.total).toBe(3)
|
||||
})
|
||||
|
||||
it('verbirgt interne Beiträge vor Kunden', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listPosts({ scope: 'customer', page: 1, perPage: 25 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).toEqual(['regel-engine', 'spaltenmenue'])
|
||||
})
|
||||
|
||||
it('filtert nach Projekt', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listPosts({ scope: 'internal', projectSlug: 'mta360', page: 1, perPage: 25 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).toEqual(['spaltenmenue'])
|
||||
})
|
||||
|
||||
it('filtert nach Zeitpunkt und Typ', async () => {
|
||||
await seed()
|
||||
|
||||
const since = await listPosts({ scope: 'internal', since: new Date('2026-07-29T00:00:00Z'), page: 1, perPage: 25 })
|
||||
const byType = await listPosts({ scope: 'internal', type: 'feature', page: 1, perPage: 25 })
|
||||
|
||||
expect(since.items).toHaveLength(2)
|
||||
expect(byType.items.map(item => item.slug)).toEqual(['regel-engine'])
|
||||
})
|
||||
|
||||
it('teilt in Seiten und meldet die Gesamtzahl', async () => {
|
||||
await seed()
|
||||
|
||||
const result = await listPosts({ scope: 'internal', page: 2, perPage: 2 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).toEqual(['spaltenmenue'])
|
||||
expect(result.total).toBe(3)
|
||||
})
|
||||
|
||||
it('liefert geplante Beiträge nicht aus', async () => {
|
||||
const { trakk } = await seed()
|
||||
await db.insert(posts).values({
|
||||
projectId: trakk.id, number: 4, slug: 'geplanter-beitrag', title: 'Geplanter Beitrag',
|
||||
typeId: postTypeId('feature'), audience: 'public', status: 'scheduled',
|
||||
publishAt: new Date('2026-07-31T08:00:00Z'),
|
||||
})
|
||||
|
||||
const result = await listPosts({ scope: 'internal', page: 1, perPage: 25 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).not.toContain('geplanter-beitrag')
|
||||
expect(result.total).toBe(3)
|
||||
})
|
||||
|
||||
it('meldet beim Schlagwort-Filter die Gesamtzahl unabhängig von der Seitengröße', async () => {
|
||||
const { trakk } = await seed()
|
||||
const [tag] = await db.insert(tags).values({ projectId: trakk.id, slug: 'engine', label: 'Engine' }).returning()
|
||||
const trakkPosts = await db.select({ id: posts.id, slug: posts.slug }).from(posts).where(eq(posts.projectId, trakk.id))
|
||||
const tagged = trakkPosts.filter(row => row.slug === 'regel-engine' || row.slug === 'interner-umbau')
|
||||
await db.insert(postTags).values(tagged.map(row => ({ postId: row.id, tagId: tag!.id })))
|
||||
|
||||
const result = await listPosts({ scope: 'internal', tag: 'engine', page: 1, perPage: 1 })
|
||||
|
||||
expect(result.items.map(item => item.slug)).toEqual(['regel-engine'])
|
||||
expect(result.total).toBe(2)
|
||||
})
|
||||
|
||||
it('grenzt beim Schlagwort-Filter auf das gesuchte Schlagwort ein und zählt keinen Beitrag doppelt', async () => {
|
||||
const { trakk } = await seed()
|
||||
const [engine] = await db.insert(tags).values({ projectId: trakk.id, slug: 'engine', label: 'Engine' }).returning()
|
||||
const [worker] = await db.insert(tags).values({ projectId: trakk.id, slug: 'worker', label: 'Worker' }).returning()
|
||||
const [regel] = await db.select({ id: posts.id }).from(posts).where(eq(posts.slug, 'regel-engine'))
|
||||
const [umbau] = await db.select({ id: posts.id }).from(posts).where(eq(posts.slug, 'interner-umbau'))
|
||||
|
||||
await db.insert(postTags).values([
|
||||
{ postId: regel!.id, tagId: engine!.id },
|
||||
{ postId: regel!.id, tagId: worker!.id },
|
||||
{ postId: umbau!.id, tagId: worker!.id },
|
||||
])
|
||||
|
||||
const byEngine = await listPosts({ scope: 'internal', tag: 'engine', page: 1, perPage: 25 })
|
||||
const byWorker = await listPosts({ scope: 'internal', tag: 'worker', page: 1, perPage: 25 })
|
||||
|
||||
expect(byEngine.items.map(item => item.slug)).toEqual(['regel-engine'])
|
||||
expect(byEngine.total).toBe(1)
|
||||
expect(byWorker.items.map(item => item.slug)).toEqual(['regel-engine', 'interner-umbau'])
|
||||
expect(byWorker.total).toBe(2)
|
||||
})
|
||||
|
||||
it('teilt bei gleichem Veröffentlichungszeitpunkt ohne Überschneidung und ohne Lücke in Seiten', async () => {
|
||||
const { trakk } = await seed()
|
||||
const sameMoment = new Date('2026-07-20T08:00:00Z')
|
||||
await db.insert(posts).values([
|
||||
{ projectId: trakk.id, number: 10, slug: 'gleich-a', title: 'Gleich A', audience: 'public', status: 'published', publishAt: sameMoment },
|
||||
{ projectId: trakk.id, number: 11, slug: 'gleich-b', title: 'Gleich B', audience: 'public', status: 'published', publishAt: sameMoment },
|
||||
{ projectId: trakk.id, number: 12, slug: 'gleich-c', title: 'Gleich C', audience: 'public', status: 'published', publishAt: sameMoment },
|
||||
])
|
||||
|
||||
const first = await listPosts({ scope: 'internal', page: 1, perPage: 3 })
|
||||
const second = await listPosts({ scope: 'internal', page: 2, perPage: 3 })
|
||||
const seen = [...first.items, ...second.items].map(item => item.slug)
|
||||
|
||||
expect(first.total).toBe(6)
|
||||
expect(new Set(seen).size).toBe(6)
|
||||
})
|
||||
|
||||
it('stellt einen veröffentlichten Beitrag ohne Zeitpunkt hinten an', async () => {
|
||||
const { trakk } = await seed()
|
||||
await db.insert(posts).values({
|
||||
projectId: trakk.id, number: 20, slug: 'ohne-zeitpunkt', title: 'Ohne Zeitpunkt',
|
||||
audience: 'public', status: 'published',
|
||||
})
|
||||
|
||||
const result = await listPosts({ scope: 'internal', page: 1, perPage: 25 })
|
||||
|
||||
expect(result.items.at(-1)?.slug).toBe('ohne-zeitpunkt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findPost', () => {
|
||||
it('liefert einen Beitrag mit Projektangabe', async () => {
|
||||
await seed()
|
||||
|
||||
const post = await findPost('regel-engine', 'customer')
|
||||
|
||||
expect(post?.projectSlug).toBe('trakk')
|
||||
expect(post?.title).toBe('Regel-Engine')
|
||||
})
|
||||
|
||||
it('liefert Kürzel und Farbe des Projekts mit', async () => {
|
||||
await seed()
|
||||
|
||||
const mitKuerzel = await findPost('regel-engine', 'customer')
|
||||
const ohneKuerzel = await findPost('spaltenmenue', 'customer')
|
||||
|
||||
expect(mitKuerzel?.projectCode).toBe('TRK')
|
||||
expect(mitKuerzel?.projectColor).toBe('#2e7d5b')
|
||||
expect(ohneKuerzel?.projectCode).toBeNull()
|
||||
})
|
||||
|
||||
it('liefert nichts, wenn die Zielgruppe es nicht erlaubt', async () => {
|
||||
await seed()
|
||||
|
||||
await expect(findPost('interner-umbau', 'customer')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { db } from '~/data/db'
|
||||
import { brands, projects } from '~/data/schema'
|
||||
import { findProjectBySlug, listBrands, listProjects } from '~/data/repositories/projects'
|
||||
|
||||
describe('listProjects', () => {
|
||||
it('liefert aktive Projekte nach Sortierung und Namen', async () => {
|
||||
const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning()
|
||||
await db.insert(projects).values([
|
||||
{ brandId: brand!.id, slug: 'trakk', name: 'Trakk', sort: 2 },
|
||||
{ brandId: brand!.id, slug: 'mta360', name: 'MTA360', sort: 1 },
|
||||
{ brandId: brand!.id, slug: 'alt', name: 'Altprojekt', isActive: false, sort: 0 },
|
||||
])
|
||||
|
||||
const result = await listProjects()
|
||||
|
||||
expect(result.map(project => project.slug)).toEqual(['mta360', 'trakk'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('listProjects sortiert nach der Spalte, nicht nach dem Alphabet', () => {
|
||||
it('stellt ein Projekt mit kleinerer Sortierung vor ein alphabetisch früheres', async () => {
|
||||
const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning()
|
||||
await db.insert(projects).values([
|
||||
{ brandId: brand!.id, slug: 'aaa', name: 'Aaa', sort: 9 },
|
||||
{ brandId: brand!.id, slug: 'zzz', name: 'Zzz', sort: 1 },
|
||||
])
|
||||
|
||||
const result = await listProjects()
|
||||
|
||||
expect(result.map(project => project.slug)).toEqual(['zzz', 'aaa'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findProjectBySlug', () => {
|
||||
it('findet ein Projekt über seinen Slug', async () => {
|
||||
const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning()
|
||||
await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' })
|
||||
|
||||
const project = await findProjectBySlug('trakk')
|
||||
|
||||
expect(project?.name).toBe('Trakk')
|
||||
})
|
||||
|
||||
it('liefert nichts für einen unbekannten Slug', async () => {
|
||||
await expect(findProjectBySlug('gibtsnicht')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listBrands', () => {
|
||||
it('sortiert Marken nach Sortierung und Namen', async () => {
|
||||
await db.insert(brands).values([
|
||||
{ slug: 'pocket-rocket', name: 'Pocket Rocket', sort: 2 },
|
||||
{ slug: 'nyo', name: 'NYO', sort: 1 },
|
||||
])
|
||||
|
||||
const result = await listBrands()
|
||||
|
||||
expect(result.map(brand => brand.slug)).toEqual(['nyo', 'pocket-rocket'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
function walk(dir: string, found: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name)
|
||||
|
||||
if (statSync(path).isDirectory()) {
|
||||
walk(path, found)
|
||||
continue
|
||||
}
|
||||
|
||||
if (name.endsWith('.tsx') || name.endsWith('.ts')) {
|
||||
found.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
const sources = walk(join(root, 'src'))
|
||||
|
||||
function read(path: string): string {
|
||||
return readFileSync(path, 'utf8')
|
||||
}
|
||||
|
||||
function offenders(test: (text: string, path: string) => boolean): string[] {
|
||||
return sources.filter(path => test(read(path), path)).map(path => path.replace(`${root}/`, ''))
|
||||
}
|
||||
|
||||
const css = read(join(root, 'src/app/globals.css'))
|
||||
|
||||
describe('Farbmodus', () => {
|
||||
it('setzt data-theme nicht fest ins Server-HTML, sonst überschreibt React die gespeicherte Wahl', () => {
|
||||
expect(offenders(text => /<html[^>]*\sdata-theme=/u.test(text))).toEqual([])
|
||||
})
|
||||
|
||||
it('definiert jede Farbe für beide Modi', () => {
|
||||
const block = (start: string): string => {
|
||||
const from = css.indexOf(start)
|
||||
|
||||
return from === -1 ? '' : css.slice(from, css.indexOf('\n}', from))
|
||||
}
|
||||
|
||||
const names = (text: string): string[] =>
|
||||
[...text.matchAll(/--color-([a-z0-9-]+):/gu)]
|
||||
.map(match => match[1])
|
||||
.filter((name): name is string => name !== undefined)
|
||||
|
||||
const light = names(block('@theme {'))
|
||||
const dark = new Set(names(block(':root[data-theme="dark"] {')))
|
||||
const missing = light.filter(name => !dark.has(name) && !name.startsWith('shot'))
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('mischt Schatten nicht aus der Tintenfarbe, weil die im Dunkeln hell ist', () => {
|
||||
expect(offenders(text => /shadow-ink|shadow-\[.*var\(--color-ink/u.test(text))).toEqual([])
|
||||
})
|
||||
|
||||
it('führt Schatten über ein Token, das den Modus kennt', () => {
|
||||
expect(css).toContain('--shadow-lift')
|
||||
expect(css.match(/--shadow-lift/gu)?.length ?? 0).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Aufbau', () => {
|
||||
it('hält den Fuß unten, auch wenn die Seite kurz ist', () => {
|
||||
expect(read(join(root, 'src/components/layout/SiteFooter.tsx'))).toContain('mt-auto')
|
||||
expect(read(join(root, 'src/components/layout/AppShell.tsx'))).toContain('min-h-full')
|
||||
})
|
||||
|
||||
it('benutzt für alle Bereiche dieselbe Hülle', () => {
|
||||
const site = read(join(root, 'src/app/(site)/layout.tsx'))
|
||||
const admin = read(join(root, 'src/app/admin/layout.tsx'))
|
||||
|
||||
expect(site).toContain('AppShell')
|
||||
expect(admin).toContain('AppShell')
|
||||
})
|
||||
|
||||
it('kennt genau zwei Breiten und keine erfundenen', () => {
|
||||
expect(css).toContain('--container-page')
|
||||
expect(css).toContain('--container-read')
|
||||
expect(offenders(text => /max-w-\[\d/u.test(text))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schreibweisen', () => {
|
||||
it('benutzt keine Pixelangaben in Klassen', () => {
|
||||
expect(offenders(text => /(?:w|h|p|m|gap|text|top|left|right|bottom)-\[\d+px\]/u.test(text))).toEqual([])
|
||||
})
|
||||
|
||||
it('lässt keine Kommentare im Quelltext', () => {
|
||||
expect(offenders((text, path) => !path.endsWith('.d.ts') && /^\s*\/\//mu.test(text))).toEqual([])
|
||||
})
|
||||
|
||||
it('benutzt keine Gedankenstriche', () => {
|
||||
const files = [...sources, join(root, 'messages/de.json'), join(root, 'messages/en.json')]
|
||||
const hits = files.filter(path => /[–—]/u.test(read(path))).map(path => path.replace(`${root}/`, ''))
|
||||
|
||||
expect(hits).toEqual([])
|
||||
})
|
||||
|
||||
it('schreibt Umlaute aus', () => {
|
||||
const de = read(join(root, 'messages/de.json'))
|
||||
|
||||
expect(de).not.toMatch(/\b(ueber|fuer|koennen|muessen|groesse|waehlen)\b/u)
|
||||
})
|
||||
|
||||
it('benutzt keine Emojis', () => {
|
||||
const files = [...sources, join(root, 'messages/de.json'), join(root, 'messages/en.json')]
|
||||
const hits = files.filter(path => /\p{Extended_Pictographic}/u.test(read(path))).map(path => path.replace(`${root}/`, ''))
|
||||
|
||||
expect(hits).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bedienbarkeit', () => {
|
||||
it('führt jeden scrollbaren Bereich über den Scroller statt nativ', () => {
|
||||
expect(offenders(text => /overflow-(?:y|x)-(?:auto|scroll)/u.test(text))).toEqual([])
|
||||
})
|
||||
|
||||
it('hält das globale Stylesheet klein', () => {
|
||||
expect(css.split('\n').length).toBeLessThanOrEqual(150)
|
||||
})
|
||||
|
||||
it('macht den Tastaturfokus sichtbar', () => {
|
||||
expect(css).toMatch(/:focus-visible/u)
|
||||
})
|
||||
|
||||
it('nimmt Rücksicht auf abgeschaltete Bewegung', () => {
|
||||
const moving = offenders(text => /animate-rise|transition-/u.test(text))
|
||||
const careless = moving.filter(path => !/motion-reduce/u.test(read(join(root, path))))
|
||||
|
||||
expect(careless).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sprache der Oberfläche', () => {
|
||||
it('hält beide Sprachdateien auf denselben Schlüsseln', () => {
|
||||
const keys = (value: unknown, prefix = ''): string[] => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return [prefix]
|
||||
}
|
||||
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, inner]) =>
|
||||
keys(inner, prefix === '' ? key : `${prefix}.${key}`))
|
||||
}
|
||||
|
||||
const de = keys(JSON.parse(read(join(root, 'messages/de.json')))).sort()
|
||||
const en = keys(JSON.parse(read(join(root, 'messages/en.json')))).sort()
|
||||
|
||||
expect(de.filter(key => !en.includes(key))).toEqual([])
|
||||
expect(en.filter(key => !de.includes(key))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isOpenPath, loginTarget, requiresSession } from '~/domain/access-paths'
|
||||
|
||||
describe('isOpenPath', () => {
|
||||
const open = [
|
||||
'/login',
|
||||
'/api/auth/sign-in/email',
|
||||
'/api/auth/callback/magic-link',
|
||||
'/api/v1/posts',
|
||||
'/api/v1/media/file/2026/07/bild.webp',
|
||||
'/_next/static/chunks/main.js',
|
||||
'/favicon.ico',
|
||||
'/robots.txt',
|
||||
]
|
||||
|
||||
for (const path of open) {
|
||||
it(`lässt ${path} ohne Anmeldung durch`, () => {
|
||||
expect(isOpenPath(path)).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
const closed = ['/', '/trakk', '/trakk/2026/07', '/trakk/ein-beitrag', '/search', '/admin', '/admin/entries/new']
|
||||
|
||||
for (const path of closed) {
|
||||
it(`verlangt für ${path} eine Anmeldung`, () => {
|
||||
expect(requiresSession(path)).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
it('lässt sich nicht durch einen ähnlichen Anfang austricksen', () => {
|
||||
expect(isOpenPath('/loginversuch')).toBe(false)
|
||||
expect(isOpenPath('/api/v1x/posts')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginTarget', () => {
|
||||
it('merkt sich das gewünschte Ziel', () => {
|
||||
expect(loginTarget('/trakk/ein-beitrag', '')).toBe('/login?next=%2Ftrakk%2Fein-beitrag')
|
||||
})
|
||||
|
||||
it('nimmt die Abfrageparameter mit', () => {
|
||||
expect(loginTarget('/search', '?q=regel')).toBe('/login?next=%2Fsearch%3Fq%3Dregel')
|
||||
})
|
||||
|
||||
it('lässt die Startseite ohne Ziel', () => {
|
||||
expect(loginTarget('/', '')).toBe('/login')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canAssignAudience, canSee, visibleAudiences } from '~/domain/audience'
|
||||
import type { Audience, ViewerScope } from '~/domain/types'
|
||||
|
||||
const cases: [ViewerScope, Audience, boolean][] = [
|
||||
['internal', 'internal', true],
|
||||
['internal', 'customer', true],
|
||||
['internal', 'public', true],
|
||||
['customer', 'internal', false],
|
||||
['customer', 'customer', true],
|
||||
['customer', 'public', true],
|
||||
['public', 'internal', false],
|
||||
['public', 'customer', false],
|
||||
['public', 'public', true],
|
||||
]
|
||||
|
||||
describe('canSee', () => {
|
||||
for (const [scope, audience, expected] of cases) {
|
||||
it(`${scope} sieht ${audience}: ${expected}`, () => {
|
||||
expect(canSee(scope, audience)).toBe(expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('visibleAudiences', () => {
|
||||
it('gibt für intern alle Zielgruppen zurück', () => {
|
||||
expect(visibleAudiences('internal').sort()).toEqual(['customer', 'internal', 'public'])
|
||||
})
|
||||
|
||||
it('gibt für Kunden keine internen Beiträge zurück', () => {
|
||||
expect(visibleAudiences('customer').sort()).toEqual(['customer', 'public'])
|
||||
})
|
||||
|
||||
it('gibt öffentlich nur öffentliche Beiträge zurück', () => {
|
||||
expect(visibleAudiences('public')).toEqual(['public'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canAssignAudience', () => {
|
||||
const cases: [ViewerScope, Audience, boolean][] = [
|
||||
['internal', 'internal', true],
|
||||
['internal', 'customer', false],
|
||||
['internal', 'public', false],
|
||||
['customer', 'internal', true],
|
||||
['customer', 'customer', true],
|
||||
['customer', 'public', false],
|
||||
['public', 'internal', true],
|
||||
['public', 'customer', true],
|
||||
['public', 'public', true],
|
||||
]
|
||||
|
||||
for (const [scope, audience, expected] of cases) {
|
||||
it(`${scope} darf ${audience} setzen: ${expected}`, () => {
|
||||
expect(canAssignAudience(scope, audience)).toBe(expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { blockMediaIds, emptyBlockData, isBlockEmpty, isBlockType, parseBlocks } from '~/domain/blocks'
|
||||
|
||||
const id = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
describe('parseBlocks', () => {
|
||||
it('nimmt alle Blocktypen in Reihenfolge an', async () => {
|
||||
const result = parseBlocks([
|
||||
{ type: 'text', data: { text: 'Hallo' } },
|
||||
{ type: 'image', data: { mediaId: id } },
|
||||
{ type: 'gallery', data: { mediaIds: [id] } },
|
||||
{ type: 'before_after', data: { beforeMediaId: id, afterMediaId: id } },
|
||||
{ type: 'video', data: { url: 'https://nyo.de/film.mp4' } },
|
||||
{ type: 'quote', data: { text: 'Kurz', source: 'Paul' } },
|
||||
{ type: 'code', data: { code: 'const a = 1', language: 'ts' } },
|
||||
{ type: 'link', data: { url: 'https://nyo.de', label: 'NYO' } },
|
||||
{ type: 'callout', data: { text: 'Achtung', tone: 'warning' } },
|
||||
])
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.ok && result.blocks.map(block => block.type)).toEqual([
|
||||
'text',
|
||||
'image',
|
||||
'gallery',
|
||||
'before_after',
|
||||
'video',
|
||||
'quote',
|
||||
'code',
|
||||
'link',
|
||||
'callout',
|
||||
])
|
||||
})
|
||||
|
||||
it('füllt fehlende Felder mit leeren Werten', () => {
|
||||
const result = parseBlocks([{ type: 'callout', data: {} }])
|
||||
|
||||
expect(result.ok && result.blocks[0]?.data).toEqual({ title: '', text: '', tone: 'info' })
|
||||
})
|
||||
|
||||
it('meldet einen unbekannten Blocktyp mit Position', () => {
|
||||
const result = parseBlocks([{ type: 'text', data: { text: 'Hallo' } }, { type: 'karussell', data: {} }])
|
||||
|
||||
expect(result).toMatchObject({ ok: false, index: 1, type: 'karussell' })
|
||||
})
|
||||
|
||||
it('lehnt eine kaputte Adresse und eine kaputte Bildkennung ab', () => {
|
||||
expect(parseBlocks([{ type: 'link', data: { url: 'kein-verweis' } }]).ok).toBe(false)
|
||||
expect(parseBlocks([{ type: 'image', data: { mediaId: 'abc' } }]).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('nimmt eine leere Bildkennung an', () => {
|
||||
expect(parseBlocks([{ type: 'image', data: { mediaId: '' } }]).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Blockhilfen', () => {
|
||||
it('sammelt alle Bildkennungen eines Blocks', () => {
|
||||
expect(blockMediaIds({ type: 'gallery', data: { mediaIds: [id, ''] } })).toEqual([id])
|
||||
expect(blockMediaIds({ type: 'before_after', data: { beforeMediaId: id, afterMediaId: '' } })).toEqual([id])
|
||||
})
|
||||
|
||||
it('erkennt leere Blöcke', () => {
|
||||
expect(isBlockEmpty({ type: 'text', data: emptyBlockData('text') })).toBe(true)
|
||||
expect(isBlockEmpty({ type: 'text', data: { text: ' ' } })).toBe(true)
|
||||
expect(isBlockEmpty({ type: 'text', data: { text: 'Inhalt' } })).toBe(false)
|
||||
expect(isBlockEmpty({ type: 'callout', data: emptyBlockData('callout') })).toBe(true)
|
||||
expect(isBlockEmpty({ type: 'image', data: { mediaId: id } })).toBe(false)
|
||||
})
|
||||
|
||||
it('kennt die erlaubten Typen', () => {
|
||||
expect(isBlockType('text')).toBe(true)
|
||||
expect(isBlockType('karussell')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isColor, normalizeColor } from '~/domain/color'
|
||||
|
||||
describe('normalizeColor', () => {
|
||||
it('ergänzt das Rautezeichen', () => {
|
||||
expect(normalizeColor('2E7D5B')).toBe('#2e7d5b')
|
||||
})
|
||||
|
||||
it('schreibt eine Kurzform aus', () => {
|
||||
expect(normalizeColor('#abc')).toBe('#aabbcc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isColor', () => {
|
||||
it('nimmt sechsstellige Hexwerte an', () => {
|
||||
expect(isColor('#2e7d5b')).toBe(true)
|
||||
})
|
||||
|
||||
it('lehnt alles andere ab', () => {
|
||||
expect(isColor('rot')).toBe(false)
|
||||
expect(isColor('#2e7d5')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emailDomain, isAllowedDomain, normaliseEmail, parseDomains } from '~/domain/email-domains'
|
||||
|
||||
const domains = parseDomains('nyo.de, pocketrocket.de,pocket-rocket.io')
|
||||
|
||||
describe('parseDomains', () => {
|
||||
it('trennt an Kommas, schneidet Leerraum und macht klein', () => {
|
||||
expect(parseDomains(' NYO.de , Pocket-Rocket.io ')).toEqual(['nyo.de', 'pocket-rocket.io'])
|
||||
})
|
||||
|
||||
it('entfernt ein führendes At-Zeichen', () => {
|
||||
expect(parseDomains('@nyo.de')).toEqual(['nyo.de'])
|
||||
})
|
||||
|
||||
it('liefert für leere Angaben eine leere Liste', () => {
|
||||
expect(parseDomains(undefined)).toEqual([])
|
||||
expect(parseDomains(' , ')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('emailDomain', () => {
|
||||
it('liest die Domain hinter dem letzten At-Zeichen', () => {
|
||||
expect(emailDomain('Vorname.Nachname@NYO.de')).toBe('nyo.de')
|
||||
})
|
||||
|
||||
it('liefert für eine Adresse ohne At-Zeichen nichts', () => {
|
||||
expect(emailDomain('keine-adresse')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedDomain', () => {
|
||||
it('lässt die freigegebenen Domains durch', () => {
|
||||
expect(isAllowedDomain('paul@nyo.de', domains)).toBe(true)
|
||||
expect(isAllowedDomain('paul@pocketrocket.de', domains)).toBe(true)
|
||||
expect(isAllowedDomain('paul@pocket-rocket.io', domains)).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt Unterdomains der freigegebenen Domains durch', () => {
|
||||
expect(isAllowedDomain('paul@mail.nyo.de', domains)).toBe(true)
|
||||
})
|
||||
|
||||
it('weist fremde Domains ab', () => {
|
||||
expect(isAllowedDomain('paul@gmail.com', domains)).toBe(false)
|
||||
expect(isAllowedDomain('paul@nyo.de.angreifer.example', domains)).toBe(false)
|
||||
expect(isAllowedDomain('paul@xnyo.de', domains)).toBe(false)
|
||||
})
|
||||
|
||||
it('weist Eingaben ohne Domain ab', () => {
|
||||
expect(isAllowedDomain('paul', domains)).toBe(false)
|
||||
})
|
||||
|
||||
it('lässt ohne konfigurierte Liste alles durch', () => {
|
||||
expect(isAllowedDomain('paul@gmail.com', [])).toBe(true)
|
||||
})
|
||||
|
||||
it('ist unabhängig von Gross- und Kleinschreibung', () => {
|
||||
expect(isAllowedDomain('Paul@NYO.DE', domains)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normaliseEmail', () => {
|
||||
it('schneidet Leerraum und macht klein', () => {
|
||||
expect(normaliseEmail(' Paul@NYO.de ')).toBe('paul@nyo.de')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseDomains } from '~/domain/email-domains'
|
||||
import {
|
||||
attemptsWithinWindow,
|
||||
decideMagicLinkRequest,
|
||||
magicLinkMaxAttempts,
|
||||
magicLinkResponse,
|
||||
type MagicLinkRequest,
|
||||
} from '~/domain/magic-link'
|
||||
|
||||
const domains = parseDomains('nyo.de,pocketrocket.de,pocket-rocket.io')
|
||||
|
||||
function request(overrides: Partial<MagicLinkRequest> = {}): MagicLinkRequest {
|
||||
return {
|
||||
email: 'paul@nyo.de',
|
||||
allowedDomains: domains,
|
||||
hasAccount: false,
|
||||
attempts: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('decideMagicLinkRequest', () => {
|
||||
it('schickt einen Link an jede freigegebene Domain, auch ohne Konto', () => {
|
||||
for (const email of ['paul@nyo.de', 'paul@pocketrocket.de', 'paul@pocket-rocket.io']) {
|
||||
expect(decideMagicLinkRequest(request({ email }))).toEqual({
|
||||
email,
|
||||
outcome: 'send',
|
||||
reason: 'allowed-domain',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('schickt einen Link an ein bestehendes Konto mit fremder Domain', () => {
|
||||
expect(decideMagicLinkRequest(request({ email: 'thevisualdesigners@gmail.com', hasAccount: true })))
|
||||
.toEqual({
|
||||
email: 'thevisualdesigners@gmail.com',
|
||||
outcome: 'send',
|
||||
reason: 'known-account',
|
||||
})
|
||||
})
|
||||
|
||||
it('schickt keinen Link an eine fremde Domain ohne Konto', () => {
|
||||
expect(decideMagicLinkRequest(request({ email: 'fremd@example.com' }))).toEqual({
|
||||
email: 'fremd@example.com',
|
||||
outcome: 'ignore',
|
||||
reason: 'domain-not-allowed',
|
||||
})
|
||||
})
|
||||
|
||||
it('lässt sich nicht durch eine angehängte Domain täuschen', () => {
|
||||
expect(decideMagicLinkRequest(request({ email: 'paul@nyo.de.angreifer.example' })).outcome).toBe('ignore')
|
||||
})
|
||||
|
||||
it('behandelt Groß- und Kleinschreibung und Leerraum gleich', () => {
|
||||
expect(decideMagicLinkRequest(request({ email: ' Paul@NYO.de ' }))).toEqual({
|
||||
email: 'paul@nyo.de',
|
||||
outcome: 'send',
|
||||
reason: 'allowed-domain',
|
||||
})
|
||||
})
|
||||
|
||||
it('erlaubt genau fünf Anforderungen im Zeitfenster', () => {
|
||||
expect(decideMagicLinkRequest(request({ attempts: magicLinkMaxAttempts })).outcome).toBe('send')
|
||||
expect(decideMagicLinkRequest(request({ attempts: magicLinkMaxAttempts + 1 }))).toEqual({
|
||||
email: 'paul@nyo.de',
|
||||
outcome: 'throttle',
|
||||
reason: 'too-many-requests',
|
||||
})
|
||||
})
|
||||
|
||||
it('bremst jede Adresse, auch die abgewiesene', () => {
|
||||
const decision = decideMagicLinkRequest(request({ email: 'fremd@example.com', attempts: 6 }))
|
||||
|
||||
expect(decision.outcome).toBe('throttle')
|
||||
})
|
||||
|
||||
it('bremst auch ein bestehendes Konto', () => {
|
||||
const decision = decideMagicLinkRequest(request({
|
||||
email: 'thevisualdesigners@gmail.com',
|
||||
hasAccount: true,
|
||||
attempts: 6,
|
||||
}))
|
||||
|
||||
expect(decision.outcome).toBe('throttle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('magicLinkResponse', () => {
|
||||
it('antwortet gleich, egal ob ein Link rausgeht oder nicht', () => {
|
||||
const sent = magicLinkResponse(decideMagicLinkRequest(request({ email: 'paul@nyo.de' })))
|
||||
const known = magicLinkResponse(decideMagicLinkRequest(request({
|
||||
email: 'paul@nyo.de',
|
||||
allowedDomains: parseDomains('example.org'),
|
||||
hasAccount: true,
|
||||
})))
|
||||
const ignored = magicLinkResponse(decideMagicLinkRequest(request({
|
||||
email: 'paul@nyo.de',
|
||||
allowedDomains: parseDomains('example.org'),
|
||||
})))
|
||||
|
||||
expect(sent).toEqual({ status: 'sent', email: 'paul@nyo.de' })
|
||||
expect(known).toEqual(sent)
|
||||
expect(ignored).toEqual(sent)
|
||||
})
|
||||
|
||||
it('verrät auch bei fremder Adresse nichts über das Konto', () => {
|
||||
const withAccount = magicLinkResponse(decideMagicLinkRequest(request({
|
||||
email: 'fremd@example.com',
|
||||
hasAccount: true,
|
||||
})))
|
||||
const withoutAccount = magicLinkResponse(decideMagicLinkRequest(request({
|
||||
email: 'fremd@example.com',
|
||||
hasAccount: false,
|
||||
})))
|
||||
|
||||
expect(withAccount).toEqual(withoutAccount)
|
||||
})
|
||||
|
||||
it('meldet nur die Bremse offen', () => {
|
||||
expect(magicLinkResponse(decideMagicLinkRequest(request({ attempts: 6 })))).toEqual({
|
||||
status: 'error',
|
||||
email: 'paul@nyo.de',
|
||||
error: 'tooMany',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('attemptsWithinWindow', () => {
|
||||
it('behält nur die Zeitpunkte im Fenster', () => {
|
||||
const now = 1_000_000
|
||||
|
||||
expect(attemptsWithinWindow([now - 1000, now - 900_000, now - 100], now, 900_000)).toEqual([
|
||||
now - 1000,
|
||||
now - 100,
|
||||
])
|
||||
})
|
||||
|
||||
it('liefert für eine leere Liste nichts', () => {
|
||||
expect(attemptsWithinWindow([], 10, 900_000)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canTransition } from '~/domain/post-status'
|
||||
import type { PostStatus } from '~/domain/types'
|
||||
|
||||
describe('canTransition', () => {
|
||||
it('erlaubt den Weg vom Entwurf bis zur Veröffentlichung', () => {
|
||||
expect(canTransition('draft', 'review')).toBe(true)
|
||||
expect(canTransition('review', 'published')).toBe(true)
|
||||
expect(canTransition('draft', 'published')).toBe(true)
|
||||
expect(canTransition('draft', 'scheduled')).toBe(true)
|
||||
expect(canTransition('scheduled', 'published')).toBe(true)
|
||||
})
|
||||
|
||||
it('erlaubt Zurückziehen und Wiederaufnahme', () => {
|
||||
expect(canTransition('published', 'archived')).toBe(true)
|
||||
expect(canTransition('archived', 'draft')).toBe(true)
|
||||
})
|
||||
|
||||
it('verbietet Sprünge zurück aus der Veröffentlichung in den Entwurf', () => {
|
||||
expect(canTransition('published', 'draft')).toBe(false)
|
||||
expect(canTransition('published', 'review')).toBe(false)
|
||||
})
|
||||
|
||||
it('verbietet gleichbleibenden Status als Übergang', () => {
|
||||
expect(canTransition('draft', 'draft')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canTransition deckt alle 25 Paare ab', () => {
|
||||
const states: PostStatus[] = ['draft', 'review', 'scheduled', 'published', 'archived']
|
||||
|
||||
const expected: Record<PostStatus, PostStatus[]> = {
|
||||
draft: ['review', 'scheduled', 'published', 'archived'],
|
||||
review: ['draft', 'scheduled', 'published', 'archived'],
|
||||
scheduled: ['draft', 'published', 'archived'],
|
||||
published: ['archived'],
|
||||
archived: ['draft'],
|
||||
}
|
||||
|
||||
for (const from of states) {
|
||||
for (const to of states) {
|
||||
const allowed = expected[from].includes(to)
|
||||
|
||||
it(`${from} nach ${to}: ${allowed}`, () => {
|
||||
expect(canTransition(from, to)).toBe(allowed)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectCode } from '~/domain/project-code'
|
||||
|
||||
describe('projectCode mit gesetztem Kürzel', () => {
|
||||
it('nimmt das gesetzte Kürzel und schreibt es groß', () => {
|
||||
expect(projectCode({ code: 'trk', slug: 'trakk' })).toBe('TRK')
|
||||
})
|
||||
|
||||
it('sticht die Ableitung aus dem Slug aus', () => {
|
||||
expect(projectCode({ code: 'TEL', slug: 'mta-telematik' })).toBe('TEL')
|
||||
})
|
||||
|
||||
it('schneidet nach vier Zeichen ab', () => {
|
||||
expect(projectCode({ code: 'orbitalis', slug: 'orbit' })).toBe('ORBI')
|
||||
})
|
||||
|
||||
it('entfernt Leerraum und Sonderzeichen im Kürzel', () => {
|
||||
expect(projectCode({ code: ' p-l s ', slug: 'pulsar' })).toBe('PLS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectCode mit fehlendem Kürzel', () => {
|
||||
it('fällt bei leerem Kürzel auf die Ableitung zurück', () => {
|
||||
expect(projectCode({ code: '', slug: 'trakk' })).toBe('TRA')
|
||||
})
|
||||
|
||||
it('fällt bei Leerraum als Kürzel auf die Ableitung zurück', () => {
|
||||
expect(projectCode({ code: ' ', slug: 'trakk' })).toBe('TRA')
|
||||
})
|
||||
|
||||
it('fällt bei null auf die Ableitung zurück', () => {
|
||||
expect(projectCode({ code: null, slug: 'trakk' })).toBe('TRA')
|
||||
})
|
||||
|
||||
it('fällt ohne Feld auf die Ableitung zurück', () => {
|
||||
expect(projectCode({ slug: 'mta360' })).toBe('MTA')
|
||||
})
|
||||
|
||||
it('fällt bei einem einzelnen Zeichen als Kürzel auf die Ableitung zurück', () => {
|
||||
expect(projectCode({ code: 'x', slug: 'pulsar' })).toBe('PUL')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectCode leitet aus dem Slug ab', () => {
|
||||
it('nimmt bei einem Wort die ersten drei Zeichen', () => {
|
||||
expect(projectCode({ slug: 'orbit' })).toBe('ORB')
|
||||
})
|
||||
|
||||
it('zählt Ziffern als Teil des Wortes', () => {
|
||||
expect(projectCode({ slug: 'mta360' })).toBe('MTA')
|
||||
})
|
||||
|
||||
it('füllt bei zwei Wörtern die Initialen auf drei Zeichen auf', () => {
|
||||
expect(projectCode({ slug: 'mta-telematik' })).toBe('MTE')
|
||||
})
|
||||
|
||||
it('nimmt bei drei Wörtern je den ersten Buchstaben', () => {
|
||||
expect(projectCode({ slug: 'pocket-rocket-labs' })).toBe('PRL')
|
||||
})
|
||||
|
||||
it('bleibt bei vier Wörtern bei drei Zeichen', () => {
|
||||
expect(projectCode({ slug: 'ein-eingang-fuer-alle' })).toBe('EEF')
|
||||
})
|
||||
|
||||
it('behandelt jedes nicht alphanumerische Zeichen als Worttrenner', () => {
|
||||
expect(projectCode({ slug: 'mta_360.pilot' })).toBe('M3P')
|
||||
})
|
||||
|
||||
it('ignoriert führende und mehrfache Trenner', () => {
|
||||
expect(projectCode({ slug: '--trakk--' })).toBe('TRA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectCode mit Sonderzeichen und Umlauten', () => {
|
||||
it('bricht bei Umlauten nicht und schreibt sie als Grundbuchstaben', () => {
|
||||
expect(projectCode({ slug: 'übung' })).toBe('UBU')
|
||||
})
|
||||
|
||||
it('trennt an Umlautwörtern korrekt', () => {
|
||||
expect(projectCode({ slug: 'grüne-wiese' })).toBe('GWI')
|
||||
})
|
||||
|
||||
it('löst ß in SS auf', () => {
|
||||
expect(projectCode({ slug: 'straße' })).toBe('STR')
|
||||
})
|
||||
|
||||
it('nimmt Umlaute auch im gesetzten Kürzel an', () => {
|
||||
expect(projectCode({ code: 'öl', slug: 'orbit' })).toBe('OL')
|
||||
})
|
||||
|
||||
it('bricht bei Zeichen ohne Entsprechung nicht', () => {
|
||||
expect(projectCode({ slug: '★★★' })).toBe('LOG')
|
||||
})
|
||||
|
||||
it('bricht bei einem leeren Slug nicht', () => {
|
||||
expect(projectCode({ code: '', slug: '' })).toBe('LOG')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectCode leitet nie weniger als drei Zeichen ab', () => {
|
||||
const slugs = [
|
||||
'trakk',
|
||||
'mta360',
|
||||
'mta-telematik',
|
||||
'orbit',
|
||||
'pulsar',
|
||||
'a',
|
||||
'a-b',
|
||||
'',
|
||||
'★',
|
||||
'ein-sehr-langer-projekt-slug-mit-vielen-woertern',
|
||||
]
|
||||
|
||||
for (const slug of slugs) {
|
||||
it(`liefert für "${slug}" drei bis vier Großbuchstaben`, () => {
|
||||
const result = projectCode({ slug })
|
||||
|
||||
expect(result.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.length).toBeLessThanOrEqual(4)
|
||||
expect(result).toBe(result.toUpperCase())
|
||||
})
|
||||
}
|
||||
|
||||
it('füllt ein einzelnes ableitbares Zeichen auf drei Zeichen auf', () => {
|
||||
expect(projectCode({ slug: 'a' })).toBe('AAA')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { checkPublish } from '~/domain/publish-checks'
|
||||
|
||||
const base = {
|
||||
title: 'Regel-Engine: Bedingung trifft Aktion',
|
||||
audience: 'internal' as const,
|
||||
blockCount: 3,
|
||||
images: [{ hasAlt: true }],
|
||||
tagCount: 1,
|
||||
hasCover: true,
|
||||
}
|
||||
|
||||
describe('checkPublish', () => {
|
||||
it('lässt einen vollständigen Beitrag ohne Sperre und ohne Hinweis durch', () => {
|
||||
expect(checkPublish(base)).toEqual({ blockers: [], hints: [] })
|
||||
})
|
||||
|
||||
it('sperrt einen Beitrag ohne Titel', () => {
|
||||
expect(checkPublish({ ...base, title: ' ' }).blockers).toContain('title_missing')
|
||||
})
|
||||
|
||||
it('sperrt einen leeren Beitrag', () => {
|
||||
expect(checkPublish({ ...base, blockCount: 0 }).blockers).toContain('content_empty')
|
||||
})
|
||||
|
||||
it('sperrt einen öffentlichen Beitrag mit Bild ohne Alt-Text', () => {
|
||||
const result = checkPublish({ ...base, audience: 'public', images: [{ hasAlt: false }] })
|
||||
expect(result.blockers).toContain('alt_text_missing')
|
||||
})
|
||||
|
||||
it('meldet fehlenden Alt-Text bei internen Beiträgen nur als Hinweis', () => {
|
||||
const result = checkPublish({ ...base, images: [{ hasAlt: false }] })
|
||||
expect(result.blockers).toEqual([])
|
||||
expect(result.hints).toContain('alt_text_missing')
|
||||
})
|
||||
|
||||
it('meldet fehlende Schlagworte und fehlendes Aufmacherbild als Hinweis', () => {
|
||||
const result = checkPublish({ ...base, tagCount: 0, hasCover: false })
|
||||
expect(result.blockers).toEqual([])
|
||||
expect(result.hints).toEqual(expect.arrayContaining(['tags_missing', 'cover_missing']))
|
||||
})
|
||||
|
||||
it('meldet fehlenden Alt-Text bei Kundenbeiträgen nur als Hinweis', () => {
|
||||
const result = checkPublish({ ...base, audience: 'customer', images: [{ hasAlt: false }] })
|
||||
|
||||
expect(result.blockers).toEqual([])
|
||||
expect(result.hints).toContain('alt_text_missing')
|
||||
})
|
||||
|
||||
it('sperrt einen öffentlichen Beitrag, wenn nur eines von mehreren Bildern keinen Alt-Text hat', () => {
|
||||
const result = checkPublish({
|
||||
...base,
|
||||
audience: 'public',
|
||||
images: [{ hasAlt: true }, { hasAlt: false }, { hasAlt: true }],
|
||||
})
|
||||
|
||||
expect(result.blockers).toContain('alt_text_missing')
|
||||
})
|
||||
|
||||
it('meldet mehrere Sperrgründe gleichzeitig', () => {
|
||||
const result = checkPublish({ ...base, title: '', blockCount: 0 })
|
||||
|
||||
expect(result.blockers).toEqual(expect.arrayContaining(['title_missing', 'content_empty']))
|
||||
})
|
||||
|
||||
it('sperrt einen Beitrag ohne Bilder nicht', () => {
|
||||
const result = checkPublish({ ...base, audience: 'public', images: [] })
|
||||
|
||||
expect(result.blockers).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isSlug, slugify } from '~/domain/slug'
|
||||
|
||||
describe('slugify', () => {
|
||||
it('macht aus einem Namen einen Slug', () => {
|
||||
expect(slugify('MTA Telematik')).toBe('mta-telematik')
|
||||
})
|
||||
|
||||
it('schreibt Umlaute aus', () => {
|
||||
expect(slugify('Größe und Öl')).toBe('groesse-und-oel')
|
||||
})
|
||||
|
||||
it('wirft Sonderzeichen weg und lässt keine Bindestriche am Rand', () => {
|
||||
expect(slugify(' Trakk / Regel-Engine! ')).toBe('trakk-regel-engine')
|
||||
})
|
||||
|
||||
it('liefert eine leere Zeichenkette, wenn nichts Verwertbares übrig bleibt', () => {
|
||||
expect(slugify('///')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSlug', () => {
|
||||
it('nimmt Kleinbuchstaben, Ziffern und Bindestriche an', () => {
|
||||
expect(isSlug('mta-360')).toBe(true)
|
||||
})
|
||||
|
||||
it('lehnt Großbuchstaben ab', () => {
|
||||
expect(isSlug('Trakk')).toBe(false)
|
||||
})
|
||||
|
||||
it('lehnt führende und doppelte Bindestriche ab', () => {
|
||||
expect(isSlug('-trakk')).toBe(false)
|
||||
expect(isSlug('trakk--web')).toBe(false)
|
||||
})
|
||||
|
||||
it('lehnt einen zu kurzen Slug ab', () => {
|
||||
expect(isSlug('a')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { listBrands } from '~/data/repositories/projects'
|
||||
import { performSaveBrand } from '~/lib/admin-brands'
|
||||
import { adminViewer, auditEntries, form, makeBrand, moderatorViewer, resetAccounts } from '../support/admin'
|
||||
|
||||
beforeEach(resetAccounts)
|
||||
|
||||
describe('performSaveBrand', () => {
|
||||
it('legt eine Marke an und protokolliert das', async () => {
|
||||
const state = await performSaveBrand(
|
||||
adminViewer(),
|
||||
form({ name: 'Pocket Rocket', slug: '', color: '#C43A22', sort: '2' }),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect(state.created).toBe(true)
|
||||
|
||||
const brands = await listBrands()
|
||||
|
||||
expect(brands).toHaveLength(1)
|
||||
expect(brands[0]).toMatchObject({ slug: 'pocket-rocket', name: 'Pocket Rocket', color: '#c43a22', sort: 2 })
|
||||
|
||||
const entries = await auditEntries()
|
||||
|
||||
expect(entries[0]).toMatchObject({ action: 'brand.create', entity: 'brand', entityId: brands[0]!.id })
|
||||
})
|
||||
|
||||
it('lehnt einen vergebenen Slug ab', async () => {
|
||||
await makeBrand('nyo', 'NYO')
|
||||
|
||||
const state = await performSaveBrand(adminViewer(), form({ name: 'NYO zwei', slug: 'nyo', color: '#191c20' }))
|
||||
|
||||
expect(state.message).toBe('slugTaken')
|
||||
expect(state.fields?.slug).toBe('slugTaken')
|
||||
expect(await listBrands()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('speichert Änderungen an einer bestehenden Marke', async () => {
|
||||
const brand = await makeBrand('nyo', 'NYO')
|
||||
|
||||
const state = await performSaveBrand(
|
||||
adminViewer(),
|
||||
form({ id: brand.id, name: 'NYO GmbH', slug: 'nyo', color: '#2b4a9b', sort: '1' }),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect(state.created).toBe(false)
|
||||
|
||||
const brands = await listBrands()
|
||||
|
||||
expect(brands[0]).toMatchObject({ name: 'NYO GmbH', color: '#2b4a9b' })
|
||||
expect((await auditEntries())[0]?.action).toBe('brand.update')
|
||||
})
|
||||
|
||||
it('lässt einen Moderator keine Marke anlegen', async () => {
|
||||
const state = await performSaveBrand(moderatorViewer(), form({ name: 'Fremd', color: '#191c20' }))
|
||||
|
||||
expect(state.message).toBe('forbidden')
|
||||
expect(await listBrands()).toHaveLength(0)
|
||||
expect(await auditEntries()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('verlangt einen Namen', async () => {
|
||||
const state = await performSaveBrand(adminViewer(), form({ name: '', color: '#191c20' }))
|
||||
|
||||
expect(state.fields?.name).toBe('nameRequired')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { findActiveClientByTokenHash, listClients } from '~/data/repositories/clients'
|
||||
import { performCreateClient, performRevokeClient, tokenPrefix } from '~/lib/admin-clients'
|
||||
import { hashToken } from '~/lib/api-auth'
|
||||
import {
|
||||
adminViewer,
|
||||
auditEntries,
|
||||
form,
|
||||
makeBrand,
|
||||
makeProject,
|
||||
moderatorViewer,
|
||||
resetAccounts,
|
||||
} from '../support/admin'
|
||||
|
||||
beforeEach(resetAccounts)
|
||||
|
||||
describe('performCreateClient', () => {
|
||||
it('legt einen Zugang an, zeigt das Token einmal und speichert nur den Hash', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const state = await performCreateClient(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk Web', mode: 'read', scope: 'customer', projectId: project.id }),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect(state.token?.startsWith(tokenPrefix)).toBe(true)
|
||||
|
||||
const clients = await listClients()
|
||||
|
||||
expect(clients).toHaveLength(1)
|
||||
expect(clients[0]).toMatchObject({
|
||||
name: 'Trakk Web',
|
||||
mode: 'read',
|
||||
scope: 'customer',
|
||||
projectId: project.id,
|
||||
canPublish: false,
|
||||
revokedAt: null,
|
||||
})
|
||||
expect(clients[0]?.tokenHash).not.toBe(state.token)
|
||||
expect(clients[0]?.tokenHash).toBe(hashToken(state.token!))
|
||||
|
||||
const found = await findActiveClientByTokenHash(hashToken(state.token!))
|
||||
|
||||
expect(found?.id).toBe(clients[0]?.id)
|
||||
})
|
||||
|
||||
it('protokolliert das Anlegen ohne das Token', async () => {
|
||||
await performCreateClient(adminViewer(), form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }))
|
||||
|
||||
const entries = await auditEntries()
|
||||
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({ action: 'client.create', entity: 'api_client' })
|
||||
expect(JSON.stringify(entries[0]?.data)).not.toContain(tokenPrefix)
|
||||
})
|
||||
|
||||
it('lässt die Projektbindung leer, wenn kein Projekt gewählt ist', async () => {
|
||||
const state = await performCreateClient(
|
||||
adminViewer(),
|
||||
form({ name: 'Alle Projekte', mode: 'read', scope: 'internal', projectId: '' }),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect((await listClients())[0]?.projectId).toBeNull()
|
||||
})
|
||||
|
||||
it('lehnt ein unbekanntes Projekt ab', async () => {
|
||||
const state = await performCreateClient(
|
||||
adminViewer(),
|
||||
form({
|
||||
name: 'Fremd',
|
||||
mode: 'read',
|
||||
scope: 'customer',
|
||||
projectId: '11111111-1111-4111-8111-111111111111',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(state.fields?.projectId).toBe('projectUnknown')
|
||||
expect(await listClients()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lehnt einen unbekannten Modus ab', async () => {
|
||||
const state = await performCreateClient(
|
||||
adminViewer(),
|
||||
form({ name: 'Fremd', mode: 'alles', scope: 'customer' }),
|
||||
)
|
||||
|
||||
expect(state.fields?.mode).toBe('modeInvalid')
|
||||
})
|
||||
|
||||
it('lässt einen Moderator keinen Zugang anlegen', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const state = await performCreateClient(
|
||||
moderatorViewer([project.id]),
|
||||
form({ name: 'Eigener Zugang', mode: 'read', scope: 'customer', projectId: project.id }),
|
||||
)
|
||||
|
||||
expect(state.message).toBe('forbidden')
|
||||
expect(await listClients()).toHaveLength(0)
|
||||
expect(await auditEntries()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('performRevokeClient', () => {
|
||||
it('widerruft einen Zugang und protokolliert das', async () => {
|
||||
const created = await performCreateClient(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }),
|
||||
)
|
||||
|
||||
const state = await performRevokeClient(adminViewer(), form({ id: created.id! }))
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
|
||||
const clients = await listClients()
|
||||
|
||||
expect(clients[0]?.revokedAt).toBeInstanceOf(Date)
|
||||
expect((await auditEntries())[0]?.action).toBe('client.revoke')
|
||||
})
|
||||
|
||||
it('lässt ein widerrufenes Token nicht mehr durch', async () => {
|
||||
const created = await performCreateClient(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }),
|
||||
)
|
||||
|
||||
await performRevokeClient(adminViewer(), form({ id: created.id! }))
|
||||
|
||||
expect(await findActiveClientByTokenHash(hashToken(created.token!))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('meldet einen zweiten Widerruf als unbekannt', async () => {
|
||||
const created = await performCreateClient(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }),
|
||||
)
|
||||
|
||||
await performRevokeClient(adminViewer(), form({ id: created.id! }))
|
||||
const state = await performRevokeClient(adminViewer(), form({ id: created.id! }))
|
||||
|
||||
expect(state.message).toBe('notFound')
|
||||
})
|
||||
|
||||
it('lässt einen Moderator keinen Zugang widerrufen', async () => {
|
||||
const created = await performCreateClient(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }),
|
||||
)
|
||||
|
||||
const state = await performRevokeClient(moderatorViewer(), form({ id: created.id! }))
|
||||
|
||||
expect(state.message).toBe('forbidden')
|
||||
expect((await listClients())[0]?.revokedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { listAllProjects } from '~/data/repositories/projects'
|
||||
import { performSaveProject } from '~/lib/admin-projects'
|
||||
import {
|
||||
adminViewer,
|
||||
auditEntries,
|
||||
form,
|
||||
makeBrand,
|
||||
makeProject,
|
||||
moderatorViewer,
|
||||
resetAccounts,
|
||||
} from '../support/admin'
|
||||
|
||||
beforeEach(resetAccounts)
|
||||
|
||||
describe('performSaveProject legt an', () => {
|
||||
it('speichert ein Projekt und schreibt einen Eintrag ins audit_log', async () => {
|
||||
const brand = await makeBrand()
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({
|
||||
name: 'MTA Telematik',
|
||||
slug: '',
|
||||
code: 'tel',
|
||||
description: 'Telemetrie für die Flotte',
|
||||
color: '#B4761A',
|
||||
brandId: brand.id,
|
||||
sort: '3',
|
||||
isActive: 'on',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect(state.created).toBe(true)
|
||||
|
||||
const projects = await listAllProjects()
|
||||
|
||||
expect(projects).toHaveLength(1)
|
||||
expect(projects[0]).toMatchObject({
|
||||
slug: 'mta-telematik',
|
||||
name: 'MTA Telematik',
|
||||
code: 'TEL',
|
||||
color: '#b4761a',
|
||||
sort: 3,
|
||||
isActive: true,
|
||||
brandId: brand.id,
|
||||
})
|
||||
|
||||
const entries = await auditEntries()
|
||||
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({
|
||||
action: 'project.create',
|
||||
entity: 'project',
|
||||
entityId: projects[0]!.id,
|
||||
actorId: 'admin-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('legt ein stillgelegtes Projekt an, wenn der Haken fehlt', async () => {
|
||||
const brand = await makeBrand()
|
||||
|
||||
await performSaveProject(adminViewer(), form({ name: 'Altprojekt', brandId: brand.id, color: '#191c20' }))
|
||||
|
||||
const projects = await listAllProjects()
|
||||
|
||||
expect(projects[0]?.isActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('performSaveProject prüft den Slug', () => {
|
||||
it('lehnt einen vergebenen Slug ab', async () => {
|
||||
const brand = await makeBrand()
|
||||
await makeProject(brand.id, 'trakk', 'Trakk')
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk Zwei', slug: 'trakk', brandId: brand.id, color: '#191c20', isActive: 'on' }),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('error')
|
||||
expect(state.message).toBe('slugTaken')
|
||||
expect(state.fields?.slug).toBe('slugTaken')
|
||||
expect(await listAllProjects()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('erlaubt beim Bearbeiten den eigenen Slug', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id, 'trakk', 'Trakk')
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({
|
||||
id: project.id,
|
||||
name: 'Trakk',
|
||||
slug: 'trakk',
|
||||
brandId: brand.id,
|
||||
color: '#2e7d5b',
|
||||
sort: '1',
|
||||
isActive: 'on',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect(state.created).toBe(false)
|
||||
|
||||
const entries = await auditEntries()
|
||||
|
||||
expect(entries[0]?.action).toBe('project.update')
|
||||
})
|
||||
|
||||
it('schreibt einen eingetippten Slug klein', async () => {
|
||||
const brand = await makeBrand()
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk', slug: 'Trakk', brandId: brand.id, color: '#191c20' }),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
|
||||
const projects = await listAllProjects()
|
||||
|
||||
expect(projects[0]?.slug).toBe('trakk')
|
||||
})
|
||||
|
||||
it('lehnt einen Slug mit Leerzeichen ab', async () => {
|
||||
const brand = await makeBrand()
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk', slug: 'trakk web', brandId: brand.id, color: '#191c20' }),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('error')
|
||||
expect(state.fields?.slug).toBe('slugInvalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('performSaveProject prüft die übrigen Felder', () => {
|
||||
it('verlangt einen Namen', async () => {
|
||||
const brand = await makeBrand()
|
||||
|
||||
const state = await performSaveProject(adminViewer(), form({ name: '', brandId: brand.id, color: '#191c20' }))
|
||||
|
||||
expect(state.fields?.name).toBe('nameRequired')
|
||||
})
|
||||
|
||||
it('lehnt eine unbekannte Marke ab', async () => {
|
||||
await makeBrand()
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({
|
||||
name: 'Trakk',
|
||||
brandId: '11111111-1111-4111-8111-111111111111',
|
||||
color: '#191c20',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(state.fields?.brandId).toBe('brandUnknown')
|
||||
expect(await listAllProjects()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lehnt eine Farbe ab, die kein Hexwert ist', async () => {
|
||||
const brand = await makeBrand()
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk', brandId: brand.id, color: 'rot' }),
|
||||
)
|
||||
|
||||
expect(state.fields?.color).toBe('colorInvalid')
|
||||
})
|
||||
|
||||
it('lehnt eine Sortierung ab, die keine Zahl ist', async () => {
|
||||
const brand = await makeBrand()
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({ name: 'Trakk', brandId: brand.id, color: '#191c20', sort: 'oben' }),
|
||||
)
|
||||
|
||||
expect(state.fields?.sort).toBe('sortInvalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('performSaveProject prüft die Rechte', () => {
|
||||
it('lässt einen Moderator kein Projekt anlegen', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const state = await performSaveProject(
|
||||
moderatorViewer([project.id]),
|
||||
form({ name: 'Neues Projekt', brandId: brand.id, color: '#191c20', isActive: 'on' }),
|
||||
)
|
||||
|
||||
expect(state.status).toBe('error')
|
||||
expect(state.message).toBe('forbidden')
|
||||
expect(await listAllProjects()).toHaveLength(1)
|
||||
expect(await auditEntries()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lässt einen Moderator sein eigenes Projekt nicht bearbeiten', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const state = await performSaveProject(
|
||||
moderatorViewer([project.id]),
|
||||
form({ id: project.id, name: 'Umbenannt', brandId: brand.id, color: '#191c20', isActive: 'on' }),
|
||||
)
|
||||
|
||||
expect(state.message).toBe('forbidden')
|
||||
|
||||
const projects = await listAllProjects()
|
||||
|
||||
expect(projects[0]?.name).toBe('Trakk')
|
||||
})
|
||||
|
||||
it('meldet einen unbekannten Datensatz beim Bearbeiten', async () => {
|
||||
const brand = await makeBrand()
|
||||
|
||||
const state = await performSaveProject(
|
||||
adminViewer(),
|
||||
form({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
name: 'Trakk',
|
||||
brandId: brand.id,
|
||||
color: '#191c20',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(state.message).toBe('notFound')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { findUserById } from '~/data/repositories/users'
|
||||
import { listAssignmentsForUser } from '~/data/repositories/user-roles'
|
||||
import { performGrantRole, performRevokeRole, performSetUserAdmin } from '~/lib/admin-users'
|
||||
import {
|
||||
adminViewer,
|
||||
auditEntries,
|
||||
form,
|
||||
makeBrand,
|
||||
makeProject,
|
||||
makeUser,
|
||||
moderatorViewer,
|
||||
resetAccounts,
|
||||
} from '../support/admin'
|
||||
|
||||
beforeEach(resetAccounts)
|
||||
|
||||
describe('performSetUserAdmin', () => {
|
||||
it('gibt einem Konto das Verwaltungsrecht und protokolliert das', async () => {
|
||||
const user = await makeUser()
|
||||
|
||||
const state = await performSetUserAdmin(adminViewer(), form({ userId: user.id, isAdmin: 'on' }))
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect((await findUserById(user.id))?.isAdmin).toBe(true)
|
||||
expect((await auditEntries())[0]).toMatchObject({ action: 'user.admin.grant', entity: 'user', entityId: user.id })
|
||||
})
|
||||
|
||||
it('entzieht das Verwaltungsrecht wieder', async () => {
|
||||
const user = await makeUser()
|
||||
|
||||
await performSetUserAdmin(adminViewer(), form({ userId: user.id, isAdmin: 'on' }))
|
||||
await performSetUserAdmin(adminViewer(), form({ userId: user.id, isAdmin: '' }))
|
||||
|
||||
expect((await findUserById(user.id))?.isAdmin).toBe(false)
|
||||
expect((await auditEntries())[0]?.action).toBe('user.admin.revoke')
|
||||
})
|
||||
|
||||
it('lässt niemanden das eigene Verwaltungsrecht ändern', async () => {
|
||||
const user = await makeUser()
|
||||
|
||||
const state = await performSetUserAdmin(adminViewer({ id: user.id }), form({ userId: user.id, isAdmin: '' }))
|
||||
|
||||
expect(state.message).toBe('selfAdmin')
|
||||
expect(await auditEntries()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lässt einen Moderator keine Rechte vergeben', async () => {
|
||||
const user = await makeUser()
|
||||
|
||||
const state = await performSetUserAdmin(moderatorViewer(), form({ userId: user.id, isAdmin: 'on' }))
|
||||
|
||||
expect(state.message).toBe('forbidden')
|
||||
expect((await findUserById(user.id))?.isAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('meldet ein unbekanntes Konto', async () => {
|
||||
const state = await performSetUserAdmin(adminViewer(), form({ userId: 'gibtsnicht', isAdmin: 'on' }))
|
||||
|
||||
expect(state.message).toBe('notFound')
|
||||
})
|
||||
})
|
||||
|
||||
describe('performGrantRole und performRevokeRole', () => {
|
||||
it('schaltet ein Konto für ein Projekt frei und protokolliert das', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const user = await makeUser()
|
||||
|
||||
const state = await performGrantRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect(await listAssignmentsForUser(user.id)).toEqual([{ projectId: project.id, role: 'moderator' }])
|
||||
expect((await auditEntries())[0]).toMatchObject({ action: 'role.grant', entityId: user.id })
|
||||
})
|
||||
|
||||
it('ist beim zweiten Freischalten nicht doppelt', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const user = await makeUser()
|
||||
|
||||
await performGrantRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
|
||||
await performGrantRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
|
||||
|
||||
expect(await listAssignmentsForUser(user.id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('entzieht die Freischaltung wieder', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const user = await makeUser()
|
||||
|
||||
await performGrantRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
|
||||
const state = await performRevokeRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
|
||||
|
||||
expect(state.status).toBe('ok')
|
||||
expect(await listAssignmentsForUser(user.id)).toHaveLength(0)
|
||||
expect((await auditEntries())[0]?.action).toBe('role.revoke')
|
||||
})
|
||||
|
||||
it('lässt einen Moderator keine Freischaltung vergeben', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const user = await makeUser()
|
||||
|
||||
const state = await performGrantRole(
|
||||
moderatorViewer([project.id]),
|
||||
form({ userId: user.id, projectId: project.id }),
|
||||
)
|
||||
|
||||
expect(state.message).toBe('forbidden')
|
||||
expect(await listAssignmentsForUser(user.id)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('meldet ein unbekanntes Projekt', async () => {
|
||||
const user = await makeUser()
|
||||
|
||||
const state = await performGrantRole(
|
||||
adminViewer(),
|
||||
form({ userId: user.id, projectId: '11111111-1111-4111-8111-111111111111' }),
|
||||
)
|
||||
|
||||
expect(state.message).toBe('notFound')
|
||||
})
|
||||
|
||||
it('meldet eine Projektkennung, die keine Kennung ist', async () => {
|
||||
const user = await makeUser()
|
||||
|
||||
const state = await performGrantRole(adminViewer(), form({ userId: user.id, projectId: 'trakk' }))
|
||||
|
||||
expect(state.message).toBe('notFound')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { db } from '~/data/db'
|
||||
import { apiClients } from '~/data/schema'
|
||||
import { hashToken, resolveClient } from '~/lib/api-auth'
|
||||
|
||||
async function seedClient(token: string, revoked = false) {
|
||||
const [client] = await db.insert(apiClients).values({
|
||||
name: 'Trakk Leseclient',
|
||||
tokenHash: hashToken(token),
|
||||
mode: 'read',
|
||||
scope: 'internal',
|
||||
revokedAt: revoked ? new Date() : null,
|
||||
}).returning()
|
||||
return client!
|
||||
}
|
||||
|
||||
describe('resolveClient', () => {
|
||||
it('findet den Client zu einem gültigen Bearer-Token', async () => {
|
||||
const client = await seedClient('lb_live_abc')
|
||||
const request = new Request('http://localhost/api/v1/posts', {
|
||||
headers: { authorization: 'Bearer lb_live_abc' },
|
||||
})
|
||||
|
||||
await expect(resolveClient(request)).resolves.toMatchObject({ id: client.id })
|
||||
})
|
||||
|
||||
it('liefert nichts ohne Kopfzeile', async () => {
|
||||
await seedClient('lb_live_abc')
|
||||
const request = new Request('http://localhost/api/v1/posts')
|
||||
|
||||
await expect(resolveClient(request)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('liefert nichts für ein widerrufenes Token', async () => {
|
||||
await seedClient('lb_live_abc', true)
|
||||
const request = new Request('http://localhost/api/v1/posts', {
|
||||
headers: { authorization: 'Bearer lb_live_abc' },
|
||||
})
|
||||
|
||||
await expect(resolveClient(request)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('speichert das Token niemals im Klartext', async () => {
|
||||
await seedClient('lb_live_abc')
|
||||
const rows = await db.select().from(apiClients)
|
||||
|
||||
expect(rows[0]!.tokenHash).not.toContain('lb_live_abc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canAccessProject,
|
||||
canManageInstance,
|
||||
canOpenAdmin,
|
||||
isAdmin,
|
||||
isModeratorOf,
|
||||
moderatedProjectIds,
|
||||
visibleProjects,
|
||||
type Viewer,
|
||||
} from '~/lib/auth-access'
|
||||
|
||||
const trakk = '11111111-1111-4111-8111-111111111111'
|
||||
const mta360 = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function viewer(overrides: Partial<Viewer> = {}): Viewer {
|
||||
return {
|
||||
id: 'user-1',
|
||||
name: 'Paul',
|
||||
email: 'paul@example.test',
|
||||
isAdmin: false,
|
||||
assignments: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('isAdmin', () => {
|
||||
it('erkennt das Verwaltungsrecht', () => {
|
||||
expect(isAdmin(viewer({ isAdmin: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('gilt nicht für ein Konto ohne Verwaltungsrecht', () => {
|
||||
expect(isAdmin(viewer())).toBe(false)
|
||||
})
|
||||
|
||||
it('gilt nicht ohne Sitzung', () => {
|
||||
expect(isAdmin(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moderatedProjectIds', () => {
|
||||
it('liefert die Projekte der Moderatorenrolle', () => {
|
||||
const person = viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] })
|
||||
|
||||
expect(moderatedProjectIds(person)).toEqual([trakk])
|
||||
})
|
||||
|
||||
it('liefert jedes Projekt nur einmal', () => {
|
||||
const person = viewer({
|
||||
assignments: [
|
||||
{ projectId: trakk, role: 'moderator' },
|
||||
{ projectId: trakk, role: 'moderator' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(moderatedProjectIds(person)).toEqual([trakk])
|
||||
})
|
||||
|
||||
it('liefert nichts ohne Sitzung', () => {
|
||||
expect(moderatedProjectIds(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('zählt Verwaltungsrecht nicht als Zuweisung', () => {
|
||||
expect(moderatedProjectIds(viewer({ isAdmin: true }))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isModeratorOf', () => {
|
||||
it('trifft auf das zugewiesene Projekt zu', () => {
|
||||
const person = viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] })
|
||||
|
||||
expect(isModeratorOf(person, trakk)).toBe(true)
|
||||
})
|
||||
|
||||
it('trifft auf ein fremdes Projekt nicht zu', () => {
|
||||
const person = viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] })
|
||||
|
||||
expect(isModeratorOf(person, mta360)).toBe(false)
|
||||
})
|
||||
|
||||
it('trifft auf einen Admin ohne Zuweisung nicht zu', () => {
|
||||
expect(isModeratorOf(viewer({ isAdmin: true }), trakk)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canOpenAdmin', () => {
|
||||
it('lässt einen Admin herein', () => {
|
||||
expect(canOpenAdmin(viewer({ isAdmin: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt einen Moderator herein', () => {
|
||||
expect(canOpenAdmin(viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] }))).toBe(true)
|
||||
})
|
||||
|
||||
it('sperrt ein angemeldetes Konto ohne Rolle aus', () => {
|
||||
expect(canOpenAdmin(viewer())).toBe(false)
|
||||
})
|
||||
|
||||
it('sperrt ohne Sitzung aus', () => {
|
||||
expect(canOpenAdmin(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canAccessProject', () => {
|
||||
it('gibt einem Admin jedes Projekt frei', () => {
|
||||
expect(canAccessProject(viewer({ isAdmin: true }), mta360)).toBe(true)
|
||||
})
|
||||
|
||||
it('gibt einem Moderator genau sein Projekt frei', () => {
|
||||
const person = viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] })
|
||||
|
||||
expect(canAccessProject(person, trakk)).toBe(true)
|
||||
expect(canAccessProject(person, mta360)).toBe(false)
|
||||
})
|
||||
|
||||
it('gibt ohne Sitzung nichts frei', () => {
|
||||
expect(canAccessProject(null, trakk)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canManageInstance', () => {
|
||||
it('bleibt Admins vorbehalten', () => {
|
||||
expect(canManageInstance(viewer({ isAdmin: true }))).toBe(true)
|
||||
expect(canManageInstance(viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('visibleProjects', () => {
|
||||
const projects = [{ id: trakk }, { id: mta360 }]
|
||||
|
||||
it('zeigt einem Admin alle Projekte', () => {
|
||||
expect(visibleProjects(viewer({ isAdmin: true }), projects)).toEqual(projects)
|
||||
})
|
||||
|
||||
it('zeigt einem Moderator nur seine Projekte', () => {
|
||||
const person = viewer({ assignments: [{ projectId: mta360, role: 'moderator' }] })
|
||||
|
||||
expect(visibleProjects(person, projects)).toEqual([{ id: mta360 }])
|
||||
})
|
||||
|
||||
it('zeigt ohne Sitzung nichts', () => {
|
||||
expect(visibleProjects(null, projects)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { internalTarget, loginPath, overviewTarget } from '~/lib/auth-routes'
|
||||
|
||||
describe('internalTarget', () => {
|
||||
it('behält ein Ziel innerhalb der Verwaltung', () => {
|
||||
expect(internalTarget('/admin/posts/neu', overviewTarget)).toBe('/admin/posts/neu')
|
||||
})
|
||||
|
||||
it('fällt auf die Verwaltungsübersicht zurück', () => {
|
||||
expect(internalTarget(undefined, overviewTarget)).toBe('/')
|
||||
expect(internalTarget('', overviewTarget)).toBe('/')
|
||||
expect(internalTarget('/search', overviewTarget)).toBe('/search')
|
||||
})
|
||||
|
||||
it('lässt keine fremde Adresse durch', () => {
|
||||
expect(internalTarget('//example.test/admin', overviewTarget)).toBe('/')
|
||||
expect(internalTarget('https://example.test/admin', overviewTarget)).toBe('/')
|
||||
expect(internalTarget('/admin\\@example.test', overviewTarget)).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginPath', () => {
|
||||
it('bleibt ohne Ziel schlicht', () => {
|
||||
expect(loginPath()).toBe('/login')
|
||||
expect(loginPath('/')).toBe('/login')
|
||||
})
|
||||
|
||||
it('hängt ein erlaubtes Ziel an', () => {
|
||||
expect(loginPath('/admin/posts/neu')).toBe('/login?next=%2Fadmin%2Fposts%2Fneu')
|
||||
})
|
||||
|
||||
it('verwirft ein fremdes Ziel', () => {
|
||||
expect(loginPath('https://example.test')).toBe('/login')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,367 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { findEntry, listEntryBlocks } from '~/data/repositories/entries'
|
||||
import {
|
||||
performArchiveEntry,
|
||||
performPublishEntry,
|
||||
performResumeEntry,
|
||||
performSaveEntry,
|
||||
performScheduleEntry,
|
||||
performUnscheduleEntry,
|
||||
} from '~/lib/entries'
|
||||
import { adminViewer, auditEntries, makeBrand, makeProject, moderatorViewer, resetAccounts } from '../support/admin'
|
||||
import { entryInput, makeMedia } from '../support/entries'
|
||||
|
||||
beforeEach(resetAccounts)
|
||||
|
||||
async function ready(audience: 'internal' | 'customer' | 'public' = 'internal') {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { audience }))
|
||||
|
||||
if (!state.ok) {
|
||||
throw new Error(`Anlegen fehlgeschlagen: ${state.error}`)
|
||||
}
|
||||
|
||||
return { project, entry: state.entry }
|
||||
}
|
||||
|
||||
describe('performSaveEntry legt an', () => {
|
||||
it('speichert Beitrag, Blöcke und Autor', async () => {
|
||||
const { entry } = await ready()
|
||||
|
||||
expect(entry.number).toBe(1)
|
||||
expect(entry.slug).toBe('regel-engine')
|
||||
expect(entry.status).toBe('draft')
|
||||
|
||||
const saved = await findEntry(entry.id)
|
||||
const blocks = await listEntryBlocks(entry.id)
|
||||
|
||||
expect(saved?.title).toBe('Regel-Engine')
|
||||
expect(saved?.authorName).toBe('Admin')
|
||||
expect(blocks).toHaveLength(1)
|
||||
expect(blocks[0]?.type).toBe('text')
|
||||
|
||||
const entries = await auditEntries()
|
||||
|
||||
expect(entries[0]).toMatchObject({ action: 'post.create', entity: 'post', entityId: entry.id })
|
||||
})
|
||||
|
||||
it('verlangt einen Titel', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { title: ' ' }))
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'titleMissing' })
|
||||
})
|
||||
|
||||
it('lehnt einen unbekannten Blocktyp ab', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const state = await performSaveEntry(
|
||||
adminViewer(),
|
||||
entryInput(project.id, { blocks: [{ type: 'unbekannt', data: {} }] as never }),
|
||||
)
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'blocksInvalid' })
|
||||
})
|
||||
|
||||
it('lässt einen Moderator nur in seinem Projekt schreiben', async () => {
|
||||
const brand = await makeBrand()
|
||||
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
|
||||
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
|
||||
|
||||
const allowed = await performSaveEntry(moderatorViewer([trakk.id]), entryInput(trakk.id))
|
||||
const denied = await performSaveEntry(moderatorViewer([trakk.id]), entryInput(orbit.id))
|
||||
|
||||
expect(allowed.ok).toBe(true)
|
||||
expect(denied).toMatchObject({ ok: false, error: 'forbidden' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('performSaveEntry ändert', () => {
|
||||
it('behält Nummer und Slug, wenn sich der Titel ändert', async () => {
|
||||
const { project, entry } = await ready()
|
||||
|
||||
const state = await performSaveEntry(
|
||||
adminViewer(),
|
||||
entryInput(project.id, { id: entry.id, title: 'Regel-Engine, zweiter Anlauf' }),
|
||||
)
|
||||
|
||||
expect(state).toMatchObject({ ok: true })
|
||||
expect(state.ok && state.entry.slug).toBe('regel-engine')
|
||||
expect(state.ok && state.entry.number).toBe(1)
|
||||
})
|
||||
|
||||
it('schreibt Blöcke in neuer Reihenfolge', async () => {
|
||||
const { project, entry } = await ready()
|
||||
|
||||
await performSaveEntry(adminViewer(), entryInput(project.id, {
|
||||
id: entry.id,
|
||||
blocks: [
|
||||
{ type: 'quote', data: { text: 'Erst das Zitat', source: '' } },
|
||||
{ type: 'text', data: { text: 'Dann der Text' } },
|
||||
],
|
||||
}))
|
||||
|
||||
const blocks = await listEntryBlocks(entry.id)
|
||||
|
||||
expect(blocks.map(block => block.type)).toEqual(['quote', 'text'])
|
||||
})
|
||||
|
||||
it('lehnt einen vergebenen Slug ab', async () => {
|
||||
const { project, entry } = await ready()
|
||||
|
||||
await performSaveEntry(adminViewer(), entryInput(project.id, { title: 'SLA-Uhr', slug: 'sla-uhr' }))
|
||||
|
||||
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { id: entry.id, slug: 'sla-uhr' }))
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'slugTaken' })
|
||||
})
|
||||
|
||||
it('lässt das Projekt wechseln und nummeriert im Zielprojekt neu', async () => {
|
||||
const brand = await makeBrand()
|
||||
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
|
||||
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(trakk.id))
|
||||
|
||||
const state = await performSaveEntry(
|
||||
adminViewer(),
|
||||
entryInput(orbit.id, { id: created.ok ? created.entry.id : '' }),
|
||||
)
|
||||
|
||||
expect(state.ok).toBe(true)
|
||||
|
||||
const saved = await findEntry(created.ok ? created.entry.id : '')
|
||||
|
||||
expect(saved?.projectId).toBe(orbit.id)
|
||||
expect(saved?.number).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Statusübergänge', () => {
|
||||
it('veröffentlicht einen Entwurf und setzt den Zeitpunkt', async () => {
|
||||
const { entry } = await ready()
|
||||
|
||||
const state = await performPublishEntry(adminViewer(), { id: entry.id })
|
||||
|
||||
expect(state).toMatchObject({ ok: true })
|
||||
expect(state.ok && state.entry.status).toBe('published')
|
||||
expect(state.ok && state.entry.publishAt).not.toBeNull()
|
||||
|
||||
const entries = await auditEntries()
|
||||
|
||||
expect(entries[0]).toMatchObject({ action: 'post.publish', entityId: entry.id })
|
||||
})
|
||||
|
||||
it('zieht einen veröffentlichten Beitrag zurück und holt ihn wieder', async () => {
|
||||
const { entry } = await ready()
|
||||
|
||||
await performPublishEntry(adminViewer(), { id: entry.id })
|
||||
|
||||
const archived = await performArchiveEntry(adminViewer(), { id: entry.id })
|
||||
const resumed = await performResumeEntry(adminViewer(), { id: entry.id })
|
||||
|
||||
expect(archived.ok && archived.entry.status).toBe('archived')
|
||||
expect(resumed.ok && resumed.entry.status).toBe('draft')
|
||||
})
|
||||
|
||||
it('setzt einen Termin und nimmt ihn zurück', async () => {
|
||||
const { entry } = await ready()
|
||||
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||
|
||||
const scheduled = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: later })
|
||||
const dropped = await performUnscheduleEntry(adminViewer(), { id: entry.id })
|
||||
|
||||
expect(scheduled.ok && scheduled.entry.status).toBe('scheduled')
|
||||
expect(scheduled.ok && scheduled.entry.publishAt).toBe(new Date(later).toISOString())
|
||||
expect(dropped.ok && dropped.entry.status).toBe('draft')
|
||||
expect(dropped.ok && dropped.entry.publishAt).toBeNull()
|
||||
})
|
||||
|
||||
it('lehnt einen Termin in der Vergangenheit ab', async () => {
|
||||
const { entry } = await ready()
|
||||
const earlier = new Date(Date.now() - 60 * 1000).toISOString()
|
||||
|
||||
const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: earlier })
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'schedulePast' })
|
||||
})
|
||||
|
||||
it('lehnt einen Übergang ab, den der Status nicht hergibt', async () => {
|
||||
const { entry } = await ready()
|
||||
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||
|
||||
await performPublishEntry(adminViewer(), { id: entry.id })
|
||||
|
||||
const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: later })
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'statusInvalid' })
|
||||
})
|
||||
|
||||
it('lässt einen fremden Moderator nichts veröffentlichen', async () => {
|
||||
const { entry } = await ready()
|
||||
|
||||
const state = await performPublishEntry(moderatorViewer([]), { id: entry.id })
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'notFound' })
|
||||
|
||||
const saved = await findEntry(entry.id)
|
||||
|
||||
expect(saved?.status).toBe('draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Veröffentlichungsprüfungen', () => {
|
||||
it('sperrt einen öffentlichen Beitrag mit Bild ohne Alt-Text', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const item = await makeMedia(project.id, null)
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
|
||||
audience: 'public',
|
||||
blocks: [
|
||||
{ type: 'text', data: { text: 'Dazu ein Bild.' } },
|
||||
{ type: 'image', data: { mediaId: item.id } },
|
||||
],
|
||||
}))
|
||||
|
||||
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'blocked' })
|
||||
expect(!state.ok && state.blockers).toContain('alt_text_missing')
|
||||
|
||||
const saved = await findEntry(created.ok ? created.entry.id : '')
|
||||
|
||||
expect(saved?.status).toBe('draft')
|
||||
})
|
||||
|
||||
it('lässt denselben Beitrag intern durch', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const item = await makeMedia(project.id, null)
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
|
||||
audience: 'internal',
|
||||
blocks: [{ type: 'image', data: { mediaId: item.id } }],
|
||||
}))
|
||||
|
||||
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
|
||||
|
||||
expect(state).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('sperrt einen Beitrag ohne Inhalt', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
|
||||
blocks: [{ type: 'text', data: { text: ' ' } }],
|
||||
}))
|
||||
|
||||
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'blocked' })
|
||||
expect(!state.ok && state.blockers).toContain('content_empty')
|
||||
})
|
||||
|
||||
it('sperrt einen Termin, solange ein Sperrgrund besteht', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(project.id, { blocks: [] }))
|
||||
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||
|
||||
const state = await performScheduleEntry(adminViewer(), {
|
||||
id: created.ok ? created.entry.id : '',
|
||||
publishAt: later,
|
||||
})
|
||||
|
||||
expect(state).toMatchObject({ ok: false, error: 'blocked' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('performSaveEntry wechselt das Projekt', () => {
|
||||
it('vergibt im Zielprojekt die nächste freie Nummer', async () => {
|
||||
const brand = await makeBrand()
|
||||
const from = await makeProject(brand.id, 'quelle', 'Quelle')
|
||||
const to = await makeProject(brand.id, 'ziel', 'Ziel')
|
||||
|
||||
const first = await performSaveEntry(adminViewer(), entryInput(to.id, { slug: 'schon-da' }))
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
|
||||
|
||||
if (!first.ok || !created.ok) {
|
||||
throw new Error('Anlegen fehlgeschlagen')
|
||||
}
|
||||
|
||||
const moved = await performSaveEntry(adminViewer(), {
|
||||
...entryInput(to.id),
|
||||
id: created.entry.id,
|
||||
})
|
||||
|
||||
if (!moved.ok) {
|
||||
throw new Error(`Wechsel fehlgeschlagen: ${moved.error}`)
|
||||
}
|
||||
|
||||
const saved = await findEntry(created.entry.id)
|
||||
|
||||
expect(saved?.projectId).toBe(to.id)
|
||||
expect(moved.entry.number).toBe(2)
|
||||
})
|
||||
|
||||
it('macht den Slug im Zielprojekt eindeutig', async () => {
|
||||
const brand = await makeBrand()
|
||||
const from = await makeProject(brand.id, 'quelle', 'Quelle')
|
||||
const to = await makeProject(brand.id, 'ziel', 'Ziel')
|
||||
|
||||
const blocking = await performSaveEntry(adminViewer(), entryInput(to.id))
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
|
||||
|
||||
if (!blocking.ok || !created.ok) {
|
||||
throw new Error('Anlegen fehlgeschlagen')
|
||||
}
|
||||
|
||||
const moved = await performSaveEntry(adminViewer(), {
|
||||
...entryInput(to.id),
|
||||
id: created.entry.id,
|
||||
})
|
||||
|
||||
if (!moved.ok) {
|
||||
throw new Error(`Wechsel fehlgeschlagen: ${moved.error}`)
|
||||
}
|
||||
|
||||
expect(moved.entry.slug).not.toBe(blocking.entry.slug)
|
||||
})
|
||||
|
||||
it('schreibt den Wechsel ins Protokoll', async () => {
|
||||
const brand = await makeBrand()
|
||||
const from = await makeProject(brand.id, 'quelle', 'Quelle')
|
||||
const to = await makeProject(brand.id, 'ziel', 'Ziel')
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
|
||||
|
||||
if (!created.ok) {
|
||||
throw new Error('Anlegen fehlgeschlagen')
|
||||
}
|
||||
|
||||
await performSaveEntry(adminViewer(), { ...entryInput(to.id), id: created.entry.id })
|
||||
|
||||
const log = await auditEntries()
|
||||
|
||||
expect(log.some(row => row.action === 'post.move')).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt einen Moderator ohne Recht am Zielprojekt nicht wechseln', async () => {
|
||||
const brand = await makeBrand()
|
||||
const from = await makeProject(brand.id, 'quelle', 'Quelle')
|
||||
const to = await makeProject(brand.id, 'ziel', 'Ziel')
|
||||
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
|
||||
|
||||
if (!created.ok) {
|
||||
throw new Error('Anlegen fehlgeschlagen')
|
||||
}
|
||||
|
||||
const result = await performSaveEntry(moderatorViewer([from.id]), {
|
||||
...entryInput(to.id),
|
||||
id: created.entry.id,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loginDomains } from '~/lib/login-domains'
|
||||
|
||||
const fallback = ['nyo.de', 'pocketrocket.de', 'pocket-rocket.io']
|
||||
|
||||
describe('loginDomains', () => {
|
||||
it('nimmt die Liste aus der Umgebung', () => {
|
||||
expect(loginDomains('example.org, Beispiel.de')).toEqual(['example.org', 'beispiel.de'])
|
||||
})
|
||||
|
||||
it('fällt ohne Angabe auf die drei Hausdomains zurück', () => {
|
||||
expect(loginDomains(undefined)).toEqual(fallback)
|
||||
expect(loginDomains('')).toEqual(fallback)
|
||||
expect(loginDomains(' , ')).toEqual(fallback)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { magicLinkWindowSeconds } from '~/domain/magic-link'
|
||||
import { forgetMagicLinkAttempts, registerMagicLinkAttempt } from '~/lib/magic-link-attempts'
|
||||
|
||||
const window = magicLinkWindowSeconds * 1000
|
||||
|
||||
describe('registerMagicLinkAttempt', () => {
|
||||
beforeEach(() => {
|
||||
forgetMagicLinkAttempts()
|
||||
})
|
||||
|
||||
it('zählt die Anforderungen einer Adresse hoch', () => {
|
||||
const now = 1_700_000_000_000
|
||||
|
||||
expect(registerMagicLinkAttempt('paul@nyo.de', now)).toBe(1)
|
||||
expect(registerMagicLinkAttempt('paul@nyo.de', now + 1000)).toBe(2)
|
||||
expect(registerMagicLinkAttempt('PAUL@nyo.de', now + 2000)).toBe(3)
|
||||
})
|
||||
|
||||
it('zählt jede Adresse für sich', () => {
|
||||
const now = 1_700_000_000_000
|
||||
|
||||
registerMagicLinkAttempt('paul@nyo.de', now)
|
||||
registerMagicLinkAttempt('paul@nyo.de', now)
|
||||
|
||||
expect(registerMagicLinkAttempt('mia@nyo.de', now)).toBe(1)
|
||||
})
|
||||
|
||||
it('vergisst Anforderungen außerhalb des Zeitfensters', () => {
|
||||
const now = 1_700_000_000_000
|
||||
|
||||
registerMagicLinkAttempt('paul@nyo.de', now)
|
||||
registerMagicLinkAttempt('paul@nyo.de', now + 1000)
|
||||
|
||||
expect(registerMagicLinkAttempt('paul@nyo.de', now + window + 1)).toBe(2)
|
||||
expect(registerMagicLinkAttempt('paul@nyo.de', now + 2 * window + 2)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import sharp from 'sharp'
|
||||
import {
|
||||
extensionForFormat,
|
||||
isSupportedFormat,
|
||||
mimeForFormat,
|
||||
plannedWidths,
|
||||
probeImage,
|
||||
renderVariants,
|
||||
supportedMimes,
|
||||
variantWidths,
|
||||
} from '~/lib/media-images'
|
||||
|
||||
async function png(width: number, height: number): Promise<Buffer> {
|
||||
return sharp({ create: { width, height, channels: 3, background: '#2b4a9b' } }).png().toBuffer()
|
||||
}
|
||||
|
||||
describe('plannedWidths', () => {
|
||||
it('erzeugt für ein grosses Bild alle vereinbarten Breiten', () => {
|
||||
expect(plannedWidths(2000)).toEqual([480, 960, 1600])
|
||||
})
|
||||
|
||||
it('vergrössert ein Bild nicht über seine eigene Breite hinaus', () => {
|
||||
expect(plannedWidths(1000)).toEqual([480, 960])
|
||||
expect(plannedWidths(600)).toEqual([480])
|
||||
})
|
||||
|
||||
it('behält bei sehr kleinen Bildern die Originalbreite', () => {
|
||||
expect(plannedWidths(320)).toEqual([320])
|
||||
})
|
||||
|
||||
it('nimmt die Grenzbreiten selbst mit', () => {
|
||||
expect(plannedWidths(480)).toEqual([480])
|
||||
expect(plannedWidths(1600)).toEqual([...variantWidths])
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeImage', () => {
|
||||
it('liest Format und Masse aus einem Bild', async () => {
|
||||
const probed = await probeImage(await png(800, 400))
|
||||
|
||||
expect(probed).toEqual({ format: 'png', width: 800, height: 400 })
|
||||
})
|
||||
|
||||
it('meldet undefined für alles, was kein Bild ist', async () => {
|
||||
expect(await probeImage(Buffer.from('das ist kein bild, sondern text'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderVariants', () => {
|
||||
it('erzeugt WebP-Fassungen in den erwarteten Breiten', async () => {
|
||||
const source = await png(2000, 1125)
|
||||
const rendered = await renderVariants(source, plannedWidths(2000))
|
||||
|
||||
expect(rendered.map(variant => variant.width)).toEqual([480, 960, 1600])
|
||||
expect(rendered.every(variant => variant.format === 'webp')).toBe(true)
|
||||
|
||||
for (const variant of rendered) {
|
||||
const meta = await sharp(variant.body).metadata()
|
||||
|
||||
expect(meta.format).toBe('webp')
|
||||
expect(meta.width).toBe(variant.width)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Formatliste', () => {
|
||||
it('kennt die angenommenen Formate', () => {
|
||||
expect(isSupportedFormat('png')).toBe(true)
|
||||
expect(isSupportedFormat('svg')).toBe(false)
|
||||
expect(mimeForFormat('jpeg')).toBe('image/jpeg')
|
||||
expect(extensionForFormat('jpeg')).toBe('jpg')
|
||||
})
|
||||
|
||||
it('nennt jeden Mime-Typ nur einmal', () => {
|
||||
const mimes = supportedMimes()
|
||||
|
||||
expect(new Set(mimes).size).toBe(mimes.length)
|
||||
expect(mimes).toContain('image/webp')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import sharp from 'sharp'
|
||||
import { db } from '~/data/db'
|
||||
import { brands, media, projects } from '~/data/schema'
|
||||
import { uploadMedia } from '~/lib/media-upload'
|
||||
import { createLocalStorage, type Storage } from '~/lib/storage'
|
||||
|
||||
vi.mock('~/lib/media-images', async importOriginal => {
|
||||
const original = await importOriginal<typeof import('~/lib/media-images')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
renderVariant: vi.fn(async () => {
|
||||
throw new Error('libvips ist ausgefallen')
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
let root: string
|
||||
let storage: Storage
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'logbuch-upload-'))
|
||||
storage = createLocalStorage(root)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function project(): Promise<string> {
|
||||
const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning()
|
||||
const [row] = await db.insert(projects).values({ brandId: brand!.id, slug: 'pulsar', name: 'Pulsar' }).returning()
|
||||
|
||||
return row!.id
|
||||
}
|
||||
|
||||
describe('uploadMedia mit gescheiterter Umwandlung', () => {
|
||||
it('behält das Original und schreibt den Fehler an das Medium', async () => {
|
||||
const projectId = await project()
|
||||
const body = await sharp({ create: { width: 1200, height: 800, channels: 3, background: '#a33b6a' } }).png().toBuffer()
|
||||
|
||||
const result = await uploadMedia({ projectId, filename: 'ausfall.png', body, storage })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
const rows = await db.select().from(media)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.variants).toEqual([])
|
||||
expect(rows[0]!.variantError).toBe('libvips ist ausgefallen')
|
||||
expect(rows[0]!.width).toBe(1200)
|
||||
expect(rows[0]!.height).toBe(800)
|
||||
expect(await storage.exists(rows[0]!.path)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contentBlocks, readingMinutes } from '~/lib/post-content'
|
||||
import type { Block } from '~/data/schema'
|
||||
|
||||
function block(id: string, type: string, data: Record<string, unknown>): Block {
|
||||
return { id, postId: 'post', sort: 0, type, data } as Block
|
||||
}
|
||||
|
||||
describe('contentBlocks', () => {
|
||||
it('lässt Blöcke unangetastet, wenn nichts doppelt ist', () => {
|
||||
const blocks = [block('a', 'text', { text: 'Ein eigener Absatz.' })]
|
||||
|
||||
expect(contentBlocks(blocks, 'Anderer Anreißer', null)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('überspringt den ersten Textblock, wenn er den Anreißer wiederholt', () => {
|
||||
const blocks = [
|
||||
block('a', 'text', { text: ' Tickets reagieren jetzt selbst. ' }),
|
||||
block('b', 'text', { text: 'Und noch etwas.' }),
|
||||
]
|
||||
|
||||
const result = contentBlocks(blocks, 'Tickets reagieren jetzt selbst.', null)
|
||||
|
||||
expect(result.map(entry => entry.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('überspringt einen Bildblock, der das Aufmacherbild wiederholt', () => {
|
||||
const blocks = [
|
||||
block('a', 'image', { mediaId: 'cover-1' }),
|
||||
block('b', 'image', { mediaId: 'andere-2' }),
|
||||
]
|
||||
|
||||
const result = contentBlocks(blocks, null, 'cover-1')
|
||||
|
||||
expect(result.map(entry => entry.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('entfernt beide Wiederholungen zusammen', () => {
|
||||
const blocks = [
|
||||
block('a', 'image', { mediaId: 'cover-1' }),
|
||||
block('b', 'text', { text: 'Der Anreißer.' }),
|
||||
block('c', 'text', { text: 'Der eigentliche Inhalt.' }),
|
||||
]
|
||||
|
||||
const result = contentBlocks(blocks, 'Der Anreißer.', 'cover-1')
|
||||
|
||||
expect(result.map(entry => entry.id)).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('behält den Bildblock, wenn es kein Aufmacherbild gibt', () => {
|
||||
const blocks = [block('a', 'image', { mediaId: 'irgendwas' })]
|
||||
|
||||
expect(contentBlocks(blocks, null, null)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readingMinutes', () => {
|
||||
it('meldet mindestens eine Minute', () => {
|
||||
expect(readingMinutes([], null)).toBe(1)
|
||||
})
|
||||
|
||||
it('rechnet Anreißer und Blöcke zusammen', () => {
|
||||
const words = Array.from({ length: 400 }, () => 'Wort').join(' ')
|
||||
|
||||
expect(readingMinutes([block('a', 'text', { text: words })], null)).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { findEntry } from '~/data/repositories/entries'
|
||||
import { publishDueEntries } from '~/lib/publish-due'
|
||||
import { auditEntries, makeBrand, makeProject, resetAccounts } from '../support/admin'
|
||||
import { makePost } from '../support/entries'
|
||||
|
||||
beforeEach(resetAccounts)
|
||||
|
||||
const past = new Date('2026-07-30T06:00:00Z')
|
||||
|
||||
const future = new Date('2026-08-30T06:00:00Z')
|
||||
|
||||
const now = new Date('2026-07-30T08:00:00Z')
|
||||
|
||||
describe('publishDueEntries', () => {
|
||||
it('veröffentlicht nur fällige terminierte Beiträge', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
const due = await makePost(project.id, { number: 1, slug: 'faellig', status: 'scheduled', publishAt: past })
|
||||
const later = await makePost(project.id, { number: 2, slug: 'spaeter', status: 'scheduled', publishAt: future })
|
||||
const draft = await makePost(project.id, { number: 3, slug: 'entwurf', status: 'draft', publishAt: past })
|
||||
|
||||
const result = await publishDueEntries(now)
|
||||
|
||||
expect(result.published.map(entry => entry.slug)).toEqual(['faellig'])
|
||||
expect((await findEntry(due.id))?.status).toBe('published')
|
||||
expect((await findEntry(later.id))?.status).toBe('scheduled')
|
||||
expect((await findEntry(draft.id))?.status).toBe('draft')
|
||||
})
|
||||
|
||||
it('behält den geplanten Zeitpunkt und schreibt ins Protokoll', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
const due = await makePost(project.id, { number: 1, slug: 'faellig', status: 'scheduled', publishAt: past })
|
||||
|
||||
await publishDueEntries(now)
|
||||
|
||||
const saved = await findEntry(due.id)
|
||||
const entries = await auditEntries()
|
||||
|
||||
expect(saved?.publishAt?.toISOString()).toBe(past.toISOString())
|
||||
expect(entries[0]).toMatchObject({ action: 'post.publish.due', entityId: due.id, actorType: 'client' })
|
||||
})
|
||||
|
||||
it('veröffentlicht bei einem zweiten Lauf nichts noch einmal', async () => {
|
||||
const brand = await makeBrand()
|
||||
const project = await makeProject(brand.id)
|
||||
|
||||
await makePost(project.id, { number: 1, slug: 'faellig', status: 'scheduled', publishAt: past })
|
||||
|
||||
const first = await publishDueEntries(now)
|
||||
const second = await publishDueEntries(now)
|
||||
|
||||
expect(first.published).toHaveLength(1)
|
||||
expect(second.published).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pageCount, readMonth, readPage, readText, readYear } from '~/lib/query'
|
||||
|
||||
describe('readText', () => {
|
||||
it('nimmt den ersten Wert und wirft Leerraum weg', () => {
|
||||
expect(readText([' Spalten ', 'zweite'])).toBe('Spalten')
|
||||
expect(readText(' ')).toBeUndefined()
|
||||
expect(readText(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readPage', () => {
|
||||
it('fällt auf Seite eins zurück', () => {
|
||||
expect(readPage('3')).toBe(3)
|
||||
expect(readPage('0')).toBe(1)
|
||||
expect(readPage('-2')).toBe(1)
|
||||
expect(readPage('abc')).toBe(1)
|
||||
expect(readPage(undefined)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readYear', () => {
|
||||
it('nimmt nur vierstellige Jahre', () => {
|
||||
expect(readYear('2026')).toBe(2026)
|
||||
expect(readYear('26')).toBeUndefined()
|
||||
expect(readYear('20261')).toBeUndefined()
|
||||
expect(readYear('zweitausend')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readMonth', () => {
|
||||
it('nimmt nur Monate von eins bis zwölf', () => {
|
||||
expect(readMonth('07')).toBe(7)
|
||||
expect(readMonth('7')).toBe(7)
|
||||
expect(readMonth('12')).toBe(12)
|
||||
expect(readMonth('0')).toBeUndefined()
|
||||
expect(readMonth('13')).toBeUndefined()
|
||||
expect(readMonth('007')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageCount', () => {
|
||||
it('zählt mindestens eine Seite', () => {
|
||||
expect(pageCount(0, 10)).toBe(1)
|
||||
expect(pageCount(10, 10)).toBe(1)
|
||||
expect(pageCount(11, 10)).toBe(2)
|
||||
expect(pageCount(7, 3)).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { monthPath, overviewPath, postPath, projectPath, searchPath, yearPath } from '~/lib/routes'
|
||||
|
||||
describe('routes', () => {
|
||||
it('lässt Seite eins aus der Adresse weg', () => {
|
||||
expect(overviewPath()).toBe('/')
|
||||
expect(overviewPath({ page: 1 })).toBe('/')
|
||||
expect(overviewPath({ page: 2 })).toBe('/?page=2')
|
||||
expect(projectPath('trakk', { page: 3 })).toBe('/trakk?page=3')
|
||||
})
|
||||
|
||||
it('schreibt Monate zweistellig', () => {
|
||||
expect(overviewPath({ year: 2026, month: 7 })).toBe('/?year=2026&month=07')
|
||||
expect(monthPath('trakk', 2026, 7)).toBe('/trakk/2026/07')
|
||||
expect(yearPath('trakk', 2026)).toBe('/trakk/2026')
|
||||
})
|
||||
|
||||
it('maskiert Slugs und Suchbegriffe', () => {
|
||||
expect(postPath('mta 360', 'a/b')).toBe('/mta%20360/a%2Fb')
|
||||
expect(searchPath({ query: 'spalten menü' })).toBe('/search?q=spalten+men%C3%BC')
|
||||
expect(searchPath()).toBe('/search')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
contentTypeForPath,
|
||||
createLocalStorage,
|
||||
isSafeObjectPath,
|
||||
resolveObjectPath,
|
||||
StoragePathError,
|
||||
type Storage,
|
||||
} from '~/lib/storage'
|
||||
|
||||
let root: string
|
||||
let storage: Storage
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'logbuch-storage-'))
|
||||
storage = createLocalStorage(root)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const escapes = [
|
||||
'../secret.txt',
|
||||
'..',
|
||||
'a/../../secret.txt',
|
||||
'/etc/passwd',
|
||||
'./a.webp',
|
||||
'a//b.webp',
|
||||
'a/./b.webp',
|
||||
'..\\secret.txt',
|
||||
'a\\b.webp',
|
||||
'',
|
||||
'C:/windows/win.ini',
|
||||
]
|
||||
|
||||
describe('isSafeObjectPath', () => {
|
||||
it('nimmt gewöhnliche Objektpfade an', () => {
|
||||
expect(isSafeObjectPath('project/media/w960.webp')).toBe(true)
|
||||
expect(isSafeObjectPath('a.webp')).toBe(true)
|
||||
})
|
||||
|
||||
it('weist jeden Ausbruch aus dem Speicher ab', () => {
|
||||
for (const path of escapes) {
|
||||
expect(isSafeObjectPath(path), path).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('weist Nullbytes und Steuerzeichen ab', () => {
|
||||
expect(isSafeObjectPath('a\u0000b.webp')).toBe(false)
|
||||
expect(isSafeObjectPath('a\nb.webp')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveObjectPath', () => {
|
||||
it('bleibt innerhalb des Speicherverzeichnisses', () => {
|
||||
expect(resolveObjectPath(root, 'a/b.webp')).toBe(resolve(root, 'a/b.webp'))
|
||||
})
|
||||
|
||||
it('wirft bei jedem Ausbruchsversuch', () => {
|
||||
for (const path of escapes) {
|
||||
expect(() => resolveObjectPath(root, path), path).toThrow(StoragePathError)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('lokaler Speicher', () => {
|
||||
it('legt ab, liest zurück und meldet den Inhaltstyp', async () => {
|
||||
await storage.put('projekt/bild/w480.webp', Buffer.from('inhalt'), 'image/webp')
|
||||
|
||||
const object = await storage.read('projekt/bild/w480.webp')
|
||||
|
||||
expect(object?.body.toString()).toBe('inhalt')
|
||||
expect(object?.contentType).toBe('image/webp')
|
||||
expect(object?.byteSize).toBe(6)
|
||||
})
|
||||
|
||||
it('meldet fehlende Objekte als undefined', async () => {
|
||||
expect(await storage.read('projekt/bild/gibtsnicht.webp')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('legt keine Datei ausserhalb des Verzeichnisses an', async () => {
|
||||
await expect(storage.put('../ausbruch.webp', Buffer.from('nein'), 'image/webp')).rejects.toThrow(StoragePathError)
|
||||
})
|
||||
|
||||
it('liest keine Datei ausserhalb des Verzeichnisses', async () => {
|
||||
const outside = resolve(root, '..', 'logbuch-outside.txt')
|
||||
|
||||
await writeFile(outside, 'geheim')
|
||||
|
||||
try {
|
||||
await expect(storage.read('../logbuch-outside.txt')).rejects.toThrow(StoragePathError)
|
||||
expect(await readFile(outside, 'utf8')).toBe('geheim')
|
||||
} finally {
|
||||
await rm(outside, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('entfernt Objekte und meldet ihre Abwesenheit', async () => {
|
||||
await storage.put('projekt/bild/weg.webp', Buffer.from('x'), 'image/webp')
|
||||
expect(await storage.exists('projekt/bild/weg.webp')).toBe(true)
|
||||
|
||||
await storage.remove('projekt/bild/weg.webp')
|
||||
expect(await storage.exists('projekt/bild/weg.webp')).toBe(false)
|
||||
})
|
||||
|
||||
it('baut eine Auslieferungs-URL unter dem Medienpfad', () => {
|
||||
expect(storage.url('projekt/bild/w960.webp')).toBe('/api/v1/media/file/projekt/bild/w960.webp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentTypeForPath', () => {
|
||||
it('kennt die Bildformate', () => {
|
||||
expect(contentTypeForPath('a/b.webp')).toBe('image/webp')
|
||||
expect(contentTypeForPath('a/b.PNG')).toBe('image/png')
|
||||
expect(contentTypeForPath('a/b.jpg')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('fällt bei Unbekanntem auf Bytes zurück', () => {
|
||||
expect(contentTypeForPath('a/b.bin')).toBe('application/octet-stream')
|
||||
expect(contentTypeForPath('ohnepunkt')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '~/data/db'
|
||||
import { apiClients, blocks, brands, postTypes, posts, projects } from '~/data/schema'
|
||||
import { seed } from '../../scripts/seed'
|
||||
|
||||
async function counts() {
|
||||
return {
|
||||
brands: (await db.select().from(brands)).length,
|
||||
projects: (await db.select().from(projects)).length,
|
||||
posts: (await db.select().from(posts)).length,
|
||||
blocks: (await db.select().from(blocks)).length,
|
||||
clients: (await db.select().from(apiClients)).length,
|
||||
}
|
||||
}
|
||||
|
||||
describe('seed', () => {
|
||||
it('legt Marken, Projekte und Beiträge an', async () => {
|
||||
await seed()
|
||||
|
||||
const projectRows = await db.select().from(projects)
|
||||
const postRows = await db.select().from(posts)
|
||||
|
||||
expect(projectRows.map(project => project.slug)).toEqual(
|
||||
expect.arrayContaining(['trakk', 'mta360', 'mta-telematik', 'orbit', 'pulsar']),
|
||||
)
|
||||
expect(postRows.length).toBeGreaterThanOrEqual(6)
|
||||
expect(postRows.every(post => post.number > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('ist zweimal hintereinander ausführbar', async () => {
|
||||
await seed()
|
||||
await expect(seed()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('erzeugt beim zweiten Lauf keine Dubletten', async () => {
|
||||
await seed()
|
||||
const first = await counts()
|
||||
|
||||
await seed()
|
||||
const second = await counts()
|
||||
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
|
||||
it('gibt jedem Beispielprojekt ein eigenes Kürzel', async () => {
|
||||
await seed()
|
||||
|
||||
const projectRows = await db.select().from(projects)
|
||||
const codes = new Map(projectRows.map(project => [project.slug, project.code]))
|
||||
|
||||
expect(codes.get('trakk')).toBe('TRK')
|
||||
expect(codes.get('mta360')).toBe('MTA')
|
||||
expect(codes.get('mta-telematik')).toBe('TEL')
|
||||
expect(codes.get('orbit')).toBe('ORB')
|
||||
expect(codes.get('pulsar')).toBe('PLS')
|
||||
expect(new Set(codes.values()).size).toBe(codes.size)
|
||||
})
|
||||
|
||||
it('hält die Kürzel auch beim zweiten Lauf', async () => {
|
||||
await seed()
|
||||
await seed()
|
||||
|
||||
const projectRows = await db.select().from(projects)
|
||||
|
||||
expect(projectRows.every(project => project.code !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('ordnet jedem Beispielbeitrag die Beitragsart zu, die er vor der Umstellung hatte', async () => {
|
||||
await seed()
|
||||
|
||||
const rows = await db
|
||||
.select({ slug: posts.slug, key: postTypes.key })
|
||||
.from(posts)
|
||||
.innerJoin(postTypes, eq(postTypes.id, posts.typeId))
|
||||
|
||||
const byPost = new Map(rows.map(row => [row.slug, row.key]))
|
||||
|
||||
expect(byPost.get('regel-engine-bedingung-trifft-aktion')).toBe('feature')
|
||||
expect(byPost.get('spalten-per-rechtsklick-verwalten')).toBe('improvement')
|
||||
expect(byPost.get('zehntausend-tracker-auf-einer-karte')).toBe('feature')
|
||||
expect(byPost.get('sla-uhr-zaehlt-feiertage-nicht-mehr-mit')).toBe('fix')
|
||||
expect(byPost.get('lokale-tabellen-konfigurationen-entfallen')).toBe('breaking')
|
||||
expect(byPost.get('ein-eingang-fuer-alle-kommentare')).toBe('info')
|
||||
expect(byPost.size).toBe(6)
|
||||
})
|
||||
|
||||
it('legt genau einen internen Beitrag an, damit die Sichtbarkeit prüfbar bleibt', async () => {
|
||||
await seed()
|
||||
|
||||
const internal = (await db.select().from(posts)).filter(post => post.audience === 'internal')
|
||||
|
||||
expect(internal.map(post => post.slug)).toEqual(['ein-eingang-fuer-alle-kommentare'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'dotenv/config'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach } from 'vitest'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { db } from '~/data/db'
|
||||
import { insertDefaultPostTypes } from './support/post-types'
|
||||
|
||||
const testStorage = join(tmpdir(), 'logbuch-test-storage')
|
||||
|
||||
rmSync(testStorage, { recursive: true, force: true })
|
||||
process.env.STORAGE_DIR = testStorage
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.execute(sql`truncate table post_read, api_client, post_tag, tag, block, post, issue, media, project, brand, post_type restart identity cascade`)
|
||||
await insertDefaultPostTypes()
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('setup', () => {
|
||||
it('liest die Testdatenbank-Adresse aus der Umgebung', () => {
|
||||
expect(process.env.DATABASE_URL_TEST).toContain('logbuch_test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { desc, sql } from 'drizzle-orm'
|
||||
import { db } from '~/data/db'
|
||||
import { auditLog, brands, projects, users, type AuditEntry, type Brand, type Project, type User } from '~/data/schema'
|
||||
import type { Viewer } from '~/lib/auth-access'
|
||||
|
||||
export async function resetAccounts(): Promise<void> {
|
||||
await db.execute(sql`truncate table audit_log, user_project_role, "user" restart identity cascade`)
|
||||
}
|
||||
|
||||
export function form(values: Record<string, string>): FormData {
|
||||
const data = new FormData()
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
data.set(key, value)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function adminViewer(overrides: Partial<Viewer> = {}): Viewer {
|
||||
return {
|
||||
id: 'admin-1',
|
||||
name: 'Admin',
|
||||
email: 'admin@example.test',
|
||||
isAdmin: true,
|
||||
assignments: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function moderatorViewer(projectIds: string[] = [], overrides: Partial<Viewer> = {}): Viewer {
|
||||
return {
|
||||
id: 'moderator-1',
|
||||
name: 'Moderator',
|
||||
email: 'moderator@example.test',
|
||||
isAdmin: false,
|
||||
assignments: projectIds.map(projectId => ({ projectId, role: 'moderator' as const })),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeBrand(slug = 'nyo', name = 'NYO'): Promise<Brand> {
|
||||
const rows = await db.insert(brands).values({ slug, name }).returning()
|
||||
return rows[0]!
|
||||
}
|
||||
|
||||
export async function makeProject(brandId: string, slug = 'trakk', name = 'Trakk'): Promise<Project> {
|
||||
const rows = await db.insert(projects).values({ brandId, slug, name }).returning()
|
||||
return rows[0]!
|
||||
}
|
||||
|
||||
export async function makeUser(email = 'paul@example.test', name = 'Paul'): Promise<User> {
|
||||
const rows = await db.insert(users).values({ id: randomUUID(), email, name }).returning()
|
||||
return rows[0]!
|
||||
}
|
||||
|
||||
export async function auditEntries(): Promise<AuditEntry[]> {
|
||||
return db.select().from(auditLog).orderBy(desc(auditLog.createdAt))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '~/data/db'
|
||||
import { media, posts, type Media, type Post } from '~/data/schema'
|
||||
import { postTypeId } from './post-types'
|
||||
import type { EntryInput } from '~/lib/entry-types'
|
||||
|
||||
export async function makeMedia(projectId: string, alt: string | null = 'Ein Bild'): Promise<Media> {
|
||||
const id = randomUUID()
|
||||
const rows = await db.insert(media).values({
|
||||
id,
|
||||
projectId,
|
||||
kind: 'image',
|
||||
originalFilename: 'bild.png',
|
||||
path: `${projectId}/${id}/original.png`,
|
||||
mime: 'image/png',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
byteSize: 2048,
|
||||
variants: [],
|
||||
alt,
|
||||
}).returning()
|
||||
|
||||
return rows[0]!
|
||||
}
|
||||
|
||||
export async function makePost(projectId: string, values: Partial<typeof posts.$inferInsert> = {}): Promise<Post> {
|
||||
const rows = await db.insert(posts).values({
|
||||
projectId,
|
||||
number: 1,
|
||||
slug: 'ein-beitrag',
|
||||
title: 'Ein Beitrag',
|
||||
...values,
|
||||
}).returning()
|
||||
|
||||
return rows[0]!
|
||||
}
|
||||
|
||||
export function entryInput(projectId: string, overrides: Partial<EntryInput> = {}): EntryInput {
|
||||
return {
|
||||
projectId,
|
||||
title: 'Regel-Engine',
|
||||
teaser: 'Bedingung trifft Aktion.',
|
||||
typeId: postTypeId('feature'),
|
||||
audience: 'internal',
|
||||
coverMediaId: null,
|
||||
blocks: [{ type: 'text', data: { text: 'Der Worker prüft jede Bedingung.' } }],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { db } from '~/data/db'
|
||||
import { postTypes, type PostTypeRow } from '~/data/schema'
|
||||
import { defaultPostTypes } from '~/domain/post-type'
|
||||
|
||||
export const fixedPostTypeIds: Record<string, string> = {
|
||||
feature: '11111111-1111-4111-8111-000000000001',
|
||||
improvement: '11111111-1111-4111-8111-000000000002',
|
||||
fix: '11111111-1111-4111-8111-000000000003',
|
||||
breaking: '11111111-1111-4111-8111-000000000004',
|
||||
info: '11111111-1111-4111-8111-000000000005',
|
||||
}
|
||||
|
||||
export function postTypeId(key: string): string {
|
||||
const id = fixedPostTypeIds[key]
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`unknown post type ${key}`)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export async function insertDefaultPostTypes(): Promise<void> {
|
||||
await db.insert(postTypes).values(defaultPostTypes.map(row => ({ ...row, id: postTypeId(row.key) })))
|
||||
}
|
||||
|
||||
export async function makePostType(values: Partial<typeof postTypes.$inferInsert> = {}): Promise<PostTypeRow> {
|
||||
const rows = await db.insert(postTypes).values({
|
||||
key: 'notiz',
|
||||
labelDe: 'Notiz',
|
||||
labelEn: 'Note',
|
||||
color: '#4a5560',
|
||||
sort: 90,
|
||||
...values,
|
||||
}).returning()
|
||||
|
||||
return rows[0]!
|
||||
}
|
||||
Reference in New Issue
Block a user