import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; // Apps Script loads every .gs file into one shared global scope. Reproduce that // by joining the files in project order and evaluating them inside a function, // which returns the names the tests use. The files are never edited for Node. const projectFiles = ['Reminders.gs', 'Code.gs']; const source = projectFiles .map((file) => readFileSync(new URL('../' + file, import.meta.url), 'utf8')) .join('\n'); const gs = vm.runInThisContext( '(function () {\n' + source + '\nreturn { headerProblem_, validateRenewalRow_, daysUntil_, buildReminderEmail_,' + ' planReminders_, reminderRowsToReset_, applyReminderPlan_, onEdit };\n})', { filename: 'apps-script-project.gs' }, )(); const HEADER = ['Customer', 'Email', 'Renewal date', 'Status', 'Reminder drafted']; // Month numbers start at 0: new Date(2026, 4, 1) is 1 May 2026, local time. const TODAY = new Date(2026, 4, 1); test('daysUntil_ counts calendar days and ignores the time of day', () => { assert.equal(gs.daysUntil_(TODAY, new Date(2026, 4, 1, 23, 59)), 0); assert.equal(gs.daysUntil_(new Date(2026, 4, 1, 23, 59), new Date(2026, 4, 2, 0, 1)), 1); assert.equal(gs.daysUntil_(TODAY, new Date(2026, 3, 30)), -1); assert.equal(gs.daysUntil_(new Date(2026, 1, 27), new Date(2026, 2, 1)), 2); // Spans the 2026 daylight saving change in the United States and Europe. assert.equal(gs.daysUntil_(new Date(2026, 2, 7), new Date(2026, 2, 30)), 23); }); test('validateRenewalRow_ reports the first problem with its row number', () => { const result = gs.validateRenewalRow_(['Ada Studio', 'ada@example', '2026-05-10', 'Active', ''], 7); assert.deepEqual(result, { problem: { rowNumber: 7, message: 'Renewal date must be a date cell.' } }); const missingEmail = gs.validateRenewalRow_(['Ada Studio', '', TODAY, 'Active', ''], 3); assert.equal(missingEmail.problem.message, 'Email must look like name@example.com.'); const badStatus = gs.validateRenewalRow_(['Ada Studio', 'ada@example.com', TODAY, 'active', ''], 4); assert.match(badStatus.problem.message, /^Status must be one of/); }); test('buildReminderEmail_ builds the subject and body from one row', () => { const row = { customer: 'Ada Studio', email: 'ada@example.com', renewalDate: new Date(2026, 4, 10), status: 'Active', reminderDrafted: false, }; assert.deepEqual(gs.buildReminderEmail_(row, 9, 2), { rowNumber: 2, to: 'ada@example.com', subject: 'Your subscription renews in 9 days', body: 'Hello Ada Studio,\n\nYour subscription renews on 2026-05-10 (in 9 days).\n' + 'Reply to this email if you want to change or cancel it.', }); assert.equal(gs.buildReminderEmail_(row, 1, 2).subject, 'Your subscription renews tomorrow'); }); test('planReminders_ selects Active rows due within 14 days without a drafted date', () => { const rows = [ ['Ada Studio', 'ada@example.com', new Date(2026, 4, 10), 'Active', ''], ['Lin Bakery', 'lin@example.com', new Date(2026, 4, 15), 'Active', ''], ['Kai Garage', 'kai@example.com', new Date(2026, 4, 16), 'Active', ''], ['Rio Books', 'rio@example.com', new Date(2026, 4, 5), 'Renewed', ''], ['Sol Cafe', 'sol@example.com', new Date(2026, 4, 5), 'Active', new Date(2026, 3, 28)], ['', '', '', '', ''], ['Max Print', 'max@example.com', 'next week', 'Active', ''], ]; const plan = gs.planReminders_(rows, TODAY); assert.deepEqual( plan.reminders.map((/** @type {{rowNumber: number}} */ email) => email.rowNumber), [2, 3], ); assert.deepEqual(plan.problems, [{ rowNumber: 8, message: 'Renewal date must be a date cell.' }]); }); test('headerProblem_ rejects a renamed column', () => { assert.equal(gs.headerProblem_(HEADER), ''); assert.match(gs.headerProblem_(['Customer', 'E-mail', 'Renewal date', 'Status', 'Reminder drafted']), /^Row 1 must be/); }); test('reminderRowsToReset_ handles single cells, pastes, and the header row', () => { assert.deepEqual(gs.reminderRowsToReset_(4, 3, 1, 1), { firstRow: 4, numRows: 1 }); assert.deepEqual(gs.reminderRowsToReset_(1, 1, 5, 5), { firstRow: 2, numRows: 4 }); assert.equal(gs.reminderRowsToReset_(1, 3, 1, 1), null); assert.equal(gs.reminderRowsToReset_(4, 4, 1, 1), null); }); /** * A fake Sheet with only the methods applyReminderPlan_ calls. * * @param {unknown[][]} values */ function fakeSheet(values) { /** @type {{row: number, column: number, value: unknown}[]} */ const writes = []; return { writes, getDataRange: () => ({ getValues: () => values.map((row) => row.slice()) }), /** @param {number} row @param {number} column */ getRange: (row, column) => ({ /** @param {unknown} value */ setValue: (value) => { writes.push({ row, column, value }); values[row - 1][column - 1] = value; }, }), }; } test('applyReminderPlan_ drafts, then marks each row, and is safe to rerun', () => { const sheet = fakeSheet([ HEADER, ['Ada Studio', 'ada@example.com', new Date(2026, 4, 10), 'Active', ''], ['Lin Bakery', 'lin@example.com', new Date(2026, 5, 30), 'Active', ''], ]); /** @type {string[]} */ const drafts = []; const result = gs.applyReminderPlan_(sheet, (/** @type {{to: string}} */ email) => drafts.push(email.to), TODAY); assert.deepEqual(result, { drafted: [2], problems: [] }); assert.deepEqual(drafts, ['ada@example.com']); assert.deepEqual(sheet.writes, [{ row: 2, column: 5, value: TODAY }]); const rerun = gs.applyReminderPlan_(sheet, () => assert.fail('no draft expected'), TODAY); assert.deepEqual(rerun.drafted, []); }); test('applyReminderPlan_ stops before drafting when the header is wrong', () => { const sheet = fakeSheet([['Name', 'Email'], ['Ada Studio', 'ada@example.com']]); assert.throws( () => gs.applyReminderPlan_(sheet, () => assert.fail('no draft expected'), TODAY), /Row 1 must be/, ); assert.deepEqual(sheet.writes, []); }); test('applyReminderPlan_ keeps the mark for a draft that succeeded before a failure', () => { const sheet = fakeSheet([ HEADER, ['Ada Studio', 'ada@example.com', new Date(2026, 4, 10), 'Active', ''], ['Lin Bakery', 'lin@example.com', new Date(2026, 4, 11), 'Active', ''], ]); let calls = 0; const failSecond = () => { calls += 1; if (calls === 2) { throw new Error('Simulated Gmail failure'); } }; assert.throws(() => gs.applyReminderPlan_(sheet, failSecond, TODAY), /Simulated Gmail failure/); assert.deepEqual(sheet.writes, [{ row: 2, column: 5, value: TODAY }]); }); test('onEdit clears Reminder drafted when a hand-built event edits a renewal date', () => { /** @type {string[]} */ const cleared = []; const sheet = { getName: () => 'Renewals', /** @param {number} row @param {number} column @param {number} numRows @param {number} numColumns */ getRange: (row, column, numRows, numColumns) => ({ clearContent: () => cleared.push(`row ${row}, column ${column}, ${numRows} x ${numColumns}`), }), }; const event = { range: { getSheet: () => sheet, getRow: () => 3, getColumn: () => 3, getNumRows: () => 2, getNumColumns: () => 1 }, }; gs.onEdit(event); assert.deepEqual(cleared, ['row 3, column 5, 2 x 1']); cleared.length = 0; gs.onEdit({ range: { ...event.range, getSheet: () => ({ ...sheet, getName: () => 'Notes' }) } }); assert.deepEqual(cleared, []); });