Files
abdulive/packages/db/src/repos.test.ts
T
ZeasyAlexandClaude Opus 5 d07c18441b S1: Einrichtungs-Assistent, Twitch-Auth, Datenbank und Chat-Aufnahme
Zugangsdaten kommen jetzt aus der App statt aus einer .env: ein
Einrichtungs-Assistent auf http://localhost:4792/setup nimmt Client-ID,
Secret und OBS-Passwort entgegen, der OAuth-Rueckruf laeuft auf denselben
Server. Gespeichert wird verschlueselt (AES-256-GCM) in einem Volume,
bewusst nicht in der Datenbank - die wird auf die Windows-Seite gesichert.
Siehe ADR-0005.

Technikbasis vereinfacht: Node 24 bringt node:sqlite, WebSocket, fetch und
die Ausfuehrung von TypeScript mit. Das Projekt hat damit null
Laufzeitabhaengigkeiten und keinen Build-Schritt; pnpm-Workspaces und die
Paketmanifeste sind entfallen, Importe laufen ueber die imports-Map der
Wurzel-package.json. erasableSyntaxOnly verhindert Syntax, die das Type
Stripping stillschweigend entfernen wuerde. Siehe Nachtrag 2 in ADR-0001.

Enthalten:
- packages/core: Config, Layout, Logger mit Secret-Maskierung, Retry, YAML
- packages/db: Migrationen, Repositories, alle SQL-Anweisungen an einem Ort
- packages/twitch: Secret-Speicher, OAuth, Helix mit Token-Bucket, IRC
- apps/ingest: Daemon, Einrichtungsseite, Spike-Erkennung
- 20 Tests (IRC-Parser, Spike-Detektor, Datenbankschicht)

Der Chat verbindet sich anonym, solange nichts hinterlegt ist - damit laesst
sich die Datenaufnahme pruefen, bevor die Twitch-Apps existieren.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
2026-09-09 09:56:55 +02:00

115 lines
4.5 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { ChatMessage } from '#shared/events.ts';
import { migrate } from './index.ts';
import { repos } from './repos.ts';
import { DatabaseSync } from 'node:sqlite';
function freshDb() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-test-'));
const db = new DatabaseSync(path.join(dir, 't.db'));
db.exec('PRAGMA foreign_keys = ON');
migrate(db);
return { db, repo: repos(db), dir };
}
function msg(over: Partial<ChatMessage> = {}): ChatMessage {
return {
id: `m-${Math.random().toString(36).slice(2)}`,
ts: '2026-09-09T20:00:00.000Z',
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
text: 'moin', emotes: [], isFirst: false, isReturning: false,
isCommand: false, isMod: false, isSub: false, isBroadcaster: false,
bits: 0, color: null, ...over,
};
}
test('legt Nutzer an und zaehlt Nachrichten', () => {
const { db, repo } = freshDb();
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
repo.recordMessage(msg(), 's1');
repo.recordMessage(msg({ text: 'zweite' }), 's1');
const user = db.prepare('SELECT * FROM users WHERE id = ?').get('42') as
{ message_count: number; login: string; first_seen_at: string };
assert.equal(user.message_count, 2);
assert.equal(user.login, 'maxi_2k');
const n = db.prepare('SELECT COUNT(*) AS n FROM chat_messages').get() as { n: number };
assert.equal(n.n, 2);
});
test('Namenswechsel erhaelt die Historie', () => {
const { db, repo } = freshDb();
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
repo.recordMessage(msg(), 's1');
// Gleiche user-id, neuer Login - so sieht eine Twitch-Namensaenderung aus
repo.recordMessage(msg({ login: 'maxi_neu', displayName: 'Maxi_Neu' }), 's1');
const rows = db.prepare('SELECT * FROM users').all() as Array<{ login: string; message_count: number }>;
assert.equal(rows.length, 1, 'darf keinen zweiten Nutzer angelegt haben');
assert.equal(rows[0]!.login, 'maxi_neu');
assert.equal(rows[0]!.message_count, 2);
});
test('zaehlt Streams, an denen jemand geschrieben hat', () => {
const { repo } = freshDb();
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
repo.openStream('s2', '2026-09-10T19:00:00.000Z');
assert.equal(repo.recordMessage(msg(), 's1').streamsChatted, 1);
assert.equal(repo.recordMessage(msg(), 's1').streamsChatted, 1);
assert.equal(repo.recordMessage(msg(), 's2').streamsChatted, 2);
});
test('doppelte Nachrichten-IDs werden nicht zweimal gespeichert', () => {
const { db, repo } = freshDb();
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
const m = msg({ id: 'immer-gleich' });
repo.recordMessage(m, 's1');
repo.recordMessage(m, 's1');
const n = db.prepare('SELECT COUNT(*) AS n FROM chat_messages').get() as { n: number };
assert.equal(n.n, 1);
});
test('Zuschauerzahlen setzen den Hoechststand', () => {
const { repo } = freshDb();
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
repo.sample('s1', '2026-09-09T19:01:00.000Z', 8);
repo.sample('s1', '2026-09-09T19:02:00.000Z', 17);
repo.sample('s1', '2026-09-09T19:03:00.000Z', 11);
assert.equal(repo.getStream('s1')?.peak_viewers, 17);
});
test('offene Sitzung wird gefunden und geschlossen', () => {
const { repo } = freshDb();
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
assert.equal(repo.latestOpenStream()?.id, 's1');
repo.closeStream('s1', '2026-09-09T22:00:00.000Z');
assert.equal(repo.latestOpenStream(), undefined);
});
test('erneutes openStream ueberschreibt eine laufende Sitzung nicht', () => {
const { repo } = freshDb();
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
repo.openStream('s1', '2026-09-09T21:00:00.000Z');
assert.equal(repo.getStream('s1')?.started_at, '2026-09-09T19:00:00.000Z');
});
test('Nachrichten ohne laufenden Stream gehen trotzdem nicht verloren', () => {
const { db, repo } = freshDb();
repo.recordMessage(msg(), null);
const row = db.prepare('SELECT stream_id FROM chat_messages').get() as { stream_id: string | null };
assert.equal(row.stream_id, null);
});
test('unique_chatters zaehlt Personen, nicht Nachrichten', () => {
const { repo } = freshDb();
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
repo.recordMessage(msg({ userId: '1', login: 'a' }), 's1');
repo.recordMessage(msg({ userId: '1', login: 'a' }), 's1');
repo.recordMessage(msg({ userId: '2', login: 'b' }), 's1');
assert.equal(repo.getStream('s1')?.unique_chatters, 2);
});