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 = {