import { test, after } from 'node:test' import assert from 'node:assert/strict' import os from 'node:os' import path from 'node:path' import { mkdtempSync } from 'node:fs' import sharp from 'sharp' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'news-posts-')) const aw = await startAppwriteMock({ schema: { newsPosts: schemaKeys('newsPosts') } }) const st = await startFakeStripe() after(() => Promise.all([aw.close(), st.close()])) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const np = await import('../server/services/newsPosts.js') const { storeNewsImage, newsImagePath } = await import('../server/services/newsImages.js') const { existsSync } = await import('node:fs') const { addMonths } = await import('../server/services/stripeBilling.js') const NOW = new Date('2026-10-01T10:00:00.000Z') const base = { type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' } const mk = (projectId, input = {}, now = NOW) => np.createPost({ customerId: 'c1', projectId, input: { ...base, ...input }, now }) const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer() test('ablauf_grenzen_berlin', async () => { const until = np.visibleUntilFor('date', NOW, '2026-10-25') assert.equal(until.toISOString(), '2026-10-25T22:59:59.999Z') const post = { visibleFrom: NOW.toISOString(), visibleUntil: until.toISOString(), takenDownAt: null } assert.equal(np.isHanging(post, new Date(until.getTime() - 1)), true) assert.equal(np.isHanging(post, until), false) const open = { visibleFrom: NOW.toISOString(), visibleUntil: null, takenDownAt: null } assert.equal(np.isHanging(open, new Date('2030-01-01')), true) assert.equal(np.isHanging({ ...open, takenDownAt: NOW.toISOString() }, new Date('2026-10-02')), false) }) test('dauer_werte', () => { assert.equal(np.visibleUntilFor('1w', NOW).getTime() - NOW.getTime(), 7 * 86400000) assert.equal(np.visibleUntilFor('2w', NOW).getTime() - NOW.getTime(), 14 * 86400000) const jan = new Date('2027-01-31T09:00:00.000Z') assert.equal(np.visibleUntilFor('1m', jan).toISOString(), addMonths(jan, 1).toISOString()) assert.equal(np.visibleUntilFor('1m', jan).toISOString(), '2027-02-28T09:00:00.000Z') assert.equal(np.visibleUntilFor('3m', NOW).toISOString(), addMonths(NOW, 3).toISOString()) assert.equal(np.visibleUntilFor('open', NOW), null) }) test('datum_grenzen_400', () => { assert.throws(() => np.visibleUntilFor('date', NOW, '2026-10-01'), { status: 400, field: 'date' }) assert.throws(() => np.visibleUntilFor('date', NOW, '2027-10-02'), { status: 400 }) assert.throws(() => np.visibleUntilFor('date', NOW, 'morgen'), { status: 400 }) assert.throws(() => np.visibleUntilFor('date', NOW, '2026-11-31'), { status: 400, field: 'date' }) assert.throws(() => np.visibleUntilFor('date', NOW, '2027-02-31'), { status: 400, field: 'date' }) assert.ok(np.visibleUntilFor('date', NOW, '2026-10-02')) assert.ok(np.visibleUntilFor('date', NOW, '2027-10-01')) }) test('validierung', () => { assert.throws(() => np.validatePostInput({ ...base, title: 'x'.repeat(61) }), { status: 400, field: 'title' }) assert.throws(() => np.validatePostInput({ ...base, type: 'photo' }), { status: 400, field: 'imageId' }) assert.throws(() => np.validatePostInput({ ...base, paper: 'lila' }), { status: 400, field: 'paper' }) assert.throws(() => np.validatePostInput({ ...base, text: '' }), { status: 400, field: 'text' }) assert.equal(np.validatePostInput({ ...base, title: undefined }).title, '') assert.equal(np.validatePostInput({ ...base, type: 'photo', title: '', text: '', imageId: 'i1' }).title, '') assert.throws(() => np.validatePostInput({ ...base, text: 'x'.repeat(401) }), { status: 400, field: 'text' }) assert.throws(() => np.validatePostInput({ ...base, type: 'combo', imageId: 'i1', text: '' }), { status: 400, field: 'text' }) assert.equal(np.validatePostInput({ ...base, type: 'photo', text: '', imageId: 'i1' }).imageId, 'i1') }) test('abnehmen_und_wieder_aufhaengen', async () => { const post = await mk('pa') assert.equal((await np.listPosts('pa', NOW)).hanging.length, 1) const later = new Date('2026-10-05T08:00:00.000Z') await np.updatePost({ customerId: 'c1', projectId: 'pa', id: post.$id, input: { action: 'takeDown' }, now: later }) const l1 = await np.listPosts('pa', later) assert.equal(l1.hanging.length, 0) assert.equal(l1.expired.length, 1) const re = await np.updatePost({ customerId: 'c1', projectId: 'pa', id: post.$id, input: { action: 'rehang', duration: '2w' }, now: later }) assert.equal(re.visibleFrom, later.toISOString()) assert.equal(re.visibleUntil, new Date(later.getTime() + 14 * 86400000).toISOString()) assert.equal((await np.listPosts('pa', later)).hanging.length, 1) }) test('oeffentlich_nur_haengende_ohne_interna', async () => { for (let i = 0; i < 8; i++) await mk('pb', { title: `Beitrag ${i}` }, new Date(NOW.getTime() + i * 1000)) for (let i = 0; i < 2; i++) await mk('pb', { duration: '1w' }, new Date('2026-09-01T10:00:00.000Z')) const { imageId } = await storeNewsImage({ customerId: 'c1', projectId: 'pb', buffer: await png() }) await mk('pb', { type: 'combo', imageId, title: 'Mit Foto' }, new Date(NOW.getTime() + 60000)) const after10 = new Date(NOW.getTime() + 120000) const p1 = await np.publicPage('pb', 1, after10) assert.equal(p1.posts.length, 6) assert.equal(p1.hasMore, true) assert.equal(p1.posts[0].title, 'Mit Foto') assert.deepEqual({ ...p1.posts[0].image, v: undefined }, { w: 40, h: 30, v: undefined }) assert.ok(Number.isFinite(p1.posts[0].image.v), 'v = Cache-Buster fuer die Route') assert.equal(p1.posts[0].date, '2026-10-01') assert.equal(p1.posts[1].image, null) for (const p of p1.posts) { assert.deepEqual(Object.keys(p).sort(), ['date', 'id', 'image', 'paper', 'text', 'title', 'type']) } const p2 = await np.publicPage('pb', 2, after10) assert.equal(p2.posts.length, 3) assert.equal(p2.hasMore, false) }) test('fremder_kunde_kann_nicht_aendern', async () => { const post = await mk('pc') await assert.rejects(np.updatePost({ customerId: 'c2', projectId: 'pc', id: post.$id, input: { action: 'takeDown' }, now: NOW }), { status: 403 }) await assert.rejects(np.updatePost({ customerId: 'c1', projectId: 'other', id: post.$id, input: { action: 'takeDown' }, now: NOW }), { status: 403 }) await assert.rejects(np.deletePost({ customerId: 'c2', projectId: 'pc', id: post.$id }), { status: 403 }) await assert.rejects(np.deletePost({ customerId: 'c1', projectId: 'pc', id: 'gibtsnicht' }), { status: 404 }) }) test('bild_gehoert_zum_projekt_und_wird_mitgeloescht', async () => { const a = await storeNewsImage({ customerId: 'c1', projectId: 'pd', buffer: await png() }) await assert.rejects(mk('pe', { type: 'photo', imageId: a.imageId }), { status: 400, field: 'imageId' }) const post = await mk('pd', { type: 'photo', imageId: a.imageId }) const b = await storeNewsImage({ customerId: 'c1', projectId: 'pd', buffer: await png() }) await np.updatePost({ customerId: 'c1', projectId: 'pd', id: post.$id, input: { ...base, type: 'photo', imageId: b.imageId }, now: NOW }) assert.equal(existsSync(newsImagePath('pd', a.imageId, 'l')), false) assert.equal(existsSync(newsImagePath('pd', b.imageId, 'l')), true) await np.deletePost({ customerId: 'c1', projectId: 'pd', id: post.$id }) assert.equal(existsSync(newsImagePath('pd', b.imageId, 'l')), false) assert.equal((await np.listPosts('pd', NOW)).hanging.length, 0) }) test('bearbeiten_datum_bild_regeln', async () => { const post = await mk('pf', { duration: 'date', date: '2026-10-10' }) const edit = (input, now) => np.updatePost({ customerId: 'c1', projectId: 'pf', id: post.$id, input: { ...base, duration: 'date', ...input }, now }) const late = new Date('2026-10-10T08:00:00.000Z') // unveraendertes Datum (heute) bleibt speicherbar, geaendertes wird geprueft const ok = await edit({ date: '2026-10-10', title: 'Neu' }, late) assert.equal(ok.title, 'Neu') assert.equal(ok.visibleUntil, post.visibleUntil) await assert.rejects(edit({ date: '2026-10-11' }, new Date('2026-10-11T08:00:00.000Z')), { status: 400, field: 'date' }) // Bild eines anderen Beitrags const img = await storeNewsImage({ customerId: 'c1', projectId: 'pf', buffer: await png() }) await mk('pf', { type: 'photo', imageId: img.imageId }) await assert.rejects(edit({ type: 'photo', imageId: img.imageId, date: '2026-10-10' }, late), { status: 400, field: 'imageId' }) }) test('create_ohne_bild_belegt_nichts', async () => { await assert.rejects(mk('pg', { type: 'photo', imageId: 'nope' }), { status: 400, field: 'imageId' }) assert.equal((await np.listPosts('pg', NOW)).hanging.length, 0) })