import { describe, expect, it } from 'vitest' import { eq } from 'drizzle-orm' import { db } from '~/data/db' import { apiClients, brands, postReads, posts, projects } from '~/data/schema' import { hashToken } from '~/lib/api-auth' import { GET as getUnread } from '~/app/api/v1/unread/route' import { POST as postRead } from '~/app/api/v1/read/route' 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' }).returning() const [mta] = await db.insert(projects).values({ brandId: brand!.id, slug: 'mta360', name: 'MTA360' }).returning() const inserted = await db.insert(posts).values([ { projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine', audience: 'customer', status: 'published', publishAt: new Date('2026-07-30T08:00:00Z'), }, { projectId: trakk!.id, number: 2, slug: 'sla-uhr', title: 'SLA-Uhr', audience: 'customer', status: 'published', publishAt: new Date('2026-07-25T08:00:00Z'), }, ]).returning() const [internal] = await db.insert(posts).values({ projectId: trakk!.id, number: 3, slug: 'interner-umbau', title: 'Interner Umbau', audience: 'internal', status: 'published', publishAt: new Date('2026-07-24T08:00:00Z'), }).returning() const [foreign] = await db.insert(posts).values({ projectId: mta!.id, number: 1, slug: 'spaltenmenue', title: 'Spaltenmenü', audience: 'customer', status: 'published', publishAt: new Date('2026-07-28T08:00:00Z'), }).returning() await db.insert(apiClients).values([ { name: 'Trakk', tokenHash: hashToken('lb_trakk'), mode: 'read', scope: 'customer', projectId: trakk!.id }, { name: 'MTA360', tokenHash: hashToken('lb_mta'), mode: 'read', scope: 'customer', projectId: mta!.id }, ]) return { postIds: inserted.map(post => post.id), internalId: internal!.id, foreignId: foreign!.id, } } function get(url: string, token = 'lb_trakk') { return new Request(url, { headers: { authorization: `Bearer ${token}` } }) } function post(body: unknown, token = 'lb_trakk') { return new Request('http://localhost/api/v1/read', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body), }) } describe('gelesen-Status', () => { it('zählt zuerst alle Beiträge als ungelesen', async () => { await seed() const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42')) const body = await response.json() expect(body.count).toBe(2) expect(body.items).toHaveLength(2) }) it('meldet Beiträge als gelesen und zählt danach weniger', async () => { const { postIds } = await seed() const marked = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] })) const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42')) const body = await response.json() expect(marked.status).toBe(200) expect(body.count).toBe(1) expect(body.items[0].slug).toBe('sla-uhr') }) it('bleibt bei doppelter Meldung stabil', async () => { const { postIds } = await seed() await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] })) const second = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] })) expect(second.status).toBe(200) }) it('trennt Nutzer voneinander', async () => { const { postIds } = await seed() await postRead(post({ external_user_id: 'u-42', post_ids: postIds })) const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-99')) const body = await response.json() expect(body.count).toBe(2) }) it('verlangt eine Nutzerkennung', async () => { await seed() const response = await getUnread(get('http://localhost/api/v1/unread')) expect(response.status).toBe(400) }) it('zählt interne Beiträge für einen Kundenclient nicht als ungelesen', async () => { await seed() const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42')) const body = await response.json() expect(body.items.map((item: { slug: string }) => item.slug)).not.toContain('interner-umbau') }) }) describe('POST /api/v1/read weist ab, was dem Client nicht gehört', () => { it('antwortet auf eine unbekannte Beitrags-Kennung mit 200 und markiert nichts', async () => { await seed() const response = await postRead(post({ external_user_id: 'u-42', post_ids: ['00000000-0000-4000-8000-000000000000'] })) const body = await response.json() expect(response.status).toBe(200) expect(body.marked).toBe(0) }) it('markiert keinen Beitrag eines fremden Projekts und speichert dazu keine Zeile', async () => { const { foreignId } = await seed() const response = await postRead(post({ external_user_id: 'u-42', post_ids: [foreignId] })) const body = await response.json() const rows = await db.select().from(postReads) expect(response.status).toBe(200) expect(body.marked).toBe(0) expect(rows).toHaveLength(0) }) it('lässt den gelesen-Status des fremden Projekts unberührt', async () => { const { foreignId } = await seed() await postRead(post({ external_user_id: 'u-42', post_ids: [foreignId] })) const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42', 'lb_mta')) const body = await response.json() expect(body.count).toBe(1) expect(body.items[0].slug).toBe('spaltenmenue') }) it('markiert für einen Kundenclient keinen internen Beitrag', async () => { const { internalId } = await seed() const response = await postRead(post({ external_user_id: 'u-42', post_ids: [internalId] })) const body = await response.json() expect(body.marked).toBe(0) }) it('markiert erlaubte Beiträge und meldet bei Wiederholung null neue', async () => { const { postIds } = await seed() const first = await postRead(post({ external_user_id: 'u-42', post_ids: postIds })) const second = await postRead(post({ external_user_id: 'u-42', post_ids: postIds })) expect((await first.json()).marked).toBe(2) expect((await second.json()).marked).toBe(0) }) it('weist einen Client ohne Projektbindung mit 422 ab', async () => { await seed() await db.insert(apiClients).values({ name: 'Ohne Projekt', tokenHash: hashToken('lb_ohne'), mode: 'read', scope: 'internal', }) const readResponse = await postRead(post({ external_user_id: 'u-42', post_ids: ['00000000-0000-4000-8000-000000000000'] }, 'lb_ohne')) const unreadResponse = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42', 'lb_ohne')) expect(readResponse.status).toBe(422) expect(unreadResponse.status).toBe(422) }) it('ignoriert eine gelesen-Zeile, die einem anderen Projekt zugeordnet ist', async () => { const { postIds } = await seed() const [mta] = await db.select().from(projects).where(eq(projects.slug, 'mta360')) await db.insert(postReads).values({ postId: postIds[0]!, projectId: mta!.id, externalUserId: 'u-42', }) const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42')) const body = await response.json() expect(body.count).toBe(2) }) it('weist einen ungültigen Rumpf mit 400 und Problem-JSON ab', async () => { await seed() const response = await postRead(post({ external_user_id: 'u-42', post_ids: ['keine-uuid'] })) const body = await response.json() expect(response.status).toBe(400) expect(response.headers.get('content-type')).toContain('application/problem+json') expect(body.type).toBe('invalid_body') }) it('verwirft unbekannte Kennungen im Stapel und markiert den Rest', async () => { const { postIds } = await seed() const response = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0], '00000000-0000-4000-8000-000000000000'], })) const body = await response.json() expect(response.status).toBe(200) expect(body.marked).toBe(1) }) })