diff --git a/.env.example b/.env.example index df0f497..eb529f3 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,6 @@ SLACK_CHANNEL_ID= DISCOURSE_BASE_URL=https://forum.example.org DISCOURSE_API_KEY=your_api_key_here DISCOURSE_API_USERNAME=system -# DISCOURSE_LOOKBACK_HOURS=24 # Gemini LLM GEMINI_API_KEY= @@ -26,6 +25,9 @@ GEMINI_API_KEY= # Digest Config # DIGEST_WINDOW_HOURS=24 +# No digest on Sat/Sun (UTC); Monday looks back DIGEST_WINDOW_HOURS + 48 instead. +# Set false to restore plain daily behaviour (weekend runs, no Monday extension). +# SKIP_WEEKEND=true # Triage # Comma-separated projects this org maintains; bug reports against them rate high. diff --git a/.github/workflows/daily-digest.yml b/.github/workflows/daily-digest.yml index c6e1f80..85fb84c 100644 --- a/.github/workflows/daily-digest.yml +++ b/.github/workflows/daily-digest.yml @@ -45,6 +45,7 @@ jobs: MAX_SUMMARY_TOKENS: ${{ vars.MAX_SUMMARY_TOKENS }} MAX_INPUT_CHARS_PER_GROUP: ${{ vars.MAX_INPUT_CHARS_PER_GROUP }} DIGEST_WINDOW_HOURS: ${{ vars.DIGEST_WINDOW_HOURS }} + SKIP_WEEKEND: ${{ vars.SKIP_WEEKEND }} MIN_MESSAGE_LENGTH: ${{ vars.MIN_MESSAGE_LENGTH }} EXCLUDE_COMMANDS: ${{ vars.EXCLUDE_COMMANDS }} EXCLUDE_LINK_ONLY: ${{ vars.EXCLUDE_LINK_ONLY }} diff --git a/AGENTS.md b/AGENTS.md index 6dee67e..f35feb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,4 +72,5 @@ All config flows through `loadConfig()` in `src/config/index.ts` (Zod schema ove Runs as a **GitHub Actions** workflow (`.github/workflows/daily-digest.yml`), **not** a hosted service. Key facts for debugging "why didn't my change take effect": - The digest job gates on `if: github.ref == 'refs/heads/main'` — **only `main` runs in production.** A change merged elsewhere won't appear in the digest until it reaches `main`. - The trigger is `workflow_dispatch` only (no in-workflow cron); a **GCP Cloud Scheduler** job calls the dispatch API daily (`scripts/setup_cloud_scheduler_dispatch.sh`). Feature branches PR directly into `main`. +- **The schedule is daily but the digest is weekday-only.** Scheduler still fires seven days a week; `getRunSchedule()` (`src/utils/time.ts`) makes the app no-op on Sat/Sun (UTC) and extends Monday's lookback by `WEEKEND_EXTRA_HOURS` (48) so the weekend rolls into it. A green weekend run with no Slack post is correct, not a failure. `SKIP_WEEKEND=false` reverts the whole policy — weekend runs happen *and* Monday loses its extension. - Operational procedures (incident playbook, required secrets/variables, recovery) live in `docs/production-runbook.md`. diff --git a/README.md b/README.md index 844bdf5..1fc3e00 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Synapse is an intelligent community digest bot designed to aggregate, summarize, - **Multi-Source Ingestion**: Seamlessly ingests messages from **Discord** channels and **Discourse** forums. - **Intelligent Summarization**: Powered by the **Vercel AI SDK**, Synapse uses advanced LLMs (like Google Gemini) to generate concise, context-aware summaries of conversations. -- **Slack Destination**: Delivers beautifully formatted daily digests directly to your **Slack** workspace. +- **Slack Destination**: Delivers beautifully formatted weekday digests directly to your **Slack** workspace — weekends are skipped and rolled into Monday's digest. - **Extensible Design**: Built on a modular architecture, making it easily extensible to support additional sources (e.g., GitHub, Telegram) and destinations (e.g., Email, Notion). ## Architecture diff --git a/docs/production-runbook.md b/docs/production-runbook.md index 3624f40..84eb335 100644 --- a/docs/production-runbook.md +++ b/docs/production-runbook.md @@ -16,12 +16,27 @@ Why this setup: ## 2) Trigger Strategy Primary trigger: -- Cloud Scheduler invokes `workflow_dispatch` daily. +- Cloud Scheduler invokes `workflow_dispatch` daily, seven days a week. Fallback trigger: -- Keep the existing `schedule` block in the workflow as a best-effort backup. +- The workflow is `workflow_dispatch` only — it has no `schedule` block. Cloud Scheduler is the sole automated trigger. - Keep `workflow_dispatch` enabled for manual recovery. +## 2a) Weekday-Only Digests + +Cloud Scheduler still fires every day, but **the application itself no-ops on Saturday and +Sunday (UTC)** and rolls the weekend into Monday: + +- Sat/Sun: the run starts, logs `Weekend (UTC) — skipping digest run`, and exits 0 before + contacting Discord, Discourse, Gemini, or Slack. **A green run with no Slack post is the + expected outcome on those days, not an incident.** +- Mon: the lookback window is `DIGEST_WINDOW_HOURS + 48` (72h by default), covering + Fri 13:00 → Mon 13:00 UTC with no gap. Its Slack header shows a date range rather than a + single date. +- `SKIP_WEEKEND` (repo Variable, default `true`) controls the whole policy. Setting it + `false` restores plain daily behaviour — weekend runs happen *and* Monday loses its + extension. Use it to force a one-off weekend run, then set it back. + ## 3) Prerequisites - GCP project with Cloud Scheduler API enabled. @@ -83,6 +98,7 @@ Required workflow variables (minimum): - `GEMINI_MODEL` - `MAX_SUMMARY_TOKENS` - `DIGEST_WINDOW_HOURS` +- `SKIP_WEEKEND` (default `true`; see §2a) - `MIN_MESSAGE_LENGTH` - `EXCLUDE_COMMANDS` - `EXCLUDE_LINK_ONLY` @@ -93,13 +109,22 @@ Required workflow variables (minimum): Check these once per day: - Cloud Scheduler job execution status is successful. - A corresponding successful run exists in the `Daily Digest` workflow. -- Slack digest arrived in the expected channel. +- Slack digest arrived in the expected channel — **Monday through Friday only.** + +On Saturday and Sunday, expect a successful workflow run and **no Slack post** (see §2a). +Verify the run logged the weekend-skip line; a weekend run that posted a digest means +`SKIP_WEEKEND` has been left `false`. ## 7) Alerting Baseline Set up two alerts: - **Trigger failure alert**: Cloud Scheduler job failed. - **Digest missing alert**: no successful `Daily Digest` run or no Slack post within expected window. + **Suppress the "no Slack post" half on Saturday and Sunday (UTC)** — it would otherwise fire + every weekend by design. The "no successful run" half still applies all seven days. + +A missing **Monday** digest is the most costly failure mode: it drops three days of community +activity, not one. Escalate it accordingly and use the recovery procedure in §9. Target response objective: - Detect failure within 30 minutes of scheduled run. @@ -150,6 +175,18 @@ If a daily run is missed: 3. Confirm Slack post delivery. 4. Document incident summary and corrective action. +A manual dispatch uses the same rolling window as a scheduled one, so recovering a *late* run +needs a wider window to reach back to the missed period. + +If a **Monday** run is missed, it was carrying three days of content and a plain re-dispatch on +Tuesday would only look back 24h. The job holds no state between runs, so widen the window +manually: +1. Set repo Variable `DIGEST_WINDOW_HOURS` to `96` (Fri 13:00 → Tue 13:00). +2. Manually dispatch `Daily Digest`. +3. Confirm Slack post delivery, then **restore `DIGEST_WINDOW_HOURS` to `24`.** + +Do not set `SKIP_WEEKEND=false` for this — that would also strip the Monday extension. + ## 10) Security & Maintenance - Rotate GitHub PAT on a fixed schedule. diff --git a/src/DigestPipeline.ts b/src/DigestPipeline.ts index b9dd4d8..a98fdb9 100644 --- a/src/DigestPipeline.ts +++ b/src/DigestPipeline.ts @@ -3,7 +3,7 @@ import { Source, Destination, Processor } from "./core/interfaces"; import { NormalizedMessage, DigestContext } from "./core/types"; import { Config } from "./config"; import { logger } from "./utils/logger"; -import { getUtcDailyWindowFrom } from "./utils/time"; +import { getRunSchedule } from "./utils/time"; import { applyMessageFilters } from "./utils/filters"; import { buildDigestBlocks, formatDigest } from "./utils/format"; import { DigestItem } from "./core/schemas"; @@ -52,7 +52,24 @@ export class DigestPipeline { async run() { logger.info("Starting digest pipeline..."); - const { start, end, dateTitle } = getUtcDailyWindowFrom(new Date()); + + const now = new Date(); + const { skip, extraHours } = getRunSchedule(now, this.config.SKIP_WEEKEND); + if (skip) { + logger.info("Weekend (UTC) — skipping digest run; content rolls into Monday."); + return; + } + + // The window described in the header is the window actually fetched: + // a rolling `now - windowHours`, not a calendar day. On Monday that + // spans the weekend, so the title carries a range instead of one date. + const windowHours = this.config.DIGEST_WINDOW_HOURS + extraHours; + const end = now; + const start = new Date(end.getTime() - windowHours * 60 * 60 * 1000); + const isoDate = (d: Date) => d.toISOString().slice(0, 10); + const dateTitle = extraHours > 0 + ? `${isoDate(start)} → ${isoDate(end)}` + : isoDate(end); const context: DigestContext = { start, end, dateTitle }; const allMessages: NormalizedMessage[] = []; @@ -62,7 +79,7 @@ export class DigestPipeline { if (source.isEnabled()) { logger.info(`Fetching from source: ${source.name}`); try { - const messages = await source.fetchMessages(this.config.DIGEST_WINDOW_HOURS); + const messages = await source.fetchMessages(windowHours); logger.info(`Fetched ${messages.length} messages from ${source.name}`); allMessages.push(...messages); } catch (err: any) { diff --git a/src/config/index.ts b/src/config/index.ts index 490ae81..35073a0 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -71,6 +71,14 @@ const ConfigSchema = z.object({ z.number().int().min(1).default(24) ), + // Suppress Saturday/Sunday runs and roll the weekend into Monday's digest + // (Monday looks back DIGEST_WINDOW_HOURS + 48). Setting this false restores + // plain daily behaviour: weekend runs happen and Monday gets no extension. + SKIP_WEEKEND: z.preprocess( + toBool, + z.boolean().default(true) + ), + LOG_LEVEL: z.preprocess(toStr, z.string().default("info")), MIN_MESSAGE_LENGTH: z.preprocess( @@ -101,11 +109,6 @@ const ConfigSchema = z.object({ DISCOURSE_API_KEY: z.preprocess(toStr, z.string()).optional(), DISCOURSE_API_USERNAME: z.preprocess(toStr, z.string()).optional(), - DISCOURSE_LOOKBACK_HOURS: z.preprocess( - toNum, - z.number().int().min(1) - ).optional(), - DISCOURSE_MAX_TOPICS: z.preprocess( toNum, z.number().int().min(1) @@ -127,6 +130,7 @@ export type Config = { MAX_INPUT_CHARS_PER_GROUP: number; DRY_RUN: boolean; DIGEST_WINDOW_HOURS: number; + SKIP_WEEKEND: boolean; LOG_LEVEL: string; MIN_MESSAGE_LENGTH: number; EXCLUDE_COMMANDS: boolean; @@ -137,7 +141,6 @@ export type Config = { DISCOURSE_BASE_URL?: string; DISCOURSE_API_KEY?: string; DISCOURSE_API_USERNAME?: string; - DISCOURSE_LOOKBACK_HOURS?: number; DISCOURSE_MAX_TOPICS?: number; @@ -178,7 +181,6 @@ export function loadConfig(): Config { DISCOURSE_BASE_URL: discoBase, DISCOURSE_API_KEY: raw.DISCOURSE_API_KEY, DISCOURSE_API_USERNAME: raw.DISCOURSE_API_USERNAME, - DISCOURSE_LOOKBACK_HOURS: raw.DISCOURSE_LOOKBACK_HOURS, DISCOURSE_MAX_TOPICS: raw.DISCOURSE_MAX_TOPICS, // derived enablement @@ -197,6 +199,7 @@ export function loadConfig(): Config { geminiModel: config.GEMINI_MODEL, dryRun: config.DRY_RUN, digestWindowHours: config.DIGEST_WINDOW_HOURS, + skipWeekend: config.SKIP_WEEKEND, maxSummaryTokens: config.MAX_SUMMARY_TOKENS, maxInputCharsPerGroup: config.MAX_INPUT_CHARS_PER_GROUP, logLevel: config.LOG_LEVEL, @@ -217,7 +220,6 @@ export function loadConfig(): Config { enabled: config.DISCOURSE_ENABLED, baseUrl: config.DISCOURSE_BASE_URL ? new URL(config.DISCOURSE_BASE_URL).hostname : undefined, maxTopics: config.DISCOURSE_MAX_TOPICS ?? null, - lookbackHours: config.DISCOURSE_LOOKBACK_HOURS ?? null, }, secrets: { GEMINI_API_KEY: mask(process.env.GEMINI_API_KEY || ""), diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index 015eae5..1b038c8 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -5,6 +5,9 @@ import { DigestItem } from "./schemas"; export interface Source { name: string; isEnabled(): boolean; + // Every source uses the same lookback, so the window advertised in the + // digest header is exactly the window fetched. Includes the Monday weekend + // catch-up when applicable. fetchMessages(windowHours: number): Promise; } diff --git a/src/services/discourse/DiscourseSource.ts b/src/services/discourse/DiscourseSource.ts index 69e559c..3c8e8b6 100644 --- a/src/services/discourse/DiscourseSource.ts +++ b/src/services/discourse/DiscourseSource.ts @@ -33,11 +33,10 @@ export class DiscourseSource implements Source { if (!this.isEnabled()) return []; const now = Date.now(); - const lookHours = this.config.DISCOURSE_LOOKBACK_HOURS ?? windowHours; - const since = now - lookHours * 60 * 60 * 1000; + const since = now - windowHours * 60 * 60 * 1000; const maxTopics = this.config.DISCOURSE_MAX_TOPICS ?? 50; - logger.debug(`Discourse fetch: since=${new Date(since).toISOString()} now=${new Date(now).toISOString()} lookHours=${lookHours}`); + logger.debug(`Discourse fetch: since=${new Date(since).toISOString()} now=${new Date(now).toISOString()} windowHours=${windowHours}`); const categoryMap = await this.fetchCategories(); const messages: NormalizedMessage[] = []; diff --git a/src/services/llm/AiSdkProcessor.ts b/src/services/llm/AiSdkProcessor.ts index ff47ff7..63bc48e 100644 --- a/src/services/llm/AiSdkProcessor.ts +++ b/src/services/llm/AiSdkProcessor.ts @@ -253,6 +253,13 @@ export class AiSdkProcessor implements Processor { break; } } + // Truncation drops the OLDEST messages. On a Monday roll-up that is + // Friday's content, so make it visible rather than silent. + if (out.length < messages.length) { + logger.warn( + `Truncated group: kept ${out.length}/${messages.length} messages (limit ${maxChars} chars)` + ); + } return out; } } diff --git a/src/utils/format.ts b/src/utils/format.ts index 9c05ec0..9a60ff0 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -45,7 +45,10 @@ export function buildDigestBlocks(params: { logger.debug("[DEBUG] buildDigestBlocks items count:", params.items.length); } - const range = `Time window: ${params.dateTitle} 00:00–${params.end.toISOString().slice(0, 10)} 00:00 UTC`; + // Describe the window actually fetched (a rolling span ending now), not a + // calendar day — on Mondays this spans the weekend. + const fmtUtc = (d: Date) => d.toISOString().slice(0, 16).replace("T", " "); + const range = `Time window: ${fmtUtc(params.start)}–${fmtUtc(params.end)} UTC`; const legend = "🔴 urgent · 🟡 notable · ⚪ routine"; diff --git a/src/utils/time.ts b/src/utils/time.ts index c1e767f..de360f5 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,17 +1,23 @@ // utils/time.ts -export function getDigestWindow(hours: number): { start: Date; end: Date } { - const end = new Date(); - const start = new Date(end.getTime() - hours * 60 * 60 * 1000); - return { start, end }; -} -// Returns UTC daily window (midnight to midnight) for a given UTC date -export function getUtcDailyWindowFrom(candidateUtc: Date): { start: Date; end: Date; dateTitle: string } { - const y = candidateUtc.getUTCFullYear(); - const m = candidateUtc.getUTCMonth(); - const d = candidateUtc.getUTCDate(); - const start = new Date(Date.UTC(y, m, d, 0, 0, 0)); - const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); - const dateTitle = start.toISOString().slice(0, 10); - return { start, end, dateTitle }; +// Sat + Sun: the two runs the weekend skip suppresses. Monday's window is +// extended by exactly this much so the skipped days are covered with no gap. +export const WEEKEND_EXTRA_HOURS = 48; + +// Decides whether this run happens at all, and how far back it looks. +// The weekday is read in UTC so the decision never depends on the host's local +// timezone. "Weekend" therefore means the UTC weekend, which will not match the +// local calendar day everywhere — the digest itself is UTC-based throughout. +// `skipWeekend` gates the whole policy — false restores plain daily behaviour +// (weekend runs happen AND Monday gets no extension). +export function getRunSchedule( + now: Date, + skipWeekend: boolean +): { skip: boolean; extraHours: number } { + if (!skipWeekend) return { skip: false, extraHours: 0 }; + + const day = now.getUTCDay(); // 0 = Sun, 1 = Mon, 6 = Sat + if (day === 0 || day === 6) return { skip: true, extraHours: 0 }; + + return { skip: false, extraHours: day === 1 ? WEEKEND_EXTRA_HOURS : 0 }; } diff --git a/test/integration/discourse.test.ts b/test/integration/discourse.test.ts index 688a6f3..8ce8b70 100644 --- a/test/integration/discourse.test.ts +++ b/test/integration/discourse.test.ts @@ -34,7 +34,6 @@ describe('DiscourseSource Integration', () => { DISCOURSE_BASE_URL: 'https://forum.example.com', DISCOURSE_API_KEY: 'key', DISCOURSE_API_USERNAME: 'user', - DISCOURSE_LOOKBACK_HOURS: 24, } as any; discourseSource = new DiscourseSource(config); }); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 8673e62..885ceea 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -19,6 +19,7 @@ describe('Config Service', () => { delete process.env.MAX_SUMMARY_TOKENS; delete process.env.DRY_RUN; delete process.env.DIGEST_WINDOW_HOURS; + delete process.env.SKIP_WEEKEND; delete process.env.LOG_LEVEL; delete process.env.MIN_MESSAGE_LENGTH; delete process.env.EXCLUDE_COMMANDS; @@ -32,6 +33,7 @@ describe('Config Service', () => { expect(config.MAX_SUMMARY_TOKENS).toBe(4000); expect(config.DRY_RUN).toBe(true); expect(config.DIGEST_WINDOW_HOURS).toBe(24); + expect(config.SKIP_WEEKEND).toBe(true); expect(config.LOG_LEVEL).toBe('info'); expect(config.MIN_MESSAGE_LENGTH).toBe(20); expect(config.EXCLUDE_COMMANDS).toBe(true); @@ -49,6 +51,7 @@ describe('Config Service', () => { process.env.MAX_INPUT_CHARS_PER_GROUP = ''; process.env.DRY_RUN = ''; process.env.DIGEST_WINDOW_HOURS = ''; + process.env.SKIP_WEEKEND = ''; process.env.LOG_LEVEL = ''; process.env.MIN_MESSAGE_LENGTH = ''; process.env.EXCLUDE_COMMANDS = ''; @@ -65,6 +68,7 @@ describe('Config Service', () => { expect(config.MAINTAINED_PROJECTS).toEqual([]); expect(config.DRY_RUN).toBe(true); expect(config.DIGEST_WINDOW_HOURS).toBe(24); + expect(config.SKIP_WEEKEND).toBe(true); expect(config.LOG_LEVEL).toBe('info'); expect(config.MIN_MESSAGE_LENGTH).toBe(20); expect(config.EXCLUDE_COMMANDS).toBe(true); @@ -78,6 +82,7 @@ describe('Config Service', () => { process.env.MAX_SUMMARY_TOKENS = '2000'; process.env.DRY_RUN = 'false'; process.env.DIGEST_WINDOW_HOURS = '48'; + process.env.SKIP_WEEKEND = 'false'; process.env.LOG_LEVEL = 'debug'; process.env.MIN_MESSAGE_LENGTH = '10'; process.env.EXCLUDE_COMMANDS = 'false'; @@ -89,6 +94,7 @@ describe('Config Service', () => { expect(config.MAX_SUMMARY_TOKENS).toBe(2000); expect(config.DRY_RUN).toBe(false); expect(config.DIGEST_WINDOW_HOURS).toBe(48); + expect(config.SKIP_WEEKEND).toBe(false); expect(config.LOG_LEVEL).toBe('debug'); expect(config.MIN_MESSAGE_LENGTH).toBe(10); expect(config.EXCLUDE_COMMANDS).toBe(false); diff --git a/test/unit/digest_pipeline.test.ts b/test/unit/digest_pipeline.test.ts index 79204ac..6411d73 100644 --- a/test/unit/digest_pipeline.test.ts +++ b/test/unit/digest_pipeline.test.ts @@ -1,5 +1,5 @@ // test/unit/digest_pipeline.test.ts -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { DigestPipeline, DigestEntry, sortDigestEntries } from "../../src/DigestPipeline"; import { Source, Destination, Processor } from "../../src/core/interfaces"; import { Config } from "../../src/config"; @@ -64,6 +64,107 @@ describe("DigestPipeline", () => { expect(mockDest.sendDigest).toHaveBeenCalled(); }); + describe("weekend skip", () => { + // The pipeline reads the clock itself, so these cases drive it with a + // fixed system time. 13:00 UTC is the hour Cloud Scheduler fires. + const makeMocks = () => ({ + source: { + name: "mock-source", + isEnabled: () => true, + fetchMessages: vi.fn().mockResolvedValue([ + { + id: "1", + source: "discord", + channelId: "chan1", + content: "valid message content", + author: "user", + createdAt: new Date().toISOString(), + url: "http://url", + } as NormalizedMessage, + ]), + } as Source, + dest: { + name: "mock-dest", + isEnabled: () => true, + sendDigest: vi.fn().mockResolvedValue(undefined), + } as Destination, + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const runAt = async (iso: string, skipWeekend: boolean) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(iso)); + config.SKIP_WEEKEND = skipWeekend; + + const { source, dest } = makeMocks(); + const p = new DigestPipeline(config, processor); + p.addSource(source); + p.addDestination(dest); + await p.run(); + return { source, dest }; + }; + + it("fetches nothing and sends nothing on Saturday", async () => { + const { source, dest } = await runAt("2026-08-15T13:00:00Z", true); + + expect(source.fetchMessages).not.toHaveBeenCalled(); + expect(processor.process).not.toHaveBeenCalled(); + expect(dest.sendDigest).not.toHaveBeenCalled(); + }); + + it("fetches nothing and sends nothing on Sunday", async () => { + const { source, dest } = await runAt("2026-08-09T13:00:00Z", true); + + expect(source.fetchMessages).not.toHaveBeenCalled(); + expect(dest.sendDigest).not.toHaveBeenCalled(); + }); + + it("gives sources a 72h window on Monday (24 + 48 weekend catch-up)", async () => { + const { source, dest } = await runAt("2026-08-10T13:00:00Z", true); + + expect(source.fetchMessages).toHaveBeenCalledWith(72); + expect(dest.sendDigest).toHaveBeenCalled(); + }); + + it("gives sources the plain window on an ordinary weekday", async () => { + const { source } = await runAt("2026-08-11T13:00:00Z", true); + + expect(source.fetchMessages).toHaveBeenCalledWith(24); + }); + + it("titles the Monday digest with the full weekend range", async () => { + const { dest } = await runAt("2026-08-10T13:00:00Z", true); + + const context = (dest.sendDigest as any).mock.calls[0][2]; + expect(context.dateTitle).toBe("2026-08-07 → 2026-08-10"); + expect(context.start.toISOString()).toBe("2026-08-07T13:00:00.000Z"); + expect(context.end.toISOString()).toBe("2026-08-10T13:00:00.000Z"); + }); + + it("titles an ordinary weekday digest with a single date", async () => { + const { dest } = await runAt("2026-08-11T13:00:00Z", true); + + const context = (dest.sendDigest as any).mock.calls[0][2]; + expect(context.dateTitle).toBe("2026-08-11"); + }); + + it("runs on Saturday with no extension when SKIP_WEEKEND is false", async () => { + const { source, dest } = await runAt("2026-08-15T13:00:00Z", false); + + expect(source.fetchMessages).toHaveBeenCalledWith(24); + expect(dest.sendDigest).toHaveBeenCalled(); + }); + + it("drops the Monday extension when SKIP_WEEKEND is false", async () => { + const { source } = await runAt("2026-08-10T13:00:00Z", false); + + expect(source.fetchMessages).toHaveBeenCalledWith(24); + }); + }); + it("skips disabled sources", async () => { const mockSource: Source = { name: "disabled-source", diff --git a/test/unit/discourse_normalization.test.ts b/test/unit/discourse_normalization.test.ts index 5e7f55a..939e067 100644 --- a/test/unit/discourse_normalization.test.ts +++ b/test/unit/discourse_normalization.test.ts @@ -81,7 +81,6 @@ describe("Discourse normalization", () => { DISCOURSE_BASE_URL: "https://forum.example", DISCOURSE_API_KEY: "key", DISCOURSE_API_USERNAME: "user", - DISCOURSE_LOOKBACK_HOURS: 24, DISCOURSE_MAX_TOPICS: 10, }; diff --git a/test/unit/discourse_pinned_old_topic_skip.test.ts b/test/unit/discourse_pinned_old_topic_skip.test.ts index e967e90..17e954d 100644 --- a/test/unit/discourse_pinned_old_topic_skip.test.ts +++ b/test/unit/discourse_pinned_old_topic_skip.test.ts @@ -70,7 +70,6 @@ describe("Discourse pagination & pinned topic handling", () => { DISCOURSE_BASE_URL: "https://forum.example", DISCOURSE_API_KEY: "key", DISCOURSE_API_USERNAME: "user", - DISCOURSE_LOOKBACK_HOURS: 24, DISCOURSE_MAX_TOPICS: 10, }; @@ -122,7 +121,6 @@ describe("Discourse pagination & pinned topic handling", () => { DISCOURSE_BASE_URL: "https://forum.example", DISCOURSE_API_KEY: "key", DISCOURSE_API_USERNAME: "user", - DISCOURSE_LOOKBACK_HOURS: 24, DISCOURSE_MAX_TOPICS: 10, }; @@ -178,7 +176,6 @@ describe("Discourse pagination & pinned topic handling", () => { DISCOURSE_BASE_URL: "https://forum.example", DISCOURSE_API_KEY: "key", DISCOURSE_API_USERNAME: "user", - DISCOURSE_LOOKBACK_HOURS: 24, DISCOURSE_MAX_TOPICS: 10, }; diff --git a/test/unit/discourse_weekend_extension.test.ts b/test/unit/discourse_weekend_extension.test.ts new file mode 100644 index 0000000..5ba1e4f --- /dev/null +++ b/test/unit/discourse_weekend_extension.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from "vitest"; + +vi.mock("node-fetch", () => { + return { + default: vi.fn(), + }; +}); + +import fetch from "node-fetch"; +import { DiscourseSource } from "../../src/services/discourse/DiscourseSource"; +import { Config } from "../../src/config"; + +function makeResp(body: any) { + return { + ok: true, + status: 200, + headers: { get: (_: string) => undefined }, + text: async () => JSON.stringify(body), + json: async () => body, + }; +} + +const HOUR = 60 * 60 * 1000; + +// A post from 60 hours ago: outside an ordinary 24h weekday window, inside the +// 72h window Monday uses to roll up the weekend. +const TOPIC_ID = 4242; +const agedDate = () => new Date(Date.now() - 60 * HOUR).toISOString(); + +function mockForum() { + (fetch as unknown as Mock).mockImplementation(async (url: string) => { + if (url.includes("/latest.json?page=")) { + return makeResp({ topic_list: { topics: [] } }); + } + if (url.includes("/latest.json")) { + return makeResp({ + topic_list: { + topics: [{ id: TOPIC_ID, slug: "weekend-topic", last_posted_at: agedDate() }], + }, + }); + } + if (url.includes(`/t/${TOPIC_ID}.json`)) { + return makeResp({ + post_stream: { + posts: [{ id: 1, username: "alice", raw: "Weekend content", created_at: agedDate() }], + }, + category_id: 5, + }); + } + return makeResp({}); + }); +} + +const config: Partial = { + DISCOURSE_ENABLED: true, + DISCOURSE_BASE_URL: "https://forum.example", + DISCOURSE_API_KEY: "key", + DISCOURSE_API_USERNAME: "user", + DISCOURSE_MAX_TOPICS: 10, +}; + +describe("Discourse weekend window", () => { + beforeEach(() => { + (fetch as unknown as Mock).mockReset(); + mockForum(); + }); + + afterEach(() => { + (fetch as unknown as Mock).mockReset(); + }); + + it("reaches weekend content when given Monday's 72h window", async () => { + const source = new DiscourseSource(config as Config); + + const msgs = await source.fetchMessages(72); + + expect(msgs.length).toBe(1); + expect(msgs[0].topicId).toBe(TOPIC_ID); + }); + + it("excludes that same content on an ordinary 24h window", async () => { + const source = new DiscourseSource(config as Config); + + const msgs = await source.fetchMessages(24); + + expect(msgs.length).toBe(0); + }); + + it("uses only the window it is given — there is no forum-specific override", async () => { + const source = new DiscourseSource(config as Config); + + // Boundary: 60h of content, so 59h misses it and 61h catches it. + expect((await source.fetchMessages(59)).length).toBe(0); + expect((await source.fetchMessages(61)).length).toBe(1); + }); +}); diff --git a/test/unit/time.test.ts b/test/unit/time.test.ts new file mode 100644 index 0000000..f7d3462 --- /dev/null +++ b/test/unit/time.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { getRunSchedule, WEEKEND_EXTRA_HOURS } from "../../src/utils/time"; + +// Fixed UTC dates at 13:00 — the hour Cloud Scheduler fires. +const AT = (isoDate: string) => new Date(`${isoDate}T13:00:00Z`); + +const SUNDAY = AT("2026-08-09"); +const MONDAY = AT("2026-08-10"); +const TUESDAY = AT("2026-08-11"); +const WEDNESDAY = AT("2026-08-12"); +const THURSDAY = AT("2026-08-13"); +const FRIDAY = AT("2026-08-14"); +const SATURDAY = AT("2026-08-15"); + +describe("getRunSchedule", () => { + describe("with SKIP_WEEKEND enabled", () => { + it("skips Saturday", () => { + expect(getRunSchedule(SATURDAY, true)).toEqual({ skip: true, extraHours: 0 }); + }); + + it("skips Sunday", () => { + expect(getRunSchedule(SUNDAY, true)).toEqual({ skip: true, extraHours: 0 }); + }); + + it("extends Monday by the two skipped days", () => { + expect(getRunSchedule(MONDAY, true)).toEqual({ + skip: false, + extraHours: WEEKEND_EXTRA_HOURS, + }); + expect(WEEKEND_EXTRA_HOURS).toBe(48); + }); + + it.each([ + ["Tuesday", TUESDAY], + ["Wednesday", WEDNESDAY], + ["Thursday", THURSDAY], + ["Friday", FRIDAY], + ])("runs %s with no extension", (_name, day) => { + expect(getRunSchedule(day, true)).toEqual({ skip: false, extraHours: 0 }); + }); + + it("uses UTC, not local time, to decide the weekday", () => { + // 23:30 UTC Friday is already Saturday in UTC+2, but must still run. + expect(getRunSchedule(new Date("2026-08-14T23:30:00Z"), true).skip).toBe(false); + // 00:30 UTC Saturday is still Friday in UTC-5, but must still skip. + expect(getRunSchedule(new Date("2026-08-15T00:30:00Z"), true).skip).toBe(true); + }); + }); + + describe("with SKIP_WEEKEND disabled", () => { + it.each([ + ["Sunday", SUNDAY], + ["Monday", MONDAY], + ["Tuesday", TUESDAY], + ["Wednesday", WEDNESDAY], + ["Thursday", THURSDAY], + ["Friday", FRIDAY], + ["Saturday", SATURDAY], + ])("restores plain daily behaviour on %s", (_name, day) => { + expect(getRunSchedule(day, false)).toEqual({ skip: false, extraHours: 0 }); + }); + + it("drops the Monday extension too, not just the weekend skip", () => { + expect(getRunSchedule(MONDAY, false).extraHours).toBe(0); + }); + }); +});