diff --git a/docs/test-plans/resend_calendar_invite_manual_testing.md b/docs/test-plans/resend_calendar_invite_manual_testing.md index 8aa8ecba..b8d0e591 100644 --- a/docs/test-plans/resend_calendar_invite_manual_testing.md +++ b/docs/test-plans/resend_calendar_invite_manual_testing.md @@ -1,18 +1,18 @@ # Test Plan: Manually Testing Resend Calendar Invite -Checks that a coach can resend the calendar invite for a series or a one-off session from the session row's ⋮ menu. The item has to be gated correctly, and the resent invite has to leave each calendar with exactly the sessions the app has: no duplicates, moved sessions at their new time, deleted sessions gone. The plan also covers the legacy-series, finished-series and failure paths. +Checks that a coach can resend the calendar invite for a series or a one-off session from the session row's ⋮ menu. The item has to be gated correctly, and the resent invite has to leave each calendar with exactly the sessions the app has: no duplicates, moved sessions at their new time, deleted sessions gone. The plan also covers the legacy-series, finished-series and failure paths. Part D checks that series keep the coach's local time across a daylight saving change, in the app and in Google Calendar. -Backend counterpart: `refactor-platform-rs/docs/implementation-plans/resend-calendar-invite.md`. +Backend counterpart: `refactor-platform-rs/docs/test-plans/ics-calendar-invites-manual-test-plan.md` (section H covers the same daylight saving cases from the backend side). > [!IMPORTANT] > Start the backend with `SESSION_REMINDER_EMAIL_TEMPLATE_ID=` (blank). Otherwise the reminder job mails every participant with a session in the next day, including real coachees in other relationships. > -> This plan sends **real email**. The only recipients allowed are `jim@refactorgroup.com` (coach) and `james.hodapp@gmail.com` (coachee), in relationship `7adf81aa-23f4-4221-9f82-afb4a5f7512e`. Before any step marked ✉, confirm that the relationship's coach and coachee are still exactly those two addresses. Each ✉ shows how many emails the step sends. The whole plan sends about 8. +> This plan sends **real email**. The only recipients allowed are `jim@refactorgroup.com` (coach) and `james.hodapp@gmail.com` (coachee), in relationship `7adf81aa-23f4-4221-9f82-afb4a5f7512e`. Before any step marked ✉, confirm that the relationship's coach and coachee are still exactly those two addresses. Each ✉ shows how many emails the step sends. Parts B and C send about 8, Part D about 10, and the cleanup about 6. ## 1. Prerequisites -1. Backend on `resend-calendar-invite` on `:4000`, migrations applied, with the session/recurring "scheduled", "rescheduled" and "cancelled" template ids set. Keep `RESEND_API_KEY` **unset** while building the fixtures in step 4, so they send no email. Set it and restart the backend before Part B. -2. Frontend on `resend-calendar-invite`, `npm run dev` on `:3000`. +1. Backend on the branch under test on `:4000`, migrations applied, with the session/recurring "scheduled", "rescheduled" and "cancelled" template ids set. Keep `RESEND_API_KEY` **unset** while building the fixtures in step 4, so they send no email. Set it and restart the backend before Part B. +2. Frontend on the branch under test, `npm run dev` on `:3000`. 3. Logins: the coach is `PW_ORG_ADMIN_LOGIN_*` and the coachee is `PW_USER_LOGIN_*`, both from `.env.local`. 4. Fixtures, created in the coach's browser with email off: 1. A weekly series of 6 sessions in the relationship above, starting next week. Neither calendar has it, which is the "lost invite" case. @@ -89,6 +89,54 @@ Run this before Part B, while `RESEND_API_KEY` is still unset. Resend the active **Pass:** the response is a 5xx (500 for missing config; a provider refusal would be 502). The toast reads "Couldn't resend the invite". No email arrives, and the coach can try again. -## 5. Cleanup +## 5. Part D: daylight saving -Delete the test series, sessions and fixtures through SQL. Don't use the API delete routes, which send cancellation emails. +Series must repeat at the coach's local clock time on both sides of a daylight saving change, in the app and in every calendar, and single-session changes after the change must land on the right calendar occurrence. + +**Setup:** set the coach's profile timezone to `America/Chicago` (it is `UTC` in local dev), and note the original value so you can restore it. Use the next US daylight saving change after today as "the change". For example, Nov 1, 2026 is a fall-back change and Mar 14, 2027 is a spring-forward change. Pick series dates so that occurrences fall on both sides of it. Calendar checks happen in the coachee's Google Calendar, viewed in Central time. + +### Case D1: ✉×2 weekly series across the change + +As the coach, create a weekly series on Thursdays at 11:00 AM, with occurrences on both sides of the change. Accept the invite in the coachee's Google Calendar ("All events"). + +**Pass:** +- In the app, every occurrence shows 11:00 AM, both before and after the change. None shows 10:00 AM or 12:00 PM. +- In Google, every occurrence is at 11:00 AM and the dates match the app one for one. + +### Case D2: ✉×2 move an occurrence after the change + +Reschedule one occurrence that falls after the change to 1:00 PM the same day. + +**Pass:** in Google, that occurrence moves to 1:00 PM, the original 11:00 AM slot is empty, and the rest of the series is unchanged. + +### Case D3: ✉×2 delete an occurrence after the change + +Delete a different occurrence that falls after the change. + +**Pass:** in Google, that occurrence is gone and the rest of the series is unchanged. + +### Case D4: ✉×2 snapshot resend + +Resend the series from the ⋮ menu. Accept it again if asked. + +**Pass:** Google still shows one series. The D2 move and the D3 deletion are both in place, and every other occurrence is at 11:00 AM on both sides of the change. + +### Case D5: ✉×2 evening series + +Create a weekly series on Thursdays at 7:00 PM. In UTC that is early Friday morning. + +**Pass:** the series is created without an error. In the app and in Google, every occurrence is on a Thursday at 7:00 PM, on both sides of the change. + +### Case D6: monthly series after the 28th is blocked + +Start creating a monthly series whose first session is on the 30th of a month. + +**Pass:** the form shows "Monthly sessions can't start on the 29th, 30th or 31st. Pick the 28th or earlier." and the submit button stays disabled. Changing the date to the 28th clears the message. No request is sent. + +**Afterwards:** restore the coach's original profile timezone. + +## 6. Cleanup + +✉ Delete the remaining test series and sessions from the dashboard: "Delete series" on each series and "Delete session" on each one-off. This also checks that cancellations reach the calendar. + +**Pass:** each upcoming series and one-off sends one cancellation to each participant, and Google removes those events. Past sessions send nothing. Remove any fixture the dashboard can't reach with SQL. diff --git a/src/types/__tests__/recurrence.test.ts b/src/types/__tests__/recurrence.test.ts index 0d340a94..c2b3fe96 100644 --- a/src/types/__tests__/recurrence.test.ts +++ b/src/types/__tests__/recurrence.test.ts @@ -421,4 +421,61 @@ describe("validateRecurrence", () => { }) ).toBe("Interval must be at least 1."); }); + + describe("monthly start day", () => { + const MONTHLY_DAY_MESSAGE = + "Monthly sessions can't start on the 29th, 30th or 31st. Pick the 28th or earlier."; + const marchDay = (day: number) => + DateTime.fromObject({ year: 2026, month: 3, day, hour: 10 }, { zone: TZ }); + const monthly = (start: DateTime | null) => + validateRecurrence({ + ...baseInput, + frequency: Frequency.Monthly, + byWeekdays: [], + start, + startWeekday: start ? weekdayFromLuxon(start.weekday) : null, + }); + + it.each([29, 30, 31])("rejects a monthly start on day %i", (day) => { + expect(monthly(marchDay(day))).toBe(MONTHLY_DAY_MESSAGE); + }); + + it("accepts a monthly start on day 28", () => { + expect(monthly(marchDay(28))).toBeNull(); + }); + + it.each([Frequency.Weekly, Frequency.Daily])( + "does not apply the rule to %s", + (frequency) => { + const day31 = marchDay(31); + const weekday = weekdayFromLuxon(day31.weekday); + expect( + validateRecurrence({ + ...baseInput, + frequency, + byWeekdays: [weekday], + start: day31, + startWeekday: weekday, + }) + ).toBeNull(); + } + ); + + it("uses the local day, not the UTC day", () => { + // 29th in UTC, 28th locally. + expect( + monthly( + DateTime.fromISO("2026-03-28T19:00", { zone: "America/Chicago" }) + ) + ).toBeNull(); + // 28th in UTC, 29th locally. + expect( + monthly(DateTime.fromISO("2026-03-29T08:00", { zone: "Asia/Tokyo" })) + ).toBe(MONTHLY_DAY_MESSAGE); + }); + + it("skips the check when no start is set", () => { + expect(monthly(null)).toBeNull(); + }); + }); }); diff --git a/src/types/recurrence.ts b/src/types/recurrence.ts index 5850085c..7eef52d8 100644 --- a/src/types/recurrence.ts +++ b/src/types/recurrence.ts @@ -233,6 +233,9 @@ export function validateRecurrence({ } } } + if (frequency === Frequency.Monthly && start && start.day > 28) { + return "Monthly sessions can't start on the 29th, 30th or 31st. Pick the 28th or earlier."; + } if (frequencySupportsWeekdays(frequency)) { if (byWeekdays.length === 0) { return "Pick at least one day of the week.";