Files
Matthieu Sieben aaedafc6ba Replace tap's event validation from "zod" to "@atproto/lex" (#4532)
* 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`
2026-01-20 14:11:24 +01:00

210 lines
5.7 KiB
TypeScript

import { once } from 'node:events'
import * as http from 'node:http'
import { AddressInfo } from 'node:net'
import { default as express } from 'express'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { Tap } from '../src/client'
describe('Tap client', () => {
describe('constructor', () => {
it('accepts http URL', () => {
const tap = new Tap('http://localhost:8080')
expect(tap.url).toBe('http://localhost:8080')
})
it('accepts https URL', () => {
const tap = new Tap('https://example.com')
expect(tap.url).toBe('https://example.com')
})
it('throws on invalid URL', () => {
expect(() => new Tap('ws://localhost:8080')).toThrow(
'Invalid URL, expected http:// or https://',
)
expect(() => new Tap('localhost:8080')).toThrow(
'Invalid URL, expected http:// or https://',
)
})
})
describe('HTTP methods', () => {
let server: http.Server<
typeof http.IncomingMessage,
typeof http.ServerResponse
>
let tap: Tap
let requests: {
path: string
method: string
body?: unknown
headers: http.IncomingHttpHeaders
}[]
beforeAll(async () => {
const app = express()
app.use(express.json())
requests = []
app.post('/repos/add', (req, res) => {
requests.push({
path: req.path,
method: req.method,
body: req.body,
headers: req.headers,
})
res.sendStatus(200)
})
app.post('/repos/remove', (req, res) => {
requests.push({
path: req.path,
method: req.method,
body: req.body,
headers: req.headers,
})
res.sendStatus(200)
})
app.get('/resolve/:did', (req, res) => {
requests.push({
path: req.path,
method: req.method,
headers: req.headers,
})
if (req.params.did === 'did:example:notfound') {
res.sendStatus(404)
return
}
res.json({
id: req.params.did,
alsoKnownAs: ['at://alice.test'],
verificationMethod: [],
service: [],
})
})
app.get('/info/:did', (req, res) => {
requests.push({
path: req.path,
method: req.method,
headers: req.headers,
})
res.json({
did: req.params.did,
handle: 'alice.test',
state: 'active',
rev: '3abc123',
records: 42,
})
})
server = app.listen()
await once(server, 'listening')
const { port } = server.address() as AddressInfo
tap = new Tap(`http://localhost:${port}`, { adminPassword: 'secret' })
})
afterAll(async () => {
await new Promise((resolve) => server.close(resolve))
})
beforeEach(() => {
requests = []
})
describe('addRepos', () => {
it('sends POST to /repos/add with dids', async () => {
await tap.addRepos(['did:example:alice', 'did:example:bob'])
expect(requests).toHaveLength(1)
expect(requests[0].path).toBe('/repos/add')
expect(requests[0].method).toBe('POST')
expect(requests[0].body).toEqual({
dids: ['did:example:alice', 'did:example:bob'],
})
})
it('includes auth header', async () => {
await tap.addRepos(['did:example:alice'])
expect(requests[0].headers.authorization).toBe('Basic YWRtaW46c2VjcmV0')
})
})
describe('removeRepos', () => {
it('sends POST to /repos/remove with dids', async () => {
await tap.removeRepos(['did:example:alice'])
expect(requests).toHaveLength(1)
expect(requests[0].path).toBe('/repos/remove')
expect(requests[0].method).toBe('POST')
expect(requests[0].body).toEqual({ dids: ['did:example:alice'] })
})
})
describe('resolveDid', () => {
it('fetches and parses DID document', async () => {
const doc = await tap.resolveDid('did:example:alice')
expect(doc).not.toBeNull()
expect(doc?.id).toBe('did:example:alice')
expect(doc?.alsoKnownAs).toEqual(['at://alice.test'])
})
it('returns null for 404', async () => {
const doc = await tap.resolveDid('did:example:notfound')
expect(doc).toBeNull()
})
})
describe('getRepoInfo', () => {
it('fetches and parses repo info', async () => {
const info = await tap.getRepoInfo('did:example:alice')
expect(info.did).toBe('did:example:alice')
expect(info.handle).toBe('alice.test')
expect(info.state).toBe('active')
expect(info.records).toBe(42)
})
})
})
describe('HTTP error handling', () => {
let server: http.Server<
typeof http.IncomingMessage,
typeof http.ServerResponse
>
let tap: Tap
beforeAll(async () => {
const app = express()
app.use(express.json())
app.post('/repos/add', (_req, res) => {
res.status(500).send('Internal Server Error')
})
app.get('/info/:did', (_req, res) => {
res.status(500).send('Internal Server Error')
})
server = app.listen()
await once(server, 'listening')
const { port } = server.address() as AddressInfo
tap = new Tap(`http://localhost:${port}`)
})
afterAll(async () => {
await new Promise((resolve) => server.close(resolve))
})
it('throws on addRepos failure', async () => {
await expect(tap.addRepos(['did:example:alice'])).rejects.toThrow(
'Failed to add repos',
)
})
it('throws on getRepoInfo failure', async () => {
await expect(tap.getRepoInfo('did:example:alice')).rejects.toThrow(
'Failed to get repo info',
)
})
})
})