diff --git a/.claude/settings.json b/.claude/settings.json index 41e4436..9303341 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,7 @@ { + "permissions": { + "ask": ["Bash(./prod.sh*)"] + }, "hooks": { "Stop": [ { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0b5c867 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bunx oxlint --deny-warnings + # download-video.ts writes its info cache under /storage at import time + - run: sudo mkdir -p -m 777 /storage + - run: bun test + env: + BOT_TOKEN: dummy + OWNER_ID: '0' + + # NOTE: no e2e job. Tried in 9dce83d: reddit/youtube hard-block GitHub's + # datacenter IPs (403 / auth walls), so e2e only works from residential + # IPs. It runs as the pre-push git hook instead (see .simple-git-hooks.json). diff --git a/.simple-git-hooks.json b/.simple-git-hooks.json index bf80003..6cf6b75 100644 --- a/.simple-git-hooks.json +++ b/.simple-git-hooks.json @@ -1,3 +1,4 @@ { - "pre-commit": "bun run lint" + "pre-commit": "./check.sh", + "pre-push": "./e2e.sh" } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..40e04c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# mp4ify-bot + +Telegram bot (@mp4ify_bot): send it a video link, it replies with the mp4. +Bun + Telegraf + yt-dlp, deployed via Docker Compose with a local +telegram-bot-api server. + +- Tests only work inside the test container (/storage is root-owned on the + host): + `UID=$(id -u) GID=$(id -g) docker compose run --rm --no-deps test bun test` +- Everything goes on a branch + PR (main is protected). Before opening the + PR, run /pr-review-toolkit:review-pr, then /code-review --fix. +- Never assume Telegram API behavior from the docs — verify against real + payloads and keep MockBotApi (test/simulate-bot-api.ts) in parity. +- X/Twitter is deliberately unsupported, for moral reasons. Do not add it. diff --git a/bunfig.e2e.toml b/bunfig.e2e.toml new file mode 100644 index 0000000..4bdc63f --- /dev/null +++ b/bunfig.e2e.toml @@ -0,0 +1,4 @@ +# bunfig for e2e runs (./e2e.sh): no coverage thresholds, since a partial +# run can't meet whole-suite coverage. Keep preload in sync with bunfig.toml. +[test] +preload = ["./test/simulate-bot-api"] diff --git a/bunfig.toml b/bunfig.toml index e5c8d19..ffe1fa7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,3 +2,6 @@ # This has to load first so that the mock of node-fetch applies to Telegraf: preload = ["./test/simulate-bot-api"] coverage = true +# Quality gate: bun test fails if any file's coverage drops below these. +# If they block you, write better tests - don't lower them. +coverageThreshold = { lines = 0.95, functions = 0.9 } diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..d0d0c8d --- /dev/null +++ b/check.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Quality gate: lint, secret scan, full test suite (with coverage thresholds). +# Runs as the pre-commit hook; can also be run manually at any time. +set -e + +bun run lint + +if command -v gitleaks >/dev/null 2>&1; then + gitleaks git --pre-commit --staged --no-banner --redact +else + echo "ERROR: gitleaks not installed - refusing to commit unscanned changes." >&2 + echo "Install: https://github.com/gitleaks/gitleaks/releases (single static binary)" >&2 + exit 1 +fi + +UID=$(id -u) GID=$(id -g) docker compose run --rm --no-deps test bun test diff --git a/e2e.sh b/e2e.sh index e65ae1f..407ecc1 100755 --- a/e2e.sh +++ b/e2e.sh @@ -1,2 +1,18 @@ #!/bin/sh -docker compose run --remove-orphans --rm -it test bash -c 'TEST_E2E=true bun test e2e' +# Usage: ./e2e.sh [full] [-u] +# full also test rate-limit-prone sites (youtube rejects more than a few +# hits per hour). Used by the deploy gate (prod.sh); the pre-push +# hook runs the reduced set. +# -u refresh snapshots. Implies full: `bun test -u` deletes snapshots of +# tests it didn't run, so a reduced -u run would silently prune the +# full-mode snapshots. +FULL="" +UPDATE="" +for arg in "$@"; do + case "$arg" in + full) FULL=1 ;; + -u) UPDATE="-u"; FULL=1 ;; + *) echo "unknown argument: $arg" >&2 && exit 64 ;; + esac +done +docker compose run --remove-orphans --rm -T -e TEST_E2E=true -e TEST_E2E_FULL="$FULL" test bun --config=bunfig.e2e.toml test e2e $UPDATE diff --git a/prod.sh b/prod.sh index 15eab77..e281539 100755 --- a/prod.sh +++ b/prod.sh @@ -1,3 +1,2 @@ #!/bin/sh -#./e2e.sh && UID=$(id -u) GID=$(id -g) docker compose up prod bot-api --build -d -UID=$(id -u) GID=$(id -g) docker compose up prod bot-api --build -d +./e2e.sh full && UID=$(id -u) GID=$(id -g) docker compose up prod bot-api --build -d diff --git a/src/download-video.ts b/src/download-video.ts index 2e47f0c..d171553 100644 --- a/src/download-video.ts +++ b/src/download-video.ts @@ -44,16 +44,12 @@ export type VideoInfo = { }[]; }; -// Cloud environments (e.g. Claude Code Web) often have SSL interception -const certFlags = () => - Bun.env.CLAUDE_CODE_REMOTE === 'true' ? ['--no-check-certificates'] : []; - // Self-update yt-dlp so extractors keep up with site changes (e.g. Reddit // requiring auth from older versions). Never throws: a failed update just // means we keep using the current version. export const updateYtdlp = async () => { try { - const proc = Bun.spawn(['yt-dlp', '--update', ...certFlags()], { + const proc = Bun.spawn(['yt-dlp', '--update'], { stdout: 'pipe', stderr: 'pipe', timeout: 120_000, @@ -85,7 +81,6 @@ const execYtdlp = async ( 'yt-dlp', url, verbose ? '--verbose' : '--no-warnings', - ...certFlags(), ...extraArgs, ]; console.debug(command.join(' ')); diff --git a/test/__snapshots__/e2e.test.ts.snap b/test/__snapshots__/e2e.test.ts.snap index 96b482d..e57c281 100644 --- a/test/__snapshots__/e2e.test.ts.snap +++ b/test/__snapshots__/e2e.test.ts.snap @@ -18,13 +18,13 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i 🎬 Video info: URL: https://www.instagram.com/reel/DKbYQgeoL3F/?igsh=MTh4MnpnYm9hdjJ5OA== -filename: Video_by_disastra_memes-[DKbYQgeoL3F].5.mp4 +filename: Video_by_disastra_memes-[DKbYQgeoL3F]..mp4 duration: 12 sec resolution: 640x584 ⬇️ Downloading... -🚀 Uploading (0.41 MB)..." +🚀 Uploading ( MB)..." , }, { @@ -37,7 +37,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i }, "reply_to_message_id": 0, "supports_streaming": true, - "video": "file:///storage/Instagram/Video_by_disastra_memes-%5BDKbYQgeoL3F%5D.5.mp4", + "video": "file:///storage/Instagram/Video_by_disastra_memes-%5BDKbYQgeoL3F%5D..mp4", "width": 640, }, ] @@ -60,7 +60,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i 🎬 Video info: URL: https://www.instagram.com/reel/DKbYQgeoL3F/?igsh=MTh4MnpnYm9hdjJ5OA== -filename: Video_by_disastra_memes-[DKbYQgeoL3F].5.mp4 +filename: Video_by_disastra_memes-[DKbYQgeoL3F]..mp4 duration: 12 sec resolution: 640x584" , @@ -75,7 +75,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i }, "reply_to_message_id": 1, "supports_streaming": true, - "video": "4zkom5l8ldh1", + "video": "if2kn4n54iuj", "width": 640, }, ] @@ -98,7 +98,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i 🎬 Video info: URL: https://www.instagram.com/reel/DKbYQgeoL3F/?igsh=MTh4MnpnYm9hdjJ5OA== -filename: Video_by_disastra_memes-[DKbYQgeoL3F].5.mp4 +filename: Video_by_disastra_memes-[DKbYQgeoL3F]..mp4 duration: 12 sec resolution: 640x584" , @@ -113,7 +113,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i }, "reply_to_message_id": 2, "supports_streaming": true, - "video": "4zkom5l8ldh1", + "video": "if2kn4n54iuj", "width": 640, }, ] @@ -134,11 +134,34 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i "text": "🧐 Scraping https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6... -ERROR: [generic] Unable to download webpage: HTTP Error 403: Blocked (caused by <HTTPError 403: Blocked>) +🎬 Video info: + +URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 +duration: 10 sec +size: MB +resolution: 480x600 +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps -💥 Download failed: yt-dlp exited with code 1" +⬇️ Downloading... + +🚀 Uploading ( MB)..." , }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 10, + "height": 600, + "reply_parameters": { + "message_id": 0, + }, + "reply_to_message_id": 0, + "supports_streaming": true, + "video": "file:///storage/Reddit/Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-%5Bauhgg4yoeo5f1%5D..mp4", + "width": 480, + }, ] `; @@ -155,13 +178,31 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i "message_id": 1, }, "text": -"🧐 Scraping https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6... - -ERROR: [generic] Unable to download webpage: HTTP Error 403: Blocked (caused by <HTTPError 403: Blocked>) +" +🎬 Video info: -💥 Download failed: yt-dlp exited with code 1" +URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 +duration: 10 sec +size: MB +resolution: 480x600 +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps" , }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 10, + "height": 600, + "reply_parameters": { + "message_id": 1, + }, + "reply_to_message_id": 1, + "supports_streaming": true, + "video": "k73nlloqf5d1", + "width": 480, + }, ] `; @@ -178,13 +219,31 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i "message_id": 2, }, "text": -"🧐 Scraping https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6... - -ERROR: [generic] Unable to download webpage: HTTP Error 403: Blocked (caused by <HTTPError 403: Blocked>) +" +🎬 Video info: -💥 Download failed: yt-dlp exited with code 1" +URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 +duration: 10 sec +size: MB +resolution: 480x600 +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps" , }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 10, + "height": 600, + "reply_parameters": { + "message_id": 2, + }, + "reply_to_message_id": 2, + "supports_streaming": true, + "video": "k73nlloqf5d1", + "width": 480, + }, ] `; @@ -206,16 +265,16 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps ⬇️ Downloading... -🚀 Uploading (1.20 MB)..." +🚀 Uploading ( MB)..." , }, { @@ -228,7 +287,7 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com }, "reply_to_message_id": 0, "supports_streaming": true, - "video": "file:///storage/Reddit/Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-%5Bauhgg4yoeo5f1%5D.hls-1023+dash-6.mp4", + "video": "file:///storage/Reddit/Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-%5Bauhgg4yoeo5f1%5D..mp4", "width": 480, }, ] @@ -251,12 +310,12 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps" +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps" , }, { @@ -269,7 +328,7 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com }, "reply_to_message_id": 1, "supports_streaming": true, - "video": "uufngxsbt7v5", + "video": "k73nlloqf5d1", "width": 480, }, ] @@ -292,12 +351,12 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps" +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps" , }, { @@ -310,7 +369,7 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com }, "reply_to_message_id": 2, "supports_streaming": true, - "video": "uufngxsbt7v5", + "video": "k73nlloqf5d1", "width": 480, }, ] @@ -334,15 +393,16 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: downlo 🎬 Video info: URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].312+234-11.mp4 +filename: Zinger_Burgers-[0COu-qMC18Y]..mp4 duration: 57 sec -size: 0.29 MB +size: MB resolution: 1080x1920 -video codec: avc1.64002A @ 5327.966 kbps +video codec: avc1.64002a @ kbps +audio codec: mp4a.40.2 @ kbps ⬇️ Downloading... -🚀 Uploading (24.97 MB)..." +🚀 Uploading ( MB)..." , }, { @@ -355,7 +415,7 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: downlo }, "reply_to_message_id": 0, "supports_streaming": true, - "video": "file:///storage/youtube/Zinger_Burgers-%5B0COu-qMC18Y%5D.312+234-11.mp4", + "video": "file:///storage/youtube/Zinger_Burgers-%5B0COu-qMC18Y%5D..mp4", "width": 1080, }, ] @@ -378,11 +438,12 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: mem ca 🎬 Video info: URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].312+234-11.mp4 +filename: Zinger_Burgers-[0COu-qMC18Y]..mp4 duration: 57 sec -size: 0.29 MB +size: MB resolution: 1080x1920 -video codec: avc1.64002A @ 5327.966 kbps" +video codec: avc1.64002a @ kbps +audio codec: mp4a.40.2 @ kbps" , }, { @@ -395,7 +456,7 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: mem ca }, "reply_to_message_id": 1, "supports_streaming": true, - "video": "2dne2ojllzgw8", + "video": "3f0qpzdt5nrk4", "width": 1080, }, ] @@ -418,11 +479,12 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: disk c 🎬 Video info: URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].312+234-11.mp4 +filename: Zinger_Burgers-[0COu-qMC18Y]..mp4 duration: 57 sec -size: 0.29 MB +size: MB resolution: 1080x1920 -video codec: avc1.64002A @ 5327.966 kbps" +video codec: avc1.64002a @ kbps +audio codec: mp4a.40.2 @ kbps" , }, { @@ -435,7 +497,7 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: disk c }, "reply_to_message_id": 2, "supports_streaming": true, - "video": "2dne2ojllzgw8", + "video": "3f0qpzdt5nrk4", "width": 1080, }, ] diff --git a/test/download-video.test.ts b/test/download-video.test.ts index 4e5d685..2fac72b 100644 --- a/test/download-video.test.ts +++ b/test/download-video.test.ts @@ -1,6 +1,3 @@ -// Ensure cloud-only flags don't affect test snapshots -delete Bun.env.CLAUDE_CODE_REMOTE; - import { afterAll, beforeEach, @@ -15,6 +12,7 @@ import * as fsPromises from 'node:fs/promises'; import { downloadVideo, getInfo, + probeDuration, sendInfo, sendVideo, updateYtdlp, @@ -146,6 +144,39 @@ describe('updateYtdlp', () => { }); }); +describe('probeDuration', () => { + it('returns the rounded duration from ffprobe', async () => { + mockSpawn.mockImplementationOnce(mockSpawnImpl('12.7\n')); + expect(await probeDuration('file.mp4')).toBe(13); + expect(mockSpawn.mock.calls[0]![0]).toEqual([ + 'ffprobe', + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'csv=p=0', + 'file.mp4', + ]); + }); + + it('returns undefined and logs when ffprobe fails', async () => { + const consoleError = spyOn(console, 'error').mockImplementation(mock()); + mockSpawn.mockImplementationOnce( + mockSpawnImpl('', 'No such file', { exitCode: 1 }), + ); + expect(await probeDuration('missing.mp4')).toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('ffprobe failed for missing.mp4'), + ); + }); + + it('returns undefined for unparseable output', async () => { + mockSpawn.mockImplementationOnce(mockSpawnImpl('N/A\n')); + expect(await probeDuration('weird.mp4')).toBeUndefined(); + }); +}); + describe('getInfo', () => { beforeEach(() => getInfo.cache.clear()); diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 6ab021c..c540eb1 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -9,7 +9,7 @@ import { mock, } from 'bun:test'; import { downloadVideo, getInfo, sendVideo } from '../src/download-video'; -import { withBotApi } from './simulate-bot-api'; +import { FORMAT_ID_RE, withBotApi } from './simulate-bot-api'; import { spyMock, waitUntil } from './test-utils'; beforeEach(() => jest.clearAllMocks()); @@ -43,12 +43,25 @@ const testUrls = [ 'https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6', // canonical url for same video 'https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo', - // fails if we run the test too often - 'http://youtube.com/shorts/0COu-qMC18Y', + // only in full mode - see e2e.sh for the modes and why + ...(Bun.env.TEST_E2E_FULL ? ['http://youtube.com/shorts/0COu-qMC18Y'] : []), ]; const clearDiskCache = async () => $`rm -rf /storage/*`.catch(() => null); +// yt-dlp's format selection shifts as sites change their offerings, which +// changes format ids in filenames, sizes, and bitrates without any change in +// bot behavior. Scrub the most volatile of those. NOT scrubbed (and still +// snapshot-breaking if the chosen format changes shape): codec profile +// strings, resolution, and duration - those are real signal. +const scrub = (messages: unknown) => + JSON.parse( + JSON.stringify(messages) + .replaceAll(FORMAT_ID_RE, '$1.$2') + .replaceAll(/\d+(\.\d+)? MB/g, ' MB') + .replaceAll(/@ \d+(\.\d+)? kbps/g, '@ kbps'), + ); + const clearInMemoryCache = () => { getInfo.cache.clear(); downloadVideo.cache.clear(); @@ -84,20 +97,20 @@ describe.if(!!Bun.env.TEST_E2E)('message handler', async () => { clearInMemoryCache(); api.sendTextMessageToBot(urlMessage(url)); await waitForVideo(25_000); - expect(api.sentMessages).toMatchSnapshot('download'); + expect(scrub(api.sentMessages)).toMatchSnapshot('download'); // in memory cache api.sentMessages.length = 0; api.sendTextMessageToBot(urlMessage(url)); await waitForVideo(5_000); - expect(api.sentMessages).toMatchSnapshot('mem cache'); + expect(scrub(api.sentMessages)).toMatchSnapshot('mem cache'); // disk cache clearInMemoryCache(); api.sentMessages.length = 0; api.sendTextMessageToBot(urlMessage(url)); await waitForVideo(5_000); - expect(api.sentMessages).toMatchSnapshot('disk cache'); + expect(scrub(api.sentMessages)).toMatchSnapshot('disk cache'); }), 40_000, ); diff --git a/test/handlers.test.ts b/test/handlers.test.ts index 53f30df..709a11d 100644 --- a/test/handlers.test.ts +++ b/test/handlers.test.ts @@ -25,9 +25,11 @@ import { spyMock, } from './test-utils.ts'; -beforeEach(() => { +beforeEach(async () => { jest.clearAllMocks(); - pendingDownloads.clearPending(); + // Must await: a fire-and-forget cleanup races with the test body and can + // consume queued mockImplementationOnce's on the shared unlink spy. + await pendingDownloads.clearPending(); }); afterAll(() => mock.restore()); spyMock(console, 'debug'); // suppress debug logs @@ -319,6 +321,26 @@ describe('confirmation for long videos (>20 min)', () => { expect(mockDownloadVideo).not.toHaveBeenCalled(); }); + it('answers silently for malformed callback data', async () => { + const cbCtx = createMockCallbackCtx('garbage', 123); + await callbackQueryHandler(cbCtx as any); + expect(cbCtx.answerCbQuery).toHaveBeenCalledWith(''); + expect(mockDownloadVideo).not.toHaveBeenCalled(); + }); + + it('survives answerCbQuery failures', async () => { + const mockError = spyOn(console, 'error').mockImplementation(() => {}); + const cbCtx = createMockCallbackCtx('garbage', 123); + (cbCtx.answerCbQuery as any).mockImplementationOnce(() => + Promise.reject(new Error('query is too old')), + ); + await callbackQueryHandler(cbCtx as any); + expect(mockError).toHaveBeenCalledWith( + 'answerCbQuery failed:', + expect.any(Error), + ); + }); + it('responds with unavailable for unknown callback data', async () => { const cbCtx = createMockCallbackCtx('dl:nonexistent', 123); await callbackQueryHandler(cbCtx as any); @@ -511,6 +533,23 @@ describe('post-download duration check', () => { expect(mockSendVideo).toHaveBeenCalled(); }); + it('logs unexpected cleanup failures on cancel', async () => { + const { cancelData } = await triggerPostDownloadConfirmation(); + const mockError = spyOn(console, 'error').mockImplementation(() => {}); + mockUnlink.mockImplementationOnce(() => + Promise.reject(Object.assign(new Error('busy'), { code: 'EBUSY' })), + ); + + const cbCtx = createMockCallbackCtx(cancelData, 123); + await callbackQueryHandler(cbCtx as any); + + expect(cbCtx.answerCbQuery).toHaveBeenCalledWith('Cancelled.'); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('Failed to clean up'), + expect.any(Error), + ); + }); + it('deletes file and does not upload on cancel', async () => { const { cancelData } = await triggerPostDownloadConfirmation(); jest.clearAllMocks(); diff --git a/test/log-message.test.ts b/test/log-message.test.ts index e02e7a2..81cac5f 100644 --- a/test/log-message.test.ts +++ b/test/log-message.test.ts @@ -47,6 +47,31 @@ describe.each([false, true])('LogMessage, edit: %p', (isEdit) => { await log.flush(); expect(ctx.reply).not.toHaveBeenCalled(); }); + + it('flushes automatically after the debounce delay', async () => { + const ctx = createMockMessageCtx(isEdit); + new LogMessage(ctx, 'debounced'); + expect(ctx.reply).not.toHaveBeenCalled(); + await Bun.sleep(200); // DEBOUNCE_MS is 150 + expect(ctx.reply).toHaveBeenCalledWith('debounced', expect.anything()); + }); + + it('does not retry failed edits with the same content', async () => { + const ctx = createMockMessageCtx(isEdit); + const mockError = spyMock(console, 'error'); + mockError.mockClear(); // spy persists across the describe.each variants + const log = new LogMessage(ctx, 'foo'); + await log.flush(); + (ctx.telegram.editMessageText as any).mockRejectedValueOnce( + new Error('message is not modified'), + ); + log.append('bar'); + await log.flush(); + expect(mockError).toHaveBeenCalledTimes(1); + // Re-flushing the same content must not attempt another edit + await log.flush(); + expect(ctx.telegram.editMessageText).toHaveBeenCalledTimes(1); + }); }); describe('NoLog', () => { diff --git a/test/simulate-bot-api.test.ts b/test/simulate-bot-api.test.ts index 224c991..d4fc510 100644 --- a/test/simulate-bot-api.test.ts +++ b/test/simulate-bot-api.test.ts @@ -177,6 +177,7 @@ describe('MockBotApi', () => { //@ts-ignore spyOn(Bun, 'file').mockReturnValue({ exists: mock().mockResolvedValue(true), + size: 1, }); const url = new URL(`${apiRoot}/bot${api.botToken}/sendVideo`); const body = JSON.stringify({ @@ -192,6 +193,26 @@ describe('MockBotApi', () => { expect(json.result.video.file_name).toBe('/tmp/real.mp4'); }); + it('handle sendVideo returns error if file is empty', async () => { + //@ts-ignore + spyOn(Bun, 'file').mockReturnValue({ + exists: mock().mockResolvedValue(true), + size: 0, + }); + const url = new URL(`${apiRoot}/bot${api.botToken}/sendVideo`); + const body = JSON.stringify({ + chat_id: api['user'].id, + video: Bun.pathToFileURL('/tmp/empty.mp4'), + width: 1, + height: 1, + duration: 1, + }); + const resp = (await api.handle(url, { method: 'POST', body })) as Response; + const json = await resp.json(); + expect(json.ok).toBe(false); + expect(json.description).toMatch(/file is empty/); + }); + it('handle unknown command throws', () => { const url = new URL(`${apiRoot}/bot${api.botToken}/unknownCommand`); expect(() => api.handle(url, { method: 'POST', body: '{}' })).toThrow( diff --git a/test/simulate-bot-api.ts b/test/simulate-bot-api.ts index 165766a..c4ab765 100644 --- a/test/simulate-bot-api.ts +++ b/test/simulate-bot-api.ts @@ -14,6 +14,11 @@ import { apiRoot } from '../src/consts'; // we should probably consolidate final state of edited messages // The only part we intercept is where it asks for updates. +// matches the `.{format_id}.` filename segment yt-dlp produces (plain or +// URL-encoded `]` before it). Shared by the e2e snapshot scrubber and the +// mock's file_id hashing so the two can't drift apart. +export const FORMAT_ID_RE = /(\]|%5D)\.[\w+-]+(\.mp4)/g; + const okResp = (result: any, description?: string) => new Response( JSON.stringify({ ok: true, result, ...(description && { description }) }), @@ -288,10 +293,17 @@ export class MockBotApi { file_id = video; } else { file_name = Bun.fileURLToPath(video); - if (!(await Bun.file(file_name).exists())) { + const file = Bun.file(file_name); + if (!(await file.exists())) { return errResp(`Bad Request: file not found: ${file_name}`); } - file_id = Bun.hash(video).toString(36); + // the real bot-api rejects empty uploads; catches truncated downloads + if (file.size === 0) { + return errResp(`Bad Request: file is empty: ${file_name}`); + } + // hash a format-normalized name so the file_id (pinned in e2e + // snapshots) stays stable when yt-dlp's format selection drifts + file_id = Bun.hash(video.replaceAll(FORMAT_ID_RE, '$1$2')).toString(36); this.fileIds.set(file_id, file_name); } const message = {