Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
188 changes: 188 additions & 0 deletions plugins/cron/index.test.ts
Original file line number Diff line number Diff line change
@@ -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)
)
})
})
48 changes: 48 additions & 0 deletions plugins/cron/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
88 changes: 88 additions & 0 deletions plugins/resend/index.test.ts
Original file line number Diff line number Diff line change
@@ -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',
'<p>Hello world</p>'
)

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: '<p>Hello world</p>',
}),
}
)
})

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')
})
})