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