Skip to content
Open
96 changes: 96 additions & 0 deletions plugins/cron/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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)
})
})
28 changes: 14 additions & 14 deletions plugins/cron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>)[] =
[]

constructor() {
super('starbasedb:cron', {
Expand All @@ -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)
})
Expand Down Expand Up @@ -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)
Expand Down
133 changes: 133 additions & 0 deletions plugins/interface/components/primitives.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Avatar as="a" href="/profile" username="outerbase" class="extra" />
)

expect(html).toContain('<a')
expect(html).toContain('href="/profile"')
expect(html).toContain('extra')
expect(html).toContain('>O</p>')
})

it('renders avatar images with accessible alt text and selected state', () => {
const html = renderToString(
<Avatar
image="/avatar.png"
toggled
username="Ada"
data-testid="avatar"
/>
)

expect(html).toContain('<button')
expect(html).toContain('after:opacity-100')
expect(html).toContain('src="/avatar.png"')
expect(html).toContain('alt="Ada"')
expect(html).toContain('data-testid="avatar"')
})

it('renders cards as links or divs with variant classes', () => {
const link = renderToString(
<Card as="a" href="/docs" variant="primary">
Docs
</Card>
)
const panel = renderToString(
<Card variant="secondary" data-testid="card">
Panel
</Card>
)

expect(link).toContain('<a')
expect(link).toContain('href="/docs"')
expect(link).toContain('btn-primary')
expect(link).toContain('Docs')

expect(panel).toContain('<div')
expect(panel).toContain('btn-secondary')
expect(panel).toContain('data-testid="card"')
})

it('renders labels with validation messaging only when invalid', () => {
const invalid = renderToString(
<Label
title="Database"
required
requiredDescription="Required"
isValid={false}
>
<input />
</Label>
)
const valid = renderToString(
<Label
title="Database"
required
requiredDescription="Required"
isValid
/>
)

expect(invalid).toContain('Database')
expect(invalid).toContain('*')
expect(invalid).toContain('Required')
expect(valid).not.toContain('Required')
})

it('renders loader and toggle sizing/state classes', () => {
const loader = renderToString(<Loader size={18} class="spin" />)
const toggle = renderToString(
<Toggle onClick={() => undefined} size="lg" toggled />
)

expect(loader).toContain('class="spin"')
expect(loader).toContain('style="height: 18px; width: 18px"')
expect(toggle).toContain('h-7.5 w-12.5')
expect(toggle).toContain('translate-x-full')
})

it('renders input wrappers with prefix, suffix, and invalid state', () => {
const wrapped = renderToString(
<Input
initialValue="abc"
isValid={false}
onValueChange={() => undefined}
placeholder="Filter"
preText="$"
postText="USD"
size="sm"
/>
)
const plain = renderToString(
<Input
className="extra-input"
initialValue="plain"
onValueChange={() => undefined}
size="lg"
/>
)

expect(wrapped).toContain('<div')
expect(wrapped).toContain('ob-size-sm')
expect(wrapped).toContain('>$</span>')
expect(wrapped).toContain('>USD</span>')
expect(wrapped).toContain('placeholder="Filter"')
expect(wrapped).toContain('text-ob-destructive')

expect(plain).toContain('<input')
expect(plain).toContain('extra-input')
expect(plain).toContain('ob-size-lg')
expect(plain).toContain('value="plain"')
})
})
47 changes: 47 additions & 0 deletions plugins/interface/pages/template/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const hydrateRoot = vi.hoisted(() => vi.fn())

vi.mock('hono/jsx/dom/client', () => ({
hydrateRoot,
}))

vi.mock('../../public/global.css', () => ({}))

describe('template page entrypoint', () => {
beforeEach(() => {
vi.resetModules()
hydrateRoot.mockClear()
})

afterEach(() => {
vi.unstubAllGlobals()
})

it('does not hydrate when the template root is missing', async () => {
const querySelector = vi.fn(() => null)
vi.stubGlobal('document', { querySelector })

await import('./index')

expect(querySelector).toHaveBeenCalledWith(
'#root[data-client="template"]'
)
expect(hydrateRoot).not.toHaveBeenCalled()
})

it('hydrates the template page when the server root is present', async () => {
const root = {
dataset: {
serverProps: '{}',
},
}
const querySelector = vi.fn(() => root)
vi.stubGlobal('document', { querySelector })

await import('./index')

expect(hydrateRoot).toHaveBeenCalledTimes(1)
expect(hydrateRoot).toHaveBeenCalledWith(root, expect.any(Object))
})
})
Loading