Kampagnen-Kachel aus einem TikTok-Link -- Etappe 2 bis 4
Der Auftrag ist damit fertig: Im Agentur-Bereich gibt es ein Feld, in das die ganze Einladungsnachricht eingefuegt wird -- und daraus entsteht eine Agentur-Event-Kachel mit Titel, Zeitraum, Banner, Aufgaben zum Abhaken, Regeln, Ligen und Preisen. Gemessen 38 ms vom Einfuegen bis zur Kachel. ETAPPE 2 -- DER WEG NACH DRAUSSEN (helfer-tiktok.mjs) linkAusText holt die Adresse aus dem Fliesstext, damit niemand sie heraussuchen muss. folgeUmleitung geht Sprung fuer Sprung (redirect "manual", hoechstens fuenf) und schickt JEDE Zwischenadresse durch istTikTok -- bisher folgte holeMitFrist den Umleitungen selbst, und istTikTok hatte nur die EINGABE gesehen. Das ist die einzige Stelle im Haus, an der unser Server eine fremdbestimmte Adresse abruft. holeSeite zieht die Grenze von 3 MB BEIM LESEN, nicht hinterher: Wer arrayBuffer() abwartet und dann die Laenge ansieht, hat die 50 MB schon im Speicher. Frist 12 s statt 8 -- gemessen 750 ms allein beim Ursprungsserver, dazu 1,1 MB Uebertragung. bildAdresseErlaubt kam beim Bauen dazu, nicht aus dem Bauplan: Die Banneradresse steht in der Seite, die TikTok ausliefert, ist also FREMDBESTIMMT. Ohne Schranke wuerde unser Server abrufen, was dort steht -- auch http://127.0.0.1:4100/ oder 169.254.169.254. Elf Faelle durchgemessen. ETAPPE 3 -- DIE ROUTE (workspace-kampagne.js, 5 Spalten, 1 Index) Rechte aus istLeitung, nicht neu erfunden. Bremse 20 je Stunde, VOR dem ersten Abruf nach draussen -- eine Bremse hinter dem Abruf bremst den fremden Server nicht. Dublettenpruefung zweimal: im Code (faengt den Normalfall) und als eindeutiger Index (faengt den Wettlauf zweier gleichzeitiger Aufrufe). 'kampagne_schon_da' zaehlt bei der Bremse MIT (gefunden von der Pruefung): Zuerst zaehlten nur Erfolg und Fehlschlag -- wer denselben Link wieder und wieder einfuegt, bekommt jedes Mal "schon da" und loeste jedes Mal einen Abruf aus, ohne gezaehlt zu werden. Gemessen: 21 Versuche, 0 gebremst. Der Regeltext wird SICHTBAR gekuerzt, an einer Abschnittsgrenze. Gipfelstuermer ergibt 7474 Zeichen, das Feld fasst 6000 (TEXT_MAX), und die PUT-Route lehnt mehr ab: Ungekuerzt entstuende eine Kachel, die DogFather OEFFNEN, aber nicht SPEICHERN kann -- er aendert ein Wort und bekommt eine Fehlermeldung ueber etwas, das er nie getippt hat. Verloren geht nichts: kampagne_daten traegt das Gelesene gegliedert. ETAPPE 4 -- DIE OBERFLAECHE Ein Textbereich (die Einladung ist mehrzeilig), der Knopf gibt sich nach 15 s von selbst frei, die Meldung steht neben dem Feld und nennt den Grund im Klartext. Ob jemand darf, sagt der Server ueber darf_kampagne_einlesen in /api/ich -- keine Rollenliste im Browser. Gemessen auf 412 und 1280 px (bild-kampagne.mjs, 20 Messungen): Knopf 44 px, Kontrast 7,68:1, nichts liegt auf dem Knopf, kein Ueberlauf, keine Bewegung bei prefers-reduced-motion. Die Antwortzeile steht auf .9rem statt der hausweiten .76rem -- dort erscheint, was der Server geantwortet hat, und eine Fehlermeldung in 12 px liest niemand zweimal. Die hausweite Klasse bleibt unberuehrt. VIERZEHN SABOTAGEN, VIERZEHN TREFFER Sieben an kampagne-lesen.mjs (Etappe 1) und sieben an Route und Weg: Umleitung ungeprueft folgen, Tag nach Serverzeit, Dublettenpruefung weglassen, dem fremden Server den Bildtyp glauben, Bremse abschalten, Bremse wieder blind fuer Dubletten, jeden einlesen lassen. Jede wurde bemerkt, jede im erwarteten Abschnitt. EINE BLIEB ZUERST BLIND, und das war lehrreich: Nimmt man die Dublettenpruefung aus dem Code, faengt der INDEX es auf -- die Antwort ist dieselbe, die Pruefung blieb gruen. Kein Schaden, aber ein blinder Fleck. Jetzt unterscheidet die Pruefung am Protokolltext, WELCHE der beiden Sicherungen gegriffen hat. VIER VORBESTEHENDE BEFUNDE MIT ERLEDIGT willkommen.html hatte kein theme-color, kein Manifest, kein apple-touch-icon. Drei Zeilen sind billiger und haltbarer als eine Ausnahme in der Pruefung. workspace-anleitung.js bildete den Tag "ab wann geht mehr" aus UTC. Wer zwischen 00:00 und 02:00 beitritt, bekam einen Termin, der einen Tag zu frueh war. workspace.js hat dafuer jetzt tagLokalVon(zeitpunkt, versatz) -- und tagLokal ist nur noch ein Aufruf davon. Es gibt also WENIGER Rechenwege als vorher, nicht mehr. pruef-anleitung.mjs legte Testdaten auf den UTC-Tag und waere zwischen 00:00 und 02:00 rot gewesen, ohne dass etwas kaputt ist. mess-anleitung-crew.mjs rechnete seinen zweiten Port als PORT + 1. Das sieht aus wie eine Ableitung und ist keine: portNummer ueberspringt gesperrte Nummern, und dann gehoert PORT + 1 der naechsten DATEI. pruef-struktur ist damit zum ersten Mal vollstaendig gruen. GEPRUEFT (alle gruen, keine Zahl gesunken) pruef-kampagne 96 (neu) · pruef-kampagne-lesen 108 · pruef-video 74 · pruef-eventkarte 94 · pruef-agentur 62 · pruef-anleitung 196 · pruef-portnummern 41 (war 40 mit einem Fehler) · pruef-struktur, pruef-zeichen 8, pruef-ports 10, pruef-buehnen-fest 13, pruef-css-klassen 39, pruef-haus-trennung 107, pruef-lesbarkeit, pruef-tippziele, pruef-deutsche-texte 12, pruef-formulare, pruef-fingermass 5, pruef-workspace-seiten, pruef-tempo-workspace Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,879 @@
|
||||
/* =====================================================================
|
||||
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";
|
||||
|
||||
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")),
|
||||
};
|
||||
const ROH_GIPFEL = seiteLesen("kampagne-gipfelstuermer.html.br");
|
||||
const SEITE_KAPUTT = seiteLesen("kampagne-kaputt.html");
|
||||
|
||||
/* 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/textbanner") return leite(`/falcon/home?activityId=${ID_TEXTBANNER}`);
|
||||
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/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/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`);
|
||||
|
||||
/* EIN EHRLICHER NAME. Kein Abruf gibt sich als Browser aus. */
|
||||
ok(gesehen.length > 0 && gesehen.every((g) => g.kennung.startsWith("DogfatherUniverse-Workspace/")),
|
||||
`alle ${gesehen.length} Abrufe nennen, wer fragt`);
|
||||
ok(/workspace\.dogfather-universe\.com/.test(KENNUNG) && !/Mozilla/i.test(KENNUNG),
|
||||
"und die Kennung ist keine Browser-Verkleidung");
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
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. */
|
||||
for (const feld of ["text", "event_aufgaben", "event_regeln"]) {
|
||||
ok((z[feld] || "").length <= 6000,
|
||||
` ${feld}: ${(z[feld] || "").length} Zeichen (höchstens 6000)`);
|
||||
}
|
||||
ok((z.event_regeln || "").includes("Der Regeltext war länger"),
|
||||
"und wo gekürzt wurde, sagt die Kachel es selbst");
|
||||
|
||||
/* 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 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]);
|
||||
ok(saetze.size === 4, `${saetze.size} verschiedene Sätze für vier 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) => h.includes("Preise")),
|
||||
`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(/Bitte nachtragen/.test(opZ.event_regeln || ""),
|
||||
"und die Kachel selbst sagt, dass etwas nachzutragen ist");
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
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");
|
||||
|
||||
/* 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");
|
||||
|
||||
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})`);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
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);
|
||||
Reference in New Issue
Block a user