diff --git a/plugins/cron/index.test.ts b/plugins/cron/index.test.ts new file mode 100644 index 0000000..a9ab427 --- /dev/null +++ b/plugins/cron/index.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { CronPlugin, CronEventPayload } from './index' +import { StarbaseApp } from '../../src/handler' +import { DataSource } from '../../src/types' + +let cronPlugin: CronPlugin +let mockDataSource: DataSource +let mockExecutionContext: ExecutionContext + +beforeEach(() => { + vi.clearAllMocks() + + mockExecutionContext = { + waitUntil: vi.fn(), + } as unknown as ExecutionContext + + mockDataSource = { + rpc: { + executeQuery: vi.fn().mockResolvedValue([]), + setAlarm: vi.fn().mockResolvedValue(true), + }, + } as unknown as DataSource + + cronPlugin = new CronPlugin() +}) + +describe('CronPlugin - Initialization', () => { + it('should initialize with correct name and auth requirements', () => { + expect(cronPlugin).toBeInstanceOf(CronPlugin) + expect(cronPlugin.name).toBe('starbasedb:cron') + expect(cronPlugin.pathPrefix).toBe('/cron') + expect(cronPlugin.opts.requiresAuth).toBe(true) + }) +}) + +describe('CronPlugin - register() and Middleware', () => { + it('should set up middleware that initializes table and schedules alarms', async () => { + let middlewareHandler: any = null + const mockApp = { + use: vi.fn((fn) => { + middlewareHandler = fn + }), + post: vi.fn(), + } as unknown as StarbaseApp + + await cronPlugin.register(mockApp) + + expect(mockApp.use).toHaveBeenCalledTimes(1) + expect(mockApp.post).toHaveBeenCalledWith( + '/cron/callback', + expect.any(Function) + ) + + const mockContext = { + get: vi.fn(() => mockDataSource), + } + const mockNext = vi.fn().mockResolvedValue(undefined) + + await middlewareHandler(mockContext, mockNext) + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith({ + sql: expect.stringContaining( + 'CREATE TABLE IF NOT EXISTS tmp_cron_tasks' + ), + params: [], + }) + expect(mockNext).toHaveBeenCalledTimes(1) + }) + + it('should handle POST /cron/callback and invoke registered callbacks', async () => { + let postHandler: any = null + const mockApp = { + use: vi.fn(), + post: vi.fn((path, handler) => { + if (path === '/cron/callback') { + postHandler = handler + } + }), + } as unknown as StarbaseApp + + await cronPlugin.register(mockApp) + + const sampleEvent: CronEventPayload = { + name: 'cleanup_job', + cron_tab: '0 * * * *', + payload: { dryRun: false }, + } + + const receivedPayloads: CronEventPayload[] = [] + cronPlugin.onEvent((evt) => { + receivedPayloads.push(evt) + }) + + const mockReqContext = { + req: { + json: vi.fn().mockResolvedValue([sampleEvent]), + }, + } + + const response = await postHandler(mockReqContext) + expect(response.status).toBe(200) + expect(receivedPayloads).toHaveLength(1) + expect(receivedPayloads[0].name).toBe('cleanup_job') + }) +}) + +describe('CronPlugin - addEvent()', () => { + it('should throw an error when called before dataSource is initialized', async () => { + await expect( + cronPlugin.addEvent( + '* * * * *', + 'task_1', + {}, + 'https://api.example.com' + ) + ).rejects.toThrow('CronPlugin not properly initialized') + }) + + it('should insert task into database and reschedule next alarm', async () => { + // Manually assign dataSource for unit test + cronPlugin['dataSource'] = mockDataSource + + // Mock executeQuery for GET_TASKS to return the newly added task + ;(mockDataSource.rpc.executeQuery as any).mockImplementation( + ({ sql }: any) => { + if (sql.includes('SELECT name, cron_tab, payload')) { + return [ + { + name: 'sync_data', + cron_tab: '*/10 * * * *', + payload: '{"target":"db"}', + }, + ] + } + return [] + } + ) + + await cronPlugin.addEvent( + '*/10 * * * *', + 'sync_data', + { target: 'db' }, + 'https://api.example.com' + ) + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith({ + sql: expect.stringContaining( + 'INSERT OR REPLACE INTO tmp_cron_tasks' + ), + params: [ + 'sync_data', + '*/10 * * * *', + JSON.stringify({ target: 'db' }), + 'https://api.example.com', + ], + }) + + expect(mockDataSource.rpc.setAlarm).toHaveBeenCalledWith( + expect.any(Number) + ) + }) +}) + +describe('CronPlugin - onEvent() with ExecutionContext', () => { + it('should route async promise to ctx.waitUntil', async () => { + let asyncFinished = false + const asyncCallback = async () => { + await new Promise((res) => setTimeout(res, 10)) + asyncFinished = true + } + + cronPlugin.onEvent(asyncCallback, mockExecutionContext) + + // Invoke callback wrapper directly + const sampleEvent: CronEventPayload = { + name: 'test', + cron_tab: '* * * * *', + payload: {}, + } + + const registeredCallback = cronPlugin['eventCallbacks'][0] + await registeredCallback(sampleEvent) + + expect(mockExecutionContext.waitUntil).toHaveBeenCalledWith( + expect.any(Promise) + ) + }) +}) diff --git a/plugins/cron/utils.test.ts b/plugins/cron/utils.test.ts new file mode 100644 index 0000000..de615c8 --- /dev/null +++ b/plugins/cron/utils.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { parseCronExpression, getNextExecutionTime } from './utils' + +describe('Cron Utils - parseCronExpression()', () => { + it('should successfully parse valid standard 5-part cron expressions', () => { + const interval = parseCronExpression('*/5 * * * *') + expect(interval).toBeDefined() + expect(typeof interval.next).toBe('function') + }) + + it('should parse hourly cron expressions', () => { + const interval = parseCronExpression('0 * * * *') + expect(interval).toBeDefined() + const nextDate = interval.next().toDate() + expect(nextDate).toBeInstanceOf(Date) + }) + + it('should throw an error for invalid cron expressions', () => { + expect(() => parseCronExpression('invalid cron string')).toThrow() + expect(() => parseCronExpression('60 * * * *')).toThrow() + }) +}) + +describe('Cron Utils - getNextExecutionTime()', () => { + it('should calculate the next execution time greater than the reference timestamp', () => { + const refTime = new Date('2026-01-01T12:00:00Z').getTime() + const nextTime = getNextExecutionTime('*/15 * * * *', refTime) + + expect(nextTime).toBeGreaterThan(refTime) + const nextDate = new Date(nextTime) + expect(nextDate.getMinutes() % 15).toBe(0) + }) + + it('should correctly calculate midnight trigger time for daily schedule', () => { + const refTime = new Date('2026-05-10T14:30:00Z').getTime() + const nextTime = getNextExecutionTime('0 0 * * *', refTime) + + expect(nextTime).toBeGreaterThan(refTime) + const nextDate = new Date(nextTime) + expect(nextDate.getHours()).toBe(0) + expect(nextDate.getMinutes()).toBe(0) + }) + + it('should throw when given an invalid cron string', () => { + const refTime = Date.now() + expect(() => getNextExecutionTime('not-a-cron', refTime)).toThrow() + }) +}) diff --git a/plugins/resend/index.test.ts b/plugins/resend/index.test.ts new file mode 100644 index 0000000..ad46c14 --- /dev/null +++ b/plugins/resend/index.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ResendPlugin } from './index' + +describe('ResendPlugin - Initialization', () => { + it('should initialize with plugin name and optional apiKey', () => { + const pluginWithoutKey = new ResendPlugin() + expect(pluginWithoutKey.name).toBe('starbasedb:resend') + expect(pluginWithoutKey.opts.requiresAuth).toBe(false) + expect(pluginWithoutKey.apiKey).toBeUndefined() + + const pluginWithKey = new ResendPlugin({ apiKey: 're_test_12345' }) + expect(pluginWithKey.apiKey).toBe('re_test_12345') + }) +}) + +describe('ResendPlugin - sendEmail()', () => { + const mockApiKey = 're_test_key_987' + let plugin: ResendPlugin + + beforeEach(() => { + vi.restoreAllMocks() + plugin = new ResendPlugin({ apiKey: mockApiKey }) + }) + + it('should make POST request to Resend API with correct headers and payload', async () => { + const mockResponseData = { id: 'email_id_123' } + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponseData), + }) + global.fetch = mockFetch + + const result = await plugin.sendEmail( + 'sender@example.com', + ['recipient1@example.com', 'recipient2@example.com'], + 'Test Subject', + '

Hello world

' + ) + + expect(result).toEqual(mockResponseData) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.resend.com/emails', + { + method: 'POST', + headers: { + Authorization: `Bearer ${mockApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: 'sender@example.com', + to: ['recipient1@example.com', 'recipient2@example.com'], + subject: 'Test Subject', + html: '

Hello world

', + }), + } + ) + }) + + it('should throw an error with API message when response is not ok', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + json: vi.fn().mockResolvedValue({ message: 'Domain not verified' }), + }) + global.fetch = mockFetch + + await expect( + plugin.sendEmail( + 'unverified@domain.com', + ['user@example.com'], + 'Alert', + 'Body' + ) + ).rejects.toThrow('Domain not verified') + }) + + it('should fallback to default error message when data.message is missing', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + json: vi.fn().mockResolvedValue({}), + }) + global.fetch = mockFetch + + await expect( + plugin.sendEmail('from@test.com', ['to@test.com'], 'Sub', 'Body') + ).rejects.toThrow('Failed to send email') + }) +})