Files
dogfather-universe/server/pruef-video.mjs
T
DogFatherGitandClaude Opus 5 7dc5356c80 Datum: der Tag kommt aus der Ortszeit, nicht aus UTC
GEFUNDEN UM 01:22, von pruef-kreislauf -- und nur, weil nachts
gearbeitet wurde.

Die Pruefung legt einen Wunsch mit dem HEUTIGEN Datum an und macht
daraus einen Termin. Gemessen:

    geschickt:   datum = 2026-10-01   (heuteLokal auf dem Server)
    gespeichert: datum = 2026-09-30

Der Termin lag also in der Vergangenheit. Auf „Was ansteht" sortiert
er sich damit in den Abschnitt „Vorbei" ein -- und der ist mit
Absicht zugeklappt. Beim Wunsch stand „Daraus wurde ein Termin", auf
dem Brett war er nicht zu sehen. Zwei Stunden jede Nacht (im Winter
eine), genau in den Stunden, in denen hier gearbeitet wird.

URSACHE

    const jetzt = () => new Date().toISOString();   // UTC
    ... jetzt().slice(0, 10) ...                     // UTC-Tag

ES WAR NICHT EINE STELLE. Nachgemessen: SECHZEHN in vierzehn Modulen,
davon ZEHN, die den falschen Tag in die Datenbank schreiben --
Termine, Wuensche, Highlights, Talente, Leads, Videos, Vorlagen --
und sechs, die „heute" vergleichen (ueberfaellige Aufgaben, Berichte,
die Frist einer Entwicklungsaufgabe).

NICHT ANGEFASST, WEIL RICHTIG: Rechnungen auf einem Datumstext mit
fester Uhrzeit (`Date.parse(tag + "T12:00:00Z") + n * 86400000`). Die
bleiben in jeder Zeitzone am selben Kalendertag -- kalender, teamlage
und serien machen es so, und das bleibt.

WARUM ES NIEMAND GEMERKT HAT

pruef-struktur sucht dieses Muster seit dem 06.09.2026. Aber:
  - sie sah NUR in die `pruef-*.mjs`, nie in die Anwendung
  - sie kannte die Schreibweise ueber eine FUNKTION nicht
    (`const jetzt = () => ...` statt `const jetzt = ...`)

Die Wache stand vor den Pruefungen, nicht vor dem Haus -- derselbe
Fehler wie heute Nacht bei den Messports: eine Sicherung, die nur die
halbe Menge kennt, faellt in der anderen Haelfte aus, und zwar
lautlos, denn sie meldet ja „nichts gefunden".

Jetzt sieht sie in beides und kennt beide Schreibweisen. Beim ersten
scharfen Lauf fand sie sofort 23 weitere Stellen in den Pruefdateien
selbst -- dieselben Zeitbomben, gegen die sie gebaut worden war.

EINE ZWEITE WACHE, WEIL ICH SELBST HINEINGELAUFEN BIN

Mein Umbauwerkzeug hat in elf Modulen `heuteLokal()` eingesetzt und
die Einfuhr weggelassen: Es hat erst ersetzt und DANN gefragt, ob der
Name schon in der Datei steht -- da stand er, mein eigener Aufruf.
`node --check` sagt dazu nichts, „Laedt jedes Server-Modul?" auch
nicht: Die Datei ist syntaktisch tadellos. Erst der Aufruf faellt um
mit `ReferenceError: heuteLokal is not defined`. Gefunden hat es
pruef-video, zufaellig. Die anderen zehn waeren durchgerutscht.

Deshalb neu: „Ruft ein Modul etwas, das es nie eingefuehrt hat?" --
die Namen des Hauses aus den export-Zeilen gelesen, nicht
aufgezaehlt. 460 Aufrufe in 350 Dateien, alle mit Einfuhr.

pruef-kreislauf STELLT JETZT DIE RICHTIGE FRAGE

Sie war rot und hat den Fehler dabei nur gestreift: „`.kette` wird
nicht sichtbar", Zeitsperre nach 15 s. Das klingt nach der Anzeige
und schickt einen zur falschen Stelle. Neu:
  - eine Zeile fragt das DATUM (ohne Browser, nennt den Fehler beim
    Namen)
  - der Browserteil klappt zu, was zu ist, und misst dann die Kette;
    „gar nicht da" wird von „da und unsichtbar" unterschieden

NEBENBEFUND IN pruef-ics

Die Probe „fast richtig" war `echt.slice(0, -1) + "A"`. Der
Schluessel ist base64url; sein letztes Zeichen ist eines von
sechzehn. Endet er auf „A", IST die Probe der echte Schluessel, der
Server antwortet zu Recht mit 200, und die Pruefung meldet ein Loch,
das es nicht gibt -- einmal je sechzehn Laeufe. Heute Nacht zweimal
hintereinander, und die Suche ging eine halbe Stunde in eine
Aenderung, die damit nichts zu tun hatte.

GEPRUEFT

  pruef-struktur   44 -> 59 Pruefungen, 0 Fehler
  pruef-ics        37 -> 38, 0 Fehler
  pruef-kreislauf  Absturz bei Nr. 17 -> 25 Pruefungen, 0 Fehler

  und gruen geblieben: treff 85, arten 28, video 74, uebernahme 39,
  entwicklung 79, content 45, vorlagen 24, zuteilung 75,
  scout-zuteilung 37, unterstuetzung 70, aufbewahrung 45,
  bewerbung 91, treff-start 42, uebergang 65, nachwuchs 262,
  auskunft 46, modi-ideen 30, neue-seiten 109, spicy 85,
  wege-nach-draussen 67, aufgabenbrett 49, agentur 62,
  bereiche-lesend 37 -- beide Haeuser

GEGENPROBEN, DIE WIRKLICH ROT WERDEN

  - den UTC-Tag im `daraus`-Weg wieder eingebaut: pruef-kreislauf
    meldet „er liegt HEUTE, nicht gestern (2026-09-30, heute ist
    2026-10-01)", 25 Pruefungen, 1 Fehler -- und der Browserteil
    bleibt gruen, weil er jetzt aufklappt. Jede Frage bei ihrer
    eigenen Pruefung.
  - eine Einfuhr aus workspace-video.js entfernt: die neue Wache
    meldet „workspace-video.js: heuteLokal() (aus helfer-tag.mjs)"
  - beide Erkennungen je gegen einen gebauten Rueckschritt geprueft
    (Funktion, Variable, zwei Schritte, Date.now-Rechnung) und gegen
    das, was NICHT anschlagen darf (UTC-Mittag, voller Zeitstempel,
    fremdes Date, Name im Kommentar, Eigenschaft am Objekt)

EIN FEHLER BEIM UMBAU, HIER FESTGEHALTEN: Mein erster Lauf ueber die
Pruefdateien hat stumpf ersetzt und dabei KOMMENTARE umgeschrieben --
in sieben Dateien stand die alte Schreibweise als Beleg in der
Begruendung, und daraus wurde das Gegenteil. Bemerkt hat es der
Vergleich der Zahlen (34 Stellen statt der gemessenen 24), nicht die
Absicht. Zurueckgenommen und mit Schutz fuer Kommentare und
Zeichenketten wiederholt.

Datenbank vorher gesichert. Keine Schemaaenderung.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:45:16 +02:00

694 lines
33 KiB
JavaScript

/* =====================================================================
EIN VIDEO IN DIE APP HOLEN (17.09.2026) — Stufe A des Video-Plans
Filipe: „damit meine videos auch da auf der app rein kommen … sobald
ich ein video poste erscheint es sofort in der app für die ganze
community … dass man das Coverbild und den Text sieht … für meinen
Account, dann DogFather Clips und HasiDog."
---------------------------------------------------------------------
DER EIGENTLICHE PUNKT STEHT IN ABSCHNITT 6
TikToks Coverbild-Adresse ist signiert und läuft ab — gemessen am
17.09.2026 an einem echten Video: `x-expires` in 48 Stunden. Wer sie
nur speichert, hat übermorgen schwarze Kacheln. Beim Bauen merkt man
das nicht, beim Testen auch nicht: Es fällt erst am übernächsten Tag
auf, und dann sieht die ganze App kaputt aus.
Deshalb wird das Bild KOPIERT. Und genau das lässt sich beweisen:
Der nachgebaute TikTok-Dienst wird im letzten Abschnitt ABGESCHALTET.
Danach muss die alte Cover-Adresse ins Leere laufen und unsere
weiterhin ein Bild liefern. Wäre es nur verlinkt, wären beide tot —
genau wie übermorgen im Betrieb.
Deshalb steht das Abschalten auch GANZ HINTEN: Jeder Abschnitt davor
braucht den Dienst noch.
---------------------------------------------------------------------
OHNE ECHTES TIKTOK
Eine Prüfung, die das Netz braucht, ist langsam, hängt an fremder
Verfügbarkeit und wird irgendwann rot, ohne dass etwas kaputt ist —
und dann schaltet man sie ab. Hier läuft ein nachgebauter Dienst auf
127.0.0.1. Die Adresse dorthin kommt aus einer Umgebungsvariablen,
die es im Betrieb nicht gibt; steht sie nicht da, gilt TikTok.
Aufrufen mit: node server/pruef-video.mjs
===================================================================== */
import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "pruef-video");
const TIKTOK = await eigenerPort(import.meta, "pruef-video (nachgebautes TikTok)", 1);
const ordner = mkdtempSync(join(tmpdir(), "ws-video-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
process.env.TIKTOK_OEMBED_BASIS = `http://127.0.0.1:${TIKTOK}/oembed`;
/* DER ABLAGEORT wird NICHT abgeschrieben, sondern abgeleitet: In
workspace.js steht `DATEN_ORDNER = dirname(DB_PFAD)`. Eine zweite
Rechnung hier wäre eine zweite Wahrheit -- und die veraltet. */
const ABLAGE = join(ordner, "dateien");
/* ---------- Das nachgebaute TikTok ----------------------------------- */
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64");
let coverAbrufe = 0;
let oembedAbrufe = 0;
/* Welche Videos gerade „geloescht" sind und welche „stoeren".
Zwei getrennte Mengen, weil es zwei getrennte Faelle sind. */
const verschwunden = new Set();
const stoerung = new Set();
const coverAdresse = (was) => `http://127.0.0.1:${TIKTOK}/${was}`
+ `?x-expires=${Math.floor(Date.now() / 1000) + 172800}&x-signature=abc`;
const tiktok = createServer((q, a) => {
const u = new URL(q.url, `http://127.0.0.1:${TIKTOK}`);
if (u.pathname === "/oembed") {
oembedAbrufe++;
const ziel = u.searchParams.get("url") || "";
/* Welcher Account? Aus der angefragten Adresse -- beim echten
Dienst ist es genauso. */
const wer = (ziel.match(/@([^/?]+)/) || [])[1] || "";
if (!wer) { a.writeHead(404); a.end("{}"); return; }
/* UMSCHALTBAR FUER DIE WACHE (Abschnitt 5b): Ein Video kann
verschwinden und wiederkommen, und TikTok kann stoeren. Beides
sind verschiedene Dinge, und genau das muss messbar sein. */
if (verschwunden.has(ziel)) { a.writeHead(404); a.end("{}"); return; }
if (stoerung.has(ziel)) { a.writeHead(503); a.end("{}"); return; }
/* Welches Cover? Am Videonamen. So lassen sich die Sonderfälle
auslösen, ohne den Dienst umzubauen. */
let bild = coverAdresse("cover.jpg");
if (/kaputt/.test(ziel)) bild = coverAdresse("kein-bild.jpg");
if (/ohnecover/.test(ziel)) bild = null;
a.writeHead(200, { "content-type": "application/json" });
a.end(JSON.stringify({
version: "1.0", type: "video",
title: "Casper springt ins Bild \u{1F604} #fyp #hund #dogfather",
author_url: `https://www.tiktok.com/@${wer}`,
/* DER ANZEIGENAME ist bewusst bei allen derselbe -- wer ihn als
Schlüssel nimmt statt des Accounts, fällt in Abschnitt 3 auf. */
author_name: "Irgendein Anzeigename",
thumbnail_url: bild,
thumbnail_width: 576, thumbnail_height: 1024,
}));
return;
}
if (u.pathname === "/cover.jpg") {
coverAbrufe++;
a.writeHead(200, { "content-type": "image/jpeg" });
a.end(PNG);
return;
}
if (u.pathname === "/kein-bild.jpg") {
/* EINE HTML-SEITE, DIE SICH ALS BILD AUSGIBT. Genau der Fall, für
den die Byteprüfung gebaut wurde: Der Typ kommt hier von einem
FREMDEN Server, er ist also noch weniger wert als eine
Behauptung des eigenen Browsers. */
a.writeHead(200, { "content-type": "image/jpeg" });
a.end("<!DOCTYPE html><html><body>kein Bild, sondern HTML</body></html>");
return;
}
a.writeHead(404); a.end("{}");
});
await new Promise((r) => tiktok.listen(TIKTOK, "127.0.0.1", r));
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
import { heuteLokal } from "./helfer-tag.mjs";
await import("./index.js");
notbremse(180_000, "pruef-video");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-DOGI-0001", salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run("Filipe", "admin", hash, salt, 32768,
createHmac("sha256", sch).update("CODE-DOGI-0001").digest("hex"), jetzt);
d.close();
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }),
});
const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
const kopf = { "Content-Type": "application/json", cookie: keks, origin: BASIS };
async function holen(url, bereich = "highlight", kanal = undefined) {
const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}/video`, {
method: "POST", headers: kopf,
body: JSON.stringify(kanal === undefined ? { url } : { url, kanal }),
});
return { status: a.status, daten: await a.json().catch(() => null) };
}
async function liste(bereich = "highlight") {
const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } });
return (await a.json().catch(() => null))?.eintraege || [];
}
const videoVon = (handle, nr = "123") =>
`http://127.0.0.1:${TIKTOK}/@${handle}/video/${nr}`;
const dateienImOrdner = () => {
try { return readdirSync(ABLAGE).length; } catch { return 0; }
};
/* =======================================================================
1. EIN LINK WIRD ZU EINEM EINTRAG
======================================================================= */
melde("");
melde("=== 1. Der Link ===");
let eintragId = 0, bildId = 0;
{
const a = await holen(videoVon("dogfather0804"));
ok(a.status === 201, `ein Video von DogFather wird eingelesen (${a.status} ${a.daten?.fehler || ""})`);
eintragId = a.daten?.id || 0;
ok(a.daten?.kanal === "dogfather", `und dem richtigen Kanal zugeordnet (${a.daten?.kanal})`);
const e = (await liste()).find((x) => x.id === eintragId);
ok(/Casper springt ins Bild/.test(e?.titel || ""),
`der Text des Videos steht da ("${(e?.titel || "").slice(0, 40)}…")`);
ok(e?.quelle_url === videoVon("dogfather0804"),
"die Adresse zum Video auch -- sonst käme niemand zum Liken hin");
ok(e?.quelle_kanal === "dogfather", `und der Kanal am Eintrag (${e?.quelle_kanal})`);
ok((e?.bilder || []).length === 1, `ein Coverbild hängt dran (${(e?.bilder || []).length})`);
bildId = e?.bilder?.[0]?.id || 0;
ok(coverAbrufe === 1, `das Cover wurde genau einmal geholt (${coverAbrufe})`);
}
/* =======================================================================
2. DAS COVER LIEGT BEI UNS
======================================================================= */
melde("");
melde("=== 2. Wo das Bild liegt ===");
{
const r = await fetch(`${BASIS}/workspace/api/dateien/${bildId}/bild`, { headers: { cookie: keks } });
ok(r.status === 200 && (r.headers.get("content-type") || "").startsWith("image/"),
`es kommt über unseren eigenen Weg (${r.status}, ${r.headers.get("content-type")})`);
const bytes = Buffer.from(await r.arrayBuffer());
ok(bytes.length === PNG.length && bytes.equals(PNG),
`Byte für Byte dasselbe Bild (${bytes.length} von ${PNG.length})`);
/* UND IN DER AUSKUNFT STEHT KEINE FREMDE ADRESSE. Ein Eintrag, dessen
Bildadresse nach draußen zeigt, wäre genau der Fehler, den
Abschnitt 6 misst -- nur fiele er dort dann nicht mehr auf, weil
die Kachel ja "ein Bild hat". */
const e = (await liste()).find((x) => x.id === eintragId);
const alsText = JSON.stringify(e?.bilder || []);
ok(!/tiktok|x-expires|127\.0\.0\.1/i.test(alsText),
`und keine abgelaufene Fremdadresse (${alsText.slice(0, 60)})`);
ok(dateienImOrdner() === 1, `eine Datei liegt in der Ablage (${dateienImOrdner()})`);
}
/* =======================================================================
3. ALLE DREI KANÄLE, JEDER AN SEINEN PLATZ
======================================================================= */
melde("");
melde("=== 3. Die drei Kanäle ===");
{
/* NICHT ABGESCHRIEBEN: Die Handles kommen aus KANAELE selbst. Eine
eigene Liste hier wäre eine zweite Wahrheit -- genau die Falle, die
in diesem Haus schon dreimal zugeschlagen hat. */
const { KANAELE } = await import("./workspace.js");
ok(KANAELE.length === 3, `es sind drei Kanäle (${KANAELE.map((k) => k.wert).join(", ")})`);
let n = 500;
for (const k of KANAELE.filter((x) => x.wert !== "dogfather")) {
const a = await holen(videoVon(k.handle.replace(/^@/, ""), String(++n)));
ok(a.status === 201 && a.daten?.kanal === k.wert,
`${k.handle} landet auf „${k.name}" (${a.status}, ${a.daten?.kanal})`);
}
/* UND DER ANZEIGENAME SPIELT KEINE ROLLE. Der nachgebaute Dienst
schickt bei allen denselben ("Irgendein Anzeigename"). Wäre er der
Schlüssel, lägen jetzt alle drei auf demselben Kanal. */
const kanaele = new Set((await liste()).map((e) => e.quelle_kanal).filter(Boolean));
ok(kanaele.size === 3,
`drei verschiedene Kanäle in der Liste (${[...kanaele].join(", ")})`);
}
/* =======================================================================
3b. WOHIN ES GELEGT WIRD, DARF MAN WAEHLEN (22.09.2026)
-----------------------------------------------------------------------
Filipe: „ich will das wenn man da video holt das man auch aussuchen
kann in welcher account es unten angezeigt werden soll."
Bis heute entschied allein der Link. Das ist gut geraten, aber es IST
geraten: Ein Ausschnitt vom Hauptkanal gehoert oft unter „Clips".
DREI FRAGEN, und die dritte ist die wichtigste:
1. Eine Wahl schlaegt das Erkannte.
2. Ein erfundener Kanal wird abgelehnt.
3. OHNE Wahl bleibt alles wie vorher -- sonst waere aus einer
Zugabe eine Verhaltensaenderung geworden, die niemand bestellt
hat.
======================================================================= */
melde("");
melde("=== 3b. Die Ablage waehlen ===");
{
const { KANAELE } = await import("./workspace.js");
const haupt = KANAELE.find((k) => k.wert === "dogfather");
const anders = KANAELE.find((k) => k.wert !== "dogfather");
/* 1. Ein Video VOM Hauptkanal, abgelegt woanders. */
const gewaehlt = await holen(
videoVon(haupt.handle.replace(/^@/, ""), "901"), "highlight", anders.wert);
ok(gewaehlt.status === 201 && gewaehlt.daten?.kanal === anders.wert,
`ein Video von ${haupt.handle} laesst sich unter „${anders.name}" ablegen `
+ `(${gewaehlt.status}, ${gewaehlt.daten?.kanal})`);
/* 2. Ein erfundener Kanal. Ohne diese Zeile koennte jeder Text in
der Spalte landen -- und die Galerie zeigt ihn dann als Fach. */
const erfunden = await holen(
videoVon(haupt.handle.replace(/^@/, ""), "902"), "highlight", "gibtsnicht");
ok(erfunden.status === 400 && erfunden.daten?.fehler === "kanal_unbekannt",
`ein erfundener Kanal wird abgelehnt (${erfunden.status}, ${erfunden.daten?.fehler})`);
/* 3. DIE GEGENPROBE. Ohne Wahl muss weiterhin der Link entscheiden --
sonst waere die Zeile oben auch gruen, wenn die Erkennung
stillschweigend abgeschaltet worden waere. */
const ohne = await holen(videoVon(haupt.handle.replace(/^@/, ""), "903"));
ok(ohne.status === 201 && ohne.daten?.kanal === "dogfather",
`ohne Wahl entscheidet weiterhin der Link (${ohne.status}, ${ohne.daten?.kanal})`);
/* 4. UND DIE SCHRANKE BLEIBT. Eine Wahl darf kein Schluessel fuer
fremde Inhalte sein -- gewaehlt wird die Spalte, nicht, wer
hereindarf. */
const fremd = await holen(videoVon("irgendwerfremd", "904"), "highlight", anders.wert);
ok(fremd.status === 403,
`ein fremder Absender kommt auch MIT Wahl nicht herein (${fremd.status})`);
}
/* =======================================================================
4. WAS NICHT HINEINKOMMT
======================================================================= */
melde("");
melde("=== 4. Die Gegenprobe ===");
{
const vorher = (await liste()).length;
/* EIN FREMDER ACCOUNT. Das ist nicht bloß falsch einsortiert -- es
wäre fremder Inhalt unter Filipes Namen. */
const f = await holen(videoVon("irgendwerAnders", "777"));
ok(f.status === 403, `ein fremder Account wird abgelehnt (${f.status})`);
ok(/keiner deiner Kan/i.test(f.daten?.fehler || ""),
`und es steht auch da, warum ("${(f.daten?.fehler || "").slice(0, 48)}…")`);
/* KEINE TIKTOK-ADRESSE. Geprüft wird der Rechnername, nicht die
Zeichenkette -- "tiktok.com.beispiel.de" gehört jemand anderem. */
for (const boese of [
"https://tiktok.com.beispiel.de/@dogfather0804/video/1",
"https://beispiel.de/@dogfather0804/video/1",
"nicht mal eine Adresse",
"",
]) {
const x = await holen(boese);
ok(x.status === 400, `abgelehnt: ${boese.slice(0, 44) || "(leer)"} (${x.status})`);
}
/* DERSELBE LINK ZWEIMAL. Wer den Knopf zweimal drückt, meint nicht
zwei Videos. */
/* GEMESSEN WIRD DER UNTERSCHIED, NICHT DIE SUMME (berichtigt 22.09.2026).
Hier stand `coverAbrufe === 3` -- eine Rechnung von dem Tag, an dem
die Prüfung drei Videos anlegte. Am 22.09. kam ein Abschnitt dazu,
und die Zeile wurde rot, ohne dass irgendetwas kaputt war.
Die Frage lautet nicht "wie viele Cover insgesamt", sondern
"holt derselbe Link ein zweites Mal?" -- das ist eine Differenz
und bleibt richtig, egal wie viele Videos davor liefen. */
const coverVorher = coverAbrufe;
const wieder = await holen(videoVon("dogfather0804"));
ok(wieder.status === 200 && wieder.daten?.schon === true,
`derselbe Link gibt denselben Eintrag zurück (${wieder.status}, schon=${wieder.daten?.schon})`);
ok(wieder.daten?.id === eintragId, `und zwar wirklich denselben (${wieder.daten?.id})`);
ok(coverAbrufe === coverVorher,
`und holt das Cover kein zweites Mal (${coverVorher} vor, ${coverAbrufe} nach dem zweiten Versuch)`);
const nachher = (await liste()).length;
ok(nachher === vorher, `kein Eintrag ist dazugekommen (${nachher} von ${vorher})`);
}
/* =======================================================================
5. WAS SICH ALS BILD AUSGIBT, ABER KEINS IST
======================================================================= */
melde("");
melde("=== 5. Die Byteprüfung ===");
{
const vorherDateien = dateienImOrdner();
/* Der Dienst schickt HTML mit `content-type: image/jpeg`. Ein
fremder Server darf den Typ behaupten -- geglaubt wird ihm nicht. */
const a = await holen(videoVon("dogfather0804", "kaputt"));
ok(a.status === 201, `das Video kommt trotzdem rein (${a.status})`);
const e = (await liste()).find((x) => x.id === a.daten?.id);
ok((e?.bilder || []).length === 0,
`aber ohne Cover -- die Bytes waren kein Bild (${(e?.bilder || []).length})`);
ok(dateienImOrdner() === vorherDateien,
`und nichts wurde abgelegt (${dateienImOrdner()} von ${vorherDateien})`);
ok(/Casper/.test(e?.titel || ""),
"der Text ist da -- ein fehlendes Cover wirft nicht das ganze Video weg");
/* UND EIN VIDEO GANZ OHNE COVER stürzt nicht ab. */
const b = await holen(videoVon("dogfather0804", "ohnecover"));
ok(b.status === 201, `ein Video ohne Coverbild geht auch (${b.status})`);
}
/* =======================================================================
5b. FUEHRT DER KNOPF NOCH IRGENDWOHIN? (Stufe 5)
Der wichtigste Satz dieses Abschnitts ist nicht „geloeschte Videos
werden erkannt", sondern: EINE STOERUNG BEI TIKTOK AENDERT NICHTS.
Die naheliegende Fassung setzt bei jedem misslungenen Abruf „weg" --
und markiert an einem schlechten Nachmittag die halbe Galerie als
geloescht. Wer das hinterher sucht, sucht lange, denn im Protokoll
steht dann sauber, dass TikTok gefragt wurde.
======================================================================= */
melde("");
melde("=== 5b. Wenn ein Video bei TikTok verschwindet ===");
{
const video = await import("./workspace-video.js");
const db2 = new DatabaseSync(process.env.WORKSPACE_DB);
const nimm = (id) => db2.prepare(
"SELECT quelle_geprueft_am, quelle_fehlt_seit,"
+ " COALESCE(quelle_fehlversuche,0) AS fehl FROM eintraege WHERE id = ?").get(id);
/* Stellt den naechsten Tag dar. Die Wache sieht verdaechtige Videos
TAEGLICH nach -- ohne Zurueckdatieren waeren alle drei Laeufe am
selben Tag, und nur der erste taete etwas. Genau so ist es
gewollt; die Pruefung muss deshalb die Uhr weiterstellen, nicht
die Regel umgehen. */
const morgen = (id, stunden = 36) => db2.prepare(
"UPDATE eintraege SET quelle_geprueft_am = ? WHERE id = ?")
.run(new Date(Date.now() - stunden * 3600_000).toISOString(), id);
/* Derselbe Account wie in Abschnitt 1 -- ein ERFUNDENER wird von der
Route zu Recht abgewiesen, und die Pruefung haette dann gemessen,
dass sie sich selbst vertippt hat. */
const adresse = videoVon("dogfather0804", "5b1");
const ein = await holen(adresse);
const id5b = ein.daten?.id;
ok(ein.status === 201 && !!id5b, `ein Video zum Beobachten ist da (#${id5b})`);
/* ---- Die drei Antworten einzeln ---------------------------------- */
ok((await video.videoNochDa(adresse)).da === true,
"ein vorhandenes Video meldet: da");
stoerung.add(adresse);
const beiStoerung = await video.videoNochDa(adresse);
ok(beiStoerung.da === null,
`eine Stoerung meldet WEISS NICHT, nicht „weg" (${beiStoerung.grund})`);
stoerung.delete(adresse);
verschwunden.add(adresse);
const beiWeg = await video.videoNochDa(adresse);
ok(beiWeg.da === false, `ein geloeschtes meldet: weg (${beiWeg.grund})`);
/* ---- Ein einzelner Fehlversuch markiert NICHT --------------------- */
const l1 = await video.videoWacheLauf();
const n1 = nimm(id5b);
ok(n1.fehl === 1 && !n1.quelle_fehlt_seit,
`nach dem ersten Fehlversuch ist es noch NICHT „weg" (Zaehler ${n1.fehl})`);
morgen(id5b);
await video.videoWacheLauf();
const n2 = nimm(id5b);
ok(n2.fehl === 2 && !n2.quelle_fehlt_seit,
`nach dem zweiten auch nicht (Zaehler ${n2.fehl})`);
morgen(id5b);
const l3 = await video.videoWacheLauf();
const n3 = nimm(id5b);
ok(n3.fehl === 3 && !!n3.quelle_fehlt_seit,
`erst der dritte markiert es (Zaehler ${n3.fehl}, seit ${String(n3.quelle_fehlt_seit).slice(0, 10)})`);
ok(l3.weg === 1, `und der Lauf sagt es auch (weg: ${l3.weg})`);
/* ---- Die Oberflaeche erfaehrt davon ------------------------------- */
const e5b = (await liste()).find((x) => x.id === id5b);
ok(!!e5b?.quelle_fehlt_seit,
"die Liste liefert den Zustand mit -- sonst zeigte die Kachel weiter den Knopf");
/* ---- DER KERN: eine Stoerung aendert NICHTS ----------------------- */
verschwunden.delete(adresse);
/* ACHT TAGE, nicht 36 Stunden: Ein bereits markiertes Video wird
absichtlich nur noch woechentlich nachgefragt (siehe die drei
Geschwindigkeiten in workspace-video.js). Die Pruefung muss die
echte Regel abbilden, nicht die bequemere. */
morgen(id5b, 8 * 24);
await video.videoWacheLauf(); /* wieder da -> Zaehler zurueck */
const zurueck = nimm(id5b);
ok(zurueck.fehl === 0 && !zurueck.quelle_fehlt_seit,
"kommt es zurueck (privat gestellt und wieder oeffentlich), faellt die Markierung weg");
/* Ebenfalls acht Tage: Nach der Rueckkehr steht der Zaehler auf
null, das Video gilt also wieder als unauffaellig -- und
unauffaellige werden nur woechentlich gefragt. */
morgen(id5b, 8 * 24);
stoerung.add(adresse);
const vorher5b = nimm(id5b);
const lS = await video.videoWacheLauf();
const nachher5b = nimm(id5b);
stoerung.delete(adresse);
ok(nachher5b.fehl === vorher5b.fehl && !nachher5b.quelle_fehlt_seit,
`eine Stoerung erhoeht den Zaehler NICHT (${vorher5b.fehl} -> ${nachher5b.fehl})`);
ok(nachher5b.quelle_geprueft_am === vorher5b.quelle_geprueft_am,
"und verschiebt auch den Zeitpunkt nicht -- sonst waere sieben Tage Ruhe,"
+ " obwohl nichts gemessen wurde");
ok(lS.unklar >= 1 && lS.geprueft === 0,
`der Lauf zaehlt sie als „weiss nicht" (unklar ${lS.unklar}, geprueft ${lS.geprueft})`);
/* ---- Und ein gesundes Video bleibt unberuehrt --------------------- */
const heil = (await liste()).find((x) => x.id === eintragId);
ok(!heil?.quelle_fehlt_seit,
"ein Video, das es noch gibt, wird nicht angefasst");
db2.close();
void l1;
}
/* =======================================================================
5c. DER KREISLAUF (Stufe 7)
„Ein Video aus einem Wunsch zeigt den Namen." Wer sich etwas
gewuenscht hat, soll sehen, dass daraus etwas geworden ist.
DER WICHTIGSTE TEIL IST DER ZWEITE: Der Titel der Quelle wird auf
der Kachel ANGEZEIGT. Eine Kette in die Content-Planung waere
deshalb kein Schoenheitsfehler, sondern ein Leck -- dort steht
Arbeit, die die Community nichts angeht.
======================================================================= */
melde("");
melde("=== 5c. Aus einem Wunsch wird ein Video ===");
{
const db3 = new DatabaseSync(process.env.WORKSPACE_DB);
const nun = new Date().toISOString();
const anlegen = (bereich, art, titel) => {
const { lastInsertRowid } = db3.prepare(`
INSERT INTO eintraege (bereich, art, titel, datum, status, erstellt, erstellt_von)
VALUES (?,?,?,?, 'offen', ?, (SELECT id FROM personen LIMIT 1))`)
.run(bereich, art, titel, heuteLokal(), nun);
return Number(lastInsertRowid);
};
const wunschId = anlegen("wunsch", "wunsch", "Bitte mal Casper beim Baden zeigen");
/* Ein Eintrag aus der Content-Planung -- NICHT aus der Community.
Er ist der Koeder fuer die Leck-Pruefung weiter unten. */
const internId = anlegen("content", "idee", "Interne Planung: Kampagne Q4");
/* ---- Der gute Weg ------------------------------------------------ */
{
const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7a"), aus: wunschId }),
});
const r = await a.json().catch(() => ({}));
ok(a.status === 201 && r.aus === wunschId,
`ein Video zu einem Wunsch wird angenommen (${a.status})`);
const e = (await liste()).find((x) => x.id === r.id);
ok(e?.aus?.id === wunschId, "der Eintrag kennt seinen Wunsch");
ok(e?.aus?.titel === "Bitte mal Casper beim Baden zeigen",
`und zeigt dessen Titel (${e?.aus?.titel})`);
ok(!!e?.aus?.von_name, `und den Namen dessen, der ihn geaeussert hat (${e?.aus?.von_name})`);
}
/* ---- DAS LECK, das es nicht geben darf --------------------------- */
{
const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7b"), aus: internId }),
});
ok(a.status === 404,
`ein Eintrag aus der Content-Planung kommt NICHT als Quelle durch (${a.status})`);
const bleibt = db3.prepare(
"SELECT COUNT(*) AS n FROM eintraege WHERE aus_eintrag_id = ?").get(internId);
ok(bleibt.n === 0, `und es ist auch nichts entstanden (${bleibt.n})`);
}
{
const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7c"), aus: 999999 }),
});
ok(a.status === 404, `eine Nummer, die es nicht gibt, ebenfalls nicht (${a.status})`);
}
{
const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7d"), aus: "irgendwas" }),
});
ok(a.status === 400, `und etwas, das keine Nummer ist, wird abgewiesen (${a.status})`);
}
/* ---- ZWEI WEGE, EIN ZIEL ----------------------------------------- */
{
/* „Daraus ein Highlight machen" gibt es laenger. Wer ihn geht und
DANACH das Video einfuegt, darf kein zweites Highlight bekommen. */
const wunsch2 = anlegen("wunsch", "wunsch", "Ein Video vom Gassigehen");
const d = await fetch(`${BASIS}/workspace/api/bereich/wunsch/${wunsch2}/daraus`, {
method: "POST", headers: kopf, body: JSON.stringify({ nach: "highlight" }),
});
const dr = await d.json().catch(() => ({}));
ok(d.status === 201 && !!dr.id, `erst „Daraus ein Highlight machen" (${d.status})`);
const v = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7e"), aus: wunsch2 }),
});
const vr = await v.json().catch(() => ({}));
ok(v.status === 201 && vr.id === dr.id && vr.angehaengt === true,
`dann das Video -- es haengt sich an den vorhandenen Eintrag (#${vr.id} vs #${dr.id})`);
const wieviele = db3.prepare(
"SELECT COUNT(*) AS n FROM eintraege WHERE aus_eintrag_id = ? AND bereich = 'highlight'")
.get(wunsch2);
ok(wieviele.n === 1,
`aus einem Wunsch ist GENAU EIN Highlight geworden, nicht zwei (${wieviele.n})`);
const e2 = (await liste()).find((x) => x.id === dr.id);
ok(e2?.titel === "Ein Video vom Gassigehen",
`und der Titel bleibt der des Wunsches, nicht der von TikTok (${e2?.titel})`);
ok(!!e2?.quelle_url, "das Video ist trotzdem dran");
/* Noch einmal dasselbe: nichts Neues, kein Fehler. */
const w = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7f"), aus: wunsch2 }),
});
const wr = await w.json().catch(() => ({}));
ok(w.status === 200 && wr.schon === true,
`ein zweites Video zum selben Wunsch aendert nichts (${w.status})`);
/* Und die Wunschkarte weiss, dass es schon ein Video gibt --
daran haengt, ob der Knopf noch angeboten wird. */
const wl = await fetch(`${BASIS}/workspace/api/bereich/wunsch`, { headers: { cookie: keks } });
const wliste = (await wl.json().catch(() => null))?.eintraege || [];
const karte = wliste.find((x) => x.id === wunsch2);
ok((karte?.wurde || []).some((z) => z.bereich === "highlight" && z.hat_video === true),
"die Wunschkarte sieht, dass daraus ein Highlight MIT Video geworden ist");
const karte1 = wliste.find((x) => x.id === wunschId);
ok(!!karte1, "und der erste Wunsch steht auch noch in der Liste");
}
db3.close();
}
/* =======================================================================
6. UND ES ÜBERLEBT, WENN TIKTOK WEG IST ← der eigentliche Punkt
======================================================================= */
/* =======================================================================
WAS DAS TEAM EINSETZT, IST SOFORT FREI (30.09.2026)
=======================================================================
Filipe: „wenn wir bei highlights videos rein setzen will ich nicht
mehr dass wir sie freigeben muessen, sobald die reingesetzt wurden
sollen die sofort zu sehen sein."
Geprueft wird die FREIGABEZEILE. Dass eine Freigabe den Eintrag
fuer die Community sichtbar macht -- und dass die Regel fuer Rollen
von aussen und fuer Termine NICHT freigibt -- steht in
`pruef-treff`. Hier geht es um den zweiten Weg zum selben Ziel: Er
hat seinen eigenen Aufruf, und genau dort wird einer vergessen.
======================================================================= */
melde("");
melde("=== Was das Team einsetzt, ist sofort frei ===");
{
/* Die Handles kommen aus KANAELE selbst -- abgeschrieben waeren sie
die zweite Liste, die irgendwann von der ersten abweicht. */
const { KANAELE: KAN } = await import("./workspace.js");
const e = await holen(videoVon(KAN[0].handle.replace(/^@/, ""), "990"), "highlight");
ok(e.status === 201 && e.daten?.id > 0,
`DogFather setzt ein Video ein (${e.status})`);
if (e.daten?.id) {
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
const frei = d2.prepare(
"SELECT COUNT(*) AS n FROM treff_freigaben WHERE eintrag_id = ?").get(e.daten.id);
/* GEGENPROBE IM SELBEN ATEMZUG: Es darf nicht alles freigegeben
sein, sondern genau dieser eine Eintrag. Eine Zeile, die
„irgendwo steht eine Freigabe" prueft, ist gruen, sobald
IRGENDETWAS freigegeben ist. */
const alle = d2.prepare("SELECT COUNT(*) AS n FROM treff_freigaben").get();
const eintraege = d2.prepare(
"SELECT COUNT(*) AS n FROM eintraege WHERE bereich = 'highlight'").get();
d2.close();
ok(frei?.n === 1, `und er ist sofort frei (${frei?.n} Freigabe)`);
ok(alle?.n <= eintraege?.n,
`freigegeben ist nur, was auch angelegt wurde `
+ `(${alle?.n} Freigaben, ${eintraege?.n} Highlights)`);
}
}
melde("");
melde("=== 6. Wenn die Quelle verschwindet ===");
{
/* Die Adresse, die TikTok uns gegeben hat -- mit ihrem Ablaufstempel. */
const fremd = coverAdresse("cover.jpg");
const vorher = await fetch(fremd).then((r) => r.ok).catch(() => false);
ok(vorher, "vorher liefert die TikTok-Adresse noch ein Bild");
/* JETZT WIRD ABGESCHALTET. Dasselbe passiert übermorgen im Betrieb,
wenn `x-expires` abläuft: Die Adresse antwortet nicht mehr. */
await new Promise((r) => tiktok.close(r));
await new Promise((r) => setTimeout(r, 200));
const nachher = await fetch(fremd).then((r) => r.ok).catch(() => false);
ok(!nachher, "danach ist sie tot -- genau wie nach 48 Stunden");
const r = await fetch(`${BASIS}/workspace/api/dateien/${bildId}/bild`, { headers: { cookie: keks } });
ok(r.status === 200, `unser Cover ist trotzdem noch da (${r.status})`);
const bytes = Buffer.from(await r.arrayBuffer());
ok(bytes.equals(PNG), `und unverändert (${bytes.length} von ${PNG.length} Bytes)`);
ok(existsSync(ABLAGE), "es liegt als Datei in unserer eigenen Ablage");
/* UND DIE KACHEL ZEIGT IHN WEITERHIN: Der Eintrag hat sein Bild noch,
die Liste kommt ohne den fremden Dienst aus. */
const e = (await liste()).find((x) => x.id === eintragId);
ok((e?.bilder || []).length === 1,
`die Kachel hat ihr Bild noch (${(e?.bilder || []).length})`);
ok(oembedAbrufe >= 5,
`der fremde Dienst wurde beim Einlesen gefragt, nie beim Anzeigen (${oembedAbrufe})`);
}
melde("");
melde(`${geprueft} Prüfungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);