diff --git a/src/github.mjs b/src/github.mjs index ebb91ac..afc1eb0 100644 --- a/src/github.mjs +++ b/src/github.mjs @@ -103,14 +103,14 @@ export const sortIssuesByRepo = issues => * @param {import('./types.d.ts').MeetingConfig} meetingConfig - Meeting configuration */ export const updateGitHubIssue = async ( - githubClient, + { rest }, number, content, { properties } ) => { const githubOrg = properties.USER ?? DEFAULT_CONFIG.githubOrg; - return githubClient.issues.update({ + return rest.issues.update({ issue_number: number, body: content, owner: githubOrg, diff --git a/test/calendar.test.mjs b/test/calendar.test.mjs index d605959..38a7501 100644 --- a/test/calendar.test.mjs +++ b/test/calendar.test.mjs @@ -1,134 +1,177 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { findNextMeetingDate } from '../src/calendar.mjs'; +import { createMeetingConfig, createMockEvent } from './helpers.mjs'; +import * as calendar from '../src/calendar.mjs'; -describe('Calendar', () => { - describe('findNextMeetingDate', () => { - it('should return null when no matching events are found', async () => { - const allEvents = []; - const meetingConfig = { - properties: { - CALENDAR_FILTER: 'Test Meeting', - GROUP_NAME: 'Test Group', - }, - }; +describe('calendar.mjs', () => { + describe('getWeekBounds', () => { + it('should return a week starting from the given date at UTC midnight', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const [start] = calendar.getWeekBounds(testDate); + + assert.strictEqual(start.getTime(), 1736899200000); + }); - const result = await findNextMeetingDate(allEvents, meetingConfig); + it('should return a week end 7 days after the start', () => { + const testDate = new Date('2025-01-15T00:00:00Z'); + const [start, end] = calendar.getWeekBounds(testDate); + const diffDays = (end - start) / (1000 * 60 * 60 * 24); - assert.strictEqual(result, null); + assert.strictEqual(diffDays, 7); }); - it('should return null when events exist but do not match the filter', async () => { - const allEvents = [ - { - summary: 'Different Meeting', - rrule: { - options: {}, - between: () => [new Date('2025-11-04T14:00:00Z')], - }, - tzid: 'UTC', - }, - ]; - const meetingConfig = { - properties: { - CALENDAR_FILTER: 'Test Meeting', - GROUP_NAME: 'Test Group', - }, - }; + it('should use current date when no date is provided', () => { + const [start, end] = calendar.getWeekBounds(); - const result = await findNextMeetingDate(allEvents, meetingConfig); + assert(start <= new Date()); + assert(end >= new Date()); + }); - assert.strictEqual(result, null); + it('should handle dates across year boundaries', () => { + const testDate = new Date('2024-12-30T00:00:00Z'); + const [start, weekEnd] = calendar.getWeekBounds(testDate); + + assert.strictEqual(start.getUTCFullYear(), 2024); + assert.strictEqual(weekEnd.getUTCFullYear(), 2025); }); - it('should return null when matching events exist but have no recurrences in the week', async () => { - const allEvents = [ - { - summary: 'Test Meeting', - rrule: { - options: {}, - between: () => [], - }, - tzid: 'UTC', - }, - ]; - const meetingConfig = { - properties: { - CALENDAR_FILTER: 'Test Meeting', - GROUP_NAME: 'Test Group', - }, - }; + it('should handle leap year dates', () => { + const testDate = new Date('2024-02-28T00:00:00Z'); + const [start, end] = calendar.getWeekBounds(testDate); - const result = await findNextMeetingDate(allEvents, meetingConfig); + assert(start < end); + assert.strictEqual((end - start) / (1000 * 60 * 60 * 24), 7); + }); + + it('should maintain UTC timezone context', () => { + const testDate = new Date('2025-01-15T23:59:59Z'); + const [start] = calendar.getWeekBounds(testDate); + + assert.strictEqual(start.getUTCHours(), 0); + assert.strictEqual(start.getUTCMinutes(), 0); + }); + }); + describe('findNextMeetingDate', () => { + it('should return null when no events match the filter', async () => { + const result = await calendar.findNextMeetingDate( + [], + createMeetingConfig() + ); assert.strictEqual(result, null); }); - it('should return the meeting date when a matching event with recurrence is found', async () => { - const expectedDate = new Date('2025-11-04T14:00:00Z'); - const allEvents = [ + it('should return null when events do not have recurring rules', async () => { + const events = [ { - summary: 'Test Meeting', - rrule: { - options: {}, - between: () => [expectedDate], - }, - tzid: 'UTC', + summary: 'Node.js Meeting', + rrule: null, }, ]; - const meetingConfig = { - properties: { - CALENDAR_FILTER: 'Test Meeting', - GROUP_NAME: 'Test Group', - }, - }; - - const result = await findNextMeetingDate(allEvents, meetingConfig); + const result = await calendar.findNextMeetingDate( + events, + createMeetingConfig() + ); + assert.strictEqual(result, null); + }); - assert.strictEqual(result, expectedDate); + it('should return null when filter does not match event summary or description', async () => { + const events = [ + createMockEvent({ + summary: 'Other Meeting', + description: 'Not related', + }), + ]; + const result = await calendar.findNextMeetingDate( + events, + createMeetingConfig() + ); + assert.strictEqual(result, null); }); - it('should match events using the description field', async () => { - const expectedDate = new Date('2025-11-04T14:00:00Z'); - const allEvents = [ - { - description: 'This is a Test Meeting', - rrule: { - options: {}, - between: () => [expectedDate], - }, - tzid: 'UTC', - }, + it('should find a matching recurring event with the correct filter', async () => { + const mockDate = new Date('2025-01-15T10:00:00Z'); + const events = [ + createMockEvent({ + summary: 'Node.js TSC Meeting', + rrule: { options: { tzid: 'UTC' }, between: () => [mockDate] }, + }), ]; - const meetingConfig = { - properties: { - CALENDAR_FILTER: 'Test Meeting', - GROUP_NAME: 'Test Group', - }, - }; + const result = await calendar.findNextMeetingDate( + events, + createMeetingConfig() + ); + assert.strictEqual(result, mockDate); + }); - const result = await findNextMeetingDate(allEvents, meetingConfig); + it('should match filter in description when summary is empty', async () => { + const mockDate = new Date('2025-01-15T10:00:00Z'); + const events = [ + createMockEvent({ + description: 'Node.js Triage Meeting', + rrule: { options: { tzid: 'UTC' }, between: () => [mockDate] }, + }), + ]; + const result = await calendar.findNextMeetingDate( + events, + createMeetingConfig({ CALENDAR_FILTER: 'Triage' }) + ); + assert.strictEqual(result, mockDate); + }); - assert.strictEqual(result, expectedDate); + it('should return first occurrence when multiple events match', async () => { + const date1 = new Date('2025-01-15T10:00:00Z'); + const date2 = new Date('2025-01-22T10:00:00Z'); + const events = [ + createMockEvent({ + summary: 'Node.js Meeting 1', + rrule: { options: { tzid: 'UTC' }, between: () => [date1, date2] }, + }), + ]; + const result = await calendar.findNextMeetingDate( + events, + createMeetingConfig({ CALENDAR_FILTER: 'Meeting' }) + ); + assert.strictEqual(result, date1); }); - it('should return null when events do not have rrule', async () => { - const allEvents = [ - { - summary: 'Test Meeting', - tzid: 'UTC', - }, + it('should set rrule timezone from event start timezone', async () => { + const mockDate = new Date('2025-01-15T10:00:00Z'); + const tzidSetter = {}; + const events = [ + createMockEvent({ + summary: 'Node.js Meeting', + start: { tz: 'America/New_York' }, + rrule: { options: tzidSetter, between: () => [mockDate] }, + }), ]; - const meetingConfig = { - properties: { - CALENDAR_FILTER: 'Test Meeting', - GROUP_NAME: 'Test Group', - }, - }; + await calendar.findNextMeetingDate(events, createMeetingConfig()); + assert.strictEqual(tzidSetter.tzid, 'America/New_York'); + }); - const result = await findNextMeetingDate(allEvents, meetingConfig); + it('should handle multiple events and return first matching', async () => { + const matchedDate = new Date('2025-01-15T10:00:00Z'); + const events = [ + createMockEvent({ summary: 'Other Event' }), + createMockEvent({ + summary: 'Node.js Meeting', + rrule: { options: {}, between: () => [matchedDate] }, + }), + ]; + const result = await calendar.findNextMeetingDate( + events, + createMeetingConfig() + ); + assert.strictEqual(result, matchedDate); + }); + it('should handle events without rrule property', async () => { + const events = [{ summary: 'Node.js Meeting' }]; + const result = await calendar.findNextMeetingDate( + events, + createMeetingConfig() + ); assert.strictEqual(result, null); }); }); diff --git a/test/github.test.mjs b/test/github.test.mjs new file mode 100644 index 0000000..54b64b9 --- /dev/null +++ b/test/github.test.mjs @@ -0,0 +1,442 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { + createIssue, + createMeetingConfig, + createMockClient, +} from './helpers.mjs'; +import * as github from '../src/github.mjs'; + +describe('github.mjs', () => { + describe('sortIssuesByRepo', () => { + it('should group issues by repository', () => { + const result = github.sortIssuesByRepo([ + createIssue(1, 'nodejs/node'), + createIssue(2, 'nodejs/node'), + createIssue(3, 'nodejs/nodejs.org'), + ]); + + assert.deepStrictEqual(Object.keys(result), [ + 'nodejs/node', + 'nodejs/nodejs.org', + ]); + assert.strictEqual(result['nodejs/node'].length, 2); + assert.strictEqual(result['nodejs/nodejs.org'].length, 1); + }); + }); + + describe('createGitHubIssue', () => { + it('should call rest.issues.create with correct parameters', async () => { + const { client, create } = createMockClient(); + + await github.createGitHubIssue( + client, + createMeetingConfig(), + 'Test Issue Title', + 'Test Issue Content' + ); + + assert.deepStrictEqual(create(), [ + [ + { + body: 'Test Issue Content', + labels: ['meeting'], + owner: 'nodejs', + repo: 'node', + title: 'Test Issue Title', + }, + ], + ]); + }); + + it('should include issue label when provided', async () => { + const { client, create } = createMockClient(); + + await github.createGitHubIssue( + client, + createMeetingConfig(), + 'Title', + 'Content' + ); + + assert.deepStrictEqual(create(), [ + [ + { + body: 'Content', + labels: ['meeting'], + owner: 'nodejs', + repo: 'node', + title: 'Title', + }, + ], + ]); + }); + + it('should not include labels when ISSUE_LABEL is not provided', async () => { + const { client, create } = createMockClient(); + + await github.createGitHubIssue( + client, + createMeetingConfig({ ISSUE_LABEL: undefined }), + 'Title', + 'Content' + ); + + assert.deepStrictEqual(create(), [ + [ + { + body: 'Content', + labels: undefined, + owner: 'nodejs', + repo: 'node', + title: 'Title', + }, + ], + ]); + }); + + it('should use default org when USER is not provided', async () => { + const { client, create } = createMockClient(); + + await github.createGitHubIssue( + client, + createMeetingConfig({ USER: undefined }), + 'Title', + 'Content' + ); + + assert.deepStrictEqual(create(), [ + [ + { + body: 'Content', + labels: ['meeting'], + owner: 'nodejs', + repo: 'node', + title: 'Title', + }, + ], + ]); + }); + }); + + describe('findGitHubIssueByTitle', () => { + it('should search for issues with exact title match', async () => { + const { client, request } = createMockClient(); + + await github.findGitHubIssueByTitle( + client, + 'Node.js Meeting 2025-01-15', + createMeetingConfig() + ); + + assert.deepStrictEqual(request(), [ + [ + 'GET /search/issues', + { + advanced_search: true, + per_page: 1, + q: 'is:open in:title repo:"nodejs/node" "Node.js Meeting 2025-01-15"', + }, + ], + ]); + }); + + it('should return first matching issue', async () => { + const { client } = createMockClient({ + request: { + items: [ + { number: 1, title: 'Issue 1' }, + { number: 2, title: 'Issue 2' }, + ], + }, + }); + + const result = await github.findGitHubIssueByTitle( + client, + 'Test', + createMeetingConfig() + ); + + assert.strictEqual(result.number, 1); + }); + + it('should return undefined when no issues found', async () => { + const { client } = createMockClient(); + + const result = await github.findGitHubIssueByTitle( + client, + 'Nonexistent', + createMeetingConfig() + ); + + assert.strictEqual(result, undefined); + }); + + it('should use default org when USER not provided', async () => { + const { client, request } = createMockClient(); + + await github.findGitHubIssueByTitle( + client, + 'Test', + createMeetingConfig({ USER: undefined }) + ); + + assert.deepStrictEqual(request(), [ + [ + 'GET /search/issues', + { + advanced_search: true, + per_page: 1, + q: 'is:open in:title repo:"nodejs/node" "Test"', + }, + ], + ]); + }); + + it('should search for open issues only', async () => { + const { client, request } = createMockClient(); + + await github.findGitHubIssueByTitle( + client, + 'Test', + createMeetingConfig() + ); + + assert.deepStrictEqual(request(), [ + [ + 'GET /search/issues', + { + advanced_search: true, + per_page: 1, + q: 'is:open in:title repo:"nodejs/node" "Test"', + }, + ], + ]); + }); + }); + + describe('updateGitHubIssue', () => { + it('should call issues.update with correct parameters', async () => { + const { client, update } = createMockClient(); + + await github.updateGitHubIssue( + client, + 42, + 'New content', + createMeetingConfig() + ); + + assert.deepStrictEqual(update(), [ + [ + { + body: 'New content', + issue_number: 42, + owner: 'nodejs', + repo: 'node', + }, + ], + ]); + }); + + it('should use default org when USER not provided', async () => { + const { client, update } = createMockClient(); + + await github.updateGitHubIssue( + client, + 1, + 'Content', + createMeetingConfig({ USER: undefined }) + ); + + assert.deepStrictEqual(update(), [ + [ + { + body: 'Content', + issue_number: 1, + owner: 'nodejs', + repo: 'node', + }, + ], + ]); + }); + }); + + describe('getAgendaIssues', () => { + it('should search for issues with agenda label', async () => { + const { client, paginate } = createMockClient(); + + await github.getAgendaIssues( + client, + { meetingGroup: 'tsc' }, + createMeetingConfig({ AGENDA_TAG: 'some-agenda' }) + ); + + assert.deepStrictEqual(paginate(), [ + [ + 'GET /search/issues', + { + advanced_search: true, + q: 'is:open label:some-agenda org:nodejs', + }, + ], + ]); + }); + + it('should use default org when USER not provided', async () => { + const { client, paginate } = createMockClient(); + + await github.getAgendaIssues( + client, + { meetingGroup: 'tsc' }, + createMeetingConfig({ USER: undefined, AGENDA_TAG: 'tsc-agenda' }) + ); + + assert.deepStrictEqual(paginate(), [ + [ + 'GET /search/issues', + { + advanced_search: true, + q: 'is:open label:tsc-agenda org:nodejs', + }, + ], + ]); + }); + + it('should construct default agenda tag from meetingGroup when not provided', async () => { + const { client, paginate } = createMockClient(); + + await github.getAgendaIssues( + client, + { meetingGroup: 'tsc' }, + createMeetingConfig({ AGENDA_TAG: undefined }) + ); + + assert.deepStrictEqual(paginate(), [ + [ + 'GET /search/issues', + { + advanced_search: true, + q: 'is:open label:tsc-agenda org:nodejs', + }, + ], + ]); + }); + + it('should return sorted issues by repo', async () => { + const { client } = createMockClient({ + paginate: [ + createIssue(1, 'nodejs/node'), + createIssue(2, 'nodejs/nodejs.org'), + ], + }); + + const result = await github.getAgendaIssues( + client, + { meetingGroup: 'tsc' }, + createMeetingConfig({ AGENDA_TAG: 'tsc-agenda' }) + ); + + assert.ok(result['nodejs/node']); + assert.ok(result['nodejs/nodejs.org']); + }); + }); + + describe('createOrUpdateGitHubIssue', () => { + it('should create new issue when no existing issue found', async () => { + const { client, create } = createMockClient(); + + await github.createOrUpdateGitHubIssue( + client, + { force: true }, + createMeetingConfig(), + 'Title', + 'Content' + ); + + assert.deepStrictEqual(create(), [ + [ + { + body: 'Content', + labels: ['meeting'], + owner: 'nodejs', + repo: 'node', + title: 'Title', + }, + ], + ]); + }); + + it('should update existing issue when content differs', async () => { + const existingIssue = { + number: 1, + title: 'Existing Issue', + body: 'Old content', + }; + const { client, update } = createMockClient({ + request: { items: [existingIssue] }, + }); + + await github.createOrUpdateGitHubIssue( + client, + { force: false }, + createMeetingConfig(), + 'Existing Issue', + 'New content' + ); + + assert.deepStrictEqual(update(), [ + [ + { + body: 'New content', + issue_number: 1, + owner: 'nodejs', + repo: 'node', + }, + ], + ]); + }); + + it('should not update existing issue when content is same', async () => { + const existingIssue = { + number: 1, + title: 'Existing Issue', + body: 'Same content', + }; + const { client, update } = createMockClient({ + request: { items: [existingIssue] }, + }); + + await github.createOrUpdateGitHubIssue( + client, + { force: false }, + createMeetingConfig(), + 'Existing Issue', + 'Same content' + ); + + assert.deepStrictEqual(update(), []); + }); + + it('should force update when force flag is true', async () => { + const existingIssue = { + number: 1, + title: 'Existing Issue', + body: 'Same content', + }; + const { client, update } = createMockClient({ + request: { items: [existingIssue] }, + }); + + await github.createOrUpdateGitHubIssue( + client, + { force: false }, + createMeetingConfig(), + 'Existing Issue', + 'Same content' + ); + + assert.deepStrictEqual(update(), []); + }); + }); +}); diff --git a/test/helpers.mjs b/test/helpers.mjs new file mode 100644 index 0000000..a8ff5d0 --- /dev/null +++ b/test/helpers.mjs @@ -0,0 +1,75 @@ +import { mock } from 'node:test'; + +export const createMockEvent = overrides => ({ + rrule: { options: {}, between: () => [] }, + ...overrides, +}); + +const createCallGetter = + ({ mock }) => + () => + mock.calls.map(c => c.arguments); + +export const createMockClient = (overrides = {}) => { + const create = mock.fn( + () => {}, + () => ({ + data: overrides.create ?? { + number: 1, + title: 'Test Issue', + body: 'Test content', + html_url: 'https://github.com/nodejs/node/issues/1', + }, + }) + ); + + const update = mock.fn( + () => {}, + () => ({ + data: overrides.update ?? { number: 1, body: 'Updated content' }, + }) + ); + + const request = mock.fn( + () => {}, + () => ({ data: overrides.request ?? { items: [] } }) + ); + + const paginate = mock.fn( + () => {}, + () => overrides.paginate ?? [] + ); + + return { + create: createCallGetter(create), + request: createCallGetter(request), + update: createCallGetter(update), + paginate: createCallGetter(paginate), + client: { + request, + paginate, + rest: { + issues: { + create, + update, + }, + }, + }, + }; +}; + +export const createMeetingConfig = (overrides = {}) => ({ + properties: { + USER: 'nodejs', + REPO: 'node', + ISSUE_LABEL: 'meeting', + CALENDAR_FILTER: 'Node.js', + ...overrides, + }, +}); + +export const createIssue = (number, repoPath) => ({ + number, + title: `Issue ${number}`, + repository_url: `https://api.github.com/repos/${repoPath}`, +}); diff --git a/test/meeting.test.mjs b/test/meeting.test.mjs index 9163cae..830a165 100644 --- a/test/meeting.test.mjs +++ b/test/meeting.test.mjs @@ -1,164 +1,324 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { generateMeetingTitle } from '../src/meeting.mjs'; +import { DEFAULT_CONFIG } from '../src/constants.mjs'; +import * as meeting from '../src/meeting.mjs'; -describe('Meeting', () => { +describe('meeting.mjs', () => { describe('generateMeetingTitle', () => { - it('should generate meeting title with default values', () => { + const titleTestCases = [ + { + name: 'should generate title with host and group name', + config: { meetingGroup: 'tsc' }, + meetingConfig: { properties: { HOST: 'Node.js', GROUP_NAME: 'TSC' } }, + date: new Date('2025-01-15T10:30:00Z'), + expectations: ['Node.js', 'TSC', '2025-01-15'], + }, + { + name: 'should use default host when HOST not provided', + config: { meetingGroup: 'tsc' }, + meetingConfig: { properties: { GROUP_NAME: 'TSC' } }, + date: new Date('2025-01-15T10:30:00Z'), + expectations: [DEFAULT_CONFIG.host, 'TSC'], + }, + { + name: 'should use meetingGroup as GROUP_NAME when GROUP_NAME not provided', + config: { meetingGroup: 'tsc' }, + meetingConfig: { properties: { HOST: 'Node.js' } }, + date: new Date('2025-01-15T10:30:00Z'), + expectations: ['tsc'], + }, + { + name: 'should format date as YYYY-MM-DD', + config: { meetingGroup: 'tsc' }, + meetingConfig: { properties: {} }, + date: new Date('2025-06-15T10:30:00Z'), + expectations: ['2025-06-15'], + }, + { + name: 'should include "Meeting" text in title', + config: { meetingGroup: 'tsc' }, + meetingConfig: { properties: {} }, + date: new Date('2025-01-15T10:30:00Z'), + expectations: ['Meeting'], + }, + ]; + + titleTestCases.forEach( + ({ name, config, meetingConfig, date, expectations }) => { + it(name, () => { + const result = meeting.generateMeetingTitle( + config, + meetingConfig, + date + ); + expectations.forEach(expectation => { + assert( + result.includes(expectation), + `Expected "${expectation}" in "${result}"` + ); + }); + }); + } + ); + + it('should generate consistent title for same inputs', () => { const config = { meetingGroup: 'tsc' }; - const meetingConfig = { properties: {} }; - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - assert.strictEqual(result, 'Node.js tsc Meeting 2023-10-15'); - }); - - it('should use HOST from properties', () => { - const config = { meetingGroup: 'tsc' }; - const meetingConfig = { - properties: { - HOST: 'OpenJS Foundation', - }, + properties: { HOST: 'Node.js', GROUP_NAME: 'TSC' }, }; + const date = new Date('2025-01-15T10:30:00Z'); - const meetingDate = new Date('2023-10-15T14:30:00Z'); + const result1 = meeting.generateMeetingTitle(config, meetingConfig, date); + const result2 = meeting.generateMeetingTitle(config, meetingConfig, date); - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - assert.strictEqual(result, 'OpenJS Foundation tsc Meeting 2023-10-15'); + assert.strictEqual(result1, result2); }); - it('should use GROUP_NAME from properties', () => { - const config = { meetingGroup: 'tsc' }; - - const meetingConfig = { - properties: { - GROUP_NAME: 'Technical Steering Committee', + const edgeCases = [ + { + name: 'should handle very long group names', + config: { meetingGroup: 'x'.repeat(100) }, + meetingConfig: { + properties: { HOST: 'Node.js', GROUP_NAME: 'y'.repeat(100) }, }, - }; - - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - assert.strictEqual( - result, - 'Node.js Technical Steering Committee Meeting 2023-10-15' - ); - }); - - it('should use both custom HOST and GROUP_NAME', () => { - const config = { meetingGroup: 'tsc' }; - - const meetingConfig = { - properties: { - HOST: 'OpenJS Foundation', - GROUP_NAME: 'Technical Steering Committee', + date: new Date('2025-01-15T10:30:00Z'), + check: result => result.includes('y'.repeat(100)), + }, + { + name: 'should handle special characters in group names', + config: { meetingGroup: 'tsc' }, + meetingConfig: { + properties: { HOST: 'Node.js', GROUP_NAME: 'TSC & CTC (merged)' }, }, - }; - - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - assert.strictEqual( - result, - 'OpenJS Foundation Technical Steering Committee Meeting 2023-10-15' - ); + date: new Date('2025-01-15T10:30:00Z'), + check: result => result.includes('TSC & CTC (merged)'), + }, + { + name: 'should handle dates at month boundaries', + config: { meetingGroup: 'tsc' }, + meetingConfig: { properties: {} }, + date: new Date('2025-01-31T23:59:59Z'), + check: result => result.includes('2025-01-31'), + }, + { + name: 'should handle leap year dates', + config: { meetingGroup: 'tsc' }, + meetingConfig: { properties: {} }, + date: new Date('2024-02-29T10:30:00Z'), + check: result => result.includes('2024-02-29'), + }, + ]; + + edgeCases.forEach(({ name, config, meetingConfig, date, check }) => { + it(name, () => { + const result = meeting.generateMeetingTitle( + config, + meetingConfig, + date + ); + assert(check(result), `Check failed for "${name}"`); + }); }); + }); - it('should handle different date formats correctly', () => { - const config = { meetingGroup: 'build' }; - const meetingConfig = { properties: {} }; - const meetingDate = new Date('2023-12-31T23:59:59Z'); - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - assert.strictEqual(result, 'Node.js build Meeting 2023-12-31'); - }); - - it('should handle different meeting groups', () => { - const config = { meetingGroup: 'security-wg' }; - - const meetingConfig = { - properties: { - GROUP_NAME: 'Security Working Group', + describe('generateMeetingAgenda', () => { + const agendaTestCases = [ + { + name: 'should format single repo with issues', + input: { + 'nodejs/node': [ + { + number: 1, + title: 'Issue 1', + html_url: 'https://github.com/nodejs/node/issues/1', + }, + { + number: 2, + title: 'Issue 2', + html_url: 'https://github.com/nodejs/node/issues/2', + }, + ], }, - }; - - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - assert.strictEqual( - result, - 'Node.js Security Working Group Meeting 2023-10-15' - ); - }); - - it('should handle edge case dates', () => { - const config = { meetingGroup: 'tsc' }; - const meetingConfig = { properties: {} }; - const meetingDate = new Date('2024-02-29T12:00:00Z'); // Leap year - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - assert.strictEqual(result, 'Node.js tsc Meeting 2024-02-29'); - }); - - it('should handle null/undefined properties gracefully', () => { - const config = { meetingGroup: 'tsc' }; - - const meetingConfig = { - properties: { - HOST: null, - GROUP_NAME: undefined, + checks: ['nodejs/node', 'Issue 1', 'Issue 2', '#1', '#2'], + }, + { + name: 'should format multiple repos with issues', + input: { + 'nodejs/node': [ + { + number: 1, + title: 'Issue 1', + html_url: 'https://github.com/nodejs/node/issues/1', + }, + ], + 'nodejs/nodejs.org': [ + { + number: 2, + title: 'Issue 2', + html_url: 'https://github.com/nodejs/nodejs.org/issues/2', + }, + ], }, - }; - - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - // Should fall back to defaults - assert.strictEqual(result, 'Node.js tsc Meeting 2023-10-15'); - }); - - it('should handle empty string properties', () => { - const config = { meetingGroup: 'tsc' }; - - const meetingConfig = { - properties: { - HOST: '', - GROUP_NAME: '', + checks: ['nodejs/node', 'nodejs/nodejs.org', 'Issue 1', 'Issue 2'], + }, + { + name: 'should skip repos with no issues', + input: { + 'nodejs/node': [ + { + number: 1, + title: 'Issue 1', + html_url: 'https://github.com/nodejs/node/issues/1', + }, + ], + 'nodejs/empty': [], }, + checks: ['nodejs/node'], + excludes: ['nodejs/empty'], + }, + { + name: 'should escape markdown special characters in issue titles', + input: { + 'nodejs/node': [ + { + number: 1, + title: 'Issue with [brackets] and stuff', + html_url: 'https://github.com/nodejs/node/issues/1', + }, + ], + }, + checks: ['\\[brackets\\]'], + }, + { + name: 'should format as markdown list', + input: { + 'nodejs/node': [ + { + number: 1, + title: 'Issue 1', + html_url: 'https://github.com/nodejs/node/issues/1', + }, + ], + }, + checks: ['* ', '### nodejs/node'], + }, + { + name: 'should include issue links', + input: { + 'nodejs/node': [ + { + number: 1, + title: 'Issue 1', + html_url: 'https://github.com/nodejs/node/issues/1', + }, + ], + }, + checks: ['[#1]', '(https://github.com/nodejs/node/issues/1)'], + }, + { + name: 'should handle empty agenda', + input: {}, + isEmpty: true, + }, + { + name: 'should handle multiple issues in one repo', + input: { + 'nodejs/node': [ + { number: 1, title: 'First', html_url: 'https://example.com/1' }, + { number: 2, title: 'Second', html_url: 'https://example.com/2' }, + { number: 3, title: 'Third', html_url: 'https://example.com/3' }, + ], + }, + lineCountMin: 4, + }, + { + name: 'should preserve issue title exactly', + input: { + 'nodejs/node': [ + { + number: 123, + title: 'Add feature X to Node.js', + html_url: 'https://github.com/nodejs/node/issues/123', + }, + ], + }, + checks: ['Add feature X to Node.js'], + }, + { + name: 'should list issues in order', + input: { + 'nodejs/node': [ + { number: 1, title: 'First', html_url: 'https://example.com/1' }, + { number: 2, title: 'Second', html_url: 'https://example.com/2' }, + { number: 3, title: 'Third', html_url: 'https://example.com/3' }, + ], + }, + checkOrder: ['First', 'Second', 'Third'], + }, + ]; + + agendaTestCases.forEach( + ({ + name, + input, + checks = [], + excludes = [], + isEmpty, + lineCountMin, + checkOrder, + }) => { + it(name, () => { + const result = meeting.generateMeetingAgenda(input); + + if (isEmpty) { + assert.strictEqual(result.trim(), ''); + } + + checks.forEach(check => { + assert(result.includes(check), `Expected "${check}" in result`); + }); + + excludes.forEach(exclude => { + assert( + !result.includes(exclude), + `Did not expect "${exclude}" in result` + ); + }); + + if (lineCountMin) { + const lines = result.split('\n'); + assert( + lines.length >= lineCountMin, + `Expected at least ${lineCountMin} lines, got ${lines.length}` + ); + } + + if (checkOrder) { + let lastIndex = -1; + checkOrder.forEach(item => { + const index = result.indexOf(item); + assert( + index > lastIndex, + `Expected "${item}" to appear after previous items` + ); + lastIndex = index; + }); + } + }); + } + ); + + it('should trim whitespace from result', () => { + const agendaIssues = { + 'nodejs/node': [ + { number: 1, title: 'Issue', html_url: 'https://example.com/1' }, + ], }; - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); - - // Empty strings are used as-is (nullish coalescing doesn't catch empty strings) - assert.strictEqual(result, ' Meeting 2023-10-15'); - }); - - it('should handle very long meeting group names', () => { - const config = { - meetingGroup: 'very-long-working-group-name-for-testing-purposes', - }; - - const meetingConfig = { properties: {} }; - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateMeetingTitle(config, meetingConfig, meetingDate); + const result = meeting.generateMeetingAgenda(agendaIssues); - assert.strictEqual( - result, - 'Node.js very-long-working-group-name-for-testing-purposes Meeting 2023-10-15' - ); + assert.strictEqual(result, result.trim()); }); }); }); diff --git a/test/snapshots/README.md b/test/snapshots/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/test/utils/dates.test.mjs b/test/utils/dates.test.mjs index f504ed1..dfea738 100644 --- a/test/utils/dates.test.mjs +++ b/test/utils/dates.test.mjs @@ -1,148 +1,243 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { formatDateTime, formatTimezones } from '../../src/utils/dates.mjs'; +import * as dates from '../../src/utils/dates.mjs'; -describe('Utils - Dates', () => { +describe('dates utility', () => { describe('formatDateTime', () => { - it('should format date with default options', () => { - const date = new Date('2023-10-15T14:30:00Z'); - const result = formatDateTime(date); + it('should format a date with default options', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatDateTime(testDate); - assert.strictEqual(typeof result, 'string'); - assert.ok(result.length > 0); + assert(typeof result === 'string'); + assert(result.length > 0); }); - it('should format date with custom options', () => { - const date = new Date('2023-10-15T14:30:00Z'); - const options = { - weekday: 'long', + it('should format a date as en-US locale', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatDateTime(testDate, { + timeZone: 'UTC', year: 'numeric', month: 'long', day: 'numeric', - }; - - const result = formatDateTime(date, options); + }); - assert.strictEqual(typeof result, 'string'); - assert.ok(result.includes('2023')); - assert.ok(result.includes('October')); + assert(result.includes('January')); + assert(result.includes('15')); + assert(result.includes('2025')); }); - it('should format date with timezone option', () => { - const date = new Date('2023-10-15T14:30:00Z'); - const options = { + it('should respect the timeZone option', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + + const utcResult = dates.formatDateTime(testDate, { + timeZone: 'UTC', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + + const nyResult = dates.formatDateTime(testDate, { timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit', + hour12: false, + }); + + // The times should be different due to different timezones + assert.notStrictEqual(utcResult, nyResult); + }); + + it('should format time in 12-hour format when hour12 is true', () => { + const testDate = new Date('2025-01-15T15:30:00Z'); + const result = dates.formatDateTime(testDate, { + timeZone: 'UTC', + hour: '2-digit', + minute: '2-digit', hour12: true, - }; + }); - const result = formatDateTime(date, options); + assert(result.includes('PM')); + }); + + it('should format time in 24-hour format when hour12 is false', () => { + const testDate = new Date('2025-01-15T15:30:00Z'); + const result = dates.formatDateTime(testDate, { + timeZone: 'UTC', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); - assert.strictEqual(typeof result, 'string'); - assert.ok(result.includes('AM') || result.includes('PM')); + assert(result.includes('15')); }); - it('should handle edge cases with invalid dates gracefully', () => { - const invalidDate = new Date('invalid'); + it('should include weekday when specified', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatDateTime(testDate, { + timeZone: 'UTC', + weekday: 'long', + }); - assert.throws(() => { - formatDateTime(invalidDate); + assert(result.includes('Wednesday')); + }); + + it('should use short weekday format when specified', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatDateTime(testDate, { + timeZone: 'UTC', + weekday: 'short', }); + + assert(result.includes('Wed')); + }); + + it('should handle empty options object', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatDateTime(testDate, {}); + + assert(typeof result === 'string'); + assert(result.length > 0); + }); + + it('should handle various timezones correctly', () => { + const testDate = new Date('2025-01-15T10:00:00Z'); + + const timezones = [ + 'America/Los_Angeles', + 'America/Denver', + 'America/Chicago', + 'America/New_York', + 'Europe/London', + 'Europe/Amsterdam', + 'Europe/Helsinki', + 'Europe/Moscow', + 'Asia/Kolkata', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Australia/Sydney', + ]; + + for (const tz of timezones) { + const result = dates.formatDateTime(testDate, { + timeZone: tz, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + + assert(typeof result === 'string'); + assert(result.length > 0); + } }); }); describe('formatTimezones', () => { - it('should return object with utc and timezones properties', () => { - const date = new Date('2023-10-15T14:30:00Z'); - const result = formatTimezones(date); - - assert.strictEqual(typeof result, 'object'); - assert.ok(Object.hasOwn(result, 'utc')); - assert.ok(Object.hasOwn(result, 'timezones')); + it('should return an object with utc and timezones properties', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatTimezones(testDate); + + assert(result.utc); + assert(result.timezones); + assert(typeof result.utc === 'string'); + assert(Array.isArray(result.timezones)); }); - it('should format UTC time correctly', () => { - const date = new Date('2023-10-15T14:30:00Z'); - const result = formatTimezones(date); + it('should include UTC time formatted with all details', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatTimezones(testDate); - assert.strictEqual(typeof result.utc, 'string'); - assert.ok(result.utc.includes('2023')); - assert.ok(result.utc.includes('Oct')); - assert.ok(result.utc.includes('2:30')); + assert(result.utc.includes('Wed')); + assert(result.utc.includes('Jan')); + assert(result.utc.includes('15')); + assert(result.utc.includes('2025')); }); - it('should return array of timezone objects', () => { - const date = new Date('2023-10-15T14:30:00Z'); - const result = formatTimezones(date); + it('should have 12 timezone entries', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatTimezones(testDate); - assert.ok(Array.isArray(result.timezones)); - assert.ok(result.timezones.length > 0); + assert.strictEqual(result.timezones.length, 12); + }); - // Check first timezone object structure - const firstTz = result.timezones[0]; + it('each timezone entry should have label and time', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatTimezones(testDate); - assert.ok(Object.hasOwn(firstTz, 'label')); - assert.ok(Object.hasOwn(firstTz, 'time')); - assert.strictEqual(typeof firstTz.label, 'string'); - assert.strictEqual(typeof firstTz.time, 'string'); + result.timezones.forEach(entry => { + assert(typeof entry.label === 'string'); + assert(typeof entry.time === 'string'); + assert(entry.label.length > 0); + assert(entry.time.length > 0); + }); }); it('should include all expected timezones', () => { - const date = new Date('2023-10-15T14:30:00Z'); - const result = formatTimezones(date); - - const expectedLabels = [ - 'US / Pacific', - 'US / Mountain', - 'US / Central', - 'US / Eastern', - 'EU / Western', - 'EU / Central', - 'EU / Eastern', - 'Moscow', - 'Chennai', - 'Hangzhou', - 'Tokyo', - 'Sydney', - ]; + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatTimezones(testDate); + + const labels = result.timezones.map(tz => tz.label); + + assert(labels.includes('US / Pacific')); + assert(labels.includes('US / Mountain')); + assert(labels.includes('US / Central')); + assert(labels.includes('US / Eastern')); + assert(labels.includes('EU / Western')); + assert(labels.includes('EU / Central')); + assert(labels.includes('EU / Eastern')); + assert(labels.includes('Moscow')); + assert(labels.includes('Chennai')); + assert(labels.includes('Hangzhou')); + assert(labels.includes('Tokyo')); + assert(labels.includes('Sydney')); + }); - const actualLabels = result.timezones.map(tz => tz.label); + it('should format times in different timezones correctly', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatTimezones(testDate); - expectedLabels.forEach(label => { - assert.ok(actualLabels.includes(label), `Missing timezone: ${label}`); + // All times should be formatted as "day, date mon, year, time AM/PM" + result.timezones.forEach(entry => { + assert(entry.time.match(/\d{1,2}:\d{2}/)); }); }); - it('should format times for different timezones', () => { - const date = new Date('2023-10-15T14:30:00Z'); - const result = formatTimezones(date); + it('should handle different dates correctly', () => { + const date1 = new Date('2025-01-15T10:30:00Z'); + const date2 = new Date('2025-06-15T10:30:00Z'); + + const result1 = dates.formatTimezones(date1); + const result2 = dates.formatTimezones(date2); + + // Results should be different for different dates + assert.notStrictEqual(result1.utc, result2.utc); + }); - // Check that different timezones have different times - const times = result.timezones.map(tz => tz.time); - const uniqueTimes = new Set(times); + it('should handle dates at year boundaries', () => { + const testDate = new Date('2024-12-31T23:59:00Z'); + const result = dates.formatTimezones(testDate); - // Should have multiple unique times since timezones differ - assert.ok(uniqueTimes.size > 1); + assert(result.utc); + assert(result.utc.length > 0); + assert(result.timezones.length === 12); }); - it('should handle midnight edge case', () => { - const date = new Date('2023-10-15T00:00:00Z'); - const result = formatTimezones(date); + it('should handle dates at month boundaries', () => { + const testDate = new Date('2025-02-01T00:00:00Z'); + const result = dates.formatTimezones(testDate); - assert.strictEqual(typeof result.utc, 'string'); - assert.ok(result.utc.includes('12:00')); - assert.ok(result.timezones.length > 0); + assert(result.utc.includes('Feb')); + assert(result.utc.includes('01')); }); - it('should handle noon edge case', () => { - const date = new Date('2023-10-15T12:00:00Z'); - const result = formatTimezones(date); + it('should format UTC time with weekday, date and time', () => { + const testDate = new Date('2025-01-15T10:30:00Z'); + const result = dates.formatTimezones(testDate); - assert.strictEqual(typeof result.utc, 'string'); - assert.ok(result.utc.includes('12:00')); - assert.ok(result.timezones.length > 0); + // UTC time should include weekday and date + assert(result.utc); + assert(result.utc.match(/\w+/)); + assert(result.utc.match(/\d{1,2}/)); }); }); }); diff --git a/test/utils/templates.test.mjs b/test/utils/templates.test.mjs index 7e05127..1022866 100644 --- a/test/utils/templates.test.mjs +++ b/test/utils/templates.test.mjs @@ -1,118 +1,227 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { parseVariables } from '../../src/utils/templates.mjs'; +import * as templates from '../../src/utils/templates.mjs'; -describe('Utils - Templates', () => { +describe('templates utility', () => { describe('parseVariables', () => { - it('should replace single variable in template', () => { - const template = 'Hello $NAME$, welcome!'; - const variables = { NAME: 'John' }; + it('should replace a single variable placeholder', () => { + const template = 'Hello $NAME$!'; + const variables = { NAME: 'World' }; + const result = templates.parseVariables(template, variables); - const result = parseVariables(template, variables); + assert.strictEqual(result, 'Hello World!'); + }); + + it('should replace multiple variable placeholders', () => { + const template = 'Hello $FIRST_NAME$ $LAST_NAME$!'; + const variables = { FIRST_NAME: 'John', LAST_NAME: 'Doe' }; + const result = templates.parseVariables(template, variables); - assert.strictEqual(result, 'Hello John, welcome!'); + assert.strictEqual(result, 'Hello John Doe!'); }); - it('should replace multiple variables in template', () => { - const template = 'Meeting: $TITLE$ on $DATE$ at $TIME$'; - const variables = { - TITLE: 'TSC Meeting', - DATE: '2023-10-15', - TIME: '14:30', - }; + it('should replace the same variable multiple times', () => { + const template = '$NAME$ says hello to $NAME$'; + const variables = { NAME: 'Alice' }; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'Alice says hello to Alice'); + }); - const result = parseVariables(template, variables); + it('should handle variables with underscores in the name', () => { + const template = 'Value: $MEETING_DATE$'; + const variables = { MEETING_DATE: '2025-01-15' }; + const result = templates.parseVariables(template, variables); - assert.strictEqual(result, 'Meeting: TSC Meeting on 2023-10-15 at 14:30'); + assert.strictEqual(result, 'Value: 2025-01-15'); }); - it('should replace same variable multiple times', () => { - const template = "$NAME$ loves $NAME$'s work"; + it('should replace remaining unmatched placeholders with empty strings', () => { + const template = 'Hello $NAME$! Welcome $UNKNOWN_VAR$!'; const variables = { NAME: 'Alice' }; + const result = templates.parseVariables(template, variables); - const result = parseVariables(template, variables); + assert.strictEqual(result, 'Hello Alice! Welcome !'); + }); + + it('should handle empty template', () => { + const template = ''; + const variables = { NAME: 'World' }; + const result = templates.parseVariables(template, variables); - assert.strictEqual(result, "Alice loves Alice's work"); + assert.strictEqual(result, ''); }); - it('should handle empty string values', () => { - const template = 'Value: $EMPTY$'; - const variables = { EMPTY: '' }; + it('should handle template with no variables', () => { + const template = 'Hello World!'; + const variables = { NAME: 'Alice' }; + const result = templates.parseVariables(template, variables); - const result = parseVariables(template, variables); + assert.strictEqual(result, 'Hello World!'); + }); - assert.strictEqual(result, 'Value: '); + it('should handle empty variables object', () => { + const template = 'Hello $NAME$!'; + const variables = {}; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'Hello !'); }); - it('should handle null/undefined values', () => { - const template = 'Value: $NULL$ and $UNDEFINED$'; - const variables = { NULL: null, UNDEFINED: undefined }; + it('should handle special characters in variable values', () => { + const template = 'Command: $CMD$'; + const variables = { CMD: 'grep "test" file.txt' }; + const result = templates.parseVariables(template, variables); - const result = parseVariables(template, variables); + assert.strictEqual(result, 'Command: grep "test" file.txt'); + }); - assert.strictEqual(result, 'Value: and '); + it('should handle newlines in variable values', () => { + const template = 'Content:\n$CONTENT$\nEnd'; + const variables = { CONTENT: 'Line 1\nLine 2' }; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'Content:\nLine 1\nLine 2\nEnd'); }); - it('should remove unmatched placeholders', () => { - const template = 'Hello $NAME$, your $UNMATCHED$ is ready'; - const variables = { NAME: 'Bob' }; + it('should handle multiple placeholders on the same line', () => { + const template = '$USER$ at $TIME$ on $DATE$'; + const variables = { USER: 'John', TIME: '10:30 AM', DATE: '2025-01-15' }; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'John at 10:30 AM on 2025-01-15'); + }); - const result = parseVariables(template, variables); + it('should not match partial placeholders', () => { + const template = '$NAM is not $NAME$'; + const variables = { NAM: 'test', NAME: 'Alice' }; + const result = templates.parseVariables(template, variables); - assert.strictEqual(result, 'Hello Bob, your is ready'); + assert.strictEqual(result, '$NAM is not Alice'); }); - it('should handle template with no placeholders', () => { - const template = 'No placeholders here'; - const variables = { NAME: 'Alice' }; + it('should handle variables with numeric and alphabetic characters', () => { + const template = '$VAR1$ and $VAR_2$'; + const variables = { VAR1: 'value1', VAR_2: 'value2' }; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'value1 and value2'); + }); - const result = parseVariables(template, variables); + it('should handle empty string as variable value', () => { + const template = 'Start$EMPTY$End'; + const variables = { EMPTY: '' }; + const result = templates.parseVariables(template, variables); - assert.strictEqual(result, 'No placeholders here'); + assert.strictEqual(result, 'StartEnd'); }); - it('should handle empty template', () => { - const template = ''; - const variables = { NAME: 'Alice' }; + it('should handle null-like string values', () => { + const template = 'Value: $VAL$'; + const variables = { VAL: 'null' }; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'Value: null'); + }); - const result = parseVariables(template, variables); + it('should handle complex template with markdown', () => { + const template = `# Meeting: $TITLE$ - assert.strictEqual(result, ''); +Date: $DATE$ +Time: $TIME$ + +## Agenda +$AGENDA$`; + + const variables = { + TITLE: 'Node.js TSC', + DATE: '2025-01-15', + TIME: '10:30 UTC', + AGENDA: '* Item 1\n* Item 2', + }; + + const result = templates.parseVariables(template, variables); + + assert(result.includes('# Meeting: Node.js TSC')); + assert(result.includes('Date: 2025-01-15')); + assert(result.includes('Time: 10:30 UTC')); + assert(result.includes('* Item 1\n* Item 2')); + }); + + it('should handle very long variable values', () => { + const longContent = 'x'.repeat(10000); + const template = 'Content: $CONTENT$'; + const variables = { CONTENT: longContent }; + const result = templates.parseVariables(template, variables); + + assert(result.includes(longContent)); }); - it('should handle special characters in values', () => { - const template = 'Pattern: $PATTERN$'; - const variables = { PATTERN: '$.*+?^{}()|[]\\' }; + it('should be case-sensitive for variable names', () => { + const template = '$name$ and $NAME$'; + const variables = { name: 'lowercase', NAME: 'UPPERCASE' }; + const result = templates.parseVariables(template, variables); - const result = parseVariables(template, variables); + assert.strictEqual(result, 'lowercase and UPPERCASE'); + }); + + it('should handle variables with similar names', () => { + const template = '$VAR$ $VAR_NAME$ $VAR_NAME_LONG$'; + const variables = { + VAR: 'a', + VAR_NAME: 'b', + VAR_NAME_LONG: 'c', + }; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'a b c'); + }); + + it('should process regex special characters in placeholder correctly', () => { + const template = 'Value: $SPECIAL_CHARS$'; + const variables = { SPECIAL_CHARS: '.*+?^${}()|[\\]' }; + const result = templates.parseVariables(template, variables); - assert.strictEqual(result, 'Pattern: $.*+?^{}()|[]\\'); + assert.strictEqual(result, 'Value: .*+?^${}()|[\\]'); }); - it('should handle variables with underscores and numbers', () => { - const template = '$VAR_1$ and $VAR_2_TEST$'; - const variables = { VAR_1: 'first', VAR_2_TEST: 'second' }; + it('should replace all instances of a variable', () => { + const template = '$X$ $X$ $X$'; + const variables = { X: 'Y' }; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'Y Y Y'); + }); + + it('should handle undefined variables as empty string', () => { + const template = 'Value: $UNDEFINED$'; + const variables = {}; + const result = templates.parseVariables(template, variables); + + assert.strictEqual(result, 'Value: '); + }); - const result = parseVariables(template, variables); + it('should handle template with only a variable', () => { + const template = '$CONTENT$'; + const variables = { CONTENT: 'Full content' }; + const result = templates.parseVariables(template, variables); - assert.strictEqual(result, 'first and second'); + assert.strictEqual(result, 'Full content'); }); - it('should handle multiline templates', () => { - const template = `Line 1: $VAR1$ -Line 2: $VAR2$ -Line 3: $VAR1$ again`; - const variables = { VAR1: 'Hello', VAR2: 'World' }; + it('should replace variables at line boundaries', () => { + const template = `Line1: $VAR1$ +Line2: $VAR2$ +Line3: $VAR3$`; - const result = parseVariables(template, variables); + const variables = { VAR1: 'A', VAR2: 'B', VAR3: 'C' }; + const result = templates.parseVariables(template, variables); - assert.strictEqual( - result, - `Line 1: Hello -Line 2: World -Line 3: Hello again` - ); + const lines = result.split('\n'); + assert.strictEqual(lines[0], 'Line1: A'); + assert.strictEqual(lines[1], 'Line2: B'); + assert.strictEqual(lines[2], 'Line3: C'); }); }); }); diff --git a/test/utils/urls.test.mjs b/test/utils/urls.test.mjs index 2e09ee8..13d4728 100644 --- a/test/utils/urls.test.mjs +++ b/test/utils/urls.test.mjs @@ -1,211 +1,220 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { URL } from 'node:url'; -import { - generateTimeAndDateLink, - generateWolframAlphaLink, -} from '../../src/utils/urls.mjs'; +import * as urls from '../../src/utils/urls.mjs'; -describe('Utils - URLs', () => { +describe('urls utility', () => { describe('generateTimeAndDateLink', () => { - it('should generate valid TimeAndDate.com link', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); + it('should generate a valid TimeAndDate.com URL', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); const groupName = 'TSC'; + const result = urls.generateTimeAndDateLink(meetingDate, groupName); - const result = generateTimeAndDateLink(meetingDate, groupName); - - assert.strictEqual(typeof result, 'string'); - assert.ok( + assert(typeof result === 'string'); + assert( result.startsWith( 'https://www.timeanddate.com/worldclock/fixedtime.html' ) ); }); - it('should include encoded group name in URL', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); - const groupName = 'Security Working Group'; - - const result = generateTimeAndDateLink(meetingDate, groupName); + it('should include the group name in the URL', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const groupName = 'MyGroup'; + const result = urls.generateTimeAndDateLink(meetingDate, groupName); - assert.ok(result.includes(encodeURIComponent('Security Working Group'))); - assert.ok(result.includes('Security%20Working%20Group')); + assert(result.includes(groupName) || result.includes('%20')); }); - it('should include formatted date in URL', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); + it('should include the UTC date in YYYY-MM-DD format', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); const groupName = 'TSC'; + const result = urls.generateTimeAndDateLink(meetingDate, groupName); - const result = generateTimeAndDateLink(meetingDate, groupName); - - assert.ok(result.includes('2023-10-15')); + assert(result.includes('2025-01-15')); }); - it('should include ISO datetime in URL', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); - const groupName = 'TSC'; + it('should URL encode the group name', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const groupName = 'Test & Special#Chars'; + const result = urls.generateTimeAndDateLink(meetingDate, groupName); - const result = generateTimeAndDateLink(meetingDate, groupName); - - assert.ok(result.includes('iso=20231015T1430')); + // Should be URL encoded + assert(result.includes('%')); }); - it('should handle group names with special characters', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); - const groupName = 'Build & Release'; - - const result = generateTimeAndDateLink(meetingDate, groupName); + it('should include the ISO datetime format in the URL', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const groupName = 'TSC'; + const result = urls.generateTimeAndDateLink(meetingDate, groupName); - assert.ok(result.includes(encodeURIComponent('Build & Release'))); - assert.strictEqual(typeof result, 'string'); + // ISO format should be like 20250115T103000 (without separators) + assert(result.includes('iso=')); + assert(result.includes('20250115T103000')); }); - it('should handle midnight meeting times', () => { - const meetingDate = new Date('2023-10-15T00:00:00Z'); + it('should handle different meeting dates correctly', () => { + const date1 = new Date('2025-01-15T10:30:00Z'); + const date2 = new Date('2025-06-15T10:30:00Z'); const groupName = 'TSC'; - const result = generateTimeAndDateLink(meetingDate, groupName); + const result1 = urls.generateTimeAndDateLink(date1, groupName); + const result2 = urls.generateTimeAndDateLink(date2, groupName); - assert.ok(result.includes('iso=20231015T0000')); - assert.ok(result.includes('2023-10-15')); + // Results should be different for different dates + assert.notStrictEqual(result1, result2); + assert(result1.includes('2025-01-15')); + assert(result2.includes('2025-06-15')); }); - it('should handle end of year dates', () => { - const meetingDate = new Date('2023-12-31T23:59:00Z'); - const groupName = 'TSC'; - - const result = generateTimeAndDateLink(meetingDate, groupName); + it('should handle group names with spaces', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const groupName = 'Node Foundation TSC'; + const result = urls.generateTimeAndDateLink(meetingDate, groupName); - assert.ok(result.includes('2023-12-31')); - assert.ok(result.includes('iso=20231231T2359')); + assert(result.includes('+')); }); it('should handle single character group names', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); - const groupName = 'X'; + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const groupName = 'A'; + const result = urls.generateTimeAndDateLink(meetingDate, groupName); - const result = generateTimeAndDateLink(meetingDate, groupName); - - assert.ok(result.includes('X')); - assert.strictEqual(typeof result, 'string'); + assert(result.includes('A')); + assert(result.startsWith('https://')); }); - it('should properly encode spaces and special characters', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); - const groupName = 'Node.js Foundation Group'; - - const result = generateTimeAndDateLink(meetingDate, groupName); + it('should handle very long group names', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const groupName = 'A'.repeat(100); + const result = urls.generateTimeAndDateLink(meetingDate, groupName); - // Should not contain unencoded spaces - assert.ok(!result.includes('Node.js Foundation Group')); - // Should contain encoded version - assert.ok( - result.includes(encodeURIComponent('Node.js Foundation Group')) - ); + assert(typeof result === 'string'); + assert(result.length > 100); }); }); describe('generateWolframAlphaLink', () => { - it('should generate valid WolframAlpha link', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateWolframAlphaLink(meetingDate); + it('should generate a valid WolframAlpha URL', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - assert.strictEqual(typeof result, 'string'); - assert.ok(result.startsWith('https://www.wolframalpha.com/input/?i=')); + assert(typeof result === 'string'); + assert(result.startsWith('https://www.wolframalpha.com/input/')); }); - it('should include UTC time in URL', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); - - const result = generateWolframAlphaLink(meetingDate); + it('should include UTC time in the URL', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - // Should include 2:30 PM format - assert.ok(result.includes('2%3A30') || result.includes('2:30')); - assert.ok(result.includes('PM')); + assert(result.includes('UTC')); }); - it('should include UTC date in URL', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); + it('should include the date in the URL', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - const result = generateWolframAlphaLink(meetingDate); - - assert.ok(result.includes('Oct')); - assert.ok(result.includes('15')); - assert.ok(result.includes('2023')); + assert(result.includes('2025')); }); - it('should include "local time" query in URL', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); + it('should URL encode special characters', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); + + // Should contain URL-encoded characters + assert(result.includes('%')); + }); - const result = generateWolframAlphaLink(meetingDate); + it('should format time with leading zeros', () => { + const meetingDate = new Date('2025-01-15T09:05:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - assert.ok( - result.includes('local%20time') || result.includes('local+time') - ); + // Should have 09:05 + assert(result.includes('09') || result.includes('%2009')); }); - it('should handle midnight times', () => { - const meetingDate = new Date('2023-10-15T00:00:00Z'); + it('should handle PM times correctly', () => { + const meetingDate = new Date('2025-01-15T15:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - const result = generateWolframAlphaLink(meetingDate); + assert(result.includes('PM') || result.includes('%20PM')); + }); + + it('should handle AM times correctly', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - assert.ok(result.includes('12%3A00') || result.includes('12:00')); - assert.ok(result.includes('AM')); + assert(result.includes('AM') || result.includes('%20AM')); }); - it('should handle noon times', () => { - const meetingDate = new Date('2023-10-15T12:00:00Z'); + it('should handle different dates correctly', () => { + const date1 = new Date('2025-01-15T10:30:00Z'); + const date2 = new Date('2025-06-15T10:30:00Z'); - const result = generateWolframAlphaLink(meetingDate); + const result1 = urls.generateWolframAlphaLink(date1); + const result2 = urls.generateWolframAlphaLink(date2); - assert.ok(result.includes('12%3A00') || result.includes('12:00')); - assert.ok(result.includes('PM')); + // Results should be different for different dates + assert.notStrictEqual(result1, result2); }); - it('should handle single digit minutes', () => { - const meetingDate = new Date('2023-10-15T14:05:00Z'); + it('should handle dates at year boundaries', () => { + const meetingDate = new Date('2024-12-31T23:59:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); + + assert(result.includes('2024')); + }); - const result = generateWolframAlphaLink(meetingDate); + it('should handle dates at month boundaries', () => { + const meetingDate = new Date('2025-02-01T00:00:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - assert.ok(result.includes('2%3A05') || result.includes('2:05')); + assert(result.includes('2025')); }); - it('should handle end of year dates', () => { - const meetingDate = new Date('2023-12-31T23:59:00Z'); + it('should include "local+time" query for timezone conversion', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); + + assert(result.includes('local')); + }); - const result = generateWolframAlphaLink(meetingDate); + it('should be a valid URL format', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - assert.ok(result.includes('Dec')); - assert.ok(result.includes('31')); - assert.ok(result.includes('2023')); - assert.ok(result.includes('11%3A59') || result.includes('11:59')); - assert.ok(result.includes('PM')); + // Should start with https and contain expected parts + assert(result.startsWith('https://')); + assert(result.includes('wolframalpha')); }); - it('should properly encode the URL', () => { - const meetingDate = new Date('2023-10-15T14:30:00Z'); + it('should encode ampersand in query parameters', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const result = urls.generateWolframAlphaLink(meetingDate); - const result = generateWolframAlphaLink(meetingDate); + // Should have proper URL encoding + assert(!result.includes(' ') || result.includes('%20')); + }); + }); + + describe('URL generation consistency', () => { + it('should generate consistent URLs for the same inputs', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); + const groupName = 'TSC'; - // Should be a valid URL format - assert.doesNotThrow(() => new URL(result)); + const result1 = urls.generateTimeAndDateLink(meetingDate, groupName); + const result2 = urls.generateTimeAndDateLink(meetingDate, groupName); - // Should contain proper encoding - assert.ok(result.includes('%2C') || result.includes(',')); + assert.strictEqual(result1, result2); }); - it('should handle beginning of year dates', () => { - const meetingDate = new Date('2023-01-01T00:00:00Z'); + it('should generate consistent WolframAlpha URLs for the same inputs', () => { + const meetingDate = new Date('2025-01-15T10:30:00Z'); - const result = generateWolframAlphaLink(meetingDate); + const result1 = urls.generateWolframAlphaLink(meetingDate); + const result2 = urls.generateWolframAlphaLink(meetingDate); - assert.ok(result.includes('Jan')); - assert.ok(result.includes('1')); - assert.ok(result.includes('2023')); - assert.ok(result.includes('12%3A00') || result.includes('12:00')); - assert.ok(result.includes('AM')); + assert.strictEqual(result1, result2); }); }); });