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