diff --git a/plugins/cron/utils.test.ts b/plugins/cron/utils.test.ts new file mode 100644 index 0000000..482eca7 --- /dev/null +++ b/plugins/cron/utils.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { parseCronExpression, getNextExecutionTime } from './utils' + +describe('cron utils', () => { + it('parseCronExpression parses a valid cron expression into an interval', () => { + const interval = parseCronExpression('0 0 * * *') + expect(interval).toBeDefined() + expect(typeof interval.next).toBe('function') + }) + + it('parseCronExpression throws on an invalid cron expression', () => { + expect(() => parseCronExpression('not a cron')).toThrow() + }) + + it('getNextExecutionTime returns the next minute boundary after the given time', () => { + const after = Date.UTC(2026, 0, 1, 12, 30, 30) // 2026-01-01 12:30:30 UTC + const next = getNextExecutionTime('* * * * *', after) + + expect(next).toBeGreaterThan(after) + expect(next - after).toBeLessThanOrEqual(60_000) + // a "next minute" always lands on a whole-minute boundary + expect(next % 60_000).toBe(0) + }) + + it('getNextExecutionTime returns a future time for a daily cron', () => { + const after = Date.UTC(2026, 0, 1, 12, 0, 0) + const next = getNextExecutionTime('0 0 * * *', after) + + expect(next).toBeGreaterThan(after) + }) + + it('getNextExecutionTime advances from one occurrence to the next', () => { + const after = Date.UTC(2026, 0, 1, 12, 0, 0) + const first = getNextExecutionTime('* * * * *', after) + const second = getNextExecutionTime('* * * * *', first) + + expect(second).toBeGreaterThan(first) + }) +}) diff --git a/plugins/resend/index.test.ts b/plugins/resend/index.test.ts new file mode 100644 index 0000000..a57d085 --- /dev/null +++ b/plugins/resend/index.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { ResendPlugin } from './index' + +describe('ResendPlugin', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('stores the provided api key', () => { + const plugin = new ResendPlugin({ apiKey: 'test-key' }) + expect(plugin.apiKey).toBe('test-key') + }) + + it('is constructible without options (no api key)', () => { + const plugin = new ResendPlugin() + expect(plugin.apiKey).toBeUndefined() + }) + + it('sendEmail posts to the Resend API and returns the response data on success', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: 'email_123' }), + }) + vi.stubGlobal('fetch', fetchMock) + + const plugin = new ResendPlugin({ apiKey: 'test-key' }) + const result = await plugin.sendEmail( + 'from@example.com', + ['to@example.com'], + 'Subject', + '
Hello
' + ) + + expect(result).toEqual({ id: 'email_123' }) + expect(fetchMock).toHaveBeenCalledOnce() + + const [url, options] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.resend.com/emails') + expect(options.method).toBe('POST') + expect(options.headers.Authorization).toBe('Bearer test-key') + expect(JSON.parse(options.body)).toEqual({ + from: 'from@example.com', + to: ['to@example.com'], + subject: 'Subject', + html: 'Hello
', + }) + }) + + it('sendEmail throws with the API error message when the response is not ok', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ message: 'Invalid API key' }), + }) + vi.stubGlobal('fetch', fetchMock) + + const plugin = new ResendPlugin({ apiKey: 'bad-key' }) + await expect( + plugin.sendEmail('a@b.com', ['c@d.com'], 'S', 'x
') + ).rejects.toThrow('Invalid API key') + }) + + it('sendEmail throws a default message when the error response has no message', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + }) + vi.stubGlobal('fetch', fetchMock) + + const plugin = new ResendPlugin({ apiKey: 'bad-key' }) + await expect( + plugin.sendEmail('a@b.com', ['c@d.com'], 'S', 'x
') + ).rejects.toThrow('Failed to send email') + }) +})