b90ff252d1
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.
189 lines
7.6 KiB
TypeScript
189 lines
7.6 KiB
TypeScript
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()
|
|
})
|
|
})
|