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,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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user