From 8a9fba0c108954adb2e9c74dc05a4b9ca2db45dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 21:15:49 +0000 Subject: [PATCH] fix: make new-post detection reliable and guard the manual trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cron loop could re-send the same audio, or miss a new one, and the HTTP entrypoint let anyone post to the chat. Four related fixes: - KV read failures no longer default to 0. getCountFromKV swallowed errors and returned 0, which the handler read as "nothing has ever been sent" and used to re-send the latest audio every 10 minutes until KV recovered. Reads and writes now propagate their errors. - KV write failures are no longer silent. updateKVCount returned a Response with status 500 that the handler logged and ignored, so the next tick re-sent the same post. setLastSentAt throws instead. - Detection compares publish times rather than post counts. A deleted post plus a new one left the category count unchanged, so the new reminder was never sent. The worker now stores the publish time of the last post it sent under fr:last-sent-at and sends only when the latest post is strictly newer, which also keeps a deletion from making an older post look new. On an empty namespace it seeds the timestamp without sending. - The manual trigger requires POST and a TRIGGER_SECRET bearer token. It previously sent to the chat on any request to any path, and now also records the timestamp so a manual send is not repeated by the next cron run. Also: errors are reported once instead of twice (send() and its callers both notified), audio larger than Telegram's 50 MB bot limit is rejected before it is buffered into memory, the duplicated sendMessage bodies and `as any` error casts are collapsed into shared helpers, and the unused cleanMp3Filename, sendTelegramMessage and getCountFromWordpress are removed. Adds a GitHub Actions workflow running typecheck and tests, an npm run typecheck script, and tests covering the send/skip/seed decisions, the trigger's auth, the KV round-trip and the date parsing. tsc --noEmit did not pass before this change; moduleResolution is now "bundler" so the vitest config's types resolve, and tests/env.d.ts types the test-only env bindings. Note for deploy: the KV key changed from `fr` (a count) to `fr:last-sent-at` (a timestamp). The first run after deploy seeds the new key without sending, so no reminder is re-posted; the old `fr` key is unused and can be deleted. Set the new TRIGGER_SECRET with `wrangler secret put TRIGGER_SECRET` — until it is set the manual trigger returns 503 and the cron path is unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JN4sR41DwRmboRfngdiTLE --- .env.example | 5 ++ .github/workflows/ci.yml | 25 ++++++ README.md | 69 +++++++++++++-- package.json | 3 +- src/handlers.ts | 128 ++++++++++++++++----------- src/index.ts | 10 +-- src/storage.ts | 65 ++++++++------ src/telegram.ts | 87 +++++++++--------- src/utils.ts | 29 ++++-- src/wordpress.ts | 61 ++++--------- tests/env.d.ts | 4 + tests/unit/handlers.test.ts | 165 +++++++++++++++++++++++++++++++++++ tests/unit/storage.test.ts | 55 ++++++++++++ tests/unit/utils.test.ts | 46 +++++----- tests/unit/wordpress.test.ts | 75 ++++++++++++++++ tsconfig.json | 4 +- vitest.config.ts | 10 +++ worker-configuration.d.ts | 1 + 18 files changed, 630 insertions(+), 212 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/env.d.ts create mode 100644 tests/unit/handlers.test.ts create mode 100644 tests/unit/storage.test.ts create mode 100644 tests/unit/wordpress.test.ts diff --git a/.env.example b/.env.example index 8056d14..0f07806 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ +# These are Cloudflare Worker secrets, not a dotenv file. +# Set each one with: npx wrangler secret put +# For local development, put the same keys in a .dev.vars file. + TELEGRAM_BOT_TOKEN= MAIN_CHAT_ID= NOTIFICATIONS_CHAT_ID= +TRIGGER_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d6660b --- /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: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test diff --git a/README.md b/README.md index d572298..247717b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,68 @@ -Run locally +# MHMIC Telegram Cron -`npm run dev1` +A Cloudflare Worker that watches the [Fajr Reminders](https://mhmic.org/fajrreminders) +category on mhmic.org and posts each new reminder's audio to a Telegram chat. -and then curl the worker +## How it works -`curl --url http://localhost:8787/` +A cron trigger runs every 10 minutes. Each run fetches the latest post in the +category and compares its publish time against the last one sent, which is kept +in KV under `fr:last-sent-at`. When the post is newer, the worker scrapes the +audio URL from the post page, downloads it, reads its metadata (title, duration, +performer) and uploads it to the chat via the Telegram Bot API. The KV timestamp +is written only after the upload succeeds, so a failed send is retried on the +next tick instead of being silently skipped. -Note: your port might be different +On the very first run against an empty KV namespace the worker records the +current position without sending, so deploying does not re-post a reminder that +has already gone out. -Get telegram chat ID +## Configuration -https://api.telegram.org/bot${BOT_TOKEN}/getUpdates +All four values are Cloudflare secrets — there is no `.env` file in production. +See `.env.example` for the list. + +``` +npx wrangler secret put TELEGRAM_BOT_TOKEN +npx wrangler secret put MAIN_CHAT_ID +npx wrangler secret put NOTIFICATIONS_CHAT_ID +npx wrangler secret put TRIGGER_SECRET +``` + +| Secret | Purpose | +| ----------------------- | ------------------------------------------------------------------------- | +| `TELEGRAM_BOT_TOKEN` | Bot token from BotFather | +| `MAIN_CHAT_ID` | Chat that receives the audio | +| `NOTIFICATIONS_CHAT_ID` | Chat that receives error alerts (optional; alerts are skipped when unset) | +| `TRIGGER_SECRET` | Shared token required by the manual HTTP trigger | + +To find a chat ID: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates` + +For local development, put the same keys in a `.dev.vars` file (git-ignored). + +## Development + +``` +npm install +npm run dev # local worker with scheduled events enabled +npm test # unit tests (vitest, workers pool) +npm run typecheck +npm run deploy +``` + +`npm run dev` prints the local port; it is usually 8787. + +## Manual trigger + +The HTTP entrypoint sends the latest reminder on demand. It requires a `POST` +and the shared secret — an unauthenticated request would let anyone post to the +chat and pull a full audio download through the worker. + +``` +curl -X POST http://localhost:8787/ \ + -H "Authorization: Bearer ${TRIGGER_SECRET}" +``` + +Responses: `200` on success, `401` for a bad or missing token, `405` for a +non-`POST` request, `503` when `TRIGGER_SECRET` is not configured, `500` when the +send itself fails. diff --git a/package.json b/package.json index 8a7598c..55ed731 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "wrangler dev --test-scheduled", "cf-typegen": "wrangler types", "build": "wrangler build", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.9.1", diff --git a/src/handlers.ts b/src/handlers.ts index 47ff013..e927ea1 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,80 +1,106 @@ -import { getLatestPost, getCountFromWordpress } from './wordpress'; +import { env } from 'cloudflare:workers'; +import { getLatestPost, getPublishedAt, WordPressPost } from './wordpress'; import { sendTelegramAudio, sendErrorNotification } from './telegram'; import { getHTML, getAudioUrl } from './audio'; -import { getCountFromKV, updateKVCount } from './storage'; +import { getLastSentAt, setLastSentAt } from './storage'; +import { secretsMatch } from './utils'; -export const send = async () => { - try { - console.log('Starting send operation'); - const post = await getLatestPost('FR'); - const html = await getHTML(post.slug); - const audioSrc = getAudioUrl(html); - - if (audioSrc) { - await sendTelegramAudio(audioSrc); - } else { - throw new Error('No audio source found'); - } +const CATEGORY = 'FR'; +const STORAGE_CATEGORY = 'fr'; - console.log('Send operation completed successfully'); - } catch (error) { - const errorMessage = `Error in send operation: ${error instanceof Error ? error.message : 'Unknown error'}`; - console.error(errorMessage); - await sendErrorNotification(errorMessage); - throw error; - } +const jsonResponse = (body: Record, status: number) => + Response.json({ ...body, timestamp: new Date().toISOString() }, { status }); + +const describeError = (error: unknown) => (error instanceof Error ? error.message : 'Unknown error'); + +/** + * Sends the audio attached to a post. Errors propagate to the caller, which owns + * error notification, so a single failure is only reported once. + */ +export const send = async (post: WordPressPost) => { + console.log(`Starting send operation for post: ${post.slug}`); + + const html = await getHTML(post.slug); + const audioSrc = getAudioUrl(html); + + await sendTelegramAudio(audioSrc); + + console.log('Send operation completed successfully'); }; export const scheduledHandler = async (event: ScheduledController): Promise => { try { console.log(`CRON triggered at ${event.cron}`); - let wasSuccessful = 'NA'; - const wordpressCount = await getCountFromWordpress('FR'); - const kvCount = await getCountFromKV('fr'); + const post = await getLatestPost(CATEGORY); + const publishedAt = getPublishedAt(post); + const lastSentAt = await getLastSentAt(STORAGE_CATEGORY); - console.log(`WordPress count: ${wordpressCount}, KV count: ${kvCount}`); + if (lastSentAt === null) { + // First run against an empty namespace. Record the current position + // rather than sending a reminder that has almost certainly gone out + // already. + await setLastSentAt(STORAGE_CATEGORY, publishedAt); + console.log(`CRON fired at ${event.cron}: seeded last sent timestamp with ${post.slug} (${publishedAt})`); + return; + } - if (wordpressCount > kvCount) { - await send(); + // Comparing publish times rather than post counts means a deleted post + // cannot make an older reminder look new. + if (publishedAt <= lastSentAt) { + console.log(`CRON fired at ${event.cron}: no new content (latest is ${post.slug})`); + return; + } - const resp = await updateKVCount('fr', wordpressCount); - wasSuccessful = resp.ok ? 'success' : 'fail'; + await send(post); - console.log(`CRON Fired and message sent ${event.cron}`); - } else { - console.log(`CRON Fired and message was NOT sent ${event.cron}`); - wasSuccessful = 'no_new_content'; - } + // Recorded only after a successful send, so a failed send is retried on + // the next tick instead of being marked as delivered. + await setLastSentAt(STORAGE_CATEGORY, publishedAt); - console.log(`Trigger fired at ${event.cron}: ${wasSuccessful}`); + console.log(`CRON fired at ${event.cron}: sent ${post.slug}`); } catch (error) { - const errorMessage = `Error in scheduled handler: ${error instanceof Error ? error.message : 'Unknown error'}`; + const errorMessage = `Error in scheduled handler: ${describeError(error)}`; console.error(errorMessage); await sendErrorNotification(errorMessage); throw error; } }; -export const fetchHandler = async (request: Request) => { +/** + * Manual trigger. Guarded by a shared secret because an unauthenticated request + * would let anyone post to the chat and pull a full audio download through the + * worker. + */ +export const fetchHandler = async (request: Request): Promise => { + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, 405); + } + + if (!env.TRIGGER_SECRET) { + console.error('TRIGGER_SECRET is not configured, refusing manual trigger'); + return jsonResponse({ error: 'Manual trigger is not configured' }, 503); + } + + const provided = (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, ''); + + if (!secretsMatch(provided, env.TRIGGER_SECRET)) { + return jsonResponse({ error: 'Unauthorized' }, 401); + } + try { - await send(); - return Response.json({ - message: 'Sent', - timestamp: new Date().toISOString(), - }); + const post = await getLatestPost(CATEGORY); + const publishedAt = getPublishedAt(post); + + await send(post); + await setLastSentAt(STORAGE_CATEGORY, publishedAt); + + return jsonResponse({ message: 'Sent', slug: post.slug }, 200); } catch (error) { - const errorMessage = `Error in fetch handler: ${error instanceof Error ? error.message : 'Unknown error'}`; + const errorMessage = `Error in fetch handler: ${describeError(error)}`; console.error(errorMessage); await sendErrorNotification(errorMessage); - return Response.json( - { - error: 'Failed to process request', - message: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }, - { status: 500 } - ); + return jsonResponse({ error: 'Failed to process request', message: describeError(error) }, 500); } }; diff --git a/src/index.ts b/src/index.ts index 2db5823..1940972 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ /** * MHMIC Telegram Cron Bot - * + * * A Cloudflare Worker that automatically sends new Fajr Reminder audio posts * from the MHMIC website to a Telegram chat when new content is available. - * + * * Features: * - Scheduled CRON job to check for new posts - * - Manual trigger via HTTP fetch + * - Manual trigger via authenticated HTTP POST * - Error notifications to dedicated error chat * - Modular architecture for maintainability */ @@ -14,8 +14,8 @@ import { scheduledHandler, fetchHandler } from './handlers'; export default { - // The scheduled handler is invoked at the interval set in our wrangler.toml's - // [[triggers]] configuration. + // The scheduled handler is invoked at the interval set in wrangler.jsonc's + // "triggers.crons" configuration. async scheduled(event): Promise { await scheduledHandler(event); }, diff --git a/src/storage.ts b/src/storage.ts index 7a4ec9f..9060643 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,34 +1,43 @@ import { env } from 'cloudflare:workers'; -export const getCountFromKV = async (category: 'fr' | 'jk') => { - try { - console.log(`Fetching count from KV for category: ${category}`); - const countStr = await env.MHMIC_TELEGRAM_BOT.get(category); - - if (countStr === null) { - console.log(`No count found for ${category}, initializing to 0`); - await env.MHMIC_TELEGRAM_BOT.put(category, '0'); - return parseInt('0'); - } - - const count = parseInt(countStr); - console.log(`Successfully fetched count from KV for ${category}: ${count}`); - return count; - } catch (error) { - console.error(`Error fetching count from KV for category ${category}:`, error); - return parseInt('0'); +export type StorageCategory = 'fr' | 'jk'; + +const lastSentKey = (category: StorageCategory) => `${category}:last-sent-at`; + +/** + * Returns the publish time (epoch ms) of the most recently sent post, or null + * when nothing has been recorded for the category yet. + * + * Read failures propagate instead of defaulting to 0. A transient KV error must + * not look like "nothing has ever been sent", which would re-send the latest + * audio on every cron tick until KV recovered. + */ +export const getLastSentAt = async (category: StorageCategory): Promise => { + const key = lastSentKey(category); + console.log(`Fetching last sent timestamp from KV for category: ${category}`); + + const stored = await env.MHMIC_TELEGRAM_BOT.get(key); + + if (stored === null) { + console.log(`No last sent timestamp found for ${category}`); + return null; } -}; -export const updateKVCount = async (category: 'fr' | 'jk', count: number) => { - try { - console.log(`Updating KV count for ${category} to: ${count}`); - await env.MHMIC_TELEGRAM_BOT.put(category, count.toString()); - console.log(`Successfully updated KV count for ${category}`); - return new Response(`count: ${count}`, { status: 200 }); - } catch (error) { - console.error(`Error updating KV count for category ${category}:`, error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - return new Response(errorMessage, { status: 500 }); + const lastSentAt = Number.parseInt(stored, 10); + + if (Number.isNaN(lastSentAt)) { + throw new Error(`Corrupt value in KV for ${key}: "${stored}"`); } + + console.log(`Last sent timestamp for ${category}: ${lastSentAt}`); + return lastSentAt; +}; + +/** + * Records the publish time (epoch ms) of the post that was just sent. + */ +export const setLastSentAt = async (category: StorageCategory, publishedAt: number): Promise => { + console.log(`Updating last sent timestamp for ${category} to: ${publishedAt}`); + await env.MHMIC_TELEGRAM_BOT.put(lastSentKey(category), publishedAt.toString()); + console.log(`Successfully updated last sent timestamp for ${category}`); }; diff --git a/src/telegram.ts b/src/telegram.ts index 4711795..3c4c31e 100644 --- a/src/telegram.ts +++ b/src/telegram.ts @@ -2,30 +2,38 @@ import { env } from 'cloudflare:workers'; import { extractAudioFilename } from './utils'; import { parseBuffer } from 'music-metadata'; -export const sendTelegramMessage = async (text: string) => { - try { - console.log(`Sending message to chat: ${env.MAIN_CHAT_ID}`); - const response = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - chat_id: env.MAIN_CHAT_ID, - text: text, - }), - }); - - if (!response.ok) { - const errorData = (await response.json()) as any; - throw new Error(`Telegram API error: ${response.status} - ${errorData.description || 'Unknown error'}`); - } +// Telegram rejects bot uploads larger than 50 MB, and the whole file is held in +// memory here, so oversized audio is rejected before it is downloaded. +const MAX_AUDIO_BYTES = 50 * 1024 * 1024; - console.log('Message sent successfully'); - } catch (error) { - console.error('Error sending Telegram message:', error); - throw error; +interface TelegramErrorResponse { + description?: string; +} + +const telegramUrl = (method: string) => `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`; + +const assertTelegramOk = async (response: Response, action: string) => { + if (response.ok) { + return; } + + const errorData = (await response.json().catch(() => ({}))) as TelegramErrorResponse; + throw new Error(`${action} failed: ${response.status} - ${errorData.description || 'Unknown error'}`); +}; + +const sendMessage = async (chatId: string, text: string) => { + const response = await fetch(telegramUrl('sendMessage'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chat_id: chatId, + text, + }), + }); + + await assertTelegramOk(response, 'Telegram sendMessage'); }; export const sendTelegramAudio = async (audioUrl: string) => { @@ -43,8 +51,18 @@ export const sendTelegramAudio = async (audioUrl: string) => { throw new Error(`Failed to download audio: ${audioResponse.status} ${audioResponse.statusText}`); } + const declaredLength = Number(audioResponse.headers.get('content-length')); + + if (Number.isFinite(declaredLength) && declaredLength > MAX_AUDIO_BYTES) { + throw new Error(`Audio is too large to send: ${declaredLength} bytes exceeds the ${MAX_AUDIO_BYTES} byte limit`); + } + const audioBuffer = await audioResponse.arrayBuffer(); + if (audioBuffer.byteLength > MAX_AUDIO_BYTES) { + throw new Error(`Audio is too large to send: ${audioBuffer.byteLength} bytes exceeds the ${MAX_AUDIO_BYTES} byte limit`); + } + // Parse metadata let duration: number | undefined; let title = cleanTitle; @@ -86,15 +104,12 @@ export const sendTelegramAudio = async (audioUrl: string) => { formData.append('performer', performer); } - const uploadResponse = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendAudio`, { + const uploadResponse = await fetch(telegramUrl('sendAudio'), { method: 'POST', body: formData, }); - if (!uploadResponse.ok) { - const errorData = (await uploadResponse.json()) as any; - throw new Error(`Failed to upload audio: ${uploadResponse.status} - ${errorData.description || 'Unknown error'}`); - } + await assertTelegramOk(uploadResponse, 'Telegram sendAudio'); }; export const sendErrorNotification = async (error: string) => { @@ -104,23 +119,7 @@ export const sendErrorNotification = async (error: string) => { } try { - const errorMessage = `🚨 MHMIC Bot Error:\n\n${error}`; - const response = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - chat_id: env.NOTIFICATIONS_CHAT_ID, - text: errorMessage, - }), - }); - - if (!response.ok) { - const errorData = (await response.json()) as any; - throw new Error(`Telegram API error: ${response.status} - ${errorData.description || 'Unknown error'}`); - } - + await sendMessage(env.NOTIFICATIONS_CHAT_ID, `🚨 MHMIC Bot Error:\n\n${error}`); console.log('Error notification sent successfully'); } catch (notificationError) { console.error('Failed to send error notification:', notificationError); diff --git a/src/utils.ts b/src/utils.ts index 91fde4e..96383d5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,3 @@ -export function cleanMp3Filename(url: string): string { - const match = url.match(/([^\/]+\.mp3)/); - return match ? match[1] : url; -} - /** * Extracts and cleans filename from a URL for audio files * @param url - The URL to extract filename from @@ -12,9 +7,29 @@ export function extractAudioFilename(url: string): { filename: string; cleanTitl // Extract filename from URL const urlParts = url.split('/'); const filename = urlParts[urlParts.length - 1].split('?')[0] || 'audio.mp3'; - + // Create clean title by replacing hyphens with spaces and removing .mp3 extension const cleanTitle = filename.replace(/-/g, ' ').replace('.mp3', ''); - + return { filename, cleanTitle }; } + +/** + * Compares two secrets without leaking where they diverge. + * + * The comparison is constant time for equal-length inputs; the length itself is + * not hidden, which is acceptable for a shared trigger token. + */ +export function secretsMatch(provided: string, expected: string): boolean { + if (provided.length !== expected.length) { + return false; + } + + let mismatch = 0; + + for (let i = 0; i < provided.length; i++) { + mismatch |= provided.charCodeAt(i) ^ expected.charCodeAt(i); + } + + return mismatch === 0; +} diff --git a/src/wordpress.ts b/src/wordpress.ts index 489e267..ecc1bba 100644 --- a/src/wordpress.ts +++ b/src/wordpress.ts @@ -1,7 +1,6 @@ // WordPress API Configuration const WEBSITE = 'https://mhmic.org'; const API_ENDPOINT = `${WEBSITE}/wp-json/wp/v2/posts`; -const CATEGORY_API_ENDPOINT = `${WEBSITE}/wp-json/wp/v2/categories`; // Category configuration const CATEGORIES = { @@ -9,10 +8,10 @@ const CATEGORIES = { JK: { id: '3', name: 'Jummah Khutbah' }, } as const; -type Category = keyof typeof CATEGORIES; +export type Category = keyof typeof CATEGORIES; // WordPress API Types -interface WordPressPost { +export interface WordPressPost { id: number; slug: string; title: { @@ -22,18 +21,28 @@ interface WordPressPost { rendered: string; }; date: string; + date_gmt: string; modified: string; link: string; categories: number[]; } -interface WordPressCategory { - id: number; - count: number; - name: string; - slug: string; - description: string; -} +/** + * Returns a post's publish time as epoch milliseconds. + * + * WordPress serves `date_gmt` without a timezone suffix, so it is pinned to UTC + * before parsing; `date` (site local time) is only a fallback. + */ +export const getPublishedAt = (post: Pick): number => { + const raw = post.date_gmt ? (post.date_gmt.endsWith('Z') ? post.date_gmt : `${post.date_gmt}Z`) : post.date; + const publishedAt = Date.parse(raw); + + if (Number.isNaN(publishedAt)) { + throw new Error(`Unparseable post date: ${post.date_gmt || post.date}`); + } + + return publishedAt; +}; /** * Fetches the latest post from a specific category @@ -67,35 +76,3 @@ export const getLatestPost = async (category: Category): Promise throw new Error(`${errorMessage}: ${error instanceof Error ? error.message : 'Unknown error'}`); } }; - -/** - * Fetches the post count for a specific category from WordPress - */ -export const getCountFromWordpress = async (category: Category): Promise => { - const categoryId = CATEGORIES[category].id; - const endpoint = `${CATEGORY_API_ENDPOINT}/${categoryId}`; - - try { - console.log(`Fetching post count for category: ${category} (${CATEGORIES[category].name})`); - - const response = await fetch(endpoint); - - if (!response.ok) { - throw new Error(`WordPress API error: ${response.status} ${response.statusText}`); - } - - const categoryInfo: WordPressCategory = await response.json(); - - if (!categoryInfo || typeof categoryInfo.count !== 'number') { - throw new Error(`Invalid category response: missing or invalid count field`); - } - - console.log(`Successfully fetched count for ${category}: ${categoryInfo.count} posts`); - - return categoryInfo.count; - } catch (error) { - const errorMessage = `Failed to fetch post count for category ${category}`; - console.error(errorMessage, error); - throw new Error(`${errorMessage}: ${error instanceof Error ? error.message : 'Unknown error'}`); - } -}; diff --git a/tests/env.d.ts b/tests/env.d.ts new file mode 100644 index 0000000..702e3b0 --- /dev/null +++ b/tests/env.d.ts @@ -0,0 +1,4 @@ +declare module 'cloudflare:test' { + // Gives the test-only `env` the same shape as the worker's bindings. + interface ProvidedEnv extends Env {} +} diff --git a/tests/unit/handlers.test.ts b/tests/unit/handlers.test.ts new file mode 100644 index 0000000..0c463b7 --- /dev/null +++ b/tests/unit/handlers.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { WordPressPost } from '../../src/wordpress'; + +vi.mock('../../src/wordpress', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getLatestPost: vi.fn() }; +}); + +vi.mock('../../src/telegram', () => ({ + sendTelegramAudio: vi.fn(), + sendErrorNotification: vi.fn(), +})); + +vi.mock('../../src/audio', () => ({ + getHTML: vi.fn(async () => ''), + getAudioUrl: vi.fn(() => 'https://files.mhmic.org/a-reminder.mp3'), +})); + +vi.mock('../../src/storage', () => ({ + getLastSentAt: vi.fn(), + setLastSentAt: vi.fn(), +})); + +import { scheduledHandler, fetchHandler } from '../../src/handlers'; +import { getLatestPost, getPublishedAt } from '../../src/wordpress'; +import { sendTelegramAudio, sendErrorNotification } from '../../src/telegram'; +import { getLastSentAt, setLastSentAt } from '../../src/storage'; + +const buildPost = (dateGmt: string, slug = 'a-reminder'): WordPressPost => ({ + id: 1, + slug, + title: { rendered: 'A Reminder' }, + content: { rendered: '

body

' }, + date: dateGmt, + date_gmt: dateGmt, + modified: dateGmt, + link: `https://mhmic.org/fajrreminders/${slug}`, + categories: [2], +}); + +const OLDER = buildPost('2025-01-09T12:00:00'); +const NEWER = buildPost('2025-01-10T12:00:00', 'a-newer-reminder'); + +const cronEvent = { cron: '*/10 * * * *' } as ScheduledController; + +const triggerRequest = (init: RequestInit & { secret?: string | null } = {}) => { + const { secret = 'test-trigger-secret', ...rest } = init; + return new Request('https://worker.example/', { + method: 'POST', + headers: secret === null ? {} : { authorization: `Bearer ${secret}` }, + ...rest, + }); +}; + +beforeEach(() => { + vi.mocked(getLatestPost).mockReset().mockResolvedValue(NEWER); + vi.mocked(getLastSentAt).mockReset().mockResolvedValue(getPublishedAt(OLDER)); + vi.mocked(setLastSentAt).mockReset().mockResolvedValue(); + vi.mocked(sendTelegramAudio).mockReset().mockResolvedValue(); + vi.mocked(sendErrorNotification).mockReset().mockResolvedValue(); +}); + +describe('scheduledHandler', () => { + it('should send when the latest post is newer than the last one sent', async () => { + await scheduledHandler(cronEvent); + + expect(sendTelegramAudio).toHaveBeenCalledOnce(); + expect(setLastSentAt).toHaveBeenCalledWith('fr', getPublishedAt(NEWER)); + }); + + it('should not send when the latest post has already been sent', async () => { + vi.mocked(getLatestPost).mockResolvedValue(OLDER); + + await scheduledHandler(cronEvent); + + expect(sendTelegramAudio).not.toHaveBeenCalled(); + expect(setLastSentAt).not.toHaveBeenCalled(); + }); + + it('should not send when the newest post was deleted and an older one is now latest', async () => { + vi.mocked(getLastSentAt).mockResolvedValue(getPublishedAt(NEWER)); + vi.mocked(getLatestPost).mockResolvedValue(OLDER); + + await scheduledHandler(cronEvent); + + expect(sendTelegramAudio).not.toHaveBeenCalled(); + }); + + it('should seed the timestamp without sending on the first run', async () => { + vi.mocked(getLastSentAt).mockResolvedValue(null); + + await scheduledHandler(cronEvent); + + expect(sendTelegramAudio).not.toHaveBeenCalled(); + expect(setLastSentAt).toHaveBeenCalledWith('fr', getPublishedAt(NEWER)); + }); + + it('should not send when the stored timestamp cannot be read', async () => { + vi.mocked(getLastSentAt).mockRejectedValue(new Error('KV unavailable')); + + await expect(scheduledHandler(cronEvent)).rejects.toThrow(/KV unavailable/); + + expect(sendTelegramAudio).not.toHaveBeenCalled(); + expect(setLastSentAt).not.toHaveBeenCalled(); + expect(sendErrorNotification).toHaveBeenCalledOnce(); + }); + + it('should leave the timestamp untouched when the send fails', async () => { + vi.mocked(sendTelegramAudio).mockRejectedValue(new Error('Telegram sendAudio failed: 400 - Bad Request')); + + await expect(scheduledHandler(cronEvent)).rejects.toThrow(/Telegram sendAudio failed/); + + expect(setLastSentAt).not.toHaveBeenCalled(); + }); + + it('should report a failure exactly once', async () => { + vi.mocked(sendTelegramAudio).mockRejectedValue(new Error('boom')); + + await expect(scheduledHandler(cronEvent)).rejects.toThrow(/boom/); + + expect(sendErrorNotification).toHaveBeenCalledOnce(); + }); +}); + +describe('fetchHandler', () => { + it('should reject a request with no credentials', async () => { + const response = await fetchHandler(triggerRequest({ secret: null })); + + expect(response.status).toBe(401); + expect(sendTelegramAudio).not.toHaveBeenCalled(); + }); + + it('should reject a request with the wrong secret', async () => { + const response = await fetchHandler(triggerRequest({ secret: 'wrong-secret-value' })); + + expect(response.status).toBe(401); + expect(sendTelegramAudio).not.toHaveBeenCalled(); + }); + + it('should reject a GET even with the correct secret', async () => { + const response = await fetchHandler(triggerRequest({ method: 'GET' })); + + expect(response.status).toBe(405); + expect(sendTelegramAudio).not.toHaveBeenCalled(); + }); + + it('should send and record the timestamp when authorized', async () => { + const response = await fetchHandler(triggerRequest()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ message: 'Sent', slug: NEWER.slug }); + expect(sendTelegramAudio).toHaveBeenCalledOnce(); + expect(setLastSentAt).toHaveBeenCalledWith('fr', getPublishedAt(NEWER)); + }); + + it('should return 500 and notify when the send fails', async () => { + vi.mocked(sendTelegramAudio).mockRejectedValue(new Error('boom')); + + const response = await fetchHandler(triggerRequest()); + + expect(response.status).toBe(500); + expect(sendErrorNotification).toHaveBeenCalledOnce(); + expect(setLastSentAt).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/storage.test.ts b/tests/unit/storage.test.ts new file mode 100644 index 0000000..b86a313 --- /dev/null +++ b/tests/unit/storage.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { getLastSentAt, setLastSentAt } from '../../src/storage'; + +const KEY = 'fr:last-sent-at'; + +beforeEach(async () => { + await env.MHMIC_TELEGRAM_BOT.delete(KEY); +}); + +describe('storage', () => { + it('should return null when nothing has been recorded yet', async () => { + await expect(getLastSentAt('fr')).resolves.toBeNull(); + }); + + it('should not write to KV while reading a missing key', async () => { + await getLastSentAt('fr'); + + await expect(env.MHMIC_TELEGRAM_BOT.get(KEY)).resolves.toBeNull(); + }); + + it('should round-trip a timestamp', async () => { + const publishedAt = Date.parse('2025-01-09T12:00:00Z'); + + await setLastSentAt('fr', publishedAt); + + await expect(getLastSentAt('fr')).resolves.toBe(publishedAt); + }); + + it('should keep categories separate', async () => { + await setLastSentAt('fr', 1000); + + await expect(getLastSentAt('jk')).resolves.toBeNull(); + }); + + it('should throw rather than report a corrupt value as a timestamp', async () => { + await env.MHMIC_TELEGRAM_BOT.put(KEY, 'not-a-number'); + + await expect(getLastSentAt('fr')).rejects.toThrow(/Corrupt value in KV/); + }); + + it('should surface a read failure instead of defaulting to zero', async () => { + const kv = env.MHMIC_TELEGRAM_BOT; + const original = kv.get.bind(kv); + kv.get = (async () => { + throw new Error('KV unavailable'); + }) as typeof kv.get; + + try { + await expect(getLastSentAt('fr')).rejects.toThrow(/KV unavailable/); + } finally { + kv.get = original; + } + }); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index c5cd6d7..c03a72b 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { extractAudioFilename, cleanMp3Filename } from '../../src/utils'; +import { extractAudioFilename, secretsMatch } from '../../src/utils'; describe('utils', () => { describe('extractAudioFilename', () => { it('should extract filename and create clean title from a simple URL', () => { const url = 'https://example.com/audio/my-audio-file.mp3'; const result = extractAudioFilename(url); - + expect(result.filename).toBe('my-audio-file.mp3'); expect(result.cleanTitle).toBe('my audio file'); }); @@ -14,7 +14,7 @@ describe('utils', () => { it('should handle URLs with query parameters', () => { const url = 'https://media.blubrry.com/fajrreminders/files.mhmic.org/Fajr-Reminders/2025/Rights-of-Rasoolullah-2-Ita-at-.mp3?_=1'; const result = extractAudioFilename(url); - + expect(result.filename).toBe('Rights-of-Rasoolullah-2-Ita-at-.mp3'); expect(result.cleanTitle).toBe('Rights of Rasoolullah 2 Ita at '); }); @@ -22,7 +22,7 @@ describe('utils', () => { it('should handle URLs with multiple hyphens', () => { const url = 'https://example.com/audio/this-is-a-long-audio-title.mp3'; const result = extractAudioFilename(url); - + expect(result.filename).toBe('this-is-a-long-audio-title.mp3'); expect(result.cleanTitle).toBe('this is a long audio title'); }); @@ -30,7 +30,7 @@ describe('utils', () => { it('should return default filename when URL has no filename', () => { const url = 'https://example.com/audio/'; const result = extractAudioFilename(url); - + expect(result.filename).toBe('audio.mp3'); expect(result.cleanTitle).toBe('audio'); }); @@ -38,7 +38,7 @@ describe('utils', () => { it('should handle URLs without file extension', () => { const url = 'https://example.com/audio/my-audio-file'; const result = extractAudioFilename(url); - + expect(result.filename).toBe('my-audio-file'); expect(result.cleanTitle).toBe('my audio file'); }); @@ -46,7 +46,7 @@ describe('utils', () => { it('should handle URLs with trailing hyphens', () => { const url = 'https://example.com/audio/my-audio-file-.mp3'; const result = extractAudioFilename(url); - + expect(result.filename).toBe('my-audio-file-.mp3'); expect(result.cleanTitle).toBe('my audio file '); }); @@ -54,7 +54,7 @@ describe('utils', () => { it('should handle empty string', () => { const url = ''; const result = extractAudioFilename(url); - + expect(result.filename).toBe('audio.mp3'); expect(result.cleanTitle).toBe('audio'); }); @@ -62,32 +62,28 @@ describe('utils', () => { it('should handle URL with only domain', () => { const url = 'https://example.com'; const result = extractAudioFilename(url); - + expect(result.filename).toBe('example.com'); expect(result.cleanTitle).toBe('example.com'); }); }); - describe('cleanMp3Filename', () => { - it('should extract MP3 filename from URL', () => { - const url = 'https://example.com/audio/test-file.mp3?param=value'; - const result = cleanMp3Filename(url); - - expect(result).toBe('test-file.mp3'); + describe('secretsMatch', () => { + it('should accept an exact match', () => { + expect(secretsMatch('s3cret-token', 's3cret-token')).toBe(true); + }); + + it('should reject a value differing in a single character', () => { + expect(secretsMatch('s3cret-tokeN', 's3cret-token')).toBe(false); }); - it('should return original URL if no MP3 extension found', () => { - const url = 'https://example.com/audio/test-file.wav'; - const result = cleanMp3Filename(url); - - expect(result).toBe(url); + it('should reject a value of a different length', () => { + expect(secretsMatch('s3cret-token-extra', 's3cret-token')).toBe(false); + expect(secretsMatch('', 's3cret-token')).toBe(false); }); - it('should handle URLs with MP3 in the middle', () => { - const url = 'https://example.com/audio/test-file.mp3/extra-path'; - const result = cleanMp3Filename(url); - - expect(result).toBe('test-file.mp3'); + it('should reject a prefix of the expected secret', () => { + expect(secretsMatch('s3cret', 's3cret-token')).toBe(false); }); }); }); diff --git a/tests/unit/wordpress.test.ts b/tests/unit/wordpress.test.ts new file mode 100644 index 0000000..c65a118 --- /dev/null +++ b/tests/unit/wordpress.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { getPublishedAt, getLatestPost, WordPressPost } from '../../src/wordpress'; + +const buildPost = (overrides: Partial = {}): WordPressPost => ({ + id: 1, + slug: 'a-reminder', + title: { rendered: 'A Reminder' }, + content: { rendered: '

body

' }, + date: '2025-01-09T07:00:00', + date_gmt: '2025-01-09T12:00:00', + modified: '2025-01-09T12:00:00', + link: 'https://mhmic.org/fajrreminders/a-reminder', + categories: [2], + ...overrides, +}); + +const mockFetch = (response: Response) => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL) => response); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('wordpress', () => { + describe('getPublishedAt', () => { + it('should read date_gmt as UTC even though WordPress omits the suffix', () => { + expect(getPublishedAt({ date: '2025-01-09T07:00:00', date_gmt: '2025-01-09T12:00:00' })).toBe(Date.parse('2025-01-09T12:00:00Z')); + }); + + it('should not double up an existing UTC suffix', () => { + expect(getPublishedAt({ date: '2025-01-09T07:00:00', date_gmt: '2025-01-09T12:00:00Z' })).toBe(Date.parse('2025-01-09T12:00:00Z')); + }); + + it('should fall back to date when date_gmt is missing', () => { + expect(getPublishedAt({ date: '2025-01-09T07:00:00Z', date_gmt: '' })).toBe(Date.parse('2025-01-09T07:00:00Z')); + }); + + it('should throw on an unparseable date', () => { + expect(() => getPublishedAt({ date: 'not-a-date', date_gmt: '' })).toThrow(/Unparseable post date/); + }); + + it('should order a newer post above an older one', () => { + const older = getPublishedAt({ date: '', date_gmt: '2025-01-09T12:00:00' }); + const newer = getPublishedAt({ date: '', date_gmt: '2025-01-10T12:00:00' }); + + expect(newer).toBeGreaterThan(older); + }); + }); + + describe('getLatestPost', () => { + it('should return the first post of the category', async () => { + const post = buildPost(); + const fetchMock = mockFetch(Response.json([post])); + + await expect(getLatestPost('FR')).resolves.toEqual(post); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(String(fetchMock.mock.calls[0][0])).toContain('categories=2'); + }); + + it('should throw when the API responds with an error status', async () => { + mockFetch(new Response('nope', { status: 500, statusText: 'Internal Server Error' })); + + await expect(getLatestPost('FR')).rejects.toThrow(/WordPress API error: 500/); + }); + + it('should throw when the category has no posts', async () => { + mockFetch(Response.json([])); + + await expect(getLatestPost('FR')).rejects.toThrow(/No posts found for category: FR/); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c014dac..6d2c207 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,9 +10,9 @@ "jsx": "react-jsx", /* Specify what module code is generated. */ - "module": "es2022", + "module": "esnext", /* Specify how TypeScript looks up a file from a given module specifier. */ - "moduleResolution": "node", + "moduleResolution": "bundler", /* Specify type package names to be included without being referenced in a source file. */ "types": ["@cloudflare/vitest-pool-workers", "./worker-configuration.d.ts", "node"], /* Enable importing .json files */ diff --git a/vitest.config.ts b/vitest.config.ts index e3c0f1b..6c7f65e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,16 @@ export default defineWorkersConfig({ wrangler: { configPath: './wrangler.jsonc', }, + miniflare: { + // Stand-ins for the production secrets so modules that read + // `env` at import time can be loaded under test. + bindings: { + TELEGRAM_BOT_TOKEN: 'test-bot-token', + MAIN_CHAT_ID: 'test-main-chat', + NOTIFICATIONS_CHAT_ID: 'test-notifications-chat', + TRIGGER_SECRET: 'test-trigger-secret', + }, + }, }, }, }, diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index d4d5220..5130c98 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -7,6 +7,7 @@ declare namespace Cloudflare { TELEGRAM_BOT_TOKEN: string; MAIN_CHAT_ID: string; NOTIFICATIONS_CHAT_ID: string; + TRIGGER_SECRET: string; } } interface Env extends Cloudflare.Env {}