Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"@types/graphql-upload": "^8.0.4",
"@types/jsonwebtoken": "^8.5.1",
"@types/knuth-shuffle-seeded": "^1.0.2",
"@types/luxon": "^1.26.5",
"@types/luxon": "^3.7.5",
"@types/node": "^15.3.0",
"@types/node-fetch": "2",
"@types/nodemailer": "^6.4.1",
Expand Down
318 changes: 318 additions & 0 deletions src/email/templates/matchTeamIntro.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/**
* Offline unit tests for matchTeamIntro email template generation. Verifies that the
* "Suggested Meeting Times (all students available)" block (commonTimeslots) is correctly
* computed from students' timeManagementPlan data, and that the luxon DateTime.fromObject
* call in localIntervalToUtc no longer throws InvalidUnitError.
*
* These tests exercise the real getList() public API with a stubbed Prisma client, so they
* cover the actual localIntervalToUtc / findCommonTimeslots code path in matchTeamIntro.ts.
*
* Run with:
* npx tsx src/email/templates/matchTeamIntro.test.ts
*/
import 'reflect-metadata';
import { MentorStatus, StudentStatus } from '@prisma/client';
import { ProjectStatus } from '../../enums';
import Handlebars from 'handlebars';
import { getList } from './matchTeamIntro';

let failures = 0;

function assert(condition: boolean, message: string): void {
if (!condition) {
failures += 1;
console.error(`FAILED: ${message}`);
} else {
console.log(`PASSED: ${message}`);
}
}

// JSON.stringify with object keys sorted recursively, so object key order (which is
// semantically insignificant) does not cause spurious failures. Array order is preserved.
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
}
return JSON.stringify(value);
}

function assertEqual<T>(actual: T, expected: T, message: string): void {
const a = stableStringify(actual);
const e = stableStringify(expected);
if (a !== e) {
failures += 1;
console.error(`FAILED: ${message}\n expected: ${e}\n actual: ${a}`);
} else {
console.log(`PASSED: ${message}`);
}
}

type Interval = { start: number; end: number };
type TimeManagementPlan = Record<string, Interval[]>;

interface StudentStub {
timezone?: string;
timeManagementPlan?: TimeManagementPlan;
status: StudentStatus;
email: string;
givenName: string;
surname: string;
}

function student(overrides: Partial<StudentStub> = {}): StudentStub {
return {
status: StudentStatus.ACCEPTED,
email: 'student@example.com',
givenName: 'Student',
surname: 'One',
...overrides,
};
}

function mentor(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
return {
status: MentorStatus.ACCEPTED,
email: 'mentor@example.com',
givenName: 'Mentor',
surname: 'One',
...overrides,
};
}

function project(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
return {
id: 'project-1',
eventId: 'event-1',
status: ProjectStatus.MATCHED,
students: [] as StudentStub[],
mentors: [] as Record<string, unknown>[],
issueUrl: 'https://example.com/issue/1',
description: 'A project',
deliverables: 'Some deliverables',
...overrides,
};
}

// A Prisma stub: project.findMany resolves to a fixed list of projects. No DB needed.
function fakePrisma(projects: Record<string, unknown>[]): { project: { findMany: () => Promise<Record<string, unknown>[]> } } {
return {
project: {
findMany: async () => projects,
},
};
}

const EVENT = {
id: 'event-1',
name: 'Test Event',
emailSignature: '',
title: 'Test',
startsAt: new Date('2024-01-01T00:00:00Z'),
defaultWeeks: 8,
};

// Minutes-since-midnight helpers for readability.
function mins(h: number, m = 0): number {
return h * 60 + m;
}

// --- Regression: the original bug -----------------------------------------------------------
// Before the fix, the very first localIntervalToUtc call threw InvalidUnitError, which the
// try/catch in findCommonTimeslots swallowed and returned {}, so getList collapsed
// commonTimeslots to undefined and the email rendered the When2meet {{else}} branch even
// though students supplied fully overlapping availability.
async function testOverlappingSameTimezoneProducesCommonTimeslots(): Promise<void> {
const plan: TimeManagementPlan = { tuesday: [{ start: mins(9), end: mins(10) }] };
const projects = [
project({
students: [
student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: plan }),
student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/New_York', timeManagementPlan: plan }),
],
mentors: [mentor()],
}),
];

const result = await getList(fakePrisma(projects) as any, EVENT as any);
assertEqual(result.length, 1, 'One email context per project');
const ctx = result[0] as { project: unknown; commonTimeslots?: Record<string, Record<string, string[]>> };
assert(ctx.commonTimeslots !== undefined, 'commonTimeslots is populated (not the bug-masked undefined)');
assertEqual(
ctx.commonTimeslots,
{ tuesday: { 'America/New_York': ['9:00 AM - 10:00 AM'] } },
'Overlapping same-timezone availability renders a single NY-formatted timeslot on Tuesday',
);
}

// --- Cross-timezone overlap proves the UTC conversion is actually correct --------------------
// NY 9-11am EST and LA 6-8am PST both map to 14:00-16:00 UTC; the overlap must be rendered in
// each student's own timezone. This is the case the bug report highlights: students supplied
// data, a real overlap exists, and the template was written to render it.
async function testOverlappingAcrossTimezonesRendersInEachTimezone(): Promise<void> {
const nyPlan: TimeManagementPlan = { tuesday: [{ start: mins(9), end: mins(11) }] };
const laPlan: TimeManagementPlan = { tuesday: [{ start: mins(6), end: mins(8) }] };
const projects = [
project({
students: [
student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: nyPlan }),
student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/Los_Angeles', timeManagementPlan: laPlan }),
],
mentors: [mentor()],
}),
];

const result = await getList(fakePrisma(projects) as any, EVENT as any);
const ctx = result[0] as { commonTimeslots?: Record<string, Record<string, string[]>> };
assert(ctx.commonTimeslots !== undefined, 'Cross-timezone overlap is detected (not swallowed by a thrown error)');
assertEqual(
ctx.commonTimeslots,
{
tuesday: {
'America/Los_Angeles': ['6:00 AM - 8:00 AM'],
'America/New_York': ['9:00 AM - 11:00 AM'],
},
},
'The 14:00-16:00 UTC overlap is rendered in each student local timezone',
);
}

// --- No console.warn should be emitted when students supplied real data ---------------------
// Before the fix, findCommonTimeslots logged `Error occurred while finding common timeslots:
// InvalidUnitError - Invalid unit zone` and returned {}. After the fix, no warning is emitted
// for valid data. Guards against the silent try/catch masking returning.
async function testNoSwallowedErrorWhenDataSupplied(): Promise<void> {
const plan: TimeManagementPlan = { tuesday: [{ start: mins(9), end: mins(10) }] };
const projects = [
project({
students: [
student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: plan }),
student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/New_York', timeManagementPlan: plan }),
],
mentors: [mentor()],
}),
];

let warned = false;
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (String(args[0]).includes('finding common timeslots')) warned = true;
originalWarn.apply(console, args as never);
};
try {
await getList(fakePrisma(projects) as any, EVENT as any);
} finally {
console.warn = originalWarn;
}
assert(!warned, 'No "Error occurred while finding common timeslots" warning is emitted when students supplied valid overlapping data');
}

// --- Legitimate empty result: students supplied no availability data -------------------------
// When students have a timezone but no timeManagementPlan, findCommonTimeslots legitimately
// returns {} without ever calling localIntervalToUtc. getList must collapse that to undefined
// so the {{else}} When2meet branch renders. This is the correct behavior, NOT the bug.
async function testNoAvailabilityDataYieldsUndefined(): Promise<void> {
const projects = [
project({
students: [
student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: undefined }),
student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/New_York', timeManagementPlan: undefined }),
],
mentors: [mentor()],
}),
];

const result = await getList(fakePrisma(projects) as any, EVENT as any);
const ctx = result[0] as { commonTimeslots?: unknown };
assert(ctx.commonTimeslots === undefined, 'No timeManagementPlan data legitimately yields commonTimeslots undefined (When2meet branch)');
}

// --- End-to-end: the actual Handlebars block from matchTeamIntro.md -------------------------
// Verifies the template's {{#if commonTimeslots}} / {{else}} branch renders the "Suggested
// Meeting Times" block (and NOT the When2meet fallback) when students supplied data, and the
// When2meet fallback (and NOT the timeslot block) when they did not. This is the user-visible
// behavior the bug report says was silently broken.
const TIMESLOT_BLOCK = `{{#if commonTimeslots}}
**Suggested Meeting Times (all students available):**
{{#each commonTimeslots}}
- {{@key}}: {{#each this}}{{#each this}}{{this}} ({{@../key}}){{#unless @last}} || {{/unless}}{{/each}}{{/each}}
{{/each}}
{{else}}
- **Mentors:** Send a [When2meet](https://www.when2meet.com/) for recurring meeting availability
{{/if}}`;

const timeslotTemplate = Handlebars.compile(TIMESLOT_BLOCK);

async function testTemplateRendersSuggestedTimesWhenDataSupplied(): Promise<void> {
const nyPlan: TimeManagementPlan = { tuesday: [{ start: mins(9), end: mins(11) }] };
const laPlan: TimeManagementPlan = { tuesday: [{ start: mins(6), end: mins(8) }] };
const projects = [
project({
students: [
student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: nyPlan }),
student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/Los_Angeles', timeManagementPlan: laPlan }),
],
mentors: [mentor()],
}),
];

const result = await getList(fakePrisma(projects) as any, EVENT as any);
const rendered = timeslotTemplate(result[0] as any);
assert(
rendered.includes('**Suggested Meeting Times (all students available):**'),
'Template renders the "Suggested Meeting Times" header when students supplied overlapping availability',
);
assert(
rendered.includes('9:00 AM - 11:00 AM (America/New_York)'),
'Template renders the NY-formatted overlap window labeled with its timezone',
);
assert(
rendered.includes('6:00 AM - 8:00 AM (America/Los_Angeles)'),
'Template renders the LA-formatted overlap window labeled with its timezone',
);
assert(
!rendered.includes('When2meet'),
'Template does NOT render the When2meet fallback when students supplied overlapping availability',
);
}

async function testTemplateRendersWhen2meetFallbackWhenNoData(): Promise<void> {
const projects = [
project({
students: [
student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: undefined }),
student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/New_York', timeManagementPlan: undefined }),
],
mentors: [mentor()],
}),
];

const result = await getList(fakePrisma(projects) as any, EVENT as any);
const rendered = timeslotTemplate(result[0] as any);
assert(
rendered.includes('Send a [When2meet]'),
'Template renders the When2meet fallback when students supplied no availability data',
);
assert(
!rendered.includes('Suggested Meeting Times'),
'Template does NOT render the "Suggested Meeting Times" block when there is no availability data',
);
}

async function main(): Promise<void> {
await testOverlappingSameTimezoneProducesCommonTimeslots();
await testOverlappingAcrossTimezonesRendersInEachTimezone();
await testNoSwallowedErrorWhenDataSupplied();
await testNoAvailabilityDataYieldsUndefined();
await testTemplateRendersSuggestedTimesWhenDataSupplied();
await testTemplateRendersWhen2meetFallbackWhenNoData();

if (failures > 0) {
console.error(`\n${failures} test(s) failed.`);
process.exit(1);
}
console.log('\nAll tests passed.');
}

main();
24 changes: 8 additions & 16 deletions src/email/templates/matchTeamIntro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,14 @@ export async function getList(prisma: PrismaClient, event: PartialEvent): Promis
function localIntervalToUtc(day: string, interval: Interval, tz: string) {
const weekdays: Record<string, number> = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7 };
const baseDay = weekdays[day.toLowerCase()] ?? 1;
const start = DateTime.fromObject({
year: 2024,
month: 1,
day: baseDay,
hour: Math.floor(interval.start / 60),
minute: interval.start % 60,
zone: tz
}).toUTC();
const end = DateTime.fromObject({
year: 2024,
month: 1,
day: baseDay,
hour: Math.floor(interval.end / 60),
minute: interval.end % 60,
zone: tz
}).toUTC();
const start = DateTime.fromObject(
{ year: 2024, month: 1, day: baseDay, hour: Math.floor(interval.start / 60), minute: interval.start % 60 },
{ zone: tz }
).toUTC();
const end = DateTime.fromObject(
{ year: 2024, month: 1, day: baseDay, hour: Math.floor(interval.end / 60), minute: interval.end % 60 },
{ zone: tz }
).toUTC();
return { start, end };
}

Expand Down
13 changes: 9 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -511,10 +511,10 @@
resolved "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz"
integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==

"@types/luxon@^1.26.5":
version "1.27.1"
resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.27.1.tgz"
integrity sha512-cPiXpOvPFDr2edMnOXlz3UBDApwUfR+cpizvxCy0n3vp9bz/qe8BWzHPIEFcy+ogUOyjKuCISgyq77ELZPmkkg==
"@types/luxon@^3.7.5":
version "3.7.5"
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.7.5.tgz#d223e1adfdadb140eca9c51647aba59b98508128"
integrity sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==

"@types/luxon@~3.3.0":
version "3.3.3"
Expand Down Expand Up @@ -572,6 +572,11 @@
resolved "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz"
integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==

"@types/node@^14.11.2":
version "14.18.63"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b"
integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==

"@types/node@^18.11.18":
version "18.19.6"
resolved "https://registry.npmjs.org/@types/node/-/node-18.19.6.tgz"
Expand Down
Loading