From e7292194bf68644cab723568aac507d7b9fc868a Mon Sep 17 00:00:00 2001 From: TomSoulage Date: Sun, 1 Nov 2026 13:46:56 +0100 Subject: [PATCH 1/6] build(deps): add the eventbridge scheduler client Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 20 ++++++++++++++++++++ package.json | 1 + 2 files changed, 21 insertions(+) diff --git a/package-lock.json b/package-lock.json index 5e9d148..5070dc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "3.1156.0", "@aws-sdk/client-eventbridge": "3.1156.0", + "@aws-sdk/client-scheduler": "3.1156.0", "@aws-sdk/client-ssm": "3.1156.0", "node-html-parser": "9.0.5" }, @@ -130,6 +131,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-scheduler": { + "version": "3.1156.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-scheduler/-/client-scheduler-3.1156.0.tgz", + "integrity": "sha512-ODbqitEsbf8gVWWdwu1UuiVDlQEAZLb1/NrdeaM1Qxmi1fQRzxD/B4f8gkcdKVW2dLDrNY0rmwFkOdAea+KUaw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.5", + "@aws-sdk/credential-provider-node": "^3.972.88", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.36.1", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-ssm": { "version": "3.1156.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1156.0.tgz", diff --git a/package.json b/package.json index c240ad9..dac5815 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "3.1156.0", "@aws-sdk/client-eventbridge": "3.1156.0", + "@aws-sdk/client-scheduler": "3.1156.0", "@aws-sdk/client-ssm": "3.1156.0", "node-html-parser": "9.0.5" } From 37be669f3f856ef61186cbc4651b7381a3a0d211 Mon Sep 17 00:00:00 2001 From: TomSoulage Date: Sun, 1 Nov 2026 13:46:56 +0100 Subject: [PATCH 2/6] feat(adapters): list the matches of the day with their kick-off time Co-Authored-By: Claude Opus 5.5 --- src/adapters/treize-mondial/parse-cells.ts | 5 +++ .../treize-mondial/parse-matches-page.ts | 5 +-- .../treize-mondial/treize-mondial-adapter.ts | 15 +++++++- src/results/match-result.ts | 7 ++++ .../treize-mondial/parse-cells.test.ts | 7 ++++ .../treize-mondial-adapter.test.ts | 38 +++++++++++++++---- 6 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/adapters/treize-mondial/parse-cells.ts b/src/adapters/treize-mondial/parse-cells.ts index 245fff4..57665fb 100644 --- a/src/adapters/treize-mondial/parse-cells.ts +++ b/src/adapters/treize-mondial/parse-cells.ts @@ -32,6 +32,11 @@ export function parsePageDay(text: string): PageDay | null { return { day: Number(groups.day), month: Number(groups.month) }; } +/** `{ day: 31, month: 10 }` → `31/10`, as in error messages. */ +export function formatPageDay(day: PageDay): string { + return `${String(day.day)}/${String(day.month)}`; +} + /** `5H40` → `05:40`; anything else (postponed, not set yet) → null. */ export function parseKickOff(text: string): string | null { const groups = KICK_OFF_PATTERN.exec(text)?.groups; diff --git a/src/adapters/treize-mondial/parse-matches-page.ts b/src/adapters/treize-mondial/parse-matches-page.ts index 37e2f1f..e1d0941 100644 --- a/src/adapters/treize-mondial/parse-matches-page.ts +++ b/src/adapters/treize-mondial/parse-matches-page.ts @@ -1,6 +1,7 @@ import { parse, type HTMLElement } from 'node-html-parser'; import type { FinalScore } from '../../results/match-result.js'; import { + formatPageDay, getCellText, isFinishedMark, parseKickOff, @@ -55,10 +56,6 @@ function getRowPageDay(cells: Row): PageDay | null { return cells.map((cell) => parsePageDay(getCellText(cell))).find((day) => day !== null) ?? null; } -function formatPageDay(day: PageDay): string { - return `${String(day.day)}/${String(day.month)}`; -} - function getRowCompetition(cells: Row): string | null { const [cell] = cells; return cells.length === COMPETITION_ROW_CELLS && cell !== undefined ? getCellText(cell) : null; diff --git a/src/adapters/treize-mondial/treize-mondial-adapter.ts b/src/adapters/treize-mondial/treize-mondial-adapter.ts index 218e26f..603eea7 100644 --- a/src/adapters/treize-mondial/treize-mondial-adapter.ts +++ b/src/adapters/treize-mondial/treize-mondial-adapter.ts @@ -1,6 +1,7 @@ -import type { MatchKey, MatchLookup } from '../../results/match-result.js'; +import type { DayMatch, MatchKey, MatchLookup } from '../../results/match-result.js'; import { fetchMatchesPage } from './fetch-matches-page.js'; import { findMatchResult, isPageForDate } from './find-match-result.js'; +import { formatPageDay } from './parse-cells.js'; import { parseMatchesPage, type MatchesPage } from './parse-matches-page.js'; export const TODAY_PAGE_URL = 'https://treizemondial.fr/matchs-du-jour-rugby-a-xiii/'; @@ -18,6 +19,18 @@ export async function getMatchResult(key: MatchKey): Promise { return page === null ? { status: 'not-found' } : findMatchResult(page, key); } +/** Every match of the main page, which must be the page for `isoDate` (YYYY-MM-DD). */ +export async function listDayMatches(isoDate: string): Promise { + const page = parsePage(TODAY_PAGE_URL, await fetchMatchesPage(TODAY_PAGE_URL)); + if (!isPageForDate(page.day, isoDate)) { + throw new Error(`Treize Mondial page ${TODAY_PAGE_URL} shows ${formatPageDay(page.day)}, not ${isoDate}`); + } + return page.matches.map(({ homeTeam, awayTeam, kickOff }) => ({ + key: { date: isoDate, homeTeam, awayTeam }, + kickOff, + })); +} + /** Reads the pages in order and stops at the first one for the date; most calls need one request. */ async function readPageForDate(isoDate: string): Promise { for (const url of PAGE_URLS) { diff --git a/src/results/match-result.ts b/src/results/match-result.ts index 6448c1c..fab8978 100644 --- a/src/results/match-result.ts +++ b/src/results/match-result.ts @@ -15,6 +15,13 @@ export interface MatchResult extends MatchKey, FinalScore { readonly competition: string; } +/** A match listed for the day, before its result is known: every source adapter returns this shape (D2). */ +export interface DayMatch { + readonly key: MatchKey; + /** Kick-off time in French local time, `HH:MM`, or null when the source does not give one yet. */ + readonly kickOff: string | null; +} + /** What a source knows about a match when it is asked for its result. */ export type MatchLookup = | { readonly status: 'finished'; readonly result: MatchResult } diff --git a/test/adapters/treize-mondial/parse-cells.test.ts b/test/adapters/treize-mondial/parse-cells.test.ts index 01eec30..63b5960 100644 --- a/test/adapters/treize-mondial/parse-cells.test.ts +++ b/test/adapters/treize-mondial/parse-cells.test.ts @@ -1,6 +1,7 @@ import { parse } from 'node-html-parser'; import { describe, expect, it } from 'vitest'; import { + formatPageDay, getCellText, isFinishedMark, parseKickOff, @@ -29,6 +30,12 @@ describe('parsePageDay', () => { }); }); +describe('formatPageDay', () => { + it('writes the day and month as on the page', () => { + expect(formatPageDay({ day: 1, month: 11 })).toBe('1/11'); + }); +}); + describe('parseKickOff', () => { it.each([ ['5H40', '05:40'], diff --git a/test/adapters/treize-mondial/treize-mondial-adapter.test.ts b/test/adapters/treize-mondial/treize-mondial-adapter.test.ts index bcc7e4b..c672ae3 100644 --- a/test/adapters/treize-mondial/treize-mondial-adapter.test.ts +++ b/test/adapters/treize-mondial/treize-mondial-adapter.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fetchMatchesPage } from '../../../src/adapters/treize-mondial/fetch-matches-page.js'; import { getMatchResult, + listDayMatches, PREVIOUS_DAY_PAGE_URL, TODAY_PAGE_URL, } from '../../../src/adapters/treize-mondial/treize-mondial-adapter.js'; @@ -14,14 +15,12 @@ const PAGES_BY_URL = new Map([ [PREVIOUS_DAY_PAGE_URL, readFixture('2026-10-31-all-finished.html')], ]); -describe('getMatchResult', () => { - beforeEach(() => { - vi.mocked(fetchMatchesPage).mockReset(); - vi.mocked(fetchMatchesPage).mockImplementation((url) => - Promise.resolve(PAGES_BY_URL.get(url) ?? ''), - ); - }); +beforeEach(() => { + vi.mocked(fetchMatchesPage).mockReset(); + vi.mocked(fetchMatchesPage).mockImplementation((url) => Promise.resolve(PAGES_BY_URL.get(url) ?? '')); +}); +describe('getMatchResult', () => { it('reads a finished match of the day from the main page only', async () => { const lookup = await getMatchResult({ date: '2026-11-01', @@ -82,3 +81,28 @@ describe('getMatchResult', () => { ).rejects.toThrow('HTTP 503'); }); }); + +describe('listDayMatches', () => { + it('lists every match of the main page with its key and kick-off time', async () => { + const matches = await listDayMatches('2026-11-01'); + + expect(matches).toHaveLength(16); + expect(matches).toContainEqual({ + key: { date: '2026-11-01', homeTeam: 'Australie (F)', awayTeam: 'Pays de Galles (F)' }, + kickOff: '05:40', + }); + expect(fetchMatchesPage).toHaveBeenCalledExactlyOnceWith(TODAY_PAGE_URL); + }); + + it('fails when the main page shows another day', async () => { + await expect(listDayMatches('2026-11-02')).rejects.toThrow( + `Treize Mondial page ${TODAY_PAGE_URL} shows 1/11, not 2026-11-02`, + ); + }); + + it('fails with the URL of a page that cannot be parsed', async () => { + vi.mocked(fetchMatchesPage).mockResolvedValue('

Maintenance

'); + + await expect(listDayMatches('2026-11-01')).rejects.toThrow(`Treize Mondial page ${TODAY_PAGE_URL}: day header`); + }); +}); From f51ae4fb7a966a05a512f4a8541eaaa7f3e89674 Mon Sep 17 00:00:00 2001 From: TomSoulage Date: Sun, 1 Nov 2026 13:46:56 +0100 Subject: [PATCH 3/6] test: share the log capture between pipeline tests Co-Authored-By: Claude Opus 5.5 --- test/capture-logs.ts | 12 ++++++++++++ test/fetch-results/check-match.test.ts | 7 +++---- test/notify/notify-match.test.ts | 6 +++--- 3 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 test/capture-logs.ts diff --git a/test/capture-logs.ts b/test/capture-logs.ts new file mode 100644 index 0000000..7098eca --- /dev/null +++ b/test/capture-logs.ts @@ -0,0 +1,12 @@ +import { createLogger, type LogFields, type Logger } from '../src/shared/logger.js'; + +export type LogLine = Record; + +/** A `createLogger` for tests that keeps every line, parsed, so assertions can read the logs. */ +export function captureLogs(): { readonly lines: LogLine[]; readonly createLogger: (context: LogFields) => Logger } { + const lines: LogLine[] = []; + return { + lines, + createLogger: (context) => createLogger(context, (line) => lines.push(JSON.parse(line) as LogLine)), + }; +} diff --git a/test/fetch-results/check-match.test.ts b/test/fetch-results/check-match.test.ts index 47d9afc..58a64d0 100644 --- a/test/fetch-results/check-match.test.ts +++ b/test/fetch-results/check-match.test.ts @@ -1,19 +1,18 @@ import { describe, expect, it, vi } from 'vitest'; import { checkMatch, type CheckMatchDependencies } from '../../src/fetch-results/check-match.js'; import type { MatchLookup } from '../../src/results/match-result.js'; -import { createLogger } from '../../src/shared/logger.js'; +import { captureLogs } from '../capture-logs.js'; const INPUT = { date: '2026-11-01', homeTeam: 'Carcassonne', awayTeam: 'Lézignan' }; const MATCH_ID = '2026-11-01|Carcassonne|Lézignan'; const RESULT = { ...INPUT, competition: 'SUPER XIII', homeScore: 22, awayScore: 18 }; function setUp(lookup: Promise, publish: Promise = Promise.resolve()) { - const lines: Record[] = []; + const { lines, createLogger } = captureLogs(); const dependencies = { getMatchResult: vi.fn(() => lookup), publishMatchFinished: vi.fn(() => publish), - createLogger: (context: Record) => - createLogger(context, (line) => lines.push(JSON.parse(line) as Record)), + createLogger, }; return { dependencies, lines }; } diff --git a/test/notify/notify-match.test.ts b/test/notify/notify-match.test.ts index b52b362..e03eaf5 100644 --- a/test/notify/notify-match.test.ts +++ b/test/notify/notify-match.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { notifyMatch, type NotifyMatchDependencies } from '../../src/notify/notify-match.js'; import type { RememberOutcome } from '../../src/notify/sent-matches.js'; -import { createLogger } from '../../src/shared/logger.js'; +import { captureLogs } from '../capture-logs.js'; const MATCH_ID = '2026-11-01|Carcassonne|Lézignan'; const DETAIL = { @@ -25,7 +25,7 @@ interface Outcomes { /** Records every call in order, so the check → send → remember sequence can be asserted. */ function setUp(outcomes: Outcomes = {}) { const calls: unknown[][] = []; - const lines: Record[] = []; + const { lines, createLogger } = captureLogs(); const dependencies: NotifyMatchDependencies = { isAlreadySent: (matchId) => { calls.push(['isAlreadySent', matchId]); @@ -39,7 +39,7 @@ function setUp(outcomes: Outcomes = {}) { calls.push(['rememberSent', matchId]); return outcomes.rememberSent ?? Promise.resolve('recorded'); }, - createLogger: (context) => createLogger(context, (line) => lines.push(JSON.parse(line) as Record)), + createLogger, }; return { dependencies, calls, lines }; } From 6ae260b746c8cb0ae28c1a1e3dcdab2bc294c3d6 Mon Sep 17 00:00:00 2001 From: TomSoulage Date: Sun, 1 Nov 2026 13:46:56 +0100 Subject: [PATCH 4/6] feat(scheduling): plan and create one-time match checks Co-Authored-By: Claude Opus 5.5 --- src/scheduling/environment.ts | 4 ++ src/scheduling/match-schedule.ts | 71 +++++++++++++++++++++++++ src/scheduling/paris-time.ts | 56 +++++++++++++++++++ src/scheduling/plan-check-time.ts | 42 +++++++++++++++ test/scheduling/match-schedule.test.ts | 66 +++++++++++++++++++++++ test/scheduling/paris-time.test.ts | 33 ++++++++++++ test/scheduling/plan-check-time.test.ts | 36 +++++++++++++ 7 files changed, 308 insertions(+) create mode 100644 src/scheduling/environment.ts create mode 100644 src/scheduling/match-schedule.ts create mode 100644 src/scheduling/paris-time.ts create mode 100644 src/scheduling/plan-check-time.ts create mode 100644 test/scheduling/match-schedule.test.ts create mode 100644 test/scheduling/paris-time.test.ts create mode 100644 test/scheduling/plan-check-time.test.ts diff --git a/src/scheduling/environment.ts b/src/scheduling/environment.ts new file mode 100644 index 0000000..5d2fcaa --- /dev/null +++ b/src/scheduling/environment.ts @@ -0,0 +1,4 @@ +/** Names of the variables, set by the stack, that say where a match check goes. They hold names and ARNs, never secrets. */ +export const MATCH_CHECK_GROUP_VARIABLE = 'MATCH_CHECK_SCHEDULE_GROUP'; +export const MATCH_CHECK_FUNCTION_VARIABLE = 'MATCH_CHECK_FUNCTION_ARN'; +export const MATCH_CHECK_ROLE_VARIABLE = 'MATCH_CHECK_ROLE_ARN'; diff --git a/src/scheduling/match-schedule.ts b/src/scheduling/match-schedule.ts new file mode 100644 index 0000000..47f1d46 --- /dev/null +++ b/src/scheduling/match-schedule.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto'; +import { + ActionAfterCompletion, + ConflictException, + CreateScheduleCommand, + FlexibleTimeWindowMode, + type CreateScheduleCommandOutput, +} from '@aws-sdk/client-scheduler'; +import { buildMatchId, type MatchKey } from '../results/match-result.js'; +import { formatParisWallClock, PARIS_TIME_ZONE, type ParisWallClock } from './paris-time.js'; + +const SCHEDULE_NAME_PREFIX = 'match-'; +/** 128 bits of SHA-256: a collision between two matches is out of reach. */ +const SCHEDULE_NAME_HASH_LENGTH = 32; + +/** Where a match check goes: the schedule group, the fetch-results function and the role Scheduler invokes it with. */ +export interface MatchCheckTarget { + readonly scheduleGroupName: string; + readonly functionArn: string; + readonly roleArn: string; +} + +export type SendCreateSchedule = (command: CreateScheduleCommand) => Promise; + +/** `already-scheduled`: a schedule with this name exists, e.g. when the planner runs twice on the same day. */ +export type ScheduleOutcome = 'created' | 'already-scheduled'; + +/** + * Schedule names allow only letters, digits, `-`, `_` and `.`, while team names have accents and spaces: + * the name is a hash of the match id, the same on every run, so a match is never scheduled twice. + */ +export function buildScheduleName(key: MatchKey): string { + const hash = createHash('sha256').update(buildMatchId(key)).digest('hex'); + return `${SCHEDULE_NAME_PREFIX}${hash.slice(0, SCHEDULE_NAME_HASH_LENGTH)}`; +} + +/** A one-time schedule that invokes fetch-results with the match key (D28), then deletes itself. */ +export function buildMatchCheckCommand( + target: MatchCheckTarget, + key: MatchKey, + checkAt: ParisWallClock, +): CreateScheduleCommand { + return new CreateScheduleCommand({ + Name: buildScheduleName(key), + GroupName: target.scheduleGroupName, + Description: `Check ${buildMatchId(key)}`, + ScheduleExpression: `at(${formatParisWallClock(checkAt)})`, + ScheduleExpressionTimezone: PARIS_TIME_ZONE, + FlexibleTimeWindow: { Mode: FlexibleTimeWindowMode.OFF }, + ActionAfterCompletion: ActionAfterCompletion.DELETE, + Target: { Arn: target.functionArn, RoleArn: target.roleArn, Input: JSON.stringify(key) }, + }); +} + +/** Schedules the check of a match; one already scheduled counts as done, so the call can be repeated. */ +export async function scheduleMatchCheck( + sendCreateSchedule: SendCreateSchedule, + target: MatchCheckTarget, + key: MatchKey, + checkAt: ParisWallClock, +): Promise { + try { + await sendCreateSchedule(buildMatchCheckCommand(target, key, checkAt)); + return 'created'; + } catch (error) { + if (error instanceof ConflictException) { + return 'already-scheduled'; + } + throw new Error(`check of ${buildMatchId(key)} not scheduled`, { cause: error }); + } +} diff --git a/src/scheduling/paris-time.ts b/src/scheduling/paris-time.ts new file mode 100644 index 0000000..a99edbd --- /dev/null +++ b/src/scheduling/paris-time.ts @@ -0,0 +1,56 @@ +/** Kick-off times on the source page and every schedule are in French local time. */ +export const PARIS_TIME_ZONE = 'Europe/Paris'; + +const ISO_DATE_LENGTH = 10; +const AT_EXPRESSION_LENGTH = 19; + +const PARIS_PARTS_FORMAT = new Intl.DateTimeFormat('en-CA', { + timeZone: PARIS_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', +}); + +/** + * A wall-clock time in Paris (what a clock there shows), held in a Date whose UTC fields are that + * wall clock. Adding hours to it ignores daylight saving changes, which happen at night, inside the + * quiet hours, where every check is moved to the morning anyway. + */ +export type ParisWallClock = Date; + +/** What a clock in Paris shows at this instant. */ +export function toParisWallClock(instant: Date): ParisWallClock { + const parts = new Map(PARIS_PARTS_FORMAT.formatToParts(instant).map(({ type, value }) => [type, value])); + const getPart = (type: Intl.DateTimeFormatPartTypes): string => { + const value = parts.get(type); + if (value === undefined) { + throw new Error(`Paris time of ${instant.toISOString()}: no ${type} in the formatted date`); + } + return value; + }; + const date = `${getPart('year')}-${getPart('month')}-${getPart('day')}`; + return new Date(`${date}T${getPart('hour')}:${getPart('minute')}:${getPart('second')}Z`); +} + +/** `('2026-11-01', '15:00')` → that wall clock in Paris; an impossible one (`25:00`) fails. */ +export function parseParisWallClock(isoDate: string, time: string): ParisWallClock { + const wallClock = new Date(`${isoDate}T${time}:00Z`); + if (Number.isNaN(wallClock.getTime())) { + throw new Error(`invalid Paris wall clock "${isoDate} ${time}"`); + } + return wallClock; +} + +/** The Paris date of a wall clock, `YYYY-MM-DD`. */ +export function getParisDate(wallClock: ParisWallClock): string { + return wallClock.toISOString().slice(0, ISO_DATE_LENGTH); +} + +/** `YYYY-MM-DDTHH:MM:SS`, the format of a Scheduler `at()` expression and of the logs. */ +export function formatParisWallClock(wallClock: ParisWallClock): string { + return wallClock.toISOString().slice(0, AT_EXPRESSION_LENGTH); +} diff --git a/src/scheduling/plan-check-time.ts b/src/scheduling/plan-check-time.ts new file mode 100644 index 0000000..c2320db --- /dev/null +++ b/src/scheduling/plan-check-time.ts @@ -0,0 +1,42 @@ +import type { DayMatch } from '../results/match-result.js'; +import { parseParisWallClock, toParisWallClock, type ParisWallClock } from './paris-time.js'; + +const MINUTE_MS = 60_000; +/** 2 h: a match lasts about 1 h 40 with half-time, so its result is expected 2 h after kick-off (D4). */ +const CHECK_DELAY_AFTER_KICK_OFF_MS = 7_200_000; +/** When the page gives no kick-off time yet: 15:00, the most common kick-off, plus 2 h. */ +const FALLBACK_CHECK_TIME = '17:00'; +/** A schedule is never created in the past, nor so close to now that it could be. */ +const MIN_DELAY_FROM_NOW_MS = MINUTE_MS; +/** No check, so no notification, from 23:00 to 08:00 (Paris): a check due then runs at 08:00. */ +const QUIET_HOURS = { startHour: 23, endHour: 8 } as const; +const DAY_MS = 86_400_000; + +/** When to check a match of the day for the first time, in Paris wall-clock time. */ +export function planFirstCheck(match: DayMatch, now: Date): ParisWallClock { + const { date } = match.key; + const expected = + match.kickOff === null + ? parseParisWallClock(date, FALLBACK_CHECK_TIME) + : new Date(parseParisWallClock(date, match.kickOff).getTime() + CHECK_DELAY_AFTER_KICK_OFF_MS); + const earliest = new Date(toParisWallClock(now).getTime() + MIN_DELAY_FROM_NOW_MS); + return moveOutOfQuietHours(expected > earliest ? expected : earliest); +} + +/** A time inside the quiet hours becomes the end of the quiet hours; any other time is kept. */ +export function moveOutOfQuietHours(wallClock: ParisWallClock): ParisWallClock { + const hour = wallClock.getUTCHours(); + if (hour >= QUIET_HOURS.startHour) { + return atQuietHoursEnd(new Date(wallClock.getTime() + DAY_MS)); + } + if (hour < QUIET_HOURS.endHour) { + return atQuietHoursEnd(wallClock); + } + return wallClock; +} + +function atQuietHoursEnd(day: ParisWallClock): ParisWallClock { + const morning = new Date(day); + morning.setUTCHours(QUIET_HOURS.endHour, 0, 0, 0); + return morning; +} diff --git a/test/scheduling/match-schedule.test.ts b/test/scheduling/match-schedule.test.ts new file mode 100644 index 0000000..d4caca7 --- /dev/null +++ b/test/scheduling/match-schedule.test.ts @@ -0,0 +1,66 @@ +import { ConflictException, ValidationException } from '@aws-sdk/client-scheduler'; +import { describe, expect, it, vi } from 'vitest'; +import { + buildMatchCheckCommand, + buildScheduleName, + scheduleMatchCheck, +} from '../../src/scheduling/match-schedule.js'; +import { parseParisWallClock } from '../../src/scheduling/paris-time.js'; + +const KEY = { date: '2026-11-01', homeTeam: 'Saint-Estève XIII Catalan', awayTeam: 'Lézignan' }; +const TARGET = { + scheduleGroupName: 'final-whistle-match-checks', + functionArn: 'arn:aws:lambda:eu-west-3:123456789012:function:fetch-results', + roleArn: 'arn:aws:iam::123456789012:role/match-checks', +}; +const CHECK_AT = parseParisWallClock('2026-11-01', '17:00'); + +describe('buildScheduleName', () => { + it('is a short name with only allowed characters, the same on every run', () => { + expect(buildScheduleName(KEY)).toMatch(/^match-[0-9a-f]{32}$/u); + expect(buildScheduleName({ ...KEY })).toBe(buildScheduleName(KEY)); + }); + + it('differs from one match to another', () => { + expect(buildScheduleName({ ...KEY, awayTeam: 'Carcassonne' })).not.toBe(buildScheduleName(KEY)); + }); +}); + +describe('buildMatchCheckCommand', () => { + it('invokes fetch-results once with the match key, in Paris time, then deletes itself', () => { + expect(buildMatchCheckCommand(TARGET, KEY, CHECK_AT).input).toEqual({ + Name: buildScheduleName(KEY), + GroupName: TARGET.scheduleGroupName, + Description: 'Check 2026-11-01|Saint-Estève XIII Catalan|Lézignan', + ScheduleExpression: 'at(2026-11-01T17:00:00)', + ScheduleExpressionTimezone: 'Europe/Paris', + FlexibleTimeWindow: { Mode: 'OFF' }, + ActionAfterCompletion: 'DELETE', + Target: { Arn: TARGET.functionArn, RoleArn: TARGET.roleArn, Input: JSON.stringify(KEY) }, + }); + }); +}); + +describe('scheduleMatchCheck', () => { + it('creates the schedule', async () => { + const send = vi.fn().mockResolvedValue({ ScheduleArn: 'arn', $metadata: {} }); + + await expect(scheduleMatchCheck(send, TARGET, KEY, CHECK_AT)).resolves.toBe('created'); + expect(send).toHaveBeenCalledOnce(); + }); + + it('counts a schedule that already exists as done', async () => { + const send = vi.fn().mockRejectedValue(new ConflictException({ message: 'exists', Message: 'exists', $metadata: {} })); + + await expect(scheduleMatchCheck(send, TARGET, KEY, CHECK_AT)).resolves.toBe('already-scheduled'); + }); + + it('fails with the match id and keeps the cause', async () => { + const cause = new ValidationException({ message: 'bad expression', Message: 'bad expression', $metadata: {} }); + const send = vi.fn().mockRejectedValue(cause); + + await expect(scheduleMatchCheck(send, TARGET, KEY, CHECK_AT)).rejects.toThrow( + expect.objectContaining({ message: 'check of 2026-11-01|Saint-Estève XIII Catalan|Lézignan not scheduled', cause }), + ); + }); +}); diff --git a/test/scheduling/paris-time.test.ts b/test/scheduling/paris-time.test.ts new file mode 100644 index 0000000..721a14c --- /dev/null +++ b/test/scheduling/paris-time.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + formatParisWallClock, + getParisDate, + parseParisWallClock, + toParisWallClock, +} from '../../src/scheduling/paris-time.js'; + +describe('toParisWallClock', () => { + it.each([ + ['winter time (UTC+1)', '2026-11-01T09:00:00Z', '2026-11-01T10:00:00'], + ['summer time (UTC+2)', '2026-07-01T08:00:00Z', '2026-07-01T10:00:00'], + ['the next Paris day before midnight UTC', '2026-10-31T23:30:15Z', '2026-11-01T00:30:15'], + ])('reads %s', (_case, instant, expected) => { + expect(formatParisWallClock(toParisWallClock(new Date(instant)))).toBe(expected); + }); +}); + +describe('parseParisWallClock', () => { + it('reads a page date and kick-off time', () => { + expect(formatParisWallClock(parseParisWallClock('2026-11-01', '15:00'))).toBe('2026-11-01T15:00:00'); + }); + + it.each(['25:00', '01:70'])('fails on the impossible time %s', (time) => { + expect(() => parseParisWallClock('2026-11-01', time)).toThrow(`invalid Paris wall clock "2026-11-01 ${time}"`); + }); +}); + +describe('getParisDate', () => { + it('gives the date of a wall clock', () => { + expect(getParisDate(toParisWallClock(new Date('2026-10-31T23:30:00Z')))).toBe('2026-11-01'); + }); +}); diff --git a/test/scheduling/plan-check-time.test.ts b/test/scheduling/plan-check-time.test.ts new file mode 100644 index 0000000..8a51289 --- /dev/null +++ b/test/scheduling/plan-check-time.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { DayMatch } from '../../src/results/match-result.js'; +import { formatParisWallClock, parseParisWallClock } from '../../src/scheduling/paris-time.js'; +import { moveOutOfQuietHours, planFirstCheck } from '../../src/scheduling/plan-check-time.js'; + +/** 10:00 in Paris, when the planner runs. */ +const PLANNER_RUN = new Date('2026-11-01T09:00:00Z'); + +function matchAt(kickOff: string | null): DayMatch { + return { key: { date: '2026-11-01', homeTeam: 'Carcassonne', awayTeam: 'Lézignan' }, kickOff }; +} + +describe('planFirstCheck', () => { + it.each([ + ['2 h after kick-off', '15:00', '2026-11-01T17:00:00'], + ['at 17:00 when the kick-off time is missing', null, '2026-11-01T17:00:00'], + ['just after now when 2 h after kick-off has passed', '05:40', '2026-11-01T10:01:00'], + ['late in the evening, before the quiet hours', '20:30', '2026-11-01T22:30:00'], + ['the next morning when 2 h after kick-off falls in the quiet hours', '21:30', '2026-11-02T08:00:00'], + ['the next morning when 2 h after kick-off is past midnight', '22:15', '2026-11-02T08:00:00'], + ])('checks %s', (_case, kickOff, expected) => { + expect(formatParisWallClock(planFirstCheck(matchAt(kickOff), PLANNER_RUN))).toBe(expected); + }); +}); + +describe('moveOutOfQuietHours', () => { + it.each([ + ['22:59', '2026-11-01T22:59:00'], + ['23:00', '2026-11-02T08:00:00'], + ['02:00', '2026-11-01T08:00:00'], + ['07:59', '2026-11-01T08:00:00'], + ['08:00', '2026-11-01T08:00:00'], + ])('moves %s to %s', (time, expected) => { + expect(formatParisWallClock(moveOutOfQuietHours(parseParisWallClock('2026-11-01', time)))).toBe(expected); + }); +}); From bc0e3e6cc8292b0a931794be8339c29973a2f876 Mon Sep 17 00:00:00 2001 From: TomSoulage Date: Sun, 1 Nov 2026 13:46:56 +0100 Subject: [PATCH 5/6] feat(planner): schedule a check per match every morning Co-Authored-By: Claude Opus 5.5 --- infra/final-whistle-stack.ts | 6 +- infra/match-check-schedules.ts | 77 ++++++++++++++ infra/planner-function.ts | 53 ++++++++++ infra/scheduler-invoke-role.ts | 15 +++ src/planner/handler.ts | 34 +++++++ src/planner/plan-day.ts | 70 +++++++++++++ test/infra/create-app.test.ts | 7 +- test/infra/final-whistle-stack.test.ts | 114 +++++++++++++++++++-- test/planner/plan-day.test.ts | 134 +++++++++++++++++++++++++ 9 files changed, 499 insertions(+), 11 deletions(-) create mode 100644 infra/match-check-schedules.ts create mode 100644 infra/planner-function.ts create mode 100644 infra/scheduler-invoke-role.ts create mode 100644 src/planner/handler.ts create mode 100644 src/planner/plan-day.ts create mode 100644 test/planner/plan-day.test.ts diff --git a/infra/final-whistle-stack.ts b/infra/final-whistle-stack.ts index 7f9365d..91e0e28 100644 --- a/infra/final-whistle-stack.ts +++ b/infra/final-whistle-stack.ts @@ -2,7 +2,9 @@ import { Stack, type StackProps } from 'aws-cdk-lib'; import { EventBus } from 'aws-cdk-lib/aws-events'; import type { Construct } from 'constructs'; import { FetchResultsFunction } from './fetch-results-function.js'; +import { MatchCheckSchedules } from './match-check-schedules.js'; import { NotifyFunction } from './notify-function.js'; +import { PlannerFunction } from './planner-function.js'; import { createSentMatchesTable } from './sent-matches-table.js'; export const EVENT_BUS_NAME = 'final-whistle'; @@ -13,7 +15,9 @@ export class FinalWhistleStack extends Stack { super(scope, id, props); // Created with its producer; consumers subscribe with their own rules (D6). const eventBus = new EventBus(this, 'EventBus', { eventBusName: EVENT_BUS_NAME }); - new FetchResultsFunction(this, 'FetchResults', { eventBus }); + const fetchResults = new FetchResultsFunction(this, 'FetchResults', { eventBus }); + const matchChecks = new MatchCheckSchedules(this, 'MatchChecks', { checkFunction: fetchResults.lambdaFunction }); + new PlannerFunction(this, 'Planner', { matchChecks }); new NotifyFunction(this, 'Notify', { eventBus, sentMatches: createSentMatchesTable(this) }); } } diff --git a/infra/match-check-schedules.ts b/infra/match-check-schedules.ts new file mode 100644 index 0000000..58ecec1 --- /dev/null +++ b/infra/match-check-schedules.ts @@ -0,0 +1,77 @@ +import { ArnFormat, Stack, Validations } from 'aws-cdk-lib'; +import { PolicyStatement, type Role } from 'aws-cdk-lib/aws-iam'; +import type { IFunction } from 'aws-cdk-lib/aws-lambda'; +import { ScheduleGroup } from 'aws-cdk-lib/aws-scheduler'; +import { Construct } from 'constructs'; +import { + MATCH_CHECK_FUNCTION_VARIABLE, + MATCH_CHECK_GROUP_VARIABLE, + MATCH_CHECK_ROLE_VARIABLE, +} from '../src/scheduling/environment.js'; +import type { PipelineFunction } from './pipeline-function.js'; +import { createSchedulerInvokeRole, SCHEDULER_SERVICE } from './scheduler-invoke-role.js'; + +const MATCH_CHECK_GROUP_NAME = 'final-whistle-match-checks'; + +export interface MatchCheckSchedulesProps { + /** The fetch-results function each match check invokes. */ + readonly checkFunction: IFunction; +} + +/** + * The one-time match checks (D4): their schedule group, and the role Scheduler invokes fetch-results with. + * Lambdas allowed to schedule checks may only create schedules in this group, with this role. + */ +export class MatchCheckSchedules extends Construct { + /** Where a check goes, for the environment of a Lambda that schedules checks. */ + readonly environment: Readonly>; + private readonly invokeRole: Role; + + constructor(scope: Construct, id: string, props: MatchCheckSchedulesProps) { + super(scope, id); + new ScheduleGroup(this, 'Group', { scheduleGroupName: MATCH_CHECK_GROUP_NAME }); + this.invokeRole = createSchedulerInvokeRole( + this, + 'InvokeRole', + 'EventBridge Scheduler: invoke fetch-results for a match check', + ); + this.invokeRole.addToPolicy( + new PolicyStatement({ actions: ['lambda:InvokeFunction'], resources: [props.checkFunction.functionArn] }), + ); + this.environment = { + [MATCH_CHECK_GROUP_VARIABLE]: MATCH_CHECK_GROUP_NAME, + [MATCH_CHECK_FUNCTION_VARIABLE]: props.checkFunction.functionArn, + [MATCH_CHECK_ROLE_VARIABLE]: this.invokeRole.roleArn, + }; + } + + /** Allows creating schedules in the group only, and passing the invoke role to Scheduler only. */ + grantScheduleChecks(pipelineFunction: PipelineFunction): void { + const { lambdaFunction } = pipelineFunction; + lambdaFunction.addToRolePolicy( + new PolicyStatement({ actions: ['scheduler:CreateSchedule'], resources: [this.getSchedulesInGroupArn()] }), + ); + lambdaFunction.addToRolePolicy( + new PolicyStatement({ + actions: ['iam:PassRole'], + resources: [this.invokeRole.roleArn], + conditions: { StringEquals: { 'iam:PassedToService': SCHEDULER_SERVICE } }, + }), + ); + Validations.of(pipelineFunction).acknowledge({ + id: `AwsSolutions::AwsSolutions-IAM5[Resource::arn::scheduler:::schedule/${MATCH_CHECK_GROUP_NAME}/*]`, + reason: + 'Each match check gets its own schedule, named at run time from the match id, so the names cannot be ' + + 'listed in advance; the wildcard is limited to the match check group and to CreateSchedule.', + }); + } + + private getSchedulesInGroupArn(): string { + return Stack.of(this).formatArn({ + service: 'scheduler', + resource: 'schedule', + resourceName: `${MATCH_CHECK_GROUP_NAME}/*`, + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); + } +} diff --git a/infra/planner-function.ts b/infra/planner-function.ts new file mode 100644 index 0000000..005ddf5 --- /dev/null +++ b/infra/planner-function.ts @@ -0,0 +1,53 @@ +import { fileURLToPath } from 'node:url'; +import { Stack, TimeZone, Validations } from 'aws-cdk-lib'; +import type { CfnFunction } from 'aws-cdk-lib/aws-lambda'; +import { Schedule, ScheduleExpression } from 'aws-cdk-lib/aws-scheduler'; +import { LambdaInvoke } from 'aws-cdk-lib/aws-scheduler-targets'; +import type { Construct } from 'constructs'; +import type { MatchCheckSchedules } from './match-check-schedules.js'; +import { PipelineFunction } from './pipeline-function.js'; +import { createSchedulerInvokeRole } from './scheduler-invoke-role.js'; + +const HANDLER_ENTRY = fileURLToPath(new URL('../src/planner/handler.ts', import.meta.url)); +// One page at 10 s, plus one CreateSchedule call per match (about fifteen on a busy day). +const TIMEOUT_SECONDS = 30; +// Same base as fetch-results (Node runtime, AWS SDK client, one page parsed). +const MEMORY_SIZE_MB = 256; +/** 10:00 in Paris: the page of the day has had time to fill in, and quiet hours are over (D4). */ +const PLANNER_HOUR = '10'; + +export interface PlannerFunctionProps { + readonly matchChecks: MatchCheckSchedules; +} + +/** The planner Lambda, run every morning: it may only read the page and schedule match checks. */ +export class PlannerFunction extends PipelineFunction { + constructor(scope: Construct, id: string, props: PlannerFunctionProps) { + super(scope, id, { + entry: HANDLER_ENTRY, + roleDescription: 'planner Lambda: write its logs and schedule match checks', + timeoutSeconds: TIMEOUT_SECONDS, + memorySizeMb: MEMORY_SIZE_MB, + environment: props.matchChecks.environment, + }); + props.matchChecks.grantScheduleChecks(this); + this.addMorningSchedule(); + } + + /** In the default group, so the planner's rights on the match check group never reach its own trigger. */ + private addMorningSchedule(): void { + const role = createSchedulerInvokeRole(this, 'MorningScheduleRole', 'EventBridge Scheduler: run the planner'); + new Schedule(this, 'MorningSchedule', { + description: 'Run the planner every morning (Paris time)', + schedule: ScheduleExpression.cron({ minute: '0', hour: PLANNER_HOUR, timeZone: TimeZone.EUROPE_PARIS }), + target: new LambdaInvoke(this.lambdaFunction, { role }), + }); + const functionLogicalId: unknown = Stack.of(this).resolve((this.lambdaFunction.node.defaultChild as CfnFunction).logicalId); + Validations.of(role).acknowledge({ + id: `AwsSolutions::AwsSolutions-IAM5[Resource::<${String(functionLogicalId)}.Arn>:*]`, + reason: + 'The CDK Lambda target grants invoke on the planner and its versions and aliases (`:*`): ' + + 'the wildcard never reaches another function.', + }); + } +} diff --git a/infra/scheduler-invoke-role.ts b/infra/scheduler-invoke-role.ts new file mode 100644 index 0000000..2915259 --- /dev/null +++ b/infra/scheduler-invoke-role.ts @@ -0,0 +1,15 @@ +import { Stack } from 'aws-cdk-lib'; +import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import type { Construct } from 'constructs'; + +export const SCHEDULER_SERVICE = 'scheduler.amazonaws.com'; + +/** A role for EventBridge Scheduler that only schedules of this account may use (confused deputy). */ +export function createSchedulerInvokeRole(scope: Construct, id: string, description: string): Role { + return new Role(scope, id, { + assumedBy: new ServicePrincipal(SCHEDULER_SERVICE, { + conditions: { StringEquals: { 'aws:SourceAccount': Stack.of(scope).account } }, + }), + description, + }); +} diff --git a/src/planner/handler.ts b/src/planner/handler.ts new file mode 100644 index 0000000..045c973 --- /dev/null +++ b/src/planner/handler.ts @@ -0,0 +1,34 @@ +import { SchedulerClient } from '@aws-sdk/client-scheduler'; +import { listDayMatches } from '../adapters/treize-mondial/treize-mondial-adapter.js'; +import type { MatchKey } from '../results/match-result.js'; +import { + MATCH_CHECK_FUNCTION_VARIABLE, + MATCH_CHECK_GROUP_VARIABLE, + MATCH_CHECK_ROLE_VARIABLE, +} from '../scheduling/environment.js'; +import { scheduleMatchCheck, type MatchCheckTarget } from '../scheduling/match-schedule.js'; +import type { ParisWallClock } from '../scheduling/paris-time.js'; +import { getRequiredEnv } from '../shared/get-required-env.js'; +import { createLogger, type LogFields } from '../shared/logger.js'; +import { planDay, type DayPlan } from './plan-day.js'; + +const SERVICE_NAME = 'planner'; + +/** Read once, at cold start. */ +const matchCheckTarget: MatchCheckTarget = { + scheduleGroupName: getRequiredEnv(MATCH_CHECK_GROUP_VARIABLE), + functionArn: getRequiredEnv(MATCH_CHECK_FUNCTION_VARIABLE), + roleArn: getRequiredEnv(MATCH_CHECK_ROLE_VARIABLE), +}; +const scheduler = new SchedulerClient({}); + +/** planner Lambda: only wires the adapter, Scheduler, the clock and the logs to `planDay`. */ +export async function handler(): Promise { + return planDay({ + listDayMatches, + scheduleMatchCheck: (key: MatchKey, checkAt: ParisWallClock) => + scheduleMatchCheck((command) => scheduler.send(command), matchCheckTarget, key, checkAt), + createLogger: (context: LogFields) => createLogger({ service: SERVICE_NAME, ...context }), + now: () => new Date(), + }); +} diff --git a/src/planner/plan-day.ts b/src/planner/plan-day.ts new file mode 100644 index 0000000..b9f6fbb --- /dev/null +++ b/src/planner/plan-day.ts @@ -0,0 +1,70 @@ +import { buildMatchId, type DayMatch, type MatchKey } from '../results/match-result.js'; +import type { ScheduleOutcome } from '../scheduling/match-schedule.js'; +import { + formatParisWallClock, + getParisDate, + toParisWallClock, + type ParisWallClock, +} from '../scheduling/paris-time.js'; +import { planFirstCheck } from '../scheduling/plan-check-time.js'; +import { describeError, type LogFields, type Logger } from '../shared/logger.js'; + +export interface PlanDayDependencies { + readonly listDayMatches: (isoDate: string) => Promise; + readonly scheduleMatchCheck: (key: MatchKey, checkAt: ParisWallClock) => Promise; + readonly createLogger: (context: LogFields) => Logger; + readonly now: () => Date; +} + +export interface DayPlan { + readonly date: string; + readonly matchCount: number; +} + +/** + * Schedules one check per match of the day (D4). A match that fails does not stop the others: the run + * fails at the end, and its retry finds the other matches already scheduled. + */ +export async function planDay(dependencies: PlanDayDependencies): Promise { + const now = dependencies.now(); + const date = getParisDate(toParisWallClock(now)); + const logger = dependencies.createLogger({ date }); + const matches = await listMatches(date, logger, dependencies); + const plannedMatches: boolean[] = []; + for (const match of matches) { + plannedMatches.push(await planMatch(match, now, dependencies)); + } + const failedCount = plannedMatches.filter((isPlanned) => !isPlanned).length; + logger.info('day planned', { matchCount: matches.length, failedCount }); + if (failedCount > 0) { + throw new Error(`${String(failedCount)} of ${String(matches.length)} match checks not scheduled for ${date}`); + } + return { date, matchCount: matches.length }; +} + +async function listMatches(date: string, logger: Logger, dependencies: PlanDayDependencies): Promise { + try { + return await dependencies.listDayMatches(date); + } catch (error) { + logger.error('matches of the day not read', { error: describeError(error) }); + throw error; + } +} + +/** Returns whether the check is scheduled; a failure is logged with the match id. */ +async function planMatch(match: DayMatch, now: Date, dependencies: PlanDayDependencies): Promise { + const logger = dependencies.createLogger({ matchId: buildMatchId(match.key) }); + try { + const checkAt = planFirstCheck(match, now); + const fields = { kickOff: match.kickOff, checkAt: formatParisWallClock(checkAt) }; + if (match.kickOff === null) { + logger.warn('kick-off time missing, fallback check time used', fields); + } + const outcome = await dependencies.scheduleMatchCheck(match.key, checkAt); + logger.info('match check scheduled', { ...fields, outcome }); + return true; + } catch (error) { + logger.error('match check not scheduled', { kickOff: match.kickOff, error: describeError(error) }); + return false; + } +} diff --git a/test/infra/create-app.test.ts b/test/infra/create-app.test.ts index c81c6c4..fe60f93 100644 --- a/test/infra/create-app.test.ts +++ b/test/infra/create-app.test.ts @@ -3,6 +3,9 @@ import { Bucket } from 'aws-cdk-lib/aws-s3'; import { describe, expect, it } from 'vitest'; import { APP_STACK_ID, createApp } from '../../infra/create-app.js'; +/** A full synthesis bundles every Lambda, which can exceed the default 5 s under load. */ +const SYNTH_TIMEOUT_MS = 30_000; + function getAppStack(app: ReturnType): Stack { return app.node.findChild(APP_STACK_ID) as Stack; } @@ -10,11 +13,11 @@ function getAppStack(app: ReturnType): Stack { describe('createApp', () => { it('synthesizes the application stack without a cdk-nag violation', () => { expect(() => createApp().synth()).not.toThrow(); - }); + }, SYNTH_TIMEOUT_MS); it('fails synthesis when a resource breaks a cdk-nag AWS Solutions rule', () => { const app = createApp(); new Bucket(getAppStack(app), 'BucketWithoutAccessLogs'); expect(() => app.synth()).toThrow(/AwsSolutions/); - }); + }, SYNTH_TIMEOUT_MS); }); diff --git a/test/infra/final-whistle-stack.test.ts b/test/infra/final-whistle-stack.test.ts index 613cdf5..88f6857 100644 --- a/test/infra/final-whistle-stack.test.ts +++ b/test/infra/final-whistle-stack.test.ts @@ -5,9 +5,12 @@ import { EVENT_BUS_NAME, FinalWhistleStack } from '../../infra/final-whistle-sta let template: Template; +/** Synthesizing the stack bundles every Lambda, which can exceed the default 10 s under load. */ +const SYNTH_TIMEOUT_MS = 30_000; + beforeAll(() => { template = Template.fromStack(new FinalWhistleStack(new App(), 'TestFinalWhistle')); -}); +}, SYNTH_TIMEOUT_MS); /** The logical id of the only resource of this type under a construct, e.g. `Notify`. */ function getLogicalId(type: string, constructId = ''): string { @@ -37,6 +40,20 @@ const LOG_WRITE_STATEMENT = (constructId: string) => ({ Resource: { 'Fn::GetAtt': [getLogicalId('AWS::Logs::LogGroup', constructId), 'Arn'] }, }); +const LAMBDA_TRUST = { Statement: [Match.objectLike({ Principal: { Service: 'lambda.amazonaws.com' } })] }; + +/** Only schedules of this account may use a Scheduler role (confused deputy). */ +const SCHEDULER_TRUST = { + Statement: [ + { + Action: 'sts:AssumeRole', + Condition: { StringEquals: { 'aws:SourceAccount': { Ref: 'AWS::AccountId' } } }, + Effect: 'Allow', + Principal: { Service: 'scheduler.amazonaws.com' }, + }, + ], +}; + function getParameterArn(name: string): unknown { return { 'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':ssm:', { Ref: 'AWS::Region' }, ':', { Ref: 'AWS::AccountId' }, `:parameter${name}`]], @@ -50,16 +67,15 @@ describe('FinalWhistleStack', () => { }); it('keeps the logs of each Lambda in its own log group, for a limited time', () => { - template.resourceCountIs('AWS::Logs::LogGroup', 2); + template.resourceCountIs('AWS::Logs::LogGroup', 3); template.allResourcesProperties('AWS::Logs::LogGroup', { RetentionInDays: 30 }); }); - it('attaches no managed policy to any Lambda role', () => { - template.resourceCountIs('AWS::IAM::Role', 2); - template.allResourcesProperties('AWS::IAM::Role', { - AssumeRolePolicyDocument: { Statement: [Match.objectLike({ Principal: { Service: 'lambda.amazonaws.com' } })] }, - ManagedPolicyArns: Match.absent(), - }); + it('gives each Lambda and each kind of schedule its own role, with no managed policy', () => { + template.resourceCountIs('AWS::IAM::Role', 5); + template.allResourcesProperties('AWS::IAM::Role', { ManagedPolicyArns: Match.absent() }); + template.resourcePropertiesCountIs('AWS::IAM::Role', { AssumeRolePolicyDocument: LAMBDA_TRUST }, 3); + template.resourcePropertiesCountIs('AWS::IAM::Role', { AssumeRolePolicyDocument: SCHEDULER_TRUST }, 2); }); }); @@ -153,3 +169,85 @@ describe('sent-matches table', () => { }); }); }); + +describe('match checks', () => { + it('have their own schedule group', () => { + template.resourceCountIs('AWS::Scheduler::ScheduleGroup', 1); + template.hasResourceProperties('AWS::Scheduler::ScheduleGroup', { Name: 'final-whistle-match-checks' }); + }); + + it('invoke fetch-results with a role that can do nothing else', () => { + expect(getProperties('AWS::IAM::Role', 'MatchChecks')).toMatchObject({ AssumeRolePolicyDocument: SCHEDULER_TRUST }); + expectPolicyStatements('MatchChecks', [ + { + Action: 'lambda:InvokeFunction', + Effect: 'Allow', + Resource: { 'Fn::GetAtt': [getLogicalId('AWS::Lambda::Function', 'FetchResults'), 'Arn'] }, + }, + ]); + }); +}); + +describe('planner', () => { + it('runs on Node.js 24, arm64, 256 MB, with where to send match checks in its environment', () => { + expect(getProperties('AWS::Lambda::Function', 'Planner')).toMatchObject({ + Runtime: 'nodejs24.x', + Architectures: ['arm64'], + Timeout: 30, + MemorySize: 256, + Environment: { + Variables: { + MATCH_CHECK_SCHEDULE_GROUP: 'final-whistle-match-checks', + MATCH_CHECK_FUNCTION_ARN: { 'Fn::GetAtt': [getLogicalId('AWS::Lambda::Function', 'FetchResults'), 'Arn'] }, + MATCH_CHECK_ROLE_ARN: { 'Fn::GetAtt': [getLogicalId('AWS::IAM::Role', 'MatchChecks'), 'Arn'] }, + }, + }, + LoggingConfig: { LogGroup: { Ref: getLogicalId('AWS::Logs::LogGroup', 'Planner') } }, + }); + }); + + it('may only write its logs, create schedules in the match check group, and pass their role to Scheduler', () => { + expectPolicyStatements('PlannerRoleDefaultPolicy', [ + LOG_WRITE_STATEMENT('Planner'), + { + Action: 'scheduler:CreateSchedule', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + ['arn:', { Ref: 'AWS::Partition' }, ':scheduler:', { Ref: 'AWS::Region' }, ':', { Ref: 'AWS::AccountId' }, ':schedule/final-whistle-match-checks/*'], + ], + }, + }, + { + Action: 'iam:PassRole', + Condition: { StringEquals: { 'iam:PassedToService': 'scheduler.amazonaws.com' } }, + Effect: 'Allow', + Resource: { 'Fn::GetAtt': [getLogicalId('AWS::IAM::Role', 'MatchChecks'), 'Arn'] }, + }, + ]); + }); + + it('runs every day at 10:00 Paris time, from a schedule outside the match check group', () => { + template.resourceCountIs('AWS::Scheduler::Schedule', 1); + template.hasResourceProperties('AWS::Scheduler::Schedule', { + ScheduleExpression: 'cron(0 10 * * ? *)', + ScheduleExpressionTimezone: 'Europe/Paris', + GroupName: Match.absent(), + Target: Match.objectLike({ + Arn: { 'Fn::GetAtt': [getLogicalId('AWS::Lambda::Function', 'Planner'), 'Arn'] }, + RoleArn: { 'Fn::GetAtt': [getLogicalId('AWS::IAM::Role', 'PlannerMorningScheduleRole'), 'Arn'] }, + }), + }); + }); + + it('is run by a role that may only invoke the planner', () => { + expect(getProperties('AWS::IAM::Role', 'PlannerMorningScheduleRole')).toMatchObject({ + AssumeRolePolicyDocument: SCHEDULER_TRUST, + }); + const plannerArn = { 'Fn::GetAtt': [getLogicalId('AWS::Lambda::Function', 'Planner'), 'Arn'] }; + expectPolicyStatements('PlannerMorningScheduleRole', [ + { Action: 'lambda:InvokeFunction', Effect: 'Allow', Resource: [plannerArn, { 'Fn::Join': ['', [plannerArn, ':*']] }] }, + ]); + }); +}); diff --git a/test/planner/plan-day.test.ts b/test/planner/plan-day.test.ts new file mode 100644 index 0000000..e47cc08 --- /dev/null +++ b/test/planner/plan-day.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; +import { planDay, type PlanDayDependencies } from '../../src/planner/plan-day.js'; +import type { DayMatch } from '../../src/results/match-result.js'; +import { formatParisWallClock } from '../../src/scheduling/paris-time.js'; +import { captureLogs } from '../capture-logs.js'; + +/** 10:00 in Paris, when the planner runs. */ +const NOW = new Date('2026-11-01T09:00:00Z'); +const AFTERNOON_MATCH: DayMatch = { + key: { date: '2026-11-01', homeTeam: 'Carcassonne', awayTeam: 'Lézignan' }, + kickOff: '15:00', +}; +const UNTIMED_MATCH: DayMatch = { + key: { date: '2026-11-01', homeTeam: 'Limoux', awayTeam: 'Albi' }, + kickOff: null, +}; + +function setUp( + matches: Promise, + scheduleMatchCheck: PlanDayDependencies['scheduleMatchCheck'] = () => Promise.resolve('created'), +) { + const { lines, createLogger } = captureLogs(); + const dependencies = { + listDayMatches: vi.fn(() => matches), + scheduleMatchCheck: vi.fn(scheduleMatchCheck), + createLogger, + now: () => NOW, + }; + return { dependencies, lines }; +} + +describe('planDay', () => { + it('schedules a check 2 h after kick-off for each match of the Paris day', async () => { + const { dependencies, lines } = setUp(Promise.resolve([AFTERNOON_MATCH])); + + await expect(planDay(dependencies)).resolves.toEqual({ date: '2026-11-01', matchCount: 1 }); + + expect(dependencies.listDayMatches).toHaveBeenCalledWith('2026-11-01'); + const [key, checkAt] = dependencies.scheduleMatchCheck.mock.calls[0] ?? []; + expect(key).toEqual(AFTERNOON_MATCH.key); + expect(checkAt && formatParisWallClock(checkAt)).toBe('2026-11-01T17:00:00'); + expect(lines).toEqual([ + { + level: 'INFO', + message: 'match check scheduled', + matchId: '2026-11-01|Carcassonne|Lézignan', + kickOff: '15:00', + checkAt: '2026-11-01T17:00:00', + outcome: 'created', + }, + { level: 'INFO', message: 'day planned', date: '2026-11-01', matchCount: 1, failedCount: 0 }, + ]); + }); + + it('warns when the kick-off time is missing and checks at the fallback time', async () => { + const { dependencies, lines } = setUp(Promise.resolve([UNTIMED_MATCH])); + + await planDay(dependencies); + + expect(lines[0]).toEqual({ + level: 'WARN', + message: 'kick-off time missing, fallback check time used', + matchId: '2026-11-01|Limoux|Albi', + kickOff: null, + checkAt: '2026-11-01T17:00:00', + }); + }); + + it('logs a match already scheduled as such, so the planner can run twice', async () => { + const { dependencies, lines } = setUp(Promise.resolve([AFTERNOON_MATCH]), () => + Promise.resolve('already-scheduled'), + ); + + await planDay(dependencies); + + expect(lines[0]).toMatchObject({ message: 'match check scheduled', outcome: 'already-scheduled' }); + }); + + it('schedules nothing on a day without matches', async () => { + const { dependencies, lines } = setUp(Promise.resolve([])); + + await expect(planDay(dependencies)).resolves.toEqual({ date: '2026-11-01', matchCount: 0 }); + + expect(dependencies.scheduleMatchCheck).not.toHaveBeenCalled(); + expect(lines).toEqual([{ level: 'INFO', message: 'day planned', date: '2026-11-01', matchCount: 0, failedCount: 0 }]); + }); + + it('schedules the other matches when one fails, then fails for a retry', async () => { + const { dependencies, lines } = setUp(Promise.resolve([AFTERNOON_MATCH, UNTIMED_MATCH]), (key) => + key.homeTeam === 'Carcassonne' ? Promise.reject(new Error('throttled')) : Promise.resolve('created'), + ); + + await expect(planDay(dependencies)).rejects.toThrow('1 of 2 match checks not scheduled for 2026-11-01'); + + expect(dependencies.scheduleMatchCheck).toHaveBeenCalledTimes(2); + expect(lines).toContainEqual( + expect.objectContaining({ + level: 'ERROR', + message: 'match check not scheduled', + matchId: '2026-11-01|Carcassonne|Lézignan', + error: expect.objectContaining({ message: 'throttled' }) as unknown, + }), + ); + expect(lines.at(-1)).toMatchObject({ message: 'day planned', matchCount: 2, failedCount: 1 }); + }); + + it('logs an impossible kick-off time with the match id and still schedules the other matches', async () => { + const impossibleMatch: DayMatch = { ...AFTERNOON_MATCH, kickOff: '25:00' }; + const { dependencies, lines } = setUp(Promise.resolve([impossibleMatch, UNTIMED_MATCH])); + + await expect(planDay(dependencies)).rejects.toThrow('1 of 2 match checks not scheduled for 2026-11-01'); + + expect(dependencies.scheduleMatchCheck).toHaveBeenCalledExactlyOnceWith(UNTIMED_MATCH.key, expect.any(Date)); + expect(lines).toContainEqual( + expect.objectContaining({ + level: 'ERROR', + message: 'match check not scheduled', + matchId: '2026-11-01|Carcassonne|Lézignan', + kickOff: '25:00', + }), + ); + }); + + it('logs a page that cannot be read, then rethrows it for a retry', async () => { + const { dependencies, lines } = setUp(Promise.reject(new Error('page shows 31/10, not 2026-11-01'))); + + await expect(planDay(dependencies)).rejects.toThrow('page shows 31/10'); + + expect(dependencies.scheduleMatchCheck).not.toHaveBeenCalled(); + expect(lines).toEqual([ + expect.objectContaining({ level: 'ERROR', message: 'matches of the day not read', date: '2026-11-01' }), + ]); + }); +}); From 2c8669de1831e20814371bbe6eb52b37743bbc24 Mon Sep 17 00:00:00 2001 From: TomSoulage Date: Sun, 1 Nov 2026 13:46:56 +0100 Subject: [PATCH 6/6] docs: record the planner decisions and update the status Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++--- docs/decisions.md | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index feab40c..fa66949 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ It is also a side project to practise serverless and event-driven design on a re ## How it works 1. **Every morning**, a schedule runs the `planner` Lambda. It reads the day's kick-off times on [Treize Mondial](https://treizemondial.fr/matchs-du-jour-rugby-a-xiii/). -2. For each match, the planner creates a **one-time schedule** about 2 hours after kick-off. No match, nothing is scheduled. +2. For each match, the planner creates a **one-time schedule** about 2 hours after kick-off, never at night (23:00 to 08:00). No match, nothing is scheduled. 3. When a schedule fires, `fetch-results` reads the result through a **source adapter** and publishes a `MatchFinished` event on an EventBridge bus. If the score is not published yet, it reschedules itself one hour later. 4. `notify` is subscribed to `MatchFinished`. For each event it: 1. **checks** in DynamoDB that the score was not already sent, @@ -57,8 +57,9 @@ Work in progress: the diagram shows the target architecture, built one step at a - Done: TypeScript project, required pull request checks (lint, types, unit tests, commitlint, Gitleaks, CodeQL), Dependabot updates, an empty CDK app synthesized on each pull request with cdk-nag rules, GitHub OIDC roles, a `cdk diff` of each pull request and a `cdk deploy` on each merge to `main`. - Done: the Treize Mondial adapter, the event bus and the `fetch-results` Lambda, which publishes `MatchFinished` once a match is finished. -- Done: the `notify` Lambda, subscribed to `MatchFinished`: it checks DynamoDB, sends the score to Telegram, then records it. Nothing invokes `fetch-results` yet. -- Next: the planner and its one-time schedules, then the DLQ, the alarms, the smoke test and the `prod` alias rollback. +- Done: the `notify` Lambda, subscribed to `MatchFinished`: it checks DynamoDB, sends the score to Telegram, then records it. +- Done: the `planner` Lambda, run every day at 10:00 (Paris time): it schedules one `fetch-results` check per match, about 2 hours after kick-off and never at night. A score not published yet at that check is not looked up again. +- Next: rescheduling a check when the score is late, then the DLQ, the alarms, the smoke test and the `prod` alias rollback. Progress is tracked in the [v1 milestone](https://github.com/TomSoulage/final-whistle/milestone/1). diff --git a/docs/decisions.md b/docs/decisions.md index 3eceb41..3225f1b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -19,6 +19,10 @@ Log of the project's structural choices. Each decision has a stable identifier ( - **D4. A morning planner instead of hourly polling.** One run each morning reads the day's kick-off times and creates one **one-time schedule** per match, about 2 hours after kick-off. Compared with polling every hour: far fewer runs, the source website is read only when a result is expected, and nothing runs on days without matches. Trade-off: a match added to the page after the planner has run is not scheduled. - **D5. Reschedule when the score is late.** If the score is not published when the schedule fires, `fetch-results` creates a new schedule one hour later instead of failing. +- **D31. The planner runs at 10:00, Paris time.** A recurring EventBridge Scheduler schedule (`cron(0 10 * * ? *)`, time zone `Europe/Paris`, so daylight saving is handled by AWS) runs `planner`. By 10:00 the page of the day has had time to fill in, which narrows the trade-off of D4 and the missing kick-off times of D34. A match whose check is already due by then (a night match abroad) is checked a minute after the planner runs. The planner reads the day through the adapter (`listDayMatches`), which fails if the main page is not for the Paris date: the run fails loudly instead of scheduling another day's matches. A match whose schedule cannot be created does not stop the others: each failure is logged with its match id, the run fails at the end, and Lambda's retry finds the other matches already scheduled. +- **D32. One-time match checks.** Each check is a one-time schedule in the `final-whistle-match-checks` group, set with an `at()` expression in Paris wall-clock time and the `Europe/Paris` time zone, never in the past (at least a minute ahead). It invokes `fetch-results` with the match key (D28) and deletes itself once it has run. Schedule names only allow letters, digits, `-`, `_` and `.`, so the name is `match-` plus 32 hex characters of the SHA-256 of the match id: the same on every run, so running the planner again creates nothing new while the checks are pending (`ConflictException` counts as already scheduled). A check that has already run has deleted itself, so it is created again and the match checked once more; a score already sent is then dropped by `notify` (D8). Scheduler invokes `fetch-results` with its own role, which may do nothing else; `planner` may only create schedules in that group and pass that role to Scheduler. Both Scheduler roles (match checks and the morning schedule) only trust schedules of this account (`aws:SourceAccount`). Two cdk-nag `IAM5` wildcard findings are acknowledged on their resources (D22): schedule names are computed at run time, and the CDK Lambda target also grants the planner's versions and aliases. The morning schedule stays in the default group, out of reach of the planner's rights. Scheduler is in the Always Free quota (D17). +- **D33. Quiet hours: no check from 23:00 to 08:00, Paris time.** A check due in that window is moved to 08:00, so no score is published, and no notification sent, at night. The rule lives where checks are scheduled, so `notify` stays unaware of it. Limit: a `notify` invocation that fails just before 23:00 is retried by Lambda a few minutes later, so a message can arrive shortly after 23:00. +- **D34. A missing kick-off time is checked at 17:00** (was P2). When the page gives no kick-off time, the first check is at 17:00: 15:00, the most common kick-off on the saved pages, plus 2 hours. The planner logs a warning, so the frequency of the case is measured. It was absent from the 29 matches of the saved pages. ### Events and delivery guarantees @@ -70,5 +74,4 @@ Log of the project's structural choices. Each decision has a stable identifier ( ## To decide - **P1. Several recipients.** Sending to more than one person (a list of chat ids), with one failure not blocking the others. -- **P2. Kick-off times missing in the morning.** What to do when a match has no kick-off time yet when the planner runs. - **P3. Retry limit.** When to stop rescheduling a match whose score never comes (postponed or abandoned match).