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() }) })