2025-01-08 17:24:31 +01:00

224 lines
6.2 KiB
TypeScript

import { lexToJson } from '@atproto/lexicon'
import { AtUri } from '@atproto/syntax'
import { type Express } from 'express'
import { CID } from 'multiformats/cid'
import { Server } from 'node:http'
import { AddressInfo } from 'node:net'
import { FeedViewPost } from '../src/lexicon/types/app/bsky/feed/defs'
import { ToolsOzoneModerationDefs } from '@atproto/api'
// Swap out identifiers and dates with stable
// values for the purpose of snapshot testing
export const forSnapshot = (obj: unknown) => {
const records = { [kTake]: 'record' }
const collections = { [kTake]: 'collection' }
const users = { [kTake]: 'user' }
const cids = { [kTake]: 'cids' }
const unknown = { [kTake]: 'unknown' }
const toWalk = lexToJson(obj as any) // remove any blobrefs/cids
return mapLeafValues(toWalk, (item) => {
const asCid = CID.asCID(item)
if (asCid !== null) {
return take(cids, asCid.toString())
}
if (typeof item !== 'string') {
return item
}
const str = item.startsWith('did:plc:') ? `at://${item}` : item
if (str.startsWith('at://')) {
const uri = new AtUri(str)
if (uri.rkey) {
return take(records, str)
}
if (uri.collection) {
return take(collections, str)
}
if (uri.hostname) {
return take(users, str)
}
return take(unknown, str)
}
if (str.match(/^\d{4}-\d{2}-\d{2}T/)) {
if (str.match(/\d{6}Z$/)) {
return constantDate.replace('Z', '000Z') // e.g. microseconds in record createdAt
} else if (str.endsWith('+00:00')) {
return constantDate.replace('Z', '+00:00') // e.g. timezone in record createdAt
} else {
return constantDate
}
}
// handles both pds and appview cursor separators
if (str.match(/^\d+(?:__|::)bafy/)) {
return constantKeysetCursor
}
if (str.match(/^\d+(?:__|::)did:plc/)) {
return constantDidCursor
}
if (str.match(/\/image\/[^/]+\/.+\/did:plc:[^/]+\/[^/]+@[\w]+$/)) {
// Match image urls (pds)
const match = str.match(
/\/image\/([^/]+)\/.+\/(did:plc:[^/]+)\/([^/]+)@[\w]+$/,
)
if (!match) return str
const [, sig, did, cid] = match
return str
.replace(sig, 'sig()')
.replace(did, take(users, did))
.replace(cid, take(cids, cid))
}
if (str.match(/\/img\/[^/]+\/.+\/did:plc:[^/]+\/[^/]+@[\w]+$/)) {
// Match image urls (bsky w/ presets)
const match = str.match(
/\/img\/[^/]+\/.+\/(did:plc:[^/]+)\/([^/]+)@[\w]+$/,
)
if (!match) return str
const [, did, cid] = match
return str.replace(did, take(users, did)).replace(cid, take(cids, cid))
}
if (str.startsWith('localhost-')) {
return 'invite-code'
}
if (str.match(/^\d+::pds-public-url-/)) {
return '0000000000000::invite-code'
}
let isCid: boolean
try {
CID.parse(str)
isCid = true
} catch (_err) {
isCid = false
}
if (isCid) {
return take(cids, str)
}
return item
})
}
// Feed testing utils
export const getOriginator = (item: FeedViewPost) => {
if (!item.reason) {
return item.post.author.did
} else {
return (item.reason.by as { [did: string]: string }).did
}
}
// Useful for remapping ids in snapshot testing, to make snapshots deterministic.
// E.g. you may use this to map this:
// [{ uri: 'did://rad'}, { uri: 'did://bad' }, { uri: 'did://rad'}]
// to this:
// [{ uri: '0'}, { uri: '1' }, { uri: '0'}]
const kTake = Symbol('take')
export function take(obj, value: string): string
export function take(obj, value: string | undefined): string | undefined
export function take(
obj: { [s: string]: number; [kTake]?: string },
value: string | undefined,
): string | undefined {
if (value === undefined) {
return
}
if (!(value in obj)) {
obj[value] = Object.keys(obj).length
}
const kind = obj[kTake]
return typeof kind === 'string'
? `${kind}(${obj[value]})`
: String(obj[value])
}
export const constantDate = new Date(0).toISOString()
export const constantKeysetCursor = '0000000000000::bafycid'
export const constantDidCursor = '0000000000000::did'
const mapLeafValues = (obj: unknown, fn: (val: unknown) => unknown) => {
if (Array.isArray(obj)) {
return obj.map((item) => mapLeafValues(item, fn))
}
if (obj && typeof obj === 'object') {
return Object.entries(obj).reduce(
(collect, [name, value]) =>
Object.assign(collect, { [name]: mapLeafValues(value, fn) }),
{},
)
}
return fn(obj)
}
export const paginateAll = async <T extends { cursor?: string }>(
fn: (cursor?: string) => Promise<T>,
limit = Infinity,
): Promise<T[]> => {
const results: T[] = []
let cursor
do {
const res = await fn(cursor)
results.push(res)
cursor = res.cursor
} while (cursor && results.length < limit)
return results
}
export async function startServer(app: Express) {
return new Promise<{
origin: string
server: Server
stop: () => Promise<void>
}>((resolve, reject) => {
const onListen = () => {
const port = (server.address() as AddressInfo).port
resolve({
server,
origin: `http://localhost:${port}`,
stop: () => stopServer(server),
})
cleanup()
}
const onError = (err: Error) => {
reject(err)
cleanup()
}
const cleanup = () => {
server.removeListener('listening', onListen)
server.removeListener('error', onError)
}
const server = app
.listen(0)
.once('listening', onListen)
.once('error', onError)
})
}
export async function stopServer(server: Server) {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
const normalizeSubjectStatus = (
subject: ToolsOzoneModerationDefs.SubjectStatusView,
) => {
return { ...subject, tags: subject.tags?.sort() }
}
export const forSubjectStatusSnapshot = (
status:
| ToolsOzoneModerationDefs.SubjectStatusView
| ToolsOzoneModerationDefs.SubjectStatusView[],
) => {
if (Array.isArray(status)) {
return forSnapshot(status.map(normalizeSubjectStatus))
}
return forSnapshot(normalizeSubjectStatus(status))
}