diff --git a/plugins/cron/index.test.ts b/plugins/cron/index.test.ts new file mode 100644 index 0000000..d4cb54e --- /dev/null +++ b/plugins/cron/index.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CronPlugin } from './index' +const events = [ + { name: 'first', cron_tab: '* * * * *', payload: {} }, + { name: 'second', cron_tab: '* * * * *', payload: {} }, +] +async function route(plugin: CronPlugin) { + const app = { use: vi.fn(), post: vi.fn() } + await plugin.register(app as any) + return () => + app.post.mock.calls[0][1]({ req: { json: async () => events } }) +} +afterEach(() => vi.restoreAllMocks()) +describe('cron callback delivery', () => { + it.each(['sync', 'async'])( + 'contains %s failures for every event and still delivers to other listeners', + async (mode) => { + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const plugin = new CronPlugin() + plugin.onEvent(() => { + if (mode === 'sync') throw new Error('listener failed') + return Promise.reject(new Error('listener failed')) + }) + const healthy = vi.fn() + plugin.onEvent(healthy) + const response = await (await route(plugin))() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ result: { success: true } }) + expect(healthy.mock.calls.map(([event]) => event.name)).toEqual([ + 'first', + 'second', + ]) + expect(error).toHaveBeenCalledTimes(2) + } + ) + it('waits for asynchronous delivery without an execution context', async () => { + const plugin = new CronPlugin() + let finish!: () => void + const pending = new Promise((resolve) => { + finish = resolve + }) + const done = vi.fn() + plugin.onEvent(async () => { + await pending + done() + }) + const handler = await route(plugin) + let replied = false + const response = handler().then(() => { + replied = true + }) + await Promise.resolve() + await Promise.resolve() + expect(replied).toBe(false) + finish() + await response + expect(done).toHaveBeenCalledTimes(2) + }) + it('defers pending delivery through waitUntil without blocking the response', async () => { + const plugin = new CronPlugin() + let finish!: () => void + const pending = new Promise((resolve) => { + finish = resolve + }) + const waitUntil = vi.fn() + plugin.onEvent(() => pending, { waitUntil } as any) + const response = await (await route(plugin))() + expect(response.status).toBe(200) + expect(waitUntil).toHaveBeenCalledTimes(2) + finish() + await Promise.all(waitUntil.mock.calls.map(([promise]) => promise)) + }) + it('handles rejected deferred callbacks before passing them to waitUntil', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const waitUntil = vi.fn() + const plugin = new CronPlugin() + plugin.onEvent( + async () => { + throw new Error('late failure') + }, + { waitUntil } as any + ) + await ( + await route(plugin) + )() + await expect( + Promise.all(waitUntil.mock.calls.map(([promise]) => promise)) + ).resolves.toEqual([undefined, undefined]) + expect(error).toHaveBeenCalledTimes(2) + }) + it('accepts a batch with no subscribers', async () => { + expect((await (await route(new CronPlugin()))()).status).toBe(200) + }) +}) diff --git a/plugins/cron/index.ts b/plugins/cron/index.ts index 313ebbb..6ab3b51 100644 --- a/plugins/cron/index.ts +++ b/plugins/cron/index.ts @@ -54,7 +54,8 @@ export interface CronEventPayload { export class CronPlugin extends StarbasePlugin { public pathPrefix: string = '/cron' private dataSource?: DataSource - private eventCallbacks: ((payload: CronEventPayload) => void)[] = [] + private eventCallbacks: ((payload: CronEventPayload) => Promise)[] = + [] constructor() { super('starbasedb:cron', { @@ -73,15 +74,11 @@ export class CronPlugin extends StarbasePlugin { app.post(`${this.pathPrefix}/callback`, async (c) => { const payload = (await c.req.json()) as CronEventPayload[] - this.eventCallbacks.forEach((callback) => { - try { - payload.forEach((element) => { - callback(element) - }) - } catch (error) { - console.error('Error in Cron event callback:', error) - } - }) + await Promise.all( + this.eventCallbacks.flatMap((callback) => + payload.map((element) => callback(element)) + ) + ) return createResponse({ success: true }, undefined, 200) }) @@ -192,10 +189,13 @@ export class CronPlugin extends StarbasePlugin { ctx?: ExecutionContext ) { const wrappedCallback = async (payload: CronEventPayload) => { - const result = callback(payload) - if (result instanceof Promise && ctx) { - ctx.waitUntil(result) - } + const delivery = Promise.resolve() + .then(() => callback(payload)) + .catch((error) => { + console.error('Error in Cron event callback:', error) + }) + if (ctx) ctx.waitUntil(delivery) + else await delivery } this.eventCallbacks.push(wrappedCallback) diff --git a/plugins/interface/components/primitives.test.tsx b/plugins/interface/components/primitives.test.tsx new file mode 100644 index 0000000..12ec5fc --- /dev/null +++ b/plugins/interface/components/primitives.test.tsx @@ -0,0 +1,133 @@ +import { renderToString } from 'hono/jsx/dom/server' +import { describe, expect, it } from 'vitest' + +import { Avatar } from './avatar' +import { Card } from './card' +import { Input } from './input/Input' +import { Label } from './label/Label' +import { Loader } from './loader/Loader' +import { Toggle } from './toggle' + +describe('interface primitive components', () => { + it('renders avatar links with fallback initials and custom classes', () => { + const html = renderToString( + + ) + + expect(html).toContain('O

') + }) + + it('renders avatar images with accessible alt text and selected state', () => { + const html = renderToString( + + ) + + expect(html).toContain(' { + const link = renderToString( + + Docs + + ) + const panel = renderToString( + + Panel + + ) + + expect(link).toContain(' { + const invalid = renderToString( + + ) + const valid = renderToString( +