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