Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c3df639
Judge invites!
ReehalS Feb 26, 2026
302ee39
Linty lint
ReehalS Feb 26, 2026
f4d2371
Fix image URL and error handling for sending emails
ReehalS Feb 26, 2026
2ee04c4
Document browser preview csv parser
ReehalS Feb 26, 2026
47aef86
Throw error for missing email ENV vars
ReehalS Feb 26, 2026
3e88930
Single mentor invite
ReehalS Feb 26, 2026
b8ae04b
Bulk mentor invites and modify invites page
ReehalS Feb 26, 2026
e08f6fa
New format bulk mentor invites
ReehalS Feb 26, 2026
9b2ef1d
Lint fixes
ReehalS Feb 27, 2026
fd07861
Single mentor invite
ReehalS Feb 26, 2026
4e25ab9
Bulk mentor invites and modify invites page
ReehalS Feb 26, 2026
e0aaf45
New format bulk mentor invites
ReehalS Feb 26, 2026
fd85880
Lint fixes
ReehalS Feb 27, 2026
458d2db
Merge branch '376-mentor-email-invites' of https://github.com/HackDav…
ReehalS Mar 3, 2026
a4fc269
Revert "Merge branch '376-mentor-email-invites' of https://github.com…
ReehalS Mar 3, 2026
40ce0dd
Move mentor invites to new folder and add vars
ReehalS Mar 3, 2026
2000a1a
Create InviteCSV parsing test
ReehalS Mar 3, 2026
8862474
Update email subject
ReehalS Mar 3, 2026
6306c93
Update sendSingleMentorInvite.ts
ReehalS Mar 3, 2026
7b63f33
Update sendBulkMentorInvites.ts
ReehalS Mar 3, 2026
013f33a
Move EMAIL_SUBJECT to be common rather than redefined for each file
ReehalS Mar 3, 2026
9162ee5
Move Judge+Mentor bulk invite creation to new processing pipeline
ReehalS Mar 3, 2026
c68ffdd
Add tests for Limiter and processBulkInvites
ReehalS Mar 3, 2026
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
72 changes: 72 additions & 0 deletions __tests__/createLimiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import createLimiter from '@actions/emails/createLimiter';

describe('createLimiter', () => {
it('runs tasks up to the concurrency limit in parallel', async () => {
const limiter = createLimiter(2);
const running: string[] = [];
const log: string[] = [];

const task = (id: string, ms: number) =>
limiter(async () => {
running.push(id);
log.push(`start:${id}(concurrent:${running.length})`);
await new Promise((r) => setTimeout(r, ms));
running.splice(running.indexOf(id), 1);
log.push(`end:${id}`);
return id;
});

const results = await Promise.all([
task('a', 50),
task('b', 50),
task('c', 10),
]);

expect(results).toEqual(['a', 'b', 'c']);
// a and b start concurrently (concurrent:1 then concurrent:2)
// c waits until one finishes, so it starts at concurrent:1 or concurrent:2
// The key invariant: concurrent count never exceeds 2
for (const entry of log) {
const match = entry.match(/concurrent:(\d+)/);
if (match) {
expect(Number(match[1])).toBeLessThanOrEqual(2);
}
}
});

it('returns the resolved value from the wrapped function', async () => {
const limiter = createLimiter(1);
const result = await limiter(() => Promise.resolve(42));
expect(result).toBe(42);
});

it('propagates rejections', async () => {
const limiter = createLimiter(1);
await expect(
limiter(() => Promise.reject(new Error('boom')))
).rejects.toThrow('boom');
});

it('releases the slot on rejection so subsequent tasks run', async () => {
const limiter = createLimiter(1);
await limiter(() => Promise.reject(new Error('fail'))).catch(() => {});
const result = await limiter(() => Promise.resolve('ok'));
expect(result).toBe('ok');
});

it('processes all items with concurrency 1 (serial)', async () => {
const limiter = createLimiter(1);
const order: number[] = [];

await Promise.all(
[1, 2, 3].map((n) =>
limiter(async () => {
order.push(n);
await new Promise((r) => setTimeout(r, 10));
})
)
);

expect(order).toEqual([1, 2, 3]);
});
});
185 changes: 185 additions & 0 deletions __tests__/parseInviteCSV.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import parseInviteCSV from '@actions/emails/parseInviteCSV';

describe('parseInviteCSV', () => {
it('parses valid CSV with header row', () => {
const csv =
'First Name,Last Name,Email\n' +
'Alice,Smith,alice@example.com\n' +
'Bob,Jones,bob@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toEqual([
{ firstName: 'Alice', lastName: 'Smith', email: 'alice@example.com' },
{ firstName: 'Bob', lastName: 'Jones', email: 'bob@example.com' },
]);
});

it('parses valid CSV without header row', () => {
const csv = 'Alice,Smith,alice@example.com\nBob,Jones,bob@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toHaveLength(2);
expect(result.body[0].firstName).toBe('Alice');
});

it('detects header with "email" keyword', () => {
const csv = 'name_first,name_last,email\nAlice,Smith,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toHaveLength(1);
});

it('detects header with "first" keyword', () => {
const csv =
'First,Last,Contact\nAlice,Smith,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toHaveLength(1);
});

it('returns error for empty CSV', () => {
const result = parseInviteCSV('');
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe('CSV file is empty.');
});

it('returns error for whitespace-only CSV', () => {
const result = parseInviteCSV(' \n \n ');
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe('CSV file is empty.');
});

it('returns error for header-only CSV', () => {
const csv = 'First Name,Last Name,Email\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe('CSV has a header but no data rows.');
});

it('returns error when row has fewer than 3 columns', () => {
const csv = 'First Name,Last Name,Email\nAlice,Smith\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatch(/expect(ed)? 3/i);
});

it('returns error for empty first name', () => {
const csv = 'First Name,Last Name,Email\n,Smith,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('First Name is empty');
});

it('returns error for empty last name', () => {
const csv = 'First Name,Last Name,Email\nAlice,,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('Last Name is empty');
});

it('returns error for invalid email', () => {
const csv = 'First Name,Last Name,Email\nAlice,Smith,not-an-email\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('not a valid email');
});

it('collects multiple row errors', () => {
const csv =
'First Name,Last Name,Email\n' +
',Smith,alice@example.com\n' +
'Bob,,bob@example.com\n' +
'Charlie,Brown,bad-email\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
const errors = result.error.split('\n');
expect(errors).toHaveLength(3);
expect(errors[0]).toContain('Row 2');
expect(errors[1]).toContain('Row 3');
expect(errors[2]).toContain('Row 4');
});

it('trims whitespace from values', () => {
const csv = ' Alice , Smith , alice@example.com \n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body[0]).toEqual({
firstName: 'Alice',
lastName: 'Smith',
email: 'alice@example.com',
});
});

it('skips empty lines', () => {
const csv =
'First Name,Last Name,Email\n' +
'\n' +
'Alice,Smith,alice@example.com\n' +
'\n' +
'Bob,Jones,bob@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toHaveLength(2);
});

it('handles extra columns gracefully', () => {
const csv =
'First Name,Last Name,Email,Phone\n' +
'Alice,Smith,alice@example.com,555-1234\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body[0]).toEqual({
firstName: 'Alice',
lastName: 'Smith',
email: 'alice@example.com',
});
});

it('handles quoted fields with commas', () => {
const csv =
'First Name,Last Name,Email\n' +
'"Alice, Jr.",Smith,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body[0].firstName).toBe('Alice, Jr.');
});

it('row numbers are correct without header', () => {
const csv = 'Alice,Smith,alice@example.com\n,Jones,bob@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('Row 2');
});
});
Loading