aaedafc6ba
* Replace `tap`'s event validation from "zod" to "@atproto/lex" * Expose `record` data as parsed atproto data (including CIDs and Uint8Arrays) * Minor change to validation of integers in lex data * tidy * tidy * test using vitest * add missing `tap` from root `tsconfig.json`
344 lines
9.1 KiB
TypeScript
344 lines
9.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { l } from '@atproto/lex'
|
|
import {
|
|
CreateEvent,
|
|
DeleteEvent,
|
|
LexIndexer,
|
|
UpdateEvent,
|
|
} from '../src/lex-indexer'
|
|
import { IdentityEvent, RecordEvent } from '../src/types'
|
|
import {
|
|
createIdentityEvent,
|
|
createMockOpts,
|
|
createRecordEvent as baseCreateRecordEvent,
|
|
} from './_util'
|
|
|
|
// Test lexicon definitions
|
|
const postNsid = 'com.example.post'
|
|
type Post = {
|
|
$type: 'com.example.post'
|
|
text: string
|
|
}
|
|
const post = {
|
|
main: l.record<'tid', Post>('tid', postNsid, l.object({ text: l.string() })),
|
|
}
|
|
|
|
const likeNsid = 'com.example.like'
|
|
type Like = {
|
|
$type: 'com.example.like'
|
|
subject: string
|
|
}
|
|
const like = {
|
|
main: l.record<'tid', Like>(
|
|
'tid',
|
|
likeNsid,
|
|
l.object({ subject: l.string() }),
|
|
),
|
|
}
|
|
|
|
const createRecordEvent = (overrides: Partial<RecordEvent> = {}): RecordEvent =>
|
|
baseCreateRecordEvent({
|
|
collection: postNsid,
|
|
record: { $type: postNsid, text: 'hello' },
|
|
...overrides,
|
|
})
|
|
|
|
describe('LexIndexer', () => {
|
|
describe('handler registration', () => {
|
|
it('registers create handler', async () => {
|
|
const indexer = new LexIndexer()
|
|
const received: CreateEvent<Post>[] = []
|
|
|
|
indexer.create(post, async (evt) => {
|
|
received.push(evt)
|
|
})
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(createRecordEvent(), opts)
|
|
|
|
expect(received).toHaveLength(1)
|
|
expect(received[0].action).toBe('create')
|
|
expect(received[0].record.text).toBe('hello')
|
|
expect(received[0].cid).toBe(
|
|
'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
|
|
)
|
|
})
|
|
|
|
it('registers update handler', async () => {
|
|
const indexer = new LexIndexer()
|
|
const received: UpdateEvent<Post>[] = []
|
|
|
|
indexer.update(post, async (evt) => {
|
|
received.push(evt)
|
|
})
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(
|
|
createRecordEvent({
|
|
action: 'update',
|
|
record: { $type: postNsid, text: 'updated' },
|
|
}),
|
|
opts,
|
|
)
|
|
|
|
expect(received).toHaveLength(1)
|
|
expect(received[0].action).toBe('update')
|
|
expect(received[0].record.text).toBe('updated')
|
|
})
|
|
|
|
it('registers delete handler', async () => {
|
|
const indexer = new LexIndexer()
|
|
const received: DeleteEvent[] = []
|
|
|
|
indexer.delete(post, async (evt) => {
|
|
received.push(evt)
|
|
})
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(
|
|
createRecordEvent({
|
|
action: 'delete',
|
|
record: undefined,
|
|
cid: undefined,
|
|
}),
|
|
opts,
|
|
)
|
|
|
|
expect(received).toHaveLength(1)
|
|
expect(received[0].action).toBe('delete')
|
|
})
|
|
|
|
it('registers put handler for both create and update', async () => {
|
|
const indexer = new LexIndexer()
|
|
const received: Array<{ action: string; text: string }> = []
|
|
|
|
indexer.put(post, async (evt) => {
|
|
received.push({ action: evt.action, text: evt.record.text })
|
|
})
|
|
|
|
const opts1 = createMockOpts()
|
|
await indexer.onEvent(createRecordEvent({ action: 'create' }), opts1)
|
|
|
|
const opts2 = createMockOpts()
|
|
await indexer.onEvent(
|
|
createRecordEvent({
|
|
action: 'update',
|
|
record: { $type: postNsid, text: 'updated' },
|
|
}),
|
|
opts2,
|
|
)
|
|
|
|
expect(received).toHaveLength(2)
|
|
expect(received[0].action).toBe('create')
|
|
expect(received[1].action).toBe('update')
|
|
})
|
|
})
|
|
|
|
describe('handler routing', () => {
|
|
it('routes to correct handler by collection', async () => {
|
|
const indexer = new LexIndexer()
|
|
const postEvents: CreateEvent<Post>[] = []
|
|
const likeEvents: CreateEvent<Like>[] = []
|
|
|
|
indexer.create(post, async (evt) => {
|
|
postEvents.push(evt)
|
|
})
|
|
indexer.create(like, async (evt) => {
|
|
likeEvents.push(evt)
|
|
})
|
|
|
|
const opts1 = createMockOpts()
|
|
await indexer.onEvent(createRecordEvent(), opts1)
|
|
|
|
const opts2 = createMockOpts()
|
|
await indexer.onEvent(
|
|
createRecordEvent({
|
|
collection: likeNsid,
|
|
record: { $type: likeNsid, subject: 'at://did:example:bob/post/123' },
|
|
}),
|
|
opts2,
|
|
)
|
|
|
|
expect(postEvents).toHaveLength(1)
|
|
expect(likeEvents).toHaveLength(1)
|
|
})
|
|
|
|
it('routes to other handler for unregistered collections', async () => {
|
|
const indexer = new LexIndexer()
|
|
const otherEvents: RecordEvent[] = []
|
|
|
|
indexer.create(post, async () => {})
|
|
indexer.other(async (evt) => {
|
|
otherEvents.push(evt)
|
|
})
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(
|
|
createRecordEvent({ collection: 'com.example.unknown' }),
|
|
opts,
|
|
)
|
|
|
|
expect(otherEvents).toHaveLength(1)
|
|
expect(otherEvents[0].collection).toBe('com.example.unknown')
|
|
})
|
|
|
|
it('routes to other handler for unregistered actions', async () => {
|
|
const indexer = new LexIndexer()
|
|
const otherEvents: RecordEvent[] = []
|
|
|
|
indexer.create(post, async () => {})
|
|
indexer.other(async (evt) => {
|
|
otherEvents.push(evt)
|
|
})
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(createRecordEvent({ action: 'delete' }), opts)
|
|
|
|
expect(otherEvents).toHaveLength(1)
|
|
expect(otherEvents[0].action).toBe('delete')
|
|
})
|
|
|
|
it('routes identity events to identity handler', async () => {
|
|
const indexer = new LexIndexer()
|
|
const received: IdentityEvent[] = []
|
|
|
|
indexer.identity(async (evt) => {
|
|
received.push(evt)
|
|
})
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(createIdentityEvent(), opts)
|
|
|
|
expect(received).toHaveLength(1)
|
|
expect(received[0].handle).toBe('alice.test')
|
|
})
|
|
})
|
|
|
|
describe('duplicate registration', () => {
|
|
it('throws on duplicate create handler', () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.create(post, async () => {})
|
|
|
|
expect(() => indexer.create(post, async () => {})).toThrow(
|
|
'Handler already registered',
|
|
)
|
|
})
|
|
|
|
it('throws on duplicate update handler', () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.update(post, async () => {})
|
|
|
|
expect(() => indexer.update(post, async () => {})).toThrow(
|
|
'Handler already registered',
|
|
)
|
|
})
|
|
|
|
it('throws on duplicate delete handler', () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.delete(post, async () => {})
|
|
|
|
expect(() => indexer.delete(post, async () => {})).toThrow(
|
|
'Handler already registered',
|
|
)
|
|
})
|
|
|
|
it('throws when put conflicts with create', () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.create(post, async () => {})
|
|
|
|
expect(() => indexer.put(post, async () => {})).toThrow(
|
|
'Handler already registered',
|
|
)
|
|
})
|
|
|
|
it('throws when create conflicts with put', () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.put(post, async () => {})
|
|
|
|
expect(() => indexer.create(post, async () => {})).toThrow(
|
|
'Handler already registered',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('schema validation', () => {
|
|
it('validates record on create', async () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.create(post, async () => {})
|
|
|
|
const opts = createMockOpts()
|
|
await expect(
|
|
indexer.onEvent(createRecordEvent({ record: { text: 123 } }), opts),
|
|
).rejects.toThrow('Record validation failed')
|
|
})
|
|
|
|
it('validates record on update', async () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.update(post, async () => {})
|
|
|
|
const opts = createMockOpts()
|
|
await expect(
|
|
indexer.onEvent(
|
|
createRecordEvent({ action: 'update', record: { invalid: true } }),
|
|
opts,
|
|
),
|
|
).rejects.toThrow('Record validation failed')
|
|
})
|
|
})
|
|
|
|
describe('ack behavior', () => {
|
|
it('calls ack after handler completes', async () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.create(post, async () => {})
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(createRecordEvent(), opts)
|
|
|
|
expect(opts.acked).toBe(true)
|
|
})
|
|
|
|
it('calls ack when routed to other handler', async () => {
|
|
const indexer = new LexIndexer()
|
|
indexer.other(async () => {})
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(createRecordEvent(), opts)
|
|
|
|
expect(opts.acked).toBe(true)
|
|
})
|
|
|
|
it('calls ack even when no handler matches', async () => {
|
|
const indexer = new LexIndexer()
|
|
|
|
const opts = createMockOpts()
|
|
await indexer.onEvent(createRecordEvent(), opts)
|
|
|
|
expect(opts.acked).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('error handling', () => {
|
|
it('calls error handler when provided', () => {
|
|
const indexer = new LexIndexer()
|
|
const errors: Error[] = []
|
|
|
|
indexer.error((err) => {
|
|
errors.push(err)
|
|
})
|
|
|
|
const testError = new Error('test error')
|
|
indexer.onError(testError)
|
|
|
|
expect(errors).toHaveLength(1)
|
|
expect(errors[0]).toBe(testError)
|
|
})
|
|
|
|
it('throws when no error handler is registered', () => {
|
|
const indexer = new LexIndexer()
|
|
const testError = new Error('test error')
|
|
|
|
expect(() => indexer.onError(testError)).toThrow('test error')
|
|
})
|
|
})
|
|
})
|