// Der Go-Live-Post geht an Menschen, die sich den Ping selbst geholt haben. // Zwei Pings fuer denselben Stream kosten Abonnenten der Rolle, und die kommen // nicht wieder. Deshalb liegt der Schwerpunkt hier auf dem, was NICHT rausgeht. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { DatabaseSync } from 'node:sqlite'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { repos, type Repos } from '#db/repos.ts'; import type { Attachment, PostedMessage, WebhookMessage } from '#discord/webhook.ts'; import { buildMessage, createAnnouncer, decide, thumbnailUrl, type GoLiveConfig, type StreamInfo, } from './golive.ts'; import { pick, OPENERS } from './templates.ts'; const MIGRATIONS = path.join( path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'packages', 'db', 'migrations'); /** Frische Datenbank im Arbeitsspeicher, mit allen Migrationen. */ function freshRepo(): Repos { const db = new DatabaseSync(':memory:'); for (const f of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith('.sql')).sort()) { db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8')); } return repos(db); } const STREAM: StreamInfo = { id: '48212345', title: 'Elden Ring, aber nur mit Fäusten', category: 'Elden Ring', login: 'abdulive_', startedAt: '2026-09-17T18:00:00.000Z', thumbnailUrl: 'https://static-cdn.twitch.tv/previews/abdulive_-{width}x{height}.jpg', }; const CFG: GoLiveConfig = { enabled: true, mode: 'live', delaySeconds: 0, cooldownHours: 6, pingRole: 'Live-Ping', obsScreenshot: false, }; type Sent = { url: string; message: WebhookMessage; files: Attachment[] }; function fakePost(sink: Sent[], fail?: string) { return async (url: string, message: WebhookMessage, files: Attachment[] = []) : Promise => { if (fail) throw new Error(fail); sink.push({ url, message, files }); return { id: '999', channel_id: '888' }; }; } const HOOKS = () => ({ live: 'https://live', draft: 'https://draft', guildId: '1' }); const ROLES = { id: async () => '777', forget: () => undefined }; function announcer(over: Partial[0]> = {}, cfg = CFG) { const sent: Sent[] = []; const a = createAnnouncer({ cfg, repo: freshRepo(), post: fakePost(sent), roles: ROLES, webhooks: HOOKS, refresh: async (s) => s, wait: async () => undefined, ...over, }); return { a, sent }; } // ---------------------------------------------------------------- Entscheidung test('ein frischer Stream wird angekuendigt', () => { assert.deepEqual( decide({ enabled: true, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0 }), { post: true }); }); test('der Kill-Switch haelt', () => { const d = decide({ enabled: false, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0 }); assert.equal(d.post, false); assert.match((d as { reason: string }).reason, /enabled/); }); test('derselbe Stream wird kein zweites Mal angekuendigt', () => { const d = decide({ enabled: true, alreadyPosted: true, lastPostAt: null, cooldownHours: 6, now: 0 }); assert.equal(d.post, false); assert.match((d as { reason: string }).reason, /bereits/); }); test('ein zweites stream.online kurz nach dem ersten laeuft in die Abklingzeit', () => { // Der Fall aus dem Betrieb: Twitch vergibt nach einem Aussetzer eine neue // Stream-ID. Dann greift `alreadyPosted` nicht, und nur die Abklingzeit // verhindert den zweiten Rundruf. const vorZehnMinuten = new Date('2026-09-17T18:00:00Z').toISOString(); const d = decide({ enabled: true, alreadyPosted: false, lastPostAt: vorZehnMinuten, cooldownHours: 6, now: Date.parse('2026-09-17T18:10:00Z'), }); assert.equal(d.post, false); assert.match((d as { reason: string }).reason, /Abklingzeit/); }); test('nach der Abklingzeit geht es wieder', () => { const d = decide({ enabled: true, alreadyPosted: false, lastPostAt: '2026-09-17T10:00:00Z', cooldownHours: 6, now: Date.parse('2026-09-17T18:00:00Z'), }); assert.equal(d.post, true); }); test('force geht durch die Abklingzeit, aber nicht durch den Kill-Switch', () => { assert.equal(decide({ enabled: true, alreadyPosted: true, lastPostAt: '2026-09-17T17:59:00Z', cooldownHours: 6, now: Date.parse('2026-09-17T18:00:00Z'), force: true, }).post, true); assert.equal(decide({ enabled: false, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0, force: true, }).post, false); }); // ---------------------------------------------------------------- Nachricht test('der Ping steht im Text UND in allowed_mentions', () => { // Nur im Text faerbt Discord die Erwaehnung ein, benachrichtigt aber // niemanden. Das ist der Fehler, den man erst nach drei leeren Streams sieht. const m = buildMessage({ stream: STREAM, roleId: '777' }); assert.match(m.content ?? '', /<@&777>/); assert.deepEqual(m.allowed_mentions, { parse: [], roles: ['777'] }); }); test('ohne aufgeloeste Rolle geht der Post raus, aber pingt niemanden', () => { const m = buildMessage({ stream: STREAM }); assert.equal(m.content?.includes('<@&'), false); assert.deepEqual(m.allowed_mentions, { parse: [] }); assert.equal(m.embeds?.[0]?.title, STREAM.title, 'der Post selbst bleibt vollstaendig'); }); test('ein Entwurf pingt auch dann nicht, wenn die Rolle bekannt ist', () => { const m = buildMessage({ stream: STREAM, roleId: '777', draft: true }); assert.equal(m.content?.includes('<@&777>'), false); assert.deepEqual(m.allowed_mentions, { parse: [] }); assert.match(m.content ?? '', /Entwurf/); }); test('Titel, Kategorie und Link stehen im Embed', () => { const e = buildMessage({ stream: STREAM }).embeds?.[0]; assert.equal(e?.title, STREAM.title); assert.equal(e?.url, 'https://twitch.tv/abdulive_'); assert.equal(e?.fields?.[0]?.value, 'Elden Ring'); assert.match(e?.fields?.[1]?.value ?? '', /^$/, 'Startzeit relativ, nicht in MESZ'); }); test('ein Anhang wird per attachment:// referenziert', () => { const e = buildMessage({ stream: STREAM, attachment: 'vorschau.jpg' }).embeds?.[0]; assert.equal(e?.image?.url, 'attachment://vorschau.jpg'); }); test('Twitchs Vorschau bekommt Groesse und einen Anhaenger gegen den Cache', () => { const url = thumbnailUrl(STREAM.thumbnailUrl, '48212345'); assert.match(url ?? '', /-1280x720\.jpg\?t=48212345$/); assert.equal(thumbnailUrl(undefined, 'x'), undefined); }); test('ein leerer Titel laesst das Embed nicht ohne Titel zurueck', () => { // Discord lehnt ein Embed mit leerem title ab, und genau das liefert Twitch // in der ersten Minute nach dem Start. const e = buildMessage({ stream: { ...STREAM, title: '', category: '' } }).embeds?.[0]; assert.ok((e?.title ?? '').length > 0); assert.ok((e?.fields?.[0]?.value ?? '').length > 0); }); test('derselbe Stream bekommt denselben Text, ein anderer einen anderen', () => { assert.equal(pick(OPENERS, 'abc'), pick(OPENERS, 'abc')); const texte = new Set(['1', '2', '3', '4', '5', '6', '7', '8'].map((s) => pick(OPENERS, s))); assert.ok(texte.size > 1, 'die Auswahl darf nicht auf einen Text zusammenfallen'); }); // ---------------------------------------------------------------- Ablauf test('der Post geht an den Go-Live-Webhook und wird protokolliert', async () => { const repo = freshRepo(); const { a, sent } = announcer({ repo }); const r = await a.announceNow(STREAM); assert.equal(r.status, 'gesendet'); assert.equal(sent.length, 1); assert.equal(sent[0]?.url, 'https://live'); assert.equal(repo.postFor(STREAM.id, 'golive', 'discord')?.status, 'gesendet'); }); test('zweimal derselbe Stream gibt genau einen Post', async () => { const repo = freshRepo(); const { a, sent } = announcer({ repo }); await a.announceNow(STREAM); const zweiter = await a.announceNow(STREAM); assert.equal(sent.length, 1); assert.equal(zweiter.status, 'uebersprungen'); }); test('im Entwurfsmodus geht es in den internen Kanal', async () => { const { a, sent } = announcer({}, { ...CFG, mode: 'draft' }); await a.announceNow(STREAM); assert.equal(sent[0]?.url, 'https://draft'); assert.equal(sent[0]?.message.allowed_mentions?.roles, undefined); }); test('ohne hinterlegten Webhook wird das gemeldet, nicht verschluckt', async () => { const repo = freshRepo(); const { a, sent } = announcer({ repo, webhooks: () => ({}) }); const r = await a.announceNow(STREAM); assert.equal(r.status, 'fehlgeschlagen'); assert.match((r as { reason: string }).reason, /discord:setup/); assert.equal(sent.length, 0); assert.equal(repo.postFor(STREAM.id, 'golive', 'discord'), undefined, 'ein Fehlschlag darf den naechsten Versuch nicht sperren'); }); test('ein fehlgeschlagener Post darf wiederholt werden', async () => { const repo = freshRepo(); const sent: Sent[] = []; let erster = true; const a = createAnnouncer({ cfg: CFG, repo, roles: ROLES, webhooks: HOOKS, refresh: async (s) => s, wait: async () => undefined, post: async (url, message, files = []) => { if (erster) { erster = false; throw new Error('Netz weg'); } sent.push({ url, message, files }); return { id: '1', channel_id: '2' }; }, }); assert.equal((await a.announceNow(STREAM)).status, 'fehlgeschlagen'); assert.equal((await a.announceNow(STREAM)).status, 'gesendet'); assert.equal(sent.length, 1); }); test('eine nicht aufloesbare Rolle stoppt den Post nicht', async () => { const { a, sent } = announcer({ roles: { id: async () => { throw new Error('403'); }, forget: () => undefined }, }); const r = await a.announceNow(STREAM); assert.equal(r.status, 'gesendet'); assert.equal((r as { pinged: boolean }).pinged, false); assert.equal(sent[0]?.message.content?.includes('<@&'), false); }); test('der Kill-Switch schickt gar nichts los', async () => { const { a, sent } = announcer({}, { ...CFG, enabled: false }); assert.equal((await a.announceNow(STREAM)).status, 'uebersprungen'); assert.equal(sent.length, 0); }); test('ein Probelauf sperrt die naechste echte Ankuendigung nicht', () => { // Genau das ist am 2026-09-17 passiert: `golive:test --echt` lief 93 Minuten // vor dem Streamstart, trug sich als gesendeter Post ein, und die // Abklingzeit hat die richtige Ankuendigung verschluckt. Der Stream lief // ohne Ping. const repo = freshRepo(); repo.insertPost({ streamId: 'probe-1', kind: 'golive-test', platform: 'discord', mode: 'live', status: 'gesendet', }); // Der Probelauf steht in der Datenbank ... assert.ok(repo.lastPost('golive-test', 'discord')); // ... zaehlt aber fuer den Echtbetrieb nicht. assert.equal(repo.lastPost('golive', 'discord'), undefined); }); test('zwei echte Ankuendigungen kurz hintereinander bleiben gesperrt', () => { // Die Abklingzeit soll ja weiter wirken - nur eben nicht gegen Probelaeufe. const repo = freshRepo(); const { a, sent } = announcer({ repo }); return a.announceNow(STREAM).then(async () => { const zweiter = await a.announceNow({ ...STREAM, id: 'anderer-stream' }); assert.equal(zweiter.status, 'uebersprungen'); assert.match((zweiter as { reason: string }).reason, /Abklingzeit/); assert.equal(sent.length, 1); }); });