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