Files
dogfather-universe/server/pruef-kampagne.mjs
T
DogFatherGitandClaude Opus 5 fdadd5d573 Teilen gibt einen fertigen Discord-Beitrag -- und die zwei Seiten bekommen ein Gesicht
Filipe: "beim teilen wird auch nur der link geteilt, und ich will dass
wenn ich es teile im discord der agentur, dass ein banner und ein
kompletter fertiger text fuer die creator entsteht und nicht einfach
der link kopiert wird damit die leute drauf gehen. das ist kake."
Und: "perfektionier die zwei events kategorien. ich will es noch viel
krasser und spezieller."

EIN LINK IN EINEM DISCORD-KANAL IST EINE AUFFORDERUNG. Wer ihn nicht
anklickt -- und das sind die meisten -- weiss nicht, dass es die
Kampagne gibt. Was in Discord steht, muss die Kampagne SEIN. Der
Teilen-Knopf legt deshalb einen fertigen Beitrag in die Zwischenablage:
Titel als Ueberschrift, Zeitraum, Einleitung, die Aufgaben als Liste,
die Geschenke mit Coinpreis, "Gut zu wissen" aus Rangliste, Zeitplan
und Teilnahme. Er steht zusaetzlich sichtbar unter der Kachel, mit
Zeichenzaehler und zweitem Kopierknopf -- was man nicht gesehen hat,
fuegt man blind ein.

ZWEI DINGE SIND DABEI NICHT BELIEBIG:
  - Der Link steht GANZ AM ENDE. Discord setzt die Bannervorschau
    UNTER die Nachricht; steht er oben, schiebt das Banner den Text
    aus dem Blick.
  - Der Link steht NACKT da. Spitze Klammern unterdruecken die
    Vorschau -- und damit genau das Banner, um das es geht.

2000 ZEICHEN, UND NICHT EINS MEHR. Gekuerzt wird auf dem Server, in
fester Reihenfolge und an Abschnittsgrenzen: erst "Gut zu wissen",
dann die Einleitung auf zwei Saetze, dann die Geschenke. Titel,
Zeitraum, Aufgaben und Link bleiben IMMER. Gemessen gegen sieben
Laengen und einen 1500-Zeichen-Titel als Haertefall.

Gebaut wird der Beitrag aus `teilenDaten()` -- derselben Funktion, aus
der auch die offene Seite entsteht. Zwei Aufbauten fuer dasselbe Event
wuerden irgendwann Verschiedenes sagen, und niemand koennte entscheiden,
welcher recht hat.

DIE ZWEI SEITEN: ein gezeichnetes Wappen in der Farbe der Spalte, ein
Kopf mit Lichtkante und Verlauf, der beim Scrollen oben stehen bleibt,
und eine Lagezeile ("2 laufen - 1 kommt - 3 vorbei"). Gezaehlt mit
derselben Regel, die die Gruppen darunter bildet; was null ist,
erscheint nicht. Augenschonend bleibt es ueber Form und Tiefe, nicht
ueber Leuchtkraft.

NEBENBEFUND, BEHOBEN: Der Einlesekasten war durch Seitenwahl und
Bildweg auf 521 px gewachsen -- auf 412 px mehr als ein halber
Telefonbildschirm, bevor man die erste Kachel sieht. Er klappt jetzt zu
(82 px) und merkt sich die Wahl; ein Druck, und er bleibt offen.

BERICHTIGT, WEIL GEMESSEN:
  - Der Bindestrich wurde entwertet: "4\-woechige", "Ahorn\-Welt". In
    Discord ist "-" nur am Zeilenanfang eine Aufzaehlung.
  - Das Klappschild hatte nur eine Handy-Regel und sonst kein
    Aussehen -- auf 1400 px ein nackter Knopf mit 27 px statt 48.
  - bild-kampagne mass elementFromPoint ausserhalb des
    Sichtfensters und meldete "etwas liegt auf dem Knopf", wo nichts
    lag; und r(null) liess den ganzen Lauf abstuerzen, statt die
    Zusicherung rot zu machen.
  - EINE GEGENPROBE WAR BLIND: DISCORD_MAX von 2000 auf 99999 zu
    setzen liess die Pruefung gruen -- der Testbeitrag ist 1230
    Zeichen lang, die Grenze wurde also nie geprueft. Jetzt mit einem
    Beitrag, der ohne Kuerzung sicher zu lang waere.

GEPRUEFT: 190 (Route und Teilen, +8), 183 (Kachel, +13), 69 Messungen
(Bild, +2), 67 (Agentur), 43 (Bildleser), struktur, css-klassen,
zeichen, deutsche-texte, tippziele. Sechs Gegenproben: Link nur noch
Link, Link oben statt unten, Grenze weg, Lagezeile weg, Kopf klebt
nicht, Kasten wieder offen -- alle sechs werden bemerkt, alle Dateien
byte-gleich wiederhergestellt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 11:20:51 +02:00

1511 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
EINE KAMPAGNEN-KACHEL AUS EINEM TIKTOK-LINK (07.10.2026)
Prüft Etappe 2 und 3 des Bauplans: den Weg nach draußen
(`helfer-tiktok.mjs`) und die Route (`workspace-kampagne.js`).
Das Auslesen selbst prüft `pruef-kampagne-lesen.mjs` — ohne Server,
ohne Netz, gegen gespeicherte Seiten.
---------------------------------------------------------------------
DER NACHGEBAUTE DIENST LIEGT AUF 127.0.0.1
Umgestellt über `TIKTOK_KAMPAGNE_BASIS` — genau wie
`TIKTOK_OEMBED_BASIS` beim Video. Eine Prüfung gegen das echte
TikTok wäre langsam, hinge am Netz und würde irgendwann rot, ohne
dass etwas kaputt ist. Sie wäre die Sorte Prüfung, die man abschaltet.
Ausgeliefert werden die ECHTEN Seiten aus `server/pruefdaten/`.
Verändert wird daran genau eine Sache: die Banneradresse zeigt auf
diesen Dienst statt auf TikToks CDN. Ohne das würde diese Prüfung
bei jedem Lauf ein Bild aus dem Internet holen — und wäre damit
genau das, was sie vermeiden soll.
---------------------------------------------------------------------
KEINE TESTDATEN IM LIVE-SYSTEM
Alles läuft auf einer frisch angelegten Datenbank in einem
Wegwerfordner. Es entsteht nie eine Kachel auf
workspace.dogfather-universe.com.
KEINE WANDUHR ALS ANNAHME bei den Daten, die geprüft werden: Die
Kampagnen in den gespeicherten Seiten haben feste Zeitstempel, und
genau die werden nachgerechnet (1791064800000 → 2026-10-04 in
Europe/Berlin). Das ist eine EINGABE, keine Annahme über „jetzt“ —
der Kalender kann es nicht einholen.
Aufrufen mit: node server/pruef-kampagne.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
import { brotliDecompressSync } from "node:zlib";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { heuteLokal } from "./helfer-tag.mjs";
const HIER = dirname(fileURLToPath(import.meta.url));
const DATEN = join(HIER, "pruefdaten");
const ordner = mkdtempSync(join(tmpdir(), "ws-kmp-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Kampagnen-Pruefung");
const TIKPORT = await eigenerPort(import.meta, "der nachgebaute TikTok-Dienst", 1);
/* DER SCHALTER. Er macht zweierlei: `istTikTok` lässt 127.0.0.1 durch,
und `bildAdresseErlaubt` ebenfalls. Beides nur hier — im Betrieb
steht die Variable nicht, und dann gilt die echte Regel. */
process.env.TIKTOK_KAMPAGNE_BASIS = "http://127.0.0.1:" + TIKPORT;
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";
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 });
};
await import("./index.js");
notbremse(300_000, "pruef-kampagne");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:" + PORT;
let fehler = 0;
let geprueft = 0;
const melde = (z) => console.log(z);
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
function nichtNachsehbar(grund, rat) {
melde("");
melde("KONNTE NICHT NACHSEHEN");
melde(" Grund: " + grund);
melde(" Was tun: " + rat);
process.exit(2);
}
/* =====================================================================
DIE GESPEICHERTEN SEITEN
===================================================================== */
function seiteLesen(name) {
const pfad = join(DATEN, name);
if (!existsSync(pfad)) {
nichtNachsehbar(`die Prüfdatei ${name} fehlt in ${DATEN}`,
"Sie ist in Gitea versioniert: git checkout -- server/pruefdaten/");
}
try {
return name.endsWith(".br")
? brotliDecompressSync(readFileSync(pfad)).toString("utf8")
: readFileSync(pfad, "utf8");
} catch (f) {
return nichtNachsehbar(`${name} lässt sich nicht lesen (${f.message})`,
"Aus Gitea neu holen.");
}
}
/** Macht das Datenpaket einer gespeicherten Seite auf, lässt daran
* ändern, schreibt es zurück. Steht hier und nicht im Leser: Der
* Leser liest, er baut nicht. */
function umbauen(html, aendern) {
const marke = html.indexOf('id="__MODERN_ROUTER_DATA__"');
const a = html.indexOf(">", marke) + 1;
const e = html.indexOf("</script>", a);
const daten = JSON.parse(html.slice(a, e));
aendern(daten.loaderData.home_page.COSMIC_D_K.value, daten);
return html.slice(0, a) + JSON.stringify(daten) + html.slice(e);
}
const BANNER_HIER = `http://127.0.0.1:${TIKPORT}/banner.png`;
const TEXT_STATT_BILD = `http://127.0.0.1:${TIKPORT}/banner.txt`;
/** Die Banneradresse auf unseren Dienst biegen — der EINZIGE Eingriff
* an den echten Seiten. */
function bannerHierher(html, ziel = BANNER_HIER) {
return umbauen(html, (w) => {
const s = JSON.parse(w.globalData.activityInfo.ac_schema_with_interaction_rules
?? JSON.stringify(w.schema));
let getroffen = 0;
(function lauf(k, t = 0) {
if (!k || typeof k !== "object" || t > 20) return;
if (typeof k.type === "string" && k.type.includes("live_top_image")
&& Array.isArray(k.props?.imageUrl)) {
for (const b of k.props.imageUrl) { b.url = ziel; b.urlSG = ziel; getroffen++; }
}
for (const v of Object.values(k)) {
if (Array.isArray(v)) { for (const x of v) lauf(x, t + 1); }
else if (v && typeof v === "object") lauf(v, t + 1);
}
})(s);
if (!getroffen) throw new Error("kein Bildbaustein gefunden");
if (w.globalData.activityInfo.ac_schema_with_interaction_rules) {
w.globalData.activityInfo.ac_schema_with_interaction_rules = JSON.stringify(s);
} else {
w.schema = typeof w.schema === "string" ? JSON.stringify(s) : s;
}
});
}
const ID_GIPFEL = "7691493175936699143";
const ID_BEENDET = "7657456323453405959";
const ID_OHNEPREISE = "7670899782609996552";
const ID_TEXTBANNER = "7600000000000000001";
/* Die vierte gespeicherte Seite -- bis zum 07.10.2026 benutzte sie
nur pruef-kampagne-lesen.mjs. Hier wird sie gebraucht, weil die
Seitenwahl eine Kampagne braucht, die es noch NICHT gibt: Bei
einer schon eingelesenen kaeme „schon da", und dann entstuende
gar keine neue Kachel, an der sich etwas messen liesse. */
const ID_GLOWUP = "7690927903777262344";
/* Die Nummer aus Filipes echtem Agenturlink -- sie steht hier,
damit die Pruefung dieselbe Form misst, die wirklich ankommt
(snake_case `activity_id`, nicht `activityId`). */
const ID_TCN = "7691301069758087175";
const SEITEN = {
[ID_GIPFEL]: bannerHierher(seiteLesen("kampagne-gipfelstuermer.html.br")),
[ID_BEENDET]: bannerHierher(seiteLesen("kampagne-beendet.html.br")),
[ID_OHNEPREISE]: bannerHierher(seiteLesen("kampagne-ohne-preise.html.br")),
[ID_GLOWUP]: bannerHierher(seiteLesen("kampagne-aufgabengruppe.html.br")),
};
const ROH_GIPFEL = seiteLesen("kampagne-gipfelstuermer.html.br");
const SEITE_KAPUTT = seiteLesen("kampagne-kaputt.html");
/* DIE LEERE HUELLE, wie TikTok sie am 07.10.2026 wirklich schickte:
17 KB, Titel "Campaign", leerer Koerper, die Skripte
tiktok-environment und gfdatav1. Hier nur so gross wie noetig --
gemessen wird die Unterscheidung, nicht die Dateigroesse. */
const HUELLE = [
"<!doctype html><html><head><meta charset=\"utf-8\">",
"<title>Campaign</title>",
'<script id="tiktok-environment" type="application/json">{"regionInfo":{}}</script>',
'<script id="gfdatav1" type="application/json">{"env":"prod"}</script>',
"</head><body><div id=\"root\"></div></body></html>",
].join("");
/* Eine Kampagne, deren Banner eine TEXTDATEI ist. Für die Gegenprobe
zu `bildArtVon`: Was von draußen kommt, ist von draußen -- und der
fremde Server behauptet den Typ. */
SEITEN[ID_TEXTBANNER] = umbauen(bannerHierher(ROH_GIPFEL, TEXT_STATT_BILD), (w) => {
w.globalData.activityInfo.activity_id = ID_TEXTBANNER;
w.globalData.activityId = ID_TEXTBANNER;
w.globalData.pageInfo.title = "Kampagne mit kaputtem Banner";
});
/* Eine Kampagne ohne Enddatum: Pflichtfeld fehlt, es darf KEINE Kachel
entstehen. */
const SEITE_OHNE_ENDE = umbauen(ROH_GIPFEL, (w) => {
delete w.globalData.activityInfo.end_time;
});
/* =====================================================================
DER NACHGEBAUTE DIENST
===================================================================== */
const { createServer } = await import("node:http");
const { pngBauen } = await import("./helfer-png-bauen.mjs");
const BANNERBILD = pngBauen(750, 500, (x, y, b, h) => {
const g = Math.max(0, 1 - Math.hypot(x / b - .5, y / h - .5) * 1.6);
return [Math.round(30 + g * 120), Math.round(40 + g * 90), Math.round(20 + g * 60)];
});
let abrufe = 0;
const gesehen = [];
const tikDienst = createServer((anfrage, antwort) => {
abrufe++;
const u = new URL(anfrage.url, "http://127.0.0.1");
gesehen.push({ pfad: u.pathname, kennung: anfrage.headers["user-agent"] || "" });
const leite = (nach) => { antwort.writeHead(302, { location: nach }); antwort.end(); };
if (u.pathname === "/t/gipfel") return leite(`/falcon/home?activityId=${ID_GIPFEL}&campaign_traffic_id=1`);
if (u.pathname === "/t/gipfel-zweiter-versand") return leite(`/falcon/home?activityId=${ID_GIPFEL}&campaign_traffic_id=2`);
if (u.pathname === "/t/beendet") return leite(`/falcon/home?activityId=${ID_BEENDET}`);
if (u.pathname === "/t/ohnepreise") return leite(`/falcon/home?activityId=${ID_OHNEPREISE}`);
if (u.pathname === "/t/glowup") return leite(`/falcon/home?activityId=${ID_GLOWUP}`);
if (u.pathname === "/t/textbanner") return leite(`/falcon/home?activityId=${ID_TEXTBANNER}`);
/* DAS CREATOR NETWORK (07.10.2026). Nachgemessen am echten
Agenturlink von Filipe: `vm.tiktok.com/ZSbACwAnK/` leitet auf
`www.tiktok.com/tcn/activity?activity_id=...` -- eine Seite,
die TikTok erst im Browser aufbaut, angemeldet. Von aussen
steht dort nichts.
Der Weg wird hier nachgebaut, damit die Pruefung nicht das
echte TikTok braucht: Sie misst dann fremde Verfuegbarkeit
statt unseren Code. Die ANTWORT auf diesen Weg ist
absichtlich eine leere Huelle -- genau wie draussen. */
if (u.pathname === "/t/agentur") {
return leite(`/tcn/activity?activity_id=${ID_TCN}&activity_type=ranklist`);
}
if (u.pathname === "/t/fremd") return leite("https://beispiel.de/ganz-woanders");
if (u.pathname === "/t/kreis") return leite("/t/kreis");
if (u.pathname === "/t/kaputt") return leite("/falcon/kaputt");
if (u.pathname === "/t/huelle") return leite("/falcon/huelle");
if (u.pathname === "/t/ohneende") return leite("/falcon/ohneende");
if (u.pathname === "/t/stumm") return leite("/falcon/stumm");
if (u.pathname === "/t/riesig") return leite("/falcon/riesig");
if (u.pathname === "/banner.png") {
antwort.writeHead(200, { "content-type": "image/png",
"content-length": BANNERBILD.length });
return antwort.end(BANNERBILD);
}
if (u.pathname === "/banner.txt") {
const b = Buffer.from("Das ist kein Bild, sondern Text.");
/* Der Typ wird BEHAUPTET -- genau darum geht es: Die Byteprüfung
glaubt dem fremden Server nicht. */
antwort.writeHead(200, { "content-type": "image/png", "content-length": b.length });
return antwort.end(b);
}
if (u.pathname === "/falcon/stumm") {
/* Nie antworten. Die Verbindung bleibt offen, bis die Frist greift. */
return;
}
if (u.pathname === "/falcon/riesig") {
antwort.writeHead(200, { "content-type": "text/html" });
/* OHNE content-length (gestückelt): Die Grenze muss beim LESEN
greifen, nicht über einen Kopf, den man auch weglassen kann. */
const brocken = Buffer.alloc(256 * 1024, 0x41);
let n = 0;
const weiter = () => {
if (antwort.writableEnded || antwort.destroyed) return;
if (n++ >= 20) { antwort.end(); return; } /* 5 MB */
if (antwort.write(brocken)) setImmediate(weiter);
else antwort.once("drain", weiter);
};
return weiter();
}
if (u.pathname === "/falcon/kaputt") {
antwort.writeHead(200, { "content-type": "text/html" });
return antwort.end(SEITE_KAPUTT);
}
if (u.pathname === "/falcon/huelle") {
/* Genau das, was TikTok am 07.10.2026 wirklich schickte:
eine kleine Seite mit Titel und zwei Skripten, ohne Inhalt. */
antwort.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return antwort.end(HUELLE);
}
if (u.pathname === "/falcon/ohneende") {
antwort.writeHead(200, { "content-type": "text/html" });
return antwort.end(SEITE_OHNE_ENDE);
}
if (u.pathname === "/falcon/home") {
const id = u.searchParams.get("activityId");
if (SEITEN[id]) {
antwort.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return antwort.end(SEITEN[id]);
}
}
antwort.writeHead(404, { "content-type": "text/plain" });
antwort.end("nicht da");
});
await new Promise((f, s) => {
tikDienst.once("error", s);
tikDienst.listen(TIKPORT, "127.0.0.1", f);
});
const TIK = `http://127.0.0.1:${TIKPORT}`;
/* =====================================================================
PERSONEN UND ZUGANG
===================================================================== */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, 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, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,1,?)").run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001");
anlegen("Luna", "creator", "CODE-CREA-0001");
anlegen("Mara", "manager", "CODE-MANA-0001");
anlegen("Bremser", "manager", "CODE-BREM-0001");
/* EIN EINTRAG IM ANDEREN HAUS -- damit „Crew unverändert“ nicht eine
Behauptung über eine leere Menge ist. `every()` auf einem leeren Feld
meldet `true`; eine Zählung über null Zeilen ebenso. */
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum,"
+ " dringlichkeit, erstellt, erstellt_von, haus)"
+ " VALUES (?,?,'treff','frage','offen',?, 'mittel', ?, ?, 'crew')")
/* DER TAG IN ORTSZEIT (berichtigt 07.10.2026, gefunden von
pruef-struktur). Hier stand `jetzt.slice(0, 10)` -- das ist der
UTC-Tag. Zwischen 00:00 und 02:00 deutscher Zeit legt das den
Eintrag auf gestern; harmlos fuer DIESE Pruefung, aber genau die
Gewohnheit, die anderswo zwei Stunden am Tag rot macht. */
.run("Etwas vom Rudel", "Gehört Team Dogi.", heuteLokal(), jetzt, idDogi);
d.close();
const { request } = await import("node:http");
const HOST = "workspace.dogfather-universe.com";
const CREW_HOST = "crew.dogfather-universe.com";
function roh(pfad, o = {}) {
return new Promise((f, s) => {
const daten = o.rumpf === undefined ? null : Buffer.from(JSON.stringify(o.rumpf));
const wirt = o.wirt || HOST;
const a = request({
host: "127.0.0.1", port: PORT, path: pfad,
method: o.methode || (daten ? "POST" : "GET"),
headers: {
Host: wirt, Origin: "https://" + wirt,
...(o.keks ? { Cookie: o.keks } : {}),
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
...(o.kopf || {}),
},
}, (r) => {
const t = [];
r.on("data", (x) => t.push(x));
r.on("end", () => f({ code: r.statusCode, kopf: r.headers,
text: Buffer.concat(t).toString("utf8") }));
});
a.on("error", s);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, code, wirt = HOST) {
const a = await roh("/workspace/api/anmelden", { rumpf: { rolle, code }, wirt });
if (a.code !== 200) {
nichtNachsehbar(`Anmeldung ${rolle} auf ${wirt} ergab ${a.code}`,
"Ohne Anmeldung lässt sich nichts von dem hier messen.");
}
return [].concat(a.kopf["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksLuna = await anmelden("creator", "CODE-CREA-0001");
const keksMara = await anmelden("manager", "CODE-MANA-0001");
const keksBrem = await anmelden("manager", "CODE-BREM-0001");
const keksDogiCrew = await anmelden("admin", "CODE-DOGI-0001", CREW_HOST);
/** Eine Einladung einlesen -- so, wie es die Oberfläche tut. */
const einlesen = (text, keks = keksDogi, wirt = HOST) =>
roh("/workspace/api/kampagne/einlesen", { rumpf: { text }, keks, wirt })
.then((a) => ({ ...a, j: JSON.parse(a.text || "{}") }));
const dbLesen = (sql, ...w) => {
const x = new DatabaseSync(process.env.WORKSPACE_DB);
try { return x.prepare(sql).all(...w); } finally { x.close(); }
};
/* =====================================================================
1. DEN LINK AUS DEM GANZEN TEXT HOLEN
===================================================================== */
melde("=== 1. Der Link steckt mitten im Fließtext ===");
const { linkAusText, istTikTok, hostVon, bildAdresseErlaubt,
FRIST_SEITE_MS, SEITE_MAX, UMLEITUNGEN_MAX, KENNUNG, holeSeite, folgeUmleitung }
= await import("./helfer-tiktok.mjs");
{
const einladung = "Dein Creator Network lädt dich ein, an einer Kampagne "
+ "teilzunehmen.\nKlicke hier, um weitere Informationen zu erhalten: "
+ "https://www.tiktok.com/t/ZSbqPsFa8/";
ok(linkAusText(einladung) === "https://www.tiktok.com/t/ZSbqPsFa8/",
`aus 150 Zeichen Einladung: ${linkAusText(einladung)}`);
ok(linkAusText("Schau mal: https://www.tiktok.com/t/ZSbqPsFa8/.") === "https://www.tiktok.com/t/ZSbqPsFa8/",
"ein Satzpunkt dahinter gehört nicht zur Adresse");
ok(linkAusText("Hier (https://www.tiktok.com/t/ABC/) steht er") === "https://www.tiktok.com/t/ABC/",
"eine Klammer dahinter auch nicht");
ok(linkAusText("Ohne jede Adresse.") === null,
"ein Text ohne Link ergibt nichts");
ok(linkAusText("https://example.com/x https://www.tiktok.com/t/B/") === "https://www.tiktok.com/t/B/",
"eine fremde Adresse davor wird übersprungen");
/* DIE GEGENPROBE, DIE ZÄHLT. "tiktok.com.beispiel.de" enthält
"tiktok.com" -- wer mit einer Zeichenkettensuche arbeitet, ruft
einen fremden Server auf. */
ok(linkAusText("https://tiktok.com.beispiel.de/t/X/") === null,
"tiktok.com.beispiel.de wird NICHT genommen");
ok(istTikTok("https://tiktok.com.beispiel.de/") === false,
" und istTikTok lehnt es einzeln ebenso ab");
ok(istTikTok("https://www.tiktok.com/t/X/") === true,
" die echte Adresse dagegen nicht");
ok(linkAusText("http://www.tiktok.com/t/X/") === null,
"und ohne Verschlüsselung gar nicht");
ok(hostVon("https://www.tiktok.com/t/X/") === "www.tiktok.com",
"der Rechnername für die Meldung an den Menschen stimmt");
}
/* =====================================================================
2. DIE UMLEITUNG, STATION FÜR STATION
===================================================================== */
melde("");
melde("=== 2. Jede Station wird einzeln geprüft ===");
{
const gut = await folgeUmleitung(`${TIK}/t/gipfel`);
ok(gut.ok && gut.url.includes(`activityId=${ID_GIPFEL}`),
`der Kurzlink führt zur Kampagne (${gut.weg.length} Stationen)`);
const weg = await folgeUmleitung(`${TIK}/t/fremd`);
ok(weg.ok === false && weg.grund === "fremd" && hostVon(weg.url) === "beispiel.de",
`eine Umleitung von TikTok weg bricht ab und nennt das Ziel (${hostVon(weg.url)})`);
const kreis = await folgeUmleitung(`${TIK}/t/kreis`);
/* weg.length ist UMLEITUNGEN_MAX + 1: Der Weg beginnt mit der
eingegebenen Adresse, danach kommen fuenf Spruenge. Hier stand
zuerst "=== UMLEITUNGEN_MAX" -- geraten, nicht gezaehlt. */
ok(kreis.ok === false && kreis.grund === "zu_viele"
&& kreis.weg.length === UMLEITUNGEN_MAX + 1,
`eine Schleife endet nach ${UMLEITUNGEN_MAX} Spruengen (${kreis.weg.length} Stationen)`);
ok(UMLEITUNGEN_MAX === 5 && FRIST_SEITE_MS === 12_000 && SEITE_MAX === 3 * 1024 * 1024,
`die drei Grenzen stehen: ${UMLEITUNGEN_MAX} Sprünge, `
+ `${FRIST_SEITE_MS / 1000} s, ${SEITE_MAX / 1048576} MB`);
/* DIE KENNUNG MUSS ZWEIERLEI KOENNEN (nachgemessen 07.10.2026).
1. NACH DER HANDYFASSUNG FRAGEN. TikTok baut die Seite
serverseitig nur fuer Handys auf. Gemessen: Desktop-Kennung
17 KB ohne Daten, Android-Kennung 939 KB mit Daten -- und es
haengt allein an diesem einen Wort.
2. SAGEN, WER FRAGT. Unser Name und unsere Adresse stehen
hinten dran. Nachgemessen liefert TikTok damit dieselben
939 KB -- es kostet also nichts, ehrlich zu sein.
Beides zusammen ist keine Verkleidung: Wir wollen die
Handyfassung und sagen dabei, wer wir sind. Faellt Teil 2 weg,
ist es eine; faellt Teil 1 weg, kommt nichts an. Deshalb wird
hier BEIDES verlangt. */
ok(gesehen.length > 0 && gesehen.every((g) => g.kennung.includes("DogfatherUniverse-Workspace/")),
`alle ${gesehen.length} Abrufe nennen, wer fragt`);
ok(/workspace\.dogfather-universe\.com/.test(KENNUNG),
" mit Adresse zum Nachsehen");
ok(/\bMobile\b/.test(KENNUNG),
" und sie fragt nach der Handyfassung -- sonst kommt nur eine leere Hülle");
}
/* =====================================================================
3. FRIST UND GRÖSSE
===================================================================== */
melde("");
melde("=== 3. Ein Server, der nicht antwortet, und einer, der zu viel schickt ===");
{
/* MIT KURZER FRIST GEMESSEN, nicht mit zwölf Sekunden: Geprüft wird
der MECHANISMUS. Dass im Betrieb 12 s gelten, steht oben als
eigene Zeile -- und die Route selbst wird unten noch einmal gegen
den stummen Server geschickt, dort mit der echten Frist. */
const t0 = Date.now();
const stumm = await holeSeite(`${TIK}/falcon/stumm`, { frist: 700 });
const dauer = Date.now() - t0;
ok(stumm.ok === false && stumm.grund === "frist" && dauer < 3000,
`ein stummer Server wird nach ${dauer} ms abgebrochen (${stumm.grund})`);
const t1 = Date.now();
const riesig = await holeSeite(`${TIK}/falcon/riesig`, { maxBytes: 512 * 1024 });
const dauer1 = Date.now() - t1;
ok(riesig.ok === false && riesig.grund === "zu_gross" && riesig.angesagt === false,
`5 MB ohne angesagte Größe werden beim LESEN abgebrochen (nach ${dauer1} ms)`);
/* DIE GEGENPROBE: Derselbe Abruf unter der Grenze geht durch. Ohne
sie bewiese die Zeile darüber nur, dass irgendetwas scheitert. */
const passt = await holeSeite(`${TIK}/falcon/home?activityId=${ID_GIPFEL}`);
ok(passt.ok === true && passt.bytes > 1_000_000 && passt.text.includes("Gipfelstürmer"),
`eine echte Seite (${Math.round(passt.bytes / 1024)} KB) kommt ganz an`);
}
/* =====================================================================
4. DIE KACHEL ENTSTEHT
===================================================================== */
melde("");
melde("=== 4. Aus der Einladung wird eine Agentur-Event-Kachel ===");
let kachelId = null;
{
const t0 = Date.now();
const a = await einlesen(
"Dein Creator Network lädt dich ein, an einer Kampagne teilzunehmen.\n"
+ `Klicke hier, um weitere Informationen zu erhalten: ${TIK}/t/gipfel`);
const dauer = Date.now() - t0;
ok(a.code === 201 && Number.isFinite(a.j.id),
`HTTP ${a.code}, Kachel #${a.j.id}, in ${dauer} ms`);
ok(dauer < 15_000, `und das in unter 15 Sekunden (${(dauer / 1000).toFixed(1)} s)`);
kachelId = a.j.id;
ok(a.j.titel === "Gipfelstürmer" && a.j.von === "2026-10-04" && a.j.bis === "2026-10-07",
`die Antwort nennt „${a.j.titel}“, ${a.j.von} bis ${a.j.bis}`);
ok(Array.isArray(a.j.hinweise) && a.j.hinweise.length === 0,
`nichts fehlte (${(a.j.hinweise || []).length} Hinweise)`);
const z = dbLesen("SELECT * FROM eintraege WHERE id = ?", kachelId)[0];
ok(!!z, "der Eintrag steht in der Datenbank");
ok(z.bereich === "agentur" && z.art === "kampagne",
`Bereich ${z.bereich}, Art ${z.art}`);
/* DER TAG IN DER ZONE DER KAMPAGNE, nicht in UTC und nicht nach der
Serverzeit. `toISOString()` ergäbe hier den 03.10. */
ok(z.datum === "2026-10-04",
`Beginn ${z.datum} -- nicht 2026-10-03, wie UTC es ergäbe`);
ok(z.event_ende === "2026-10-07", `Ende ${z.event_ende}`);
ok(z.kampagne_zone === "Europe/Berlin", `Zone ${z.kampagne_zone}`);
ok(z.kampagne_id === ID_GIPFEL, `Nummer ${z.kampagne_id}`);
ok(z.kampagne_quelle === `${TIK}/t/gipfel`,
"der eingefügte Link steht unverändert dabei");
ok(!!z.kampagne_geholt_am && Math.abs(Date.parse(z.kampagne_geholt_am) - Date.now()) < 120_000,
"mit Zeitstempel");
ok(z.nur_leitung === 0, "eine Kampagne ist für alle, nicht nur für die Leitung");
ok(z.titel === "Gipfelstürmer" && (z.text || "").includes("Alpen-Vibes"),
"Titel und Einleitung stehen drin");
/* KEIN FELD LÄNGER ALS DAS FORMULAR ERLAUBT. Sonst entstünde eine
Kachel, die DogFather öffnen, aber nicht speichern kann.
JE FELD SEINE EIGENE GRENZE (berichtigt 07.10.2026): `text`
ist die Beschreibung und darf 6000 Zeichen haben (TEXT_MAX),
die zwei Eventfelder 20000 (EVENT_TEXT_MAX). Beide gegen
dieselbe Zahl zu prüfen hieße, eine der zwei Grenzen nicht
zu prüfen. */
const { EVENT_TEXT_MAX } = await import("./workspace-bereiche.js");
ok(EVENT_TEXT_MAX === 20000,
`die Grenze für die Eventfelder steht auf ${EVENT_TEXT_MAX}`);
for (const [feld, grenze] of [
["text", 6000], ["event_aufgaben", EVENT_TEXT_MAX], ["event_regeln", EVENT_TEXT_MAX],
]) {
ok((z[feld] || "").length <= grenze,
` ${feld}: ${(z[feld] || "").length} Zeichen (höchstens ${grenze})`);
}
/* ---- DER REGELTEXT PASST JETZT GANZ HINEIN (07.10.2026) ----
Hier stand das Gegenteil: „und wo gekürzt wurde, sagt die
Kachel es selbst". Das war richtig, solange das Feld 6000
Zeichen fasste -- der Regeltext von „Gipfelstürmer" ist
gemessen 7679 Zeichen lang, also fielen jedes Mal rund 1600
Zeichen echter Inhalt weg. Filipe: „ich will alle
informationen die möglich sind wenn man den link hochlädt."
Gemessen wird deshalb jetzt, dass NICHTS gekürzt wurde -- und
zwar mit Beleg, dass der Text wirklich länger als die alte
Grenze ist. Sonst wäre die Zeile auch bei einem leeren Feld
grün. */
ok((z.event_regeln || "").length > 6000,
`der Regeltext ist ${(z.event_regeln || "").length} Zeichen lang `
+ "-- mit der alten Grenze von 6000 wäre hier gekürzt worden");
ok(!(z.event_regeln || "").includes("Der Regeltext war länger"),
"und er steht jetzt vollständig in der Kachel, ohne Kürzungsfußnote");
/* DER SCHLUSS DES TEXTES IST DA. „Nicht gekürzt" ließe sich auch
behaupten, wenn nur der Anfang drinstünde; geprüft wird der
LETZTE Abschnitt, den die Kürzung als ersten weggenommen hat. */
ok(/TEILNAHME\nLänder: /.test(z.event_regeln || "")
|| /## TEILNAHME/.test(z.event_regeln || ""),
"bis zum letzten Abschnitt (TEILNAHME mit den Ländern)");
/* ---- UND DIE KÜRZUNG SELBST BLEIBT GEPRÜFT ----------------
Sie läuft im Betrieb nicht mehr (kein gemessener Regeltext
ist so lang). Eine Funktion, die nie läuft, geht beim
nächsten Umbau still kaputt -- und ausgerechnet dann, wenn
eine Kampagne mit 25000 Zeichen kommt. Also einzeln, mit
kleiner Grenze. */
{
const { kuerzen } = await import("./workspace-kampagne.js");
/* ZEHN ABSCHNITTE, rund 600 Zeichen. Beim ersten Versuch standen
hier drei Abschnitte mit 61 Zeichen und eine Grenze von 200 --
der Text passte also hinein und wurde zu Recht nicht gekürzt.
Die Prüfung meldete „sagt nicht, dass gekürzt wurde" und hatte
formal recht: Es wurde nichts gekürzt. Eine Prüfung, deren
Voraussetzung nicht erfüllt ist, misst nichts (CLAUDE.md,
03.09.2026) -- deshalb muss der Text WIRKLICH zu lang sein,
und genau das wird vorher festgestellt. */
const lang = Array.from({ length: 10 }, (x, i) =>
`## ABSCHNITT ${i + 1}\nEine Zeile mit Inhalt, Nummer ${i + 1}.`).join("\n\n");
ok(lang.length > 400,
`der Probetext ist ${lang.length} Zeichen lang, die Grenze 400 `
+ "-- er MUSS also gekürzt werden");
const kurz = kuerzen(lang, 400);
ok(kurz.length <= 400 && kurz.length < lang.length,
`kuerzen(…, 400) hält die Grenze ein (${kurz.length} statt ${lang.length})`);
ok(kurz.includes("Der Regeltext war länger"),
" und sagt, dass gekürzt wurde");
/* AN EINER ABSCHNITTSGRENZE, nicht mitten im Wort: Ein
abgeschnittenes Wort sieht nicht nach einer Grenze aus,
sondern nach einem Fehler beim Einlesen. */
const inhalt = kurz.split("\n\n*")[0];
ok(/\.$|^## [A-ZÄÖÜ]+$/m.test(inhalt.split("\n").pop() || ""),
` an einer Zeilengrenze: …${JSON.stringify(inhalt.slice(-24))}`);
ok(kuerzen(lang, 20000) === lang,
" und ein Text, der passt, bleibt unangetastet");
}
/* DER AUSZUG BEWAHRT, WAS NICHT INS FELD PASST. */
const auszug = JSON.parse(z.kampagne_daten || "{}");
ok(auszug.preise?.length === 6 && auszug.geschenke?.length === 5
&& auszug.klapptexte?.length === 2,
`die Rohdaten haben alles: ${auszug.preise?.length} Preisgruppen, `
+ `${auszug.geschenke?.length} Geschenke, ${auszug.klapptexte?.length} Klapptexte`);
}
/* =====================================================================
5. FÜNF HAKEN ZUM ABHAKEN
===================================================================== */
melde("");
melde("=== 5. Die Aufgaben werden zu Haken ===");
{
const b = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksDogi })).text);
const k = (b.eintraege || []).find((e) => e.id === kachelId);
ok(!!k, "die Kachel steht auf dem Agentur-Brett");
ok(Array.isArray(k.event_punkte) && k.event_punkte.length === 5,
`${(k.event_punkte || []).length} Haken`);
ok((k.event_punkte || [])[0]?.text === "1 Diamant = 1 Punkt",
`der erste heißt „${(k.event_punkte || [])[0]?.text}“`);
ok((k.event_punkte || []).every((p) => !/^[-–—•·*▪◦‣>+]/.test(p.text)),
"keiner trägt ein Aufzählungszeichen, das niemand getippt hat");
/* EIN HAKEN BLEIBT STEHEN. Abnahmepunkt 2 des Bauplans. */
const s = k.event_punkte[0].schluessel;
const setzen = await roh(`/workspace/api/eintrag/${kachelId}/punkt/${s}`,
{ methode: "PUT", rumpf: { an: true }, keks: keksLuna });
ok(setzen.code === 200, `ein Creator setzt einen Haken (HTTP ${setzen.code})`);
const b2 = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksLuna })).text);
const k2 = (b2.eintraege || []).find((e) => e.id === kachelId);
/* DAS FELD HEISST `meiner`, nicht `an` (berichtigt 07.10.2026).
Abgeschrieben aus workspace-bereiche.js statt geraten -- hier
stand zuerst `an`, und die Pruefung meldete einen Fehler an einer
Stelle, an der keiner war. */
ok((k2?.event_punkte || []).find((p) => p.schluessel === s)?.meiner === true,
"und er steht beim nächsten Laden noch da");
}
/* =====================================================================
6. DER WICHTIGSTE NACHWEIS: DAS SCHEMA, NICHT DAS WÖRTERBUCH
===================================================================== */
melde("");
melde("=== 6. „1 schenkende Person“ steht NICHT in der Kachel ===");
{
const z = dbLesen("SELECT * FROM eintraege WHERE id = ?", kachelId)[0];
const alles = [z.titel, z.text, z.event_aufgaben, z.event_regeln, z.kampagne_daten]
.join("\n");
/* GEGENPROBE ZUERST: Der Satz steht sehr wohl in der Seite, die wir
gelesen haben. Ohne diese Zeile wäre die nächste auch dann grün,
wenn gar nichts gelesen worden wäre. */
ok(ROH_GIPFEL.includes("schenkende Person"),
"Gegenprobe: in der gelesenen Seite kommt er vor");
ok(alles.length > 3000 && !alles.includes("schenkende"),
`in ${alles.length} Zeichen Kachelinhalt kommt er nicht vor`);
}
/* =====================================================================
7. DAS BANNER LIEGT BEI UNS
===================================================================== */
melde("");
melde("=== 7. Das Banner wird geholt, nicht verlinkt ===");
{
const dat = dbLesen("SELECT * FROM dateien WHERE eintrag_id = ?", kachelId);
ok(dat.length === 1, `${dat.length} Datei am Eintrag`);
ok(dat[0]?.typ === "image/png" && dat[0]?.groesse > 1000,
`als ${dat[0]?.typ}, ${dat[0]?.groesse} Bytes`);
ok(existsSync(join(ordner, "dateien", dat[0]?.name_datei || "x")),
"und sie liegt wirklich auf der Platte");
ok(!(dat[0]?.name_datei || "").startsWith("http"),
"gespeichert ist ein Dateiname, keine fremde Adresse");
/* SICHTBAR AUCH FÜR EINEN CREATOR. Am 03.10.2026 war genau das
schon einmal ein 404. */
/* DER LESWEG IST /api/dateien/:id/bild (berichtigt 07.10.2026).
Hier stand /api/bereich/agentur/:id/bild -- das ist der Weg zum
HOCHLADEN (POST), und als GET gibt es ihn nicht. Abgeschrieben aus
workspace-bereiche.js statt geraten. */
const bild = await roh(`/workspace/api/dateien/${dat[0]?.id}/bild`, { keks: keksLuna });
ok(bild.code === 200, `ein Creator bekommt das Banner (HTTP ${bild.code})`);
/* GEGENPROBE: ohne Anmeldung nicht. Sonst bewiese die Zeile darueber
nur, dass irgendeine Datei ausgeliefert wird. */
const ohne = await roh(`/workspace/api/dateien/${dat[0]?.id}/bild`);
ok(ohne.code === 401 || ohne.code === 403,
`Gegenprobe: ohne Anmeldung nicht (HTTP ${ohne.code})`);
/* GEGENPROBE: Eine Textdatei mit `content-type: image/png` wird
abgelehnt -- die Byteprüfung glaubt dem fremden Server nicht. */
const t = await einlesen(`Einladung: ${TIK}/t/textbanner`);
ok(t.code === 201, `eine Kampagne mit Textdatei als Banner entsteht trotzdem (HTTP ${t.code})`);
const datT = dbLesen("SELECT * FROM dateien WHERE eintrag_id = ?", t.j.id);
ok(datT.length === 0, `aber ohne Datei (${datT.length})`);
ok((t.j.hinweise || []).some((h) => h.includes("Banner")),
"und die Kachel sagt, dass das Banner fehlt");
/* DIE SCHRANKE GEGEN FREMDE ZIELE. */
ok(bildAdresseErlaubt("https://p16.tiktokcdn.com/x.png") === true
&& bildAdresseErlaubt("https://169.254.169.254/latest/") === false
&& bildAdresseErlaubt("http://p16.tiktokcdn.com/x.png") === false,
"eine Banneradresse ins eigene Netz würde abgelehnt");
}
/* =====================================================================
8. DIESELBE KAMPAGNE NUR EINMAL
===================================================================== */
melde("");
melde("=== 8. Zwei Einladungen zur selben Kampagne ergeben EINE Kachel ===");
{
const vorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE kampagne_id = ?", ID_GIPFEL)[0].n;
const zweit = await einlesen(`Noch eine Einladung: ${TIK}/t/gipfel-zweiter-versand`);
ok(zweit.code === 200 && zweit.j.schon === true && zweit.j.id === kachelId,
`ein ZWEITER Kurzlink auf dieselbe Kampagne: HTTP ${zweit.code}, `
+ `dieselbe Kachel #${zweit.j.id}`);
const nachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE kampagne_id = ?", ID_GIPFEL)[0].n;
ok(vorher === 1 && nachher === 1, `und es blieb bei ${nachher} Eintrag`);
/* WELCHE DER ZWEI SICHERUNGEN HAT GEGRIFFEN? (nachgetragen 07.10.2026)
Es gibt zwei: die Pruefung im Code (faengt den Normalfall) und den
eindeutigen Index (faengt den Wettlauf). Beide antworten gleich --
und genau daran ist eine Gegenprobe gescheitert: Nimmt man die
Pruefung im Code heraus, faengt der Index es auf, die Antwort ist
dieselbe, und die Pruefung blieb gruen.
Das war kein Schaden, aber ein blinder Fleck. Unterschieden wird
deshalb am Protokolltext: Der Index vermerkt "Wettlauf, Index".
Steht das im Normalfall da, fehlt die Pruefung im Code. */
const wie = dbLesen("SELECT detail FROM protokoll WHERE aktion = 'kampagne_schon_da'"
+ " ORDER BY id DESC LIMIT 1")[0];
ok(/-> Eintrag [0-9]+$/.test((wie?.detail || "").trim()),
`gefangen hat es die Pruefung im Code: „${wie?.detail}“`);
ok(!/Wettlauf/.test(wie?.detail || ""),
" nicht erst der Index -- sonst waere die Pruefung im Code wirkungslos");
/* GEGENPROBE: Eine ANDERE Kampagne entsteht sehr wohl. */
const andere = await einlesen(`Einladung: ${TIK}/t/beendet`);
ok(andere.code === 201 && andere.j.id !== kachelId,
`Gegenprobe: eine andere Kampagne wird angelegt (#${andere.j.id}, `
+ `„${andere.j.titel}“)`);
/* Und sie kommt aus der ZWEITEN Schemaquelle -- eine beendete
Kampagne liefert das Interaktionsschema nicht mehr. */
const az = dbLesen("SELECT kampagne_daten FROM eintraege WHERE id = ?", andere.j.id)[0];
ok(JSON.parse(az.kampagne_daten).gelesen_aus === "value.schema",
" gelesen aus value.schema, weil sie beendet ist");
/* DER EINDEUTIGE INDEX fängt den Wettlauf, den der Code nicht sieht. */
const idx = dbLesen("SELECT sql FROM sqlite_master WHERE name = 'idx_eintraege_kampagne'");
ok(idx.length === 1 && /UNIQUE/i.test(idx[0].sql) && /kampagne_id IS NOT NULL/i.test(idx[0].sql),
"und ein eindeutiger Index steht dahinter, nicht nur die Prüfung im Code");
}
/* =====================================================================
9. DREI VERSCHIEDENE, VERSTÄNDLICHE MELDUNGEN
===================================================================== */
melde("");
melde("=== 9. Was schiefgehen kann, wird verständlich gesagt ===");
{
const vorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege")[0].n;
const ohneLink = await einlesen("Hier steht gar keine Adresse drin.");
ok(ohneLink.code === 400 && /keine TikTok-Adresse/i.test(ohneLink.j.fehler || ""),
`kein Link: HTTP ${ohneLink.code} — „${ohneLink.j.fehler}“`);
const fremd = await einlesen(`Klick: ${TIK}/t/fremd`);
ok(fremd.code === 400 && /beispiel\.de/.test(fremd.j.fehler || ""),
`fremdes Ziel: HTTP ${fremd.code} — „${fremd.j.fehler}“`);
const huelle = await einlesen(`Klick: ${TIK}/t/huelle`);
ok(huelle.code === 502 && /Hülle/.test(huelle.j.fehler || ""),
`leere Hülle: HTTP ${huelle.code} — „${(huelle.j.fehler || "").slice(0, 95)}…“`);
ok(/nicht an dir/.test(huelle.j.fehler || ""),
" und sagt ausdrücklich, dass es nicht am Menschen davor liegt");
ok(!/umgebaut/.test(huelle.j.fehler || ""),
" ohne ihn auf die falsche Suche zu schicken");
const kaputt = await einlesen(`Klick: ${TIK}/t/kaputt`);
ok(kaputt.code === 502 && /__MODERN_ROUTER_DATA__/.test(kaputt.j.fehler || ""),
`umgebaute Seite: HTTP ${kaputt.code} — „${(kaputt.j.fehler || "").slice(0, 90)}…“`);
const ohneEnde = await einlesen(`Klick: ${TIK}/t/ohneende`);
ok(ohneEnde.code === 502 && /Ende/.test(ohneEnde.j.fehler || ""),
`ohne Enddatum: HTTP ${ohneEnde.code} — „${(ohneEnde.j.fehler || "").slice(0, 90)}…“`);
/* DIE DREI MELDUNGEN SIND WIRKLICH VERSCHIEDEN. Eine Prüfung, die
nur „es kam ein Fehler“ sagt, wäre auch grün, wenn überall
dasselbe stünde. */
const saetze = new Set([ohneLink.j.fehler, fremd.j.fehler,
kaputt.j.fehler, ohneEnde.j.fehler, huelle.j.fehler]);
ok(saetze.size === 5, `${saetze.size} verschiedene Sätze für fünf Fälle`);
ok([...saetze].every((s) => s && !/\b50\d\b/.test(s) && s.length > 25),
"keiner davon sagt „Fehler 502“, alle sind ganze Sätze");
const nachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege")[0].n;
ok(vorher === nachher,
`und keiner der vier hat eine halbe Kachel hinterlassen (${vorher} = ${nachher})`);
/* OHNE PREISE: 201 MIT HINWEIS, nicht 502. Unvollständig ist etwas
anderes als falsch. */
const op = await einlesen(`Klick: ${TIK}/t/ohnepreise`);
ok(op.code === 201 && (op.j.hinweise || []).some((h) => /Preis/.test(h)),
`ohne Preise: HTTP ${op.code} mit Hinweis „${(op.j.hinweise || [])[0]}“`);
const opZ = dbLesen("SELECT event_regeln FROM eintraege WHERE id = ?", op.j.id)[0];
ok(/Preis/.test(opZ.event_regeln || ""),
"und die Kachel selbst sagt, dass etwas nachzutragen ist");
}
/* =====================================================================
9b. DER AGENTURLINK — UND WOHIN DIE KACHEL GEHÖRT (07.10.2026)
Filipe: „das sind die von der agentur selbst. die krieg ich so,
perfektioniere das und auch das ich auswählen kann wo es
hingehört, zu tiktok oder der agentur.“
===================================================================== */
melde("");
melde("=== 9b. Agenturlink und Seitenwahl ===");
{
/* AUS DEM ECHTEN MODUL, nicht nachgebaut: Geprüft wird die
Funktion, die im Betrieb läuft. Eine Abschrift hier wäre eine
zweite Fassung derselben Regel -- und die läuft auseinander. */
const { nummerAusAdresse, istCreatorNetwork } = await import("./workspace-kampagne.js");
/* ---- Die Nummer in BEIDEN Schreibweisen ------------------- */
ok(nummerAusAdresse(`${TIK}/falcon/home?activityId=${ID_GIPFEL}`) === ID_GIPFEL,
"activityId wird gelesen (die Kampagnenseite)");
ok(nummerAusAdresse(`${TIK}/tcn/activity?activity_id=${ID_TCN}`) === ID_TCN,
"activity_id ebenso (das Creator Network)");
/* GEGENPROBE: Etwas, das keine Nummer ist, gibt auch keine --
sonst wäre „beide Schreibweisen“ auch bei einer Funktion
grün, die immer irgendetwas zurückgibt. */
ok(nummerAusAdresse(`${TIK}/tcn/activity?activity_id=hallo`) === null,
" und Buchstaben sind keine Nummer");
ok(nummerAusAdresse(`${TIK}/tcn/activity`) === null,
" und ohne Angabe kommt nichts");
/* ---- Die Erkennung selbst, mit beiden Richtungen ---------- */
ok(istCreatorNetwork(`${TIK}/tcn/activity?activity_id=${ID_TCN}`),
"eine /tcn/-Adresse wird als Creator Network erkannt");
/* GEGENPROBE: Die echte Kampagnenseite NICHT -- sonst wäre jede
Einladung ein Agenturlink und nichts ließe sich mehr einlesen. */
ok(!istCreatorNetwork(`${TIK}/falcon/campaign/fusion_cosmic_runtime/home?activityId=${ID_GIPFEL}`),
" die echte Kampagnenseite dagegen nicht");
ok(!istCreatorNetwork(`${TIK}/tcnx/activity`),
" und ein Pfad, der nur so anfängt, auch nicht");
/* ---- Der Agenturlink wird ERKANNT, nicht blind geholt ----- */
const vorher = abrufe;
const tcn = await einlesen(`Hier die Einladung: ${TIK}/t/agentur`);
ok(tcn.code === 422,
`ein Creator-Network-Link gibt 422, nicht 502 (HTTP ${tcn.code})`);
ok(/Creator Network/.test(tcn.j.fehler || ""),
`und sagt, was für eine Seite das ist: „${tcn.j.fehler}“`);
/* DIE MELDUNG SAGT, WAS ZU TUN IST. Eine Ursache ohne Ausweg
lässt den Menschen davor stehen. */
ok(/von Hand/.test(tcn.j.fehler || ""),
" und was man stattdessen tun kann");
/* UND DIE SEITE WIRD GAR NICHT ERST GEHOLT. 233 KB für eine
Hülle, von der wir schon wissen, dass nichts drinsteht --
und danach eine falsche Ursache melden. Gezählt werden die
Abrufe beim nachgebauten Dienst: die Weiterleitung ja, die
Seite selbst nicht. */
const geholt = abrufe - vorher;
ok(geholt <= 2,
`nur der Weg wurde verfolgt, die Hülle nicht geholt (${geholt} Abrufe)`);
/* ---- DIE SEITENWAHL BEIM EINLESEN ------------------------- */
const mitSeite = await roh("/workspace/api/kampagne/einlesen", {
methode: "POST", keks: keksDogi, wirt: HOST,
rumpf: { text: `Klick: ${TIK}/t/glowup`, seite: "agentur" },
});
const neuId = JSON.parse(mitSeite.text || "{}").id;
ok(mitSeite.code === 201 && Number.isInteger(neuId),
`eine Kampagne mit Seitenwahl entsteht (HTTP ${mitSeite.code}, #${neuId})`);
const gz = dbLesen("SELECT kampagne_id, kampagne_seite FROM eintraege WHERE id = ?", neuId)[0];
ok(gz?.kampagne_seite === "agentur",
`und steht auf der gewählten Seite: ${JSON.stringify(gz?.kampagne_seite)}`);
/* GEGENPROBE: Sie kommt trotzdem von TikTok -- die Herkunft wird
nicht verfälscht, nur die Zuordnung gewählt. Das ist der
ganze Unterschied zwischen beiden Angaben. */
ok(gz?.kampagne_id === ID_GLOWUP,
`die Herkunft bleibt unberührt (${gz?.kampagne_id})`);
/* OHNE WAHL BLEIBT ES NULL -- „niemand hat gewählt“ ist etwas
anderes als „links“. Die zuerst eingelesene Kachel hatte keine
Wahl dabei. */
const alt = dbLesen("SELECT kampagne_seite FROM eintraege WHERE id = ?", kachelId)[0];
ok(alt?.kampagne_seite === null,
`ohne Wahl bleibt die Seite offen (${JSON.stringify(alt?.kampagne_seite)})`);
/* Ein erfundener Wert wird NICHT stillschweigend übernommen. */
const quatsch = await roh(`/workspace/api/bereich/agentur/${kachelId}`, {
methode: "PATCH", keks: keksDogi, wirt: HOST,
rumpf: { kampagne_seite: "mond" },
});
ok(quatsch.code === 400,
`ein erfundener Wert wird abgelehnt (HTTP ${quatsch.code})`);
const unberuehrt = dbLesen("SELECT kampagne_seite FROM eintraege WHERE id = ?", kachelId)[0];
ok(unberuehrt?.kampagne_seite === null,
" und die Kachel bleibt, wie sie war");
/* ---- NACHTRÄGLICH VERSCHIEBEN ----------------------------- */
const schieben = await roh(`/workspace/api/bereich/agentur/${kachelId}`, {
methode: "PATCH", keks: keksDogi, wirt: HOST,
rumpf: { kampagne_seite: "agentur" },
});
ok(schieben.code === 200, `die Seite lässt sich ändern (HTTP ${schieben.code})`);
const nachher = dbLesen("SELECT kampagne_seite FROM eintraege WHERE id = ?", kachelId)[0];
ok(nachher?.kampagne_seite === "agentur",
`und steht jetzt rechts (${nachher?.kampagne_seite})`);
/* ZURÜCKNEHMEN GEHT AUCH. Eine Wahl ohne Weg zurück ist beim
ersten Versehen ein Dauerzustand. */
const zurueck = await roh(`/workspace/api/bereich/agentur/${kachelId}`, {
methode: "PATCH", keks: keksDogi, wirt: HOST,
rumpf: { kampagne_seite: "" },
});
const offen = dbLesen("SELECT kampagne_seite FROM eintraege WHERE id = ?", kachelId)[0];
ok(zurueck.code === 200 && offen?.kampagne_seite === null,
`und wieder auf automatisch (${JSON.stringify(offen?.kampagne_seite)})`);
/* ---- EIN CREATOR DARF DAS NICHT --------------------------- */
/* Wo eine Kachel steht, ist eine Entscheidung über das Brett,
nicht über den eigenen Eintrag. */
const alsCreator = await roh(`/workspace/api/bereich/agentur/${kachelId}`, {
methode: "PATCH", keks: keksLuna, wirt: HOST,
rumpf: { kampagne_seite: "tiktok" },
});
const danach = dbLesen("SELECT kampagne_seite FROM eintraege WHERE id = ?", kachelId)[0];
ok(danach?.kampagne_seite === null,
`ein Creator verschiebt nichts (HTTP ${alsCreator.code}, `
+ `Seite ${JSON.stringify(danach?.kampagne_seite)})`);
}
/* =====================================================================
10. EIN STUMMER SERVER HÄNGT DIE ROUTE NICHT AUF
===================================================================== */
melde("");
melde("=== 10. Die echte Frist am echten Weg ===");
{
const t0 = Date.now();
const a = await einlesen(`Klick: ${TIK}/t/stumm`);
const dauer = Date.now() - t0;
ok(a.code === 502 && /12 s/.test(a.j.fehler || ""),
`HTTP ${a.code} nach ${(dauer / 1000).toFixed(1)} s — „${a.j.fehler}“`);
ok(dauer >= 11_000 && dauer < 20_000,
`die Frist greift bei 12 s, nicht früher und nicht nie (${(dauer / 1000).toFixed(1)} s)`);
}
/* =====================================================================
11. WER DARF DAS
===================================================================== */
melde("");
melde("=== 11. Kampagnen anlegen dürfen DogFather und die Manager ===");
{
const creator = await einlesen(`Klick: ${TIK}/t/gipfel`, keksLuna);
ok(creator.code === 403, `ein Creator darf nicht (HTTP ${creator.code})`);
ok(/DogFather und die Manager/.test(creator.j.fehler || ""),
`und erfährt warum: „${creator.j.fehler}“`);
const ohne = await roh("/workspace/api/kampagne/einlesen", { rumpf: { text: "x" } });
ok(ohne.code === 401, `ohne Anmeldung gar nicht (HTTP ${ohne.code})`);
/* GEGENPROBE: Eine Managerin darf -- sonst bewiese die Zeile oben
nur, dass die Route überhaupt ablehnt. */
const mara = await einlesen(`Klick: ${TIK}/t/gipfel`, keksMara);
ok(mara.code === 200 && mara.j.schon === true,
`eine Managerin darf (HTTP ${mara.code}, Kampagne war schon da)`);
/* UND DIE OBERFLÄCHE FRAGT DIESELBE REGEL. Keine Rollenliste im
Browser -- am 10.09.2026 widersprachen sich zwei solche Listen
drei Zeilen auseinander. */
const ichDogi = JSON.parse((await roh("/workspace/api/ich", { keks: keksDogi })).text);
const ichLuna = JSON.parse((await roh("/workspace/api/ich", { keks: keksLuna })).text);
const ichMara = JSON.parse((await roh("/workspace/api/ich", { keks: keksMara })).text);
ok(ichDogi.darf_kampagne_einlesen === true && ichMara.darf_kampagne_einlesen === true
&& ichLuna.darf_kampagne_einlesen === false,
"/api/ich sagt der Oberfläche dasselbe wie die Schranke der Route");
/* UND AUF DER CREW-ADRESSE SAGT SIE NEIN. Das ist der Fall, den
es in der Wirklichkeit gibt: DogFather ist dort ebenfalls
`admin`, und ohne diese Antwort stuende der Kasten im Rudel. */
const ichCrew = JSON.parse((await roh("/workspace/api/ich",
{ keks: keksDogiCrew, wirt: CREW_HOST })).text);
ok(ichCrew.rolle === "admin" && ichCrew.darf_kampagne_einlesen === false,
`auf der Crew-Adresse sagt sie nein (Rolle ${ichCrew.rolle},`
+ ` darf ${ichCrew.darf_kampagne_einlesen})`);
/* FREMDE HERKUNFT. Die Wache steht in index.js, VOR diesem Router. */
const fremd = await new Promise((f, s) => {
const daten = Buffer.from(JSON.stringify({ text: "x" }));
const a = request({
host: "127.0.0.1", port: PORT, path: "/workspace/api/kampagne/einlesen",
method: "POST",
headers: { Host: HOST, Origin: "https://boeswillig.example", Cookie: keksDogi,
"Content-Type": "application/json", "Content-Length": daten.length },
}, (r) => { r.resume(); r.on("end", () => f({ code: r.statusCode })); });
a.on("error", s); a.write(daten); a.end();
});
ok(fremd.code === 403, `eine Anfrage von fremder Seite wird abgewiesen (HTTP ${fremd.code})`);
}
/* =====================================================================
12. DAS ANDERE HAUS
===================================================================== */
melde("");
melde("=== 12. Team Dogi bleibt unberührt ===");
{
const crewVorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE haus = 'crew'")[0].n;
ok(crewVorher === 1,
`es gibt ${crewVorher} Crew-Eintrag -- gezählt wird über etwas, nicht über nichts`);
const alle = dbLesen("SELECT haus, COUNT(*) AS n FROM eintraege"
+ " WHERE kampagne_id IS NOT NULL GROUP BY haus");
ok(alle.length === 1 && alle[0].haus === "agentur",
`alle ${alle[0]?.n} Kampagnen liegen im Haus „${alle[0]?.haus}“`);
/* VON DER CREW-ADRESSE AUS: nicht sichtbar. */
const crewBrett = await roh("/workspace/api/bereich/agentur",
{ keks: keksDogiCrew, wirt: CREW_HOST });
const sichtbar = JSON.parse(crewBrett.text || "{}").eintraege || [];
ok(!sichtbar.some((e) => e.id === kachelId),
`die Kampagne erscheint auf der Crew-Adresse NICHT (${sichtbar.length} Einträge dort)`);
/* GEGENPROBE: Auf der Agentur-Adresse sehr wohl. */
const wsBrett = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksDogi })).text);
ok((wsBrett.eintraege || []).some((e) => e.id === kachelId),
"Gegenprobe: auf der Agentur-Adresse schon");
/* UND VON DER CREW-ADRESSE AUS LAESST SIE SICH GAR NICHT ANLEGEN.
DogFather ist in beiden Haeusern `admin` -- die Rollenfrage
allein laesst ihn also durch. Ohne diese Schranke bekaeme die
Kampagne das Haus "crew", und die Trennung waere an genau der
Stelle unterlaufen, an der niemand nachsieht. */
const vonCrew = await einlesen(`Klick: ${TIK}/t/gipfel`, keksDogiCrew, CREW_HOST);
ok(vonCrew.code === 403,
`von der Crew-Adresse: HTTP ${vonCrew.code} — „${vonCrew.j.fehler}“`);
ok(/[Aa]gentur/.test(vonCrew.j.fehler || ""),
" und die Meldung sagt, wo es hingehoert");
const crewNachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE haus = 'crew'")[0].n;
ok(crewVorher === crewNachher,
`und die Zahl der Crew-Einträge ist unverändert (${crewVorher} = ${crewNachher})`);
}
/* =====================================================================
12b. TEILEN — eine offene Seite, und nur so viel wie nötig
===================================================================== */
melde("");
melde("=== 12b. Ein Event nach draußen teilen ===");
{
/* Der offene Weg laeuft ohne Anmeldung und ohne Keks -- so, wie
Discord ihn abruft. `roh()` schickt zwar einen Host-Kopf mit,
aber keinen Keks, wenn keiner uebergeben wird. */
const offen = (pfad) => roh(pfad, { wirt: HOST });
/* ---- Vorher gibt es nichts ---------------------------------- */
const vorher = await offen("/e/gibtesnichtgibtesnicht");
ok(vorher.code === 404, `ein erfundener Schlüssel gibt 404 (${vorher.code})`);
ok(!/war mal|zurückgezogen|zurueckgezogen/i.test(vorher.text),
" und verrät nicht, ob es ihn einmal gab");
/* ---- Nur die Leitung darf teilen ----------------------------- */
const alsLuna = await roh(`/workspace/api/eintrag/${kachelId}/teilen`,
{ methode: "POST", keks: keksLuna, rumpf: {} });
ok(alsLuna.code === 403, `ein Creator darf nicht teilen (${alsLuna.code})`);
/* ---- DogFather teilt ------------------------------------------ */
const t1 = await roh(`/workspace/api/eintrag/${kachelId}/teilen`,
{ methode: "POST", keks: keksDogi, rumpf: {} });
const j1 = JSON.parse(t1.text || "{}");
ok(t1.code === 200 && /\/e\/[A-Za-z0-9_-]{20,}$/.test(j1.weg || ""),
`der Link entsteht: ${String(j1.weg).replace(/^https?:\/\/[^/]+/, "…")}`);
/* ==== UND EIN FERTIGER BEITRAG FÜR DISCORD (08.10.2026) ======
Filipe: „beim teilen wird auch nur der link geteilt, und ich
will dass wenn ich es teile im discord der agentur, dass ein
banner und ein kompletter fertiger text für die creator
entsteht und nicht einfach der link kopiert wird damit die
leute drauf gehen. das ist kake.“
Gemessen wird nicht „es kommt ein Text“, sondern dass der Text
die vier Dinge trägt, wegen denen man ihn schreibt: Titel,
Zeitraum, Aufgaben, Link — und dass Discord ihn annimmt. */
const beitrag = String(j1.text || "");
ok(beitrag.length > 200,
`der fertige Beitrag kommt mit (${beitrag.length} Zeichen)`);
/* DISCORD NIMMT 2000 ZEICHEN JE NACHRICHT. Eine längere wird
beim Einfügen abgeschnitten — mitten im Wort, und niemand
merkt es, bis ein Creator nachfragt. */
ok(beitrag.length <= 2000,
`und bleibt unter der Discord-Grenze (${beitrag.length} von 2000)`);
ok(beitrag.includes("Gipfelstürmer"), "der Titel steht drin");
ok(/04\.10\.2026/.test(beitrag), "der Zeitraum steht drin");
ok(beitrag.includes("1 Diamant = 1 Punkt"),
"die Aufgaben stehen drin — genau das, was beim Link fehlte");
ok(/Ahorn-Welt/.test(beitrag) && /12\.000 Coins/.test(beitrag),
"die Geschenke mit Coinpreis ebenso");
/* DER LINK STEHT GANZ AM ENDE UND NACKT.
Am Ende, weil Discord die Vorschau UNTER die Nachricht setzt --
steht er oben, schiebt das Banner den Text aus dem Blick.
Nackt, weil spitze Klammern die Vorschau unterdrücken: dann
gäbe es genau das Banner nicht, um das es geht. */
ok(beitrag.trimEnd().endsWith(j1.weg),
`der Link steht ganz am Ende (…${beitrag.trimEnd().slice(-34)})`);
ok(!beitrag.includes(`<${j1.weg}>`),
" und nackt, nicht in spitzen Klammern — sonst kein Banner");
/* DISCORD-AUSZEICHNUNG: Überschriften und Aufzählungen, damit es
in Discord wie ein Aushang aussieht und nicht wie eine
Textwand. */
ok(/^# /m.test(beitrag) && /^## /m.test(beitrag),
"Überschriften sind gesetzt (# und ##)");
ok(/^- /m.test(beitrag), "und die Aufgaben als Aufzählung");
/* EIN BINDESTRICH MITTEN IM WORT WIRD NICHT ENTWERTET. Beim
ersten Blick auf den Beitrag stand dort „Ahorn\\-Welt“ und
„4\\-wöchige“ — die Rückstriche sichtbar im Text. In Discord
ist `-` nur am Zeilenanfang eine Aufzählung. */
ok(!/\w\\-\w/.test(beitrag),
"kein Rückstrich mitten im Wort");
/* ---- DIE GRENZE WIRD AN EINEM LANGEN BEITRAG GEMESSEN -------
GEFUNDEN DURCH DIE GEGENPROBE (08.10.2026): Als Sabotage wurde
`DISCORD_MAX` von 2000 auf 99999 gesetzt -- und die Pruefung
blieb GRUEN. Der Beitrag von „Gipfelstuermer" ist 1230 Zeichen
lang; ob die Grenze bei 2000 oder bei 99999 steht, aendert daran
nichts. `beitrag.length <= 2000` hat also nie die GRENZE
geprueft, sondern nur diesen einen Beitrag.
Genau die Sorte gruener Haken, die nichts misst (CLAUDE.md,
01.09.2026). Geprueft wird deshalb mit einem Beitrag, der ohne
Kuerzung sicher zu lang WAERE -- und daneben, dass das
Wesentliche trotzdem stehen bleibt. Ein Beitrag, der die Grenze
einhaelt, indem er alles wegwirft, waere auch keine Loesung. */
{
const { discordText } = await import("./workspace-teilen.js");
const satz = "Gipfelstürmer ist eine TikTok LIVE Kampagne, in welcher "
+ "Creator*innen Punkte sammeln können, wenn sie Diamanten sammeln. ";
const lang = {
titel: "Ein sehr langer Kampagnentitel zum Messen",
zeitraum: "04.10.2026 – 07.10.2026",
text: satz.repeat(40),
basis: "https://beispiel.de/e/abc",
vonTikTok: true,
aufgaben: Array.from({ length: 12 }, (x, i) =>
`Aufgabe ${i + 1} mit einem recht ausführlichen Text dahinter`),
gaben: Array.from({ length: 8 }, (x, i) =>
({ text: `Geschenk ${i + 1}`, coins: (i + 1) * 999, zusatz: null })),
abschnitte: [{ art: "rangliste", titel: "RANGLISTE", zeilen: [
{ art: "punkt", text: "Gewertet wird vom 04.10. bis zum 07.10." }] }],
};
/* ERST BELEGEN, DASS DER FALL UEBERHAUPT EINTRITT. Ohne diese
Zeile misst die naechste wieder nichts. */
const ungekuerzt = satz.repeat(40).length;
ok(ungekuerzt > 2000,
`der Probetext allein ist ${ungekuerzt} Zeichen lang -- er MUSS gekürzt werden`);
const kurz = discordText(lang);
ok(kurz.length <= 2000,
`und der Beitrag bleibt bei ${kurz.length} Zeichen`);
ok(kurz.length < ungekuerzt,
" es wurde also wirklich gekürzt, nicht nur gemessen");
/* WAS IMMER STEHEN BLEIBT: Titel, Zeitraum, Aufgaben, Link.
Das sind die vier Dinge, wegen denen man den Beitrag
schreibt. */
ok(kurz.includes("Ein sehr langer Kampagnentitel"), " der Titel bleibt");
ok(/04\.10\.2026/.test(kurz), " der Zeitraum bleibt");
ok(/^- Aufgabe 1 /m.test(kurz), " die Aufgaben bleiben");
ok(kurz.trimEnd().endsWith("https://beispiel.de/e/abc"),
" und der Link steht am Ende");
/* DER AEUSSERSTE FALL: ein Titel, der allein fast die Grenze
sprengt. Auch dann muss der Link dastehen -- ohne ihn ist der
Beitrag eine Sackgasse. */
const riesig = discordText({ ...lang, titel: "A".repeat(1500) });
ok(riesig.length <= 2000 && riesig.trimEnd().endsWith("https://beispiel.de/e/abc"),
`selbst bei einem 1500-Zeichen-Titel: ${riesig.length} Zeichen, Link am Ende`);
}
/* UND NICHTS ÜBER UNS. Dieselbe Grenze wie auf der offenen
Seite: Was die Kampagne sagt, geht hinaus — wer wir sind,
nicht. Der Beitrag landet in einem Kanal, den auch Fremde
lesen können. */
const verbotenImBeitrag = [
["ein Name", "Dogfather"], ["eine Rolle", "admin"],
["die Kampagnennummer", ID_GIPFEL],
["ein Hinweis an DogFather", "bitte nachtragen"],
["ein Feldname", "erstellt_von"],
].filter(([, w]) => beitrag.includes(w));
ok(verbotenImBeitrag.length === 0,
"nichts Internes im Beitrag"
+ (verbotenImBeitrag.length
? `: ${verbotenImBeitrag.map((x) => x[0]).join(", ")}` : ""));
/* DERSELBE SCHLUESSEL BEIM ZWEITEN DRUCK. Ein neuer wuerde den
Link still ins Leere laufen lassen, den jemand schon gepostet
hat. */
const t2 = await roh(`/workspace/api/eintrag/${kachelId}/teilen`,
{ methode: "POST", keks: keksDogi, rumpf: {} });
const j2 = JSON.parse(t2.text || "{}");
ok(j2.weg === j1.weg && j2.schon === true,
"der zweite Druck gibt denselben Link, keinen neuen");
/* UND DEN BEITRAG GLEICH MIT. Wer ihn ein zweites Mal in einen
anderen Kanal stellen will, soll nicht erst zurückziehen und
neu teilen müssen. */
ok(String(j2.text || "") === beitrag,
`und denselben Beitrag (${String(j2.text || "").length} Zeichen)`);
const schluessel = String(j1.weg).split("/e/")[1];
/* ---- Die offene Seite ----------------------------------------- */
const seiteOffen = await offen(`/e/${schluessel}`);
ok(seiteOffen.code === 200, `die Seite ist ohne Anmeldung da (${seiteOffen.code})`);
ok(/<meta property="og:title" content="Gipfelstürmer"/.test(seiteOffen.text),
"og:title trägt den Titel — daraus baut Discord die Überschrift");
ok(/<meta property="og:description" content="[^"]*2026/.test(seiteOffen.text),
"og:description trägt den Zeitraum");
ok(/<meta property="og:image" content="[^"]+\/bild"/.test(seiteOffen.text),
"og:image zeigt auf das Banner");
ok(/twitter:card" content="summary_large_image"/.test(seiteOffen.text),
" und zwar als großes Bild, nicht als Briefmarke");
ok(/noindex/.test(seiteOffen.text)
&& /noindex/.test(String(seiteOffen.kopf["x-robots-tag"] || "")),
"Suchmaschinen bleiben draußen — im HTML UND im Kopf der Antwort");
/* ---- WAS DIE KAMPAGNE SAGT, GEHT HINAUS -- WER WIR SIND, NICHT
DIE GRENZE IST AM 07.10.2026 VERSCHOBEN WORDEN, auf Filipes
Ansage: „auch wenn man teilt soll es einfach perfekt sein. man
soll alle infos sehen die man braucht."
Vorher standen „eine Aufgabe" und „der Regeltext" in dieser
Liste, und das war zu eng gedacht. Diese Angaben stammen von
einer OEFFENTLICHEN TikTok-Seite; sie weiterzugeben verraet
nichts ueber uns, und ohne sie ist der Link eine Einladung,
die nicht sagt, wozu.
DIE GRENZE LIEGT BEIM GEGENSTAND, NICHT BEI DER MENGE: nichts
ueber Personen (Name, Rolle, Haken), nichts ueber die
Verwaltung (Feldnamen, Haus, Keks), nichts aus der Buchhaltung
(Kampagnennummer, Steckbrief). Jede dieser Zeilen steht hier
weiterhin -- die Liste ist kuerzer geworden, nicht weicher.
UND NEU: KEINE HINWEISZEILE. „Die Preise konnten nicht gelesen
werden — bitte nachtragen" ist eine Notiz AN DOGFATHER. Auf
einer Seite, die er in Discord postet, stuende damit vor
seinen Creatorn, dass seine Kachel unfertig ist. Geprueft wird
das weiter unten an einer Kampagne, die wirklich einen Hinweis
hat -- an „Gipfelstuermer" waere es ein gruener Haken ohne
Messung, denn die hat gar keinen. */
const verboten = [
["ein Name", "Dogfather"],
["eine Rolle", "admin"],
["die Kampagnennummer", ID_GIPFEL],
["die Kurznummer", "1140116740"],
["ein Feldname aus der Datenbank", "erstellt_von"],
["die Hausangabe", "\"haus\""],
["ein Keks", "workspace_sitzung"],
["ein Hinweis an DogFather", "bitte nachtragen"],
["die Kuerzungsfussnote", "länger als das Feld fasst"],
];
/* NUR DER KOERPER, NICHT DAS STILBLATT (berichtigt 07.10.2026).
Hier stand zuerst schlicht "agentur" als verbotenes Wort -- und
die Pruefung wurde rot, weil im eingebetteten Stil die Regel
[data-quelle="agentur"] steht. Das ist eine Auswahlregel, keine
Auskunft ueber ein Haus. Eine Pruefung, die ein Wort im
Stilblatt fuer ein Leck haelt, meldet jeden Tag einen Schaden,
den es nicht gibt -- und wird nach dem zweiten Mal weggeklickt. */
const ohneStil = seiteOffen.text.split('<style')[0] + (seiteOffen.text.split('</style>')[1] || '');
const durchgerutscht = verboten.filter(([, wort]) => ohneStil.includes(wort));
ok(durchgerutscht.length === 0,
`nichts Internes auf der offenen Seite`
+ (durchgerutscht.length ? `: ${durchgerutscht.map((x) => x[0]).join(", ")}` : ""));
/* GEGENPROBE: Das, was hinausgehen SOLL, steht auch wirklich da --
sonst wäre die Zeile darüber auch bei einer leeren Seite grün. */
ok(seiteOffen.text.includes("Gipfelstürmer")
&& /04\.10\.2026/.test(seiteOffen.text),
"Gegenprobe: Titel und Zeitraum stehen sehr wohl darauf");
/* ---- UND JETZT ALLES, WAS MAN BRAUCHT (07.10.2026) ---------
Gemessen wird nicht „die Seite ist lang", sondern dass jede
der vier Auskünfte wirklich dasteht: Aufgaben, Geschenke mit
Preis, Überschriften, gegliederte Unterstufen. */
const koerper = ohneStil;
ok(/<h2 class="k__schild2">Aufgaben<\/h2>/.test(koerper)
&& koerper.includes("1 Diamant = 1 Punkt"),
"die Aufgaben stehen auf der Seite");
const wieVieleAufgaben = (koerper.match(/<li>/g) || []).length;
ok(wieVieleAufgaben >= 5,
`und zwar alle fünf, nicht nur eine (${wieVieleAufgaben} Zeilen insgesamt)`);
ok(/Diese Geschenke zählen/.test(koerper) && koerper.includes("Ahorn-Welt"),
"die Geschenke stehen darauf");
ok(/12\.000 Coins/.test(koerper),
"mit Coinpreis und Tausenderpunkt");
ok(/<p class="k__woher">KAMPAGNENGESCHENKE/.test(koerper),
"unter der Überschrift, die die Kampagne dafür vorgesehen hat");
const titel = [...koerper.matchAll(/<h3 class="k__titel">([^<]+)<\/h3>/g)]
.map((m) => m[1]);
ok(titel.includes("BELOHNUNGEN") && titel.includes("TEILNAHMEBEDINGUNGEN"),
`die Abschnitte sind als Überschriften gesetzt: ${JSON.stringify(titel.slice(0, 8))}`);
ok(titel.includes("Superstars") && titel.includes("1. Liga"),
"auch die Ligen aus dem Klapptext");
ok(/data-stufe="3"/.test(koerper) && /data-stufe="2"/.test(koerper),
"und sie stehen auf zwei Stufen, nicht alle gleichrangig");
/* DIE VORSCHAU IN DISCORD BLEIBT KURZ. Discord zeigt rund 300
Zeichen; eine Beschreibung von 7000 Zeichen wäre dort
abgeschnitten und hier nutzlos. Die LANGE Fassung steht im
Körper, die kurze im Kopf -- das ist der ganze Trick. */
const beschr = /<meta property="og:description" content="([^"]*)"/.exec(seiteOffen.text);
/* 300 IST DIE ZAHL, DIE ZAEHLT -- so viel zeigt Discord.
Beim ersten Lauf kam 304 heraus, weil die Textgrenze (280)
den Zeitraum davor nicht mitrechnete. */
ok(beschr && beschr[1].length <= 300,
`og:description bleibt unter 300 Zeichen (${beschr?.[1].length})`);
/* UND SIE IST NICHT LEER. „Kurz" ist auch eine leere
Beschreibung -- dann zeigte Discord nur den Titel. */
ok(beschr && beschr[1].length > 40,
` und sagt wirklich etwas (${beschr?.[1].length} Zeichen)`);
ok(koerper.length > 3000,
`der Körper trägt dagegen alles (${koerper.length} Zeichen)`);
/* ---- EINE KAMPAGNE MIT HINWEIS ------------------------------
Das ist die Messung, die „keine Notiz an DogFather geht
hinaus" überhaupt erst zu einer Messung macht:
„Gipfelstürmer" hat null Hinweise, „ohne Preise" hat einen.
Ohne diesen Abschnitt wäre die Zeile oben ein grüner Haken
über einem leeren Feld (CLAUDE.md, 01.09.2026). */
const opId = dbLesen("SELECT id FROM eintraege WHERE kampagne_id = ?", ID_OHNEPREISE)[0]?.id;
ok(!!opId, `die Kampagne ohne Preise liegt in der Datenbank (#${opId})`);
const opRegeln = dbLesen("SELECT event_regeln FROM eintraege WHERE id = ?", opId)[0];
ok(/bitte nachtragen|keine Preisliste/.test(opRegeln?.event_regeln || ""),
"und ihre Kachel trägt wirklich einen Hinweis (sonst misst das Folgende nichts)");
const opT = await roh(`/workspace/api/eintrag/${opId}/teilen`,
{ methode: "POST", keks: keksDogi, rumpf: {} });
const opS = String(JSON.parse(opT.text || "{}").weg || "").split("/e/")[1];
ok(!!opS, `sie lässt sich teilen (HTTP ${opT.code})`);
const opSeite = await offen(`/e/${opS}`);
const opKoerper = opSeite.text.split("<style")[0]
+ (opSeite.text.split("</style>")[1] || "");
ok(opSeite.code === 200, `die Seite steht (${opSeite.code})`);
ok(!/bitte nachtragen/.test(opKoerper) && !/keine Preisliste/.test(opKoerper),
"KEIN Hinweis an DogFather steht auf der geteilten Seite");
/* GEGENPROBE: Der Rest der Kampagne steht sehr wohl darauf --
sonst wäre die Zeile darüber auch bei einer leeren Seite grün. */
ok(/Diese Geschenke zählen/.test(opKoerper) && /<h3 class="k__titel">/.test(opKoerper),
"Gegenprobe: Geschenke und Überschriften stehen sehr wohl darauf");
await roh(`/workspace/api/eintrag/${opId}/teilen`, { methode: "DELETE", keks: keksDogi });
/* ---- Das Banner ------------------------------------------------ */
const bild = await offen(`/e/${schluessel}/bild`);
ok(bild.code === 200 && /^image\//.test(String(bild.kopf["content-type"] || "")),
`das Banner kommt ohne Anmeldung (${bild.code}, ${bild.kopf["content-type"]})`);
/* ---- Zurückziehen wirkt sofort -------------------------------- */
const weg = await roh(`/workspace/api/eintrag/${kachelId}/teilen`,
{ methode: "DELETE", keks: keksDogi });
ok(weg.code === 200, `zurückziehen geht (${weg.code})`);
const danach = await offen(`/e/${schluessel}`);
ok(danach.code === 404, `und die Seite ist sofort weg (${danach.code})`);
const bildDanach = await offen(`/e/${schluessel}/bild`);
ok(bildDanach.code === 404, ` auch das Banner (${bildDanach.code})`);
/* ---- Was NICHT geteilt werden darf ---------------------------- */
const fremd = dbLesen("SELECT id FROM eintraege WHERE haus = 'crew' LIMIT 1")[0];
const crewTeilen = await roh(`/workspace/api/eintrag/${fremd.id}/teilen`,
{ methode: "POST", keks: keksDogi, rumpf: {} });
ok(crewTeilen.code === 403,
`ein Eintrag aus dem Haus Team Dogi lässt sich nicht teilen (${crewTeilen.code})`);
const crewZ = dbLesen("SELECT teilen_token FROM eintraege WHERE id = ?", fremd.id)[0];
ok(crewZ.teilen_token === null,
" und bekommt auch keinen Schlüssel");
/* DER EINDEUTIGE INDEX steht dahinter -- zwei Events mit demselben
Schlüssel wären eine Seite, die zwei Dinge zeigt. */
const idx = dbLesen("SELECT sql FROM sqlite_master WHERE name = 'idx_eintraege_teilen'");
ok(idx.length === 1 && /UNIQUE/i.test(idx[0].sql),
"ein eindeutiger Index sichert die Schlüssel ab");
}
/* =====================================================================
13. DAS PROTOKOLL
===================================================================== */
melde("");
melde("=== 13. Jeder Versuch steht im Protokoll, nicht nur der Erfolg ===");
{
const p = dbLesen("SELECT aktion, COUNT(*) AS n FROM protokoll"
+ " WHERE aktion LIKE 'kampagne%' GROUP BY aktion ORDER BY aktion");
const nach = Object.fromEntries(p.map((z) => [z.aktion, z.n]));
ok((nach.kampagne_eingelesen || 0) >= 3,
`${nach.kampagne_eingelesen || 0} erfolgreiche Einlesevorgänge`);
ok((nach.kampagne_gescheitert || 0) >= 5,
`${nach.kampagne_gescheitert || 0} gescheiterte -- genau die, die sonst spurlos wären`);
ok((nach.kampagne_schon_da || 0) >= 2,
`${nach.kampagne_schon_da || 0}× „war schon da“`);
const einer = dbLesen("SELECT detail FROM protokoll WHERE aktion = 'kampagne_gescheitert'"
+ " ORDER BY id DESC LIMIT 1")[0];
ok((einer?.detail || "").length > 5,
`und der Grund steht dabei: „${(einer?.detail || "").slice(0, 70)}“`);
}
/* =====================================================================
14. DIE BREMSE
===================================================================== */
melde("");
melde("=== 14. Zwanzig in einer Stunde, dann ist Schluss ===");
{
/* EINE EIGENE PERSON dafür: Die Bremse zählt je Person, und die
Versuche der Abschnitte darüber gehören DogFather. */
const { KAMPAGNEN_JE_STUNDE } = await import("./workspace-kampagne.js");
ok(KAMPAGNEN_JE_STUNDE === 20, `die Grenze steht bei ${KAMPAGNEN_JE_STUNDE}`);
let letzterGute = null;
for (let i = 1; i <= KAMPAGNEN_JE_STUNDE; i++) {
letzterGute = await einlesen(`Klick: ${TIK}/t/gipfel`, keksBrem);
if (letzterGute.code === 429) break;
}
ok(letzterGute && letzterGute.code !== 429,
`der ${KAMPAGNEN_JE_STUNDE}. Versuch geht noch durch (HTTP ${letzterGute?.code})`);
const zuviel = await einlesen(`Klick: ${TIK}/t/gipfel`, keksBrem);
ok(zuviel.code === 429, `der ${KAMPAGNEN_JE_STUNDE + 1}. wird gebremst (HTTP ${zuviel.code})`);
ok(/Minute/.test(zuviel.j.fehler || ""),
`und sagt, wann es weitergeht: „${zuviel.j.fehler}“`);
/* GEGENPROBE: Eine andere Person ist NICHT gebremst. Die Bremse ist
persönlich, keine Sperre für alle. */
const andere = await einlesen(`Klick: ${TIK}/t/gipfel`, keksMara);
ok(andere.code !== 429, `eine andere Person darf weiter (HTTP ${andere.code})`);
/* UND DIE BREMSE VERLÄNGERT SICH NICHT SELBST: Ihre eigene Meldung
zählt nicht mit, sonst käme man nie wieder heraus. */
const bremsZaehler = dbLesen("SELECT COUNT(*) AS n FROM protokoll"
+ " WHERE aktion = 'kampagne_bremse'")[0].n;
ok(bremsZaehler >= 1, `${bremsZaehler} Bremsvorgänge stehen im Protokoll`);
const gezaehlt = dbLesen("SELECT COUNT(*) AS n FROM protokoll"
+ " WHERE aktion IN ('kampagne_eingelesen','kampagne_gescheitert','kampagne_schon_da')"
+ " AND person_id = (SELECT id FROM personen WHERE name = 'Bremser')")[0].n;
ok(gezaehlt === KAMPAGNEN_JE_STUNDE,
`gezählt werden genau ${gezaehlt} von ${KAMPAGNEN_JE_STUNDE} Versuchen`
+ " -- die Bremsmeldungen selbst nicht");
}
/* =====================================================================
SCHLUSS
===================================================================== */
melde("");
melde(`${abrufe} Abrufe beim nachgebauten Dienst, keiner im echten Netz.`);
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
tikDienst.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }
process.exit(fehler ? 1 : 0);