diff --git a/cspell.json b/cspell.json index b78d2e082..79f7e7809 100644 --- a/cspell.json +++ b/cspell.json @@ -8,6 +8,7 @@ "words": [ "AGPL", "asynckit", + "Backoff", "blurhash", "builtins", "carryforward", diff --git a/examples/timeline-with-streaming.ts b/examples/timeline-with-streaming.ts index 046bd483d..be02c3fba 100644 --- a/examples/timeline-with-streaming.ts +++ b/examples/timeline-with-streaming.ts @@ -1,19 +1,69 @@ -import { login } from 'masto'; +import { createWebSocketClient } from 'masto'; -const masto = await login({ - url: 'https://example.com', - accessToken: 'YOUR TOKEN', -}); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -// Connect to the streaming api -const stream = await masto.v1.stream.streamPublicTimeline(); +// https://en.wikipedia.org/wiki/Exponential_backoff +class ExponentialBackoff { + private errorCount = 0; -// Subscribe to updates -stream.on('update', (status) => { - console.log(`${status.account.username}: ${status.content}`); -}); + constructor(private readonly baseSeconds: number) {} -// Subscribe to notifications -stream.on('notification', (notification) => { - console.log(`${notification.account.username}: ${notification.type}`); -}); + get timeout() { + return this.baseSeconds ** this.errorCount * 1000; + } + + clear() { + this.errorCount = 0; + } + + async sleep() { + await sleep(this.timeout); + this.errorCount++; + } +} + +// ======================================== + +const backoff = new ExponentialBackoff(2); + +const subscribe = async (): Promise => { + try { + const masto = createWebSocketClient({ + url: '', + accessToken: '', + streamingApiUrl: '', + }); + + console.log('subscribed to #mastojs'); + + for await (const event of masto.subscribe('hashtag', { tag: 'mastojs' })) { + switch (event.event) { + case 'update': { + console.log('new post', event.payload.content); + break; + } + case 'delete': { + console.log('deleted post', event.payload); + break; + } + default: { + break; + } + } + } + + backoff.clear(); + } catch (error) { + console.error(error); + } finally { + console.log('Reconnecting in ' + backoff.timeout + 'ms'); + await backoff.sleep(); + await subscribe(); + } +}; + +try { + await subscribe(); +} catch (error) { + console.error(error); +} diff --git a/package.json b/package.json index 7414ef5c7..852282a87 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@mastojs/ponyfills": "^1.0.4", "change-case": "^4.1.2", "eventemitter3": "^5.0.0", + "events-to-async": "^2.0.0", "isomorphic-ws": "^5.0.0", "qs": "^6.11.0", "ws": "^8.13.0" diff --git a/src/__mocks__/index.ts b/src/__mocks__/index.ts index 1b5f772b1..0f025bf8e 100644 --- a/src/__mocks__/index.ts +++ b/src/__mocks__/index.ts @@ -1,3 +1,2 @@ export * from './http-mock-impl'; -export * from './ws-mock-impl'; export * from './logger-mock-impl'; diff --git a/src/__mocks__/ws-mock-impl.ts b/src/__mocks__/ws-mock-impl.ts deleted file mode 100644 index 19f74367a..000000000 --- a/src/__mocks__/ws-mock-impl.ts +++ /dev/null @@ -1,20 +0,0 @@ -import EventEmitter from 'eventemitter3'; - -import type { EventTypeMap, Ws, WsEvents } from '../ws'; - -export const wsDisconnect = jest.fn(); -export const wsOn = jest.fn(); -export const wsStream = jest.fn(); - -export class WsEventsMockImpl - extends EventEmitter - implements WsEvents -{ - static connect = jest.fn(); - disconnect = wsDisconnect; - override on = wsOn; -} - -export class WsMockImpl implements Ws { - stream = wsStream; -} diff --git a/src/config.spec.ts b/src/config.spec.ts index 4cd574eec..a2fb8e5a3 100644 --- a/src/config.spec.ts +++ b/src/config.spec.ts @@ -78,7 +78,7 @@ describe('Config', () => { new SerializerNativeImpl(), ); - const url = config.resolveWebsocketPath('/path/to/somewhere'); + const url = config.resolveWebsocketPath('/path/to/somewhere').toString(); expect(url).toEqual('wss://mastodon.social/path/to/somewhere'); }); @@ -123,7 +123,7 @@ describe('Config', () => { new SerializerNativeImpl(), ); - const url = config.resolveWebsocketPath('/path/to/somewhere'); + const url = config.resolveWebsocketPath('/path/to/somewhere').toString(); expect(url).toEqual( 'wss://mastodon.social/path/to/somewhere?access_token=token', ); diff --git a/src/config.ts b/src/config.ts index fc18415dc..ecfca3332 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,20 +63,20 @@ export class MastoConfig { resolveWebsocketPath( path: string, params: Record = {}, - ): string { + ): URL { if (this.props.streamingApiUrl == undefined) { throw new MastoInvalidArgumentError( 'You need to specify `streamingApiUrl` to use this feature', ); } - const url = new URL(this.props.streamingApiUrl.replace(/\/$/, '') + path); + const url = new URL(path, this.props.streamingApiUrl); if (this.props.useInsecureWebSocketToken) { params.accessToken = this.props.accessToken; } url.search = this.serializer.serializeQueryString(params); - return url.toString(); + return url; } createTimeout(): Timeout { diff --git a/src/login.ts b/src/login.ts index caa87b768..2d7a6593a 100644 --- a/src/login.ts +++ b/src/login.ts @@ -5,6 +5,7 @@ import { HttpNativeImpl } from './http'; import { LoggerConsoleImpl } from './logger'; import type { OAuthAPIClient, RestAPIClient } from './mastodon'; import { SerializerNativeImpl } from './serializers'; +import { WebSocketAPIConnector } from './ws'; export const createClient = (props: MastoConfigProps): RestAPIClient => { const serializer = new SerializerNativeImpl(); @@ -23,3 +24,13 @@ export const createOAuthClient = (props: MastoConfigProps): OAuthAPIClient => { const builder = createBuilder(http, ['oauth']) as OAuthAPIClient; return builder; }; + +export function createWebSocketClient( + props: MastoConfigProps, +): WebSocketAPIConnector { + const serializer = new SerializerNativeImpl(); + const config = new MastoConfig(props, serializer); + const logger = new LoggerConsoleImpl(config.getLogLevel()); + const connector = new WebSocketAPIConnector(config, serializer, logger); + return connector; +} diff --git a/src/mastodon/index.ts b/src/mastodon/index.ts index 8a21250b8..5a691259a 100644 --- a/src/mastodon/index.ts +++ b/src/mastodon/index.ts @@ -2,3 +2,4 @@ export * as v1 from './v1'; export * as v2 from './v2'; export * from './client'; export * from './repository'; +export * from './websocket'; diff --git a/src/mastodon/v2/repositories/filter-repository.ts b/src/mastodon/v2/repositories/filter-repository.ts index 98d2aec46..fe797b505 100644 --- a/src/mastodon/v2/repositories/filter-repository.ts +++ b/src/mastodon/v2/repositories/filter-repository.ts @@ -13,7 +13,7 @@ export interface CreateFilterParams { /** Integer. How many seconds from now should the filter expire? */ readonly expiresIn?: number | null; - readonly keywordsAttributes?: readonly { + readonly keywordsAttributes?: { /** String. A keyword to be added to the newly-created filter group. */ readonly keyword?: string | null; /** Boolean. Whether the keyword should consider word boundaries. */ diff --git a/src/mastodon/websocket/client.ts b/src/mastodon/websocket/client.ts new file mode 100644 index 000000000..5b1d81ea3 --- /dev/null +++ b/src/mastodon/websocket/client.ts @@ -0,0 +1,47 @@ +import type { Event } from './event'; + +export type Stream = + | 'public' + | 'public:media' + | 'public:local' + | 'public:local:media' + | 'public:remote' + | 'public:remote:media' + | 'hashtag' + | 'hashtag:local' + | 'user' + | 'user:notification' + | 'list' + | 'direct'; + +export type SubscribeListParams = { + readonly list: string; +}; + +export type SubscribeHashtagParams = { + readonly tag: string; +}; + +export interface WebSocketAPIConnection { + readonly events: AsyncIterableIterator; + readonly readyState: number; + + subscribe( + stream: 'list', + params: SubscribeListParams, + ): AsyncIterableIterator; + subscribe( + stream: 'hashtag' | 'hashtag:local', + params: SubscribeHashtagParams, + ): AsyncIterableIterator; + subscribe(stream: Stream): AsyncIterableIterator; + + unsubscribe(stream: 'list', params: SubscribeListParams): void; + unsubscribe( + stream: 'hashtag' | 'hashtag:local', + params: SubscribeHashtagParams, + ): void; + unsubscribe(stream: Stream): void; + + close(): void; +} diff --git a/src/mastodon/websocket/event.ts b/src/mastodon/websocket/event.ts new file mode 100644 index 000000000..1cfe2c797 --- /dev/null +++ b/src/mastodon/websocket/event.ts @@ -0,0 +1,60 @@ +import type { mastodon } from '../..'; + +export type RawEventOk = { + stream: string[]; + event: string; + payload: string; +}; + +export type RawEventError = { + error: string; +}; + +export type RawEvent = RawEventOk | RawEventError; + +type BaseEvent = { + stream: string[]; + event: T; + payload: U; +}; + +export type UpdateEvent = BaseEvent<'update', mastodon.v1.Status>; + +export type DeleteEvent = BaseEvent<'delete', string>; + +export type NotificationEvent = BaseEvent< + 'notification', + mastodon.v1.Notification +>; + +export type FiltersChangedEvent = BaseEvent<'filters_changed', undefined>; + +export type ConversationEvent = BaseEvent< + 'conversation', + mastodon.v1.Conversation +>; + +export type AnnouncementEvent = BaseEvent< + 'announcement', + mastodon.v1.Announcement +>; + +export type AnnouncementReactionEvent = BaseEvent< + 'announcement.reaction', + mastodon.v1.Reaction +>; + +export type AnnouncementDeleteEvent = BaseEvent<'announcement.delete', string>; + +export type StatusUpdateEvent = BaseEvent<'status.update', mastodon.v1.Status>; + +export type Event = + | UpdateEvent + | DeleteEvent + | NotificationEvent + | FiltersChangedEvent + | ConversationEvent + | AnnouncementEvent + | AnnouncementReactionEvent + | AnnouncementDeleteEvent + | StatusUpdateEvent; diff --git a/src/mastodon/websocket/index.ts b/src/mastodon/websocket/index.ts new file mode 100644 index 000000000..ab18ba4e3 --- /dev/null +++ b/src/mastodon/websocket/index.ts @@ -0,0 +1,2 @@ +export * from './client'; +export * from './event'; diff --git a/src/paginator.ts b/src/paginator.ts index 5f1ac9556..659bd42be 100644 --- a/src/paginator.ts +++ b/src/paginator.ts @@ -3,14 +3,24 @@ import qs from 'qs'; import type { Http } from './http'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +const mixins = + (globalThis as any).AsyncIterator == undefined + ? class {} + : (globalThis as any).AsyncIterator; +/* eslint-enable @typescript-eslint/no-explicit-any */ + export class Paginator - implements AsyncIterableIterator, PromiseLike + extends mixins + implements PromiseLike { constructor( private readonly http: Http, private nextPath?: string, private nextParams?: Params, - ) {} + ) { + super(); + } async next(): Promise> { if (this.nextPath == undefined) { @@ -61,12 +71,14 @@ export class Paginator return this.next().then((value) => onfulfilled(value.value!), onrejected); } - [Symbol.asyncIterator](): AsyncGenerator< + [Symbol.asyncIterator](): AsyncIterator< Entity, undefined, Params | undefined > { - return this; + // TODO: Use polyfill on demand + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this as any as AsyncIterator; } private clear() { diff --git a/src/serializers/serializer-native-impl.spec.ts b/src/serializers/serializer-native-impl.spec.ts index 5fa3fdc06..aeca2f0fc 100644 --- a/src/serializers/serializer-native-impl.spec.ts +++ b/src/serializers/serializer-native-impl.spec.ts @@ -1,7 +1,8 @@ +import type { Serializer } from './serializer'; import { SerializerNativeImpl } from './serializer-native-impl'; describe('SerializerNativeImpl', () => { - const serializer = new SerializerNativeImpl(); + const serializer: Serializer = new SerializerNativeImpl(); it('encodes an object to JSON', () => { const data = serializer.serialize('json', { diff --git a/src/serializers/serializer-native-impl.ts b/src/serializers/serializer-native-impl.ts index 6de6f044b..1d7fa1242 100644 --- a/src/serializers/serializer-native-impl.ts +++ b/src/serializers/serializer-native-impl.ts @@ -9,6 +9,7 @@ import type { Encoding, Serializer } from './serializer'; import { transformKeys } from './transform-keys'; export class SerializerNativeImpl implements Serializer { + serialize(type: 'json', rawData: unknown): string | undefined; serialize(type: Encoding, rawData: unknown): BodyInit | undefined { if (rawData == undefined) { return; diff --git a/src/serializers/serializer.ts b/src/serializers/serializer.ts index c7371e0f7..7f652fc8f 100644 --- a/src/serializers/serializer.ts +++ b/src/serializers/serializer.ts @@ -3,6 +3,7 @@ import type { BodyInit } from '@mastojs/ponyfills'; export type Encoding = 'none' | 'json' | 'form-url-encoded' | 'multipart-form'; export interface Serializer { + serialize(type: 'json', data: unknown): string | undefined; serialize(type: Encoding, data: unknown): BodyInit | undefined; serializeQueryString(data: unknown): string; deserialize>(type: Encoding, data: unknown): T; diff --git a/src/utils/exponential-backoff.ts b/src/utils/exponential-backoff.ts new file mode 100644 index 000000000..46c762bef --- /dev/null +++ b/src/utils/exponential-backoff.ts @@ -0,0 +1,21 @@ +import { delay } from './delay'; + +// https://en.wikipedia.org/wiki/Exponential_backoff +export class ExponentialBackoff { + private errorCount = 0; + + constructor(private readonly baseSeconds: number) {} + + get timeout(): number { + return this.baseSeconds ** this.errorCount * 1000; + } + + clear(): void { + this.errorCount = 0; + } + + async sleep(): Promise { + await delay(this.timeout); + this.errorCount++; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 7855f209e..fec643eb0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './timeout'; export * from './merge-abort-signals'; export * from './merge-headers-init'; export * from './wait-for'; +export * from './exponential-backoff'; diff --git a/src/utils/wait-for.ts b/src/utils/wait-for.ts index ad6d44c29..9345f0463 100644 --- a/src/utils/wait-for.ts +++ b/src/utils/wait-for.ts @@ -1,21 +1,39 @@ import type { mastodon } from '..'; +import { MastoHttpNotFoundError, MastoTimeoutError } from '../errors'; +import { delay } from './delay'; +import { Timeout } from './timeout'; -export const waitForMediaAttachment = ( +export const waitForMediaAttachment = async ( client: mastodon.RestAPIClient, id: string, + ms = 60 * 1000, ): Promise => { - return new Promise((resolve, reject) => { - const interval = setInterval(async () => { - try { - const media = await client.v1.media.select(id).fetch(); - if (media.url != undefined) { - clearInterval(interval); - resolve(media); - } - } catch (error) { - clearInterval(interval); - reject(error); + let media: mastodon.v1.MediaAttachment | undefined; + const timeout = new Timeout(ms); + + while (media == undefined) { + if (timeout.signal.aborted) { + throw new MastoTimeoutError( + 'The media encoding has been timed out in your instance.', + ); + } + + await delay(1000); + + try { + const processing = await client.v1.media.select(id).fetch(); + + if (processing.url != undefined) { + media = processing; + timeout.clear(); + } + } catch (error) { + if (error instanceof MastoHttpNotFoundError) { + continue; } - }, 1000); - }); + throw error; + } + } + + return media; }; diff --git a/src/ws/index.ts b/src/ws/index.ts index 9b1d5532f..5c29b8c5b 100644 --- a/src/ws/index.ts +++ b/src/ws/index.ts @@ -1,2 +1,2 @@ -export * from './ws'; -export * from './ws-native-impl'; +export * from './websocket-api-connection'; +export * from './websocket-api-connector'; diff --git a/src/ws/websocket-api-connection.ts b/src/ws/websocket-api-connection.ts new file mode 100644 index 000000000..223d92e3e --- /dev/null +++ b/src/ws/websocket-api-connection.ts @@ -0,0 +1,96 @@ +import type WebSocket from 'isomorphic-ws'; + +import { MastoUnexpectedError } from '../errors'; +import type { + Event, + RawEvent, + Stream, + WebSocketAPIConnection, +} from '../mastodon'; +import type { Serializer } from '../serializers'; +import { createWebSocketAsyncIterator } from './websocket-async'; + +export class WebSocketAPIConnectionImpl implements WebSocketAPIConnection { + readonly events: AsyncIterableIterator; + + constructor( + private readonly ws: WebSocket, + private readonly serializer: Serializer, + ) { + this.events = this.createEvents(); + } + + get readyState(): number { + return this.ws.readyState; + } + + async *subscribe( + stream: Stream, + params?: Record, + ): AsyncIterableIterator { + const data = this.serializer.serialize('json', { + type: 'subscribe', + stream, + ...params, + }); + + if (data == undefined) { + throw new MastoUnexpectedError('Failed to serialize data'); + } + + this.ws.send(data); + + for await (const event of this.events) { + if (!event.stream.includes(stream)) { + continue; + } + yield event; + } + } + + unsubscribe(stream: Stream, params?: Record): void { + const data = this.serializer.serialize('json', { + type: 'unsubscribe', + stream, + ...params, + }); + + if (data == undefined) { + throw new MastoUnexpectedError('Failed to serialize data'); + } + + this.ws.send(data); + } + + close(): void { + this.ws.close(); + } + + private async *createEvents() { + const messages = createWebSocketAsyncIterator(this.ws); + + for await (const message of messages) { + const event = await this.parseEvent(message.data as string); + yield event; + } + } + + private async parseEvent(rawEvent: string): Promise { + const data = this.serializer.deserialize('json', rawEvent); + + if ('error' in data) { + throw new MastoUnexpectedError(data.error); + } + + const payload = + data.event === 'delete' + ? data.payload + : this.serializer.deserialize('json', data.payload); + + return { + stream: data.stream, + event: data.event, + payload: payload, + } as Event; + } +} diff --git a/src/ws/websocket-api-connector.ts b/src/ws/websocket-api-connector.ts new file mode 100644 index 000000000..82a5fac88 --- /dev/null +++ b/src/ws/websocket-api-connector.ts @@ -0,0 +1,76 @@ +import { WebSocket } from 'ws'; + +import type { Logger, MastoConfig, Serializer } from '..'; +import { WebSocketAPIConnectionImpl } from '..'; +import type { WebSocketAPIConnection } from '../mastodon'; +import { ExponentialBackoff } from '../utils'; + +export interface ConnectParams { + readonly retry?: boolean; +} + +export class WebSocketAPIConnector { + constructor( + private readonly config: MastoConfig, + private readonly serializer: Serializer, + private readonly logger: Logger, + ) {} + + connect(params: { + retry: true; + }): AsyncIterableIterator; + connect(params?: { retry: false }): Promise; + connect( + params: ConnectParams = {}, + ): + | Promise + | AsyncIterableIterator { + const { retry = false } = params; + return retry ? this._connectRetry() : this._connect(); + } + + private _connect(): Promise { + const url = this.config.resolveWebsocketPath('/api/v1/streaming'); + const protocols = this.config.createWebsocketProtocols(); + const ws = new WebSocket(url.toString(), protocols); + + return new Promise((resolve, reject) => { + ws.addEventListener('error', (error) => reject(error), { once: true }); + ws.addEventListener( + 'open', + () => { + const client = new WebSocketAPIConnectionImpl(ws, this.serializer); + resolve(client); + }, + { once: true }, + ); + }); + } + + private async *_connectRetry(): AsyncIterableIterator { + const backoff = new ExponentialBackoff(2); + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const ws = await this._connect(); + this.logger.debug('WebSocket connection established'); + yield ws; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of ws.events) { + // noop + } + + backoff.clear(); + } catch (error) { + this.logger.error('WebSocket error occurred', error); + } finally { + this.logger.debug( + `WebSocket connection closed. Reconnecting in ${backoff.timeout} ms`, + ); + await backoff.sleep(); + } + } + } +} diff --git a/src/ws/websocket-async.ts b/src/ws/websocket-async.ts new file mode 100644 index 000000000..753e65513 --- /dev/null +++ b/src/ws/websocket-async.ts @@ -0,0 +1,30 @@ +import { on } from 'events-to-async'; +import type WebSocket from 'isomorphic-ws'; + +export async function* createWebSocketAsyncIterator( + ws: WebSocket, +): AsyncGenerator { + const handleClose = (e: WebSocket.CloseEvent) => { + events.return?.(e); + }; + + const handleError = (e: WebSocket.ErrorEvent) => { + events.throw?.(e); + }; + + const events = on<[WebSocket.MessageEvent]>((handler) => { + ws.addEventListener('message', handler); + ws.addEventListener('error', handleError); + ws.addEventListener('close', handleClose); + + return () => { + ws.removeEventListener('message', handler); + ws.removeEventListener('error', handleError); + ws.removeEventListener('close', handleClose); + }; + }); + + for await (const [event] of events) { + yield event; + } +} diff --git a/src/ws/ws-native-impl.ts b/src/ws/ws-native-impl.ts deleted file mode 100644 index a84dda6e1..000000000 --- a/src/ws/ws-native-impl.ts +++ /dev/null @@ -1,97 +0,0 @@ -import EventEmitter from 'eventemitter3'; -import WebSocket from 'isomorphic-ws'; - -import type { MastoConfig } from '../config'; -import type { Logger } from '../logger'; -import type { Serializer } from '../serializers'; -import type { Event, EventType, EventTypeMap, Ws, WsEvents } from './ws'; - -/** - * Mastodon streaming api wrapper - */ -export class WsEventsNativeImpl - extends EventEmitter - implements WsEvents -{ - constructor( - private readonly ws: WebSocket, - private readonly serializer: Serializer, - private readonly logger: Logger, - ) { - super(); - } - - /** - * Connect to the websocket endpoint - * @param url URL of the websocket endpoint - * @param protocols Subprotocol(s) for `Sec-Websocket-Protocol` - * @param params URL parameters - */ - static connect( - url: string, - serializer: Serializer, - logger: Logger, - protocols?: string | string[], - ): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(url, protocols); - const instance = new WsEventsNativeImpl(ws, serializer, logger); - ws.addEventListener('message', instance.handleMessage); - ws.addEventListener('error', reject); - ws.addEventListener('open', () => resolve(instance)); - }); - } - - /** - * Disconnect from the websocket endpoint - */ - disconnect(): void { - if (!this.ws) return; - this.ws.close(); - } - - /** - * Parse JSON data and emit it as an event - * @param message Websocket message - */ - private handleMessage = ({ data }: WebSocket.MessageEvent): void => { - const { event, payload } = this.serializer.deserialize('json', data); - - this.logger.info(`↓ WEBSOCKET ${event}`); - this.logger.debug('\tbody', payload); - - // https://github.com/neet/masto.js/issues/750 - if (event === 'delete') { - return void this.emit(event, payload); - } - - let args: EventTypeMap[EventType] = []; - try { - args.push(this.serializer.deserialize('json', payload)); - } catch { - args = []; - } - - this.emit(event, ...args); - }; -} - -export class WsNativeImpl implements Ws { - constructor( - private readonly config: MastoConfig, - private readonly serializer: Serializer, - private readonly logger: Logger, - ) {} - - stream( - path: string, - params: Record = {}, - ): Promise { - return WsEventsNativeImpl.connect( - this.config.resolveWebsocketPath(path, params), - this.serializer, - this.logger, - this.config.createWebsocketProtocols(), - ); - } -} diff --git a/src/ws/ws.ts b/src/ws/ws.ts deleted file mode 100644 index e51bfdc03..000000000 --- a/src/ws/ws.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type EventEmitter from 'eventemitter3'; - -import type { - Announcement, - Conversation, - Notification, - Reaction, - Status, -} from '../mastodon/v1/entities'; - -/** - * Map of event name and callback argument - * @see https://docs.joinmastodon.org/methods/streaming/#events - */ -export interface EventTypeMap { - /** A new Status has appeared. Payload contains a Status cast to a string. Available since v1.0.0 */ - update: [Status]; - /** A status has been deleted. Payload contains the String ID of the deleted Status. Available since v1.0.0 */ - delete: [Status['id']]; - /** A new notification has appeared. Payload contains a Notification cast to a string. Available since v1.4.2 */ - notification: [Notification]; - /** Keyword filters have been changed. Either does not contain a payload (for WebSocket connections), or contains an undefined payload (for HTTP connections). Available since v2.4.3 */ - filters_changed: []; - /** A direct conversation has been updated. Payload contains a Conversation cast to a string. Available since v2.6.0 */ - conversation: [Conversation]; - /** A Status has been edited. Payload contains a Status cast to a string. Available since v3.5.0 */ - 'status.update': [Status]; - /** An announcement has been published. Payload contains an Announcement cast to a string. Available since v3.1.0 */ - announcement: [Announcement]; - /** An announcement has received an emoji reaction. Payload contains a Hash (with name, count, and announcement_id) cast to a string. Available since v3.1.0 */ - 'announcement.reaction': [Reaction]; - /** An announcement has been deleted. Payload contains the String ID of the deleted Announcement. Available since v3.1.0 */ - 'announcement.delete': [Announcement['id']]; -} - -/** Supported event names */ -export type EventType = keyof EventTypeMap; - -/** Mastodon event */ -export interface Event { - event: EventType; - payload: string; -} - -export type WsEventHandler = ( - ...data: EventTypeMap[T] -) => unknown; - -export interface WsEvents extends Omit, 'on'> { - readonly disconnect: () => void; - readonly on: (name: T, cb: WsEventHandler) => void; -} - -export interface Ws { - stream(path: string, params: unknown): Promise; -} diff --git a/test-utils/global.d.ts b/test-utils/global.d.ts index 6cf0de38a..7986f0d8c 100644 --- a/test-utils/global.d.ts +++ b/test-utils/global.d.ts @@ -1,10 +1,10 @@ /* eslint-disable no-var */ import type { mastodon } from '../src'; -import type { ClientPool, TokenPool } from './pools'; +import type { SessionPoolImpl, TokenPool } from './pools'; declare global { var admin: mastodon.RestAPIClient; - var clients: ClientPool; + var sessions: SessionPoolImpl; /** Should only be used inside /test-utils */ var __misc__: { diff --git a/test-utils/jest-global-setup.ts b/test-utils/jest-global-setup.ts index bfbea3bb4..d67d00e83 100644 --- a/test-utils/jest-global-setup.ts +++ b/test-utils/jest-global-setup.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ +import './jest-polyfills'; + import { createClient, createOAuthClient } from '../src'; import { TokenPoolImpl } from './pools'; @@ -15,10 +17,6 @@ export default async (): Promise => { }); const container = process.env.MASTODON_CONTAINER ?? 'mastodon'; - // if (container == undefined) { - // throw new Error('MASTODON_CONTAINER is not defined'); - // } - const tokenPool = new TokenPoolImpl(container, oauth, app); const adminToken = await oauth.token.create( diff --git a/test-utils/jest-polyfills.ts b/test-utils/jest-polyfills.ts new file mode 100644 index 000000000..59763466f --- /dev/null +++ b/test-utils/jest-polyfills.ts @@ -0,0 +1,2 @@ +import { installIntoGlobal } from 'iterator-helpers-polyfill'; +installIntoGlobal(); diff --git a/test-utils/jest-setup-after-env.ts b/test-utils/jest-setup-after-env.ts index 8a395d177..eebaa84b5 100644 --- a/test-utils/jest-setup-after-env.ts +++ b/test-utils/jest-setup-after-env.ts @@ -1,7 +1,8 @@ +import './jest-polyfills'; import './jest-extend-expect'; import { createClient } from '../src'; -import { ClientPoolImpl } from './pools'; +import { SessionPoolImpl } from './pools'; jest.setTimeout(1000 * 60); @@ -11,4 +12,4 @@ globalThis.admin = createClient({ accessToken: __misc__.adminToken.accessToken, }); -globalThis.clients = new ClientPoolImpl(); +globalThis.sessions = new SessionPoolImpl(); diff --git a/test-utils/pools/base-pool.ts b/test-utils/pools/base-pool.ts new file mode 100644 index 000000000..3854c7a74 --- /dev/null +++ b/test-utils/pools/base-pool.ts @@ -0,0 +1,65 @@ +type UseFn = (client: T) => Promise; +type UseFnMany = (client: T[]) => Promise; + +export type Pool = { + acquire(n?: 1 | undefined): Promise; + acquire(n: number): Promise; + release(token: T): Promise; + release(tokens: T[]): Promise; + use(fn: UseFn): Promise; + use(n: number, fn: UseFnMany): Promise; +}; + +export abstract class BasePool implements Pool { + protected abstract acquireOne(): Promise; + protected abstract releaseOne(client: T): Promise; + + async acquire(n?: 1 | undefined): Promise; + async acquire(n: number): Promise; + async acquire(n = 1): Promise { + if (n === 1) { + return this.acquireOne(); + } + return Promise.all(Array.from({ length: n }).map(() => this.acquireOne())); + } + + async release(client: T): Promise; + async release(clients: T[]): Promise; + async release(clients: T | T[]): Promise { + await (Array.isArray(clients) + ? Promise.all(clients.map((client) => this.releaseOne(client))) + : this.releaseOne(clients)); + } + + async use(fn: UseFn): Promise; + async use(n: number, fn: UseFnMany): Promise; + async use( + fnOrNumber: number | UseFn, + fnOrUndefined?: UseFnMany, + ): Promise { + if (typeof fnOrNumber === 'function' && fnOrUndefined == undefined) { + const fn = fnOrNumber; + const client = await this.acquire(1); + + try { + return await fn(client); + } finally { + await this.release(client); + } + } + + if (typeof fnOrNumber === 'number' && typeof fnOrUndefined === 'function') { + const n = fnOrNumber; + const fn = fnOrUndefined; + const clients = await this.acquire(n); + + try { + return await fn(clients); + } finally { + await this.release(clients); + } + } + + throw new Error('Invalid arguments'); + } +} diff --git a/test-utils/pools/client-pool.ts b/test-utils/pools/client-pool.ts deleted file mode 100644 index 290bb120d..000000000 --- a/test-utils/pools/client-pool.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { mastodon } from '../../src'; -import { createClient } from '../../src'; - -type UseFn = (client: mastodon.RestAPIClient) => Promise; -type UseFnMany = (client: mastodon.RestAPIClient[]) => Promise; - -export type ClientPool = { - acquire(n?: 1 | undefined): Promise; - acquire(n: number): Promise; - - release(token: mastodon.RestAPIClient): Promise; - release(tokens: mastodon.RestAPIClient[]): Promise; - - use(fn: UseFn): Promise; - use(n: number, fn: UseFnMany): Promise; -}; - -export class ClientPoolImpl implements ClientPool { - private readonly clientToToken = new WeakMap< - mastodon.RestAPIClient, - mastodon.v1.Token - >(); - - async acquire(n?: 1 | undefined): Promise; - async acquire(n: number): Promise; - async acquire( - n = 1, - ): Promise { - if (n === 1) { - return this.acquireClient(); - } - - return Promise.all( - Array.from({ length: n }).map(() => this.acquireClient()), - ); - } - - async release(client: mastodon.RestAPIClient): Promise; - async release(clients: mastodon.RestAPIClient[]): Promise; - async release( - clients: mastodon.RestAPIClient | mastodon.RestAPIClient[], - ): Promise { - await (Array.isArray(clients) - ? Promise.all(clients.map((client) => this.releaseClient(client))) - : this.releaseClient(clients)); - } - - async use(fn: UseFn): Promise; - async use(n: number, fn: UseFnMany): Promise; - async use( - fnOrNumber: number | UseFn, - fnOrUndefined?: UseFnMany, - ): Promise { - if (typeof fnOrNumber === 'function' && fnOrUndefined == undefined) { - const fn = fnOrNumber; - const client = await this.acquire(1); - - try { - return await fn(client); - } finally { - await this.release(client); - } - } - - if (typeof fnOrNumber === 'number' && typeof fnOrUndefined === 'function') { - const n = fnOrNumber; - const fn = fnOrUndefined; - const clients = await this.acquire(n); - - try { - return await fn(clients); - } finally { - await this.release(clients); - } - } - - throw new Error('Invalid arguments'); - } - - private acquireClient = async () => { - const token = await __misc__.tokens.acquire(); - - const client = createClient({ - url: __misc__.url, - streamingApiUrl: __misc__.instance.urls.streamingApi, - accessToken: token.accessToken, - }); - - this.clientToToken.set(client, token); - return client; - }; - - private releaseClient = async (client: mastodon.RestAPIClient) => { - const token = this.clientToToken.get(client); - if (token == undefined) { - return; - } - - await globalThis.__misc__.tokens.release(token); - this.clientToToken.delete(client); - }; -} diff --git a/test-utils/pools/index.ts b/test-utils/pools/index.ts index afec515c1..b0c1a6d83 100644 --- a/test-utils/pools/index.ts +++ b/test-utils/pools/index.ts @@ -1,2 +1,3 @@ -export * from './client-pool'; +export * from './session-pool'; export * from './token-pool'; +export * from './base-pool'; diff --git a/test-utils/pools/session-pool.ts b/test-utils/pools/session-pool.ts new file mode 100644 index 000000000..cc619410a --- /dev/null +++ b/test-utils/pools/session-pool.ts @@ -0,0 +1,24 @@ +import type { mastodon } from '../../src'; +import type { Session } from '../session'; +import { createSession } from '../session'; +import { BasePool } from './base-pool'; + +export class SessionPoolImpl extends BasePool { + private readonly sessionToToken = new WeakMap(); + + protected acquireOne = async (): Promise => { + const token = await __misc__.tokens.acquire(); + const session = await createSession(token); + this.sessionToToken.set(session, token); + return session; + }; + + protected releaseOne = async (session: Session): Promise => { + const token = this.sessionToToken.get(session); + if (token == undefined) { + return; + } + await globalThis.__misc__.tokens.release(token); + this.sessionToToken.delete(session); + }; +} diff --git a/test-utils/pools/token-pool.ts b/test-utils/pools/token-pool.ts index 545ca61bd..cc2ed904f 100644 --- a/test-utils/pools/token-pool.ts +++ b/test-utils/pools/token-pool.ts @@ -36,8 +36,8 @@ export class TokenPoolImpl implements TokenPool { } return this.create(); }, - destroy: async () => { - return; + destroy: async (token) => { + this.tokens.push(token); }, }, { max: 10 }, diff --git a/test-utils/session.ts b/test-utils/session.ts new file mode 100644 index 000000000..702f0736b --- /dev/null +++ b/test-utils/session.ts @@ -0,0 +1,33 @@ +import type { mastodon, WebSocketAPIConnector } from '../src'; +import { createClient, createWebSocketClient } from '../src'; + +export interface Session { + readonly id: string; + readonly acct: string; + readonly rest: mastodon.RestAPIClient; + readonly ws: WebSocketAPIConnector; +} + +export const createSession = async ( + token: mastodon.v1.Token, +): Promise => { + const rest = createClient({ + url: __misc__.url, + accessToken: token.accessToken, + }); + + const ws = createWebSocketClient({ + url: __misc__.url, + streamingApiUrl: __misc__.instance.urls.streamingApi, + accessToken: token.accessToken, + }); + + const account = await rest.v1.accounts.verifyCredentials.fetch(); + + return Object.freeze({ + id: account.id, + acct: account.acct, + rest, + ws, + }); +}; diff --git a/tests/v1/accounts.spec.ts b/tests/v1/accounts.spec.ts index 35ba709ee..933d866e4 100644 --- a/tests/v1/accounts.spec.ts +++ b/tests/v1/accounts.spec.ts @@ -2,11 +2,11 @@ import crypto from 'node:crypto'; describe('account', () => { it('creates an account', () => { - return clients.use(async (client) => { + return sessions.use(async (session) => { const username = crypto.randomBytes(8).toString('hex'); const email = `${username}@example.com`; - const token = await client.v1.accounts.create( + const token = await session.rest.v1.accounts.create( { username, email, @@ -22,16 +22,16 @@ describe('account', () => { }); it('verifies credential', () => { - return clients.use(async (alice) => { - const me = await alice.v1.accounts.verifyCredentials.fetch(); + return sessions.use(async (session) => { + const me = await session.rest.v1.accounts.verifyCredentials.fetch(); expect(me.username).not.toBeNull(); }); }); it('updates credential', () => { - return clients.use(async (alice) => { + return sessions.use(async (session) => { const random = Math.random().toString(); - const me = await alice.v1.accounts.updateCredentials.update( + const me = await session.rest.v1.accounts.updateCredentials.update( { displayName: random }, { encoding: 'multipart-form' }, ); @@ -40,80 +40,59 @@ describe('account', () => { }); it('fetches an account with ID', () => { - return clients.use(async (alice) => { - const me = await alice.v1.accounts.verifyCredentials.fetch(); - const someone = await admin.v1.accounts.select(me.id).fetch(); - expect(me.id).toBe(someone.id); + return sessions.use(async (session) => { + const someone = await admin.v1.accounts.select(session.id).fetch(); + expect(session.id).toBe(someone.id); }); }); it('follows / unfollow by ID', () => { - return clients.use(2, async ([alice, bob]) => { - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - - let relationship = await alice.v1.accounts.select(bobId).follow(); + return sessions.use(2, async ([alice, bob]) => { + let relationship = await alice.rest.v1.accounts.select(bob.id).follow(); expect(relationship.following).toBe(true); - relationship = await alice.v1.accounts.select(bobId).unfollow(); + relationship = await alice.rest.v1.accounts.select(bob.id).unfollow(); expect(relationship.following).toBe(false); }); }); it('blocks / unblock by ID', () => { - return clients.use(2, async ([alice, bob]) => { - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - - let relationship = await alice.v1.accounts.select(bobId).block(); + return sessions.use(2, async ([alice, bob]) => { + let relationship = await alice.rest.v1.accounts.select(bob.id).block(); expect(relationship.blocking).toBe(true); - relationship = await alice.v1.accounts.select(bobId).unblock(); + relationship = await alice.rest.v1.accounts.select(bob.id).unblock(); expect(relationship.blocking).toBe(false); }); }); it('can pin / unpin by ID', () => { - return clients.use(2, async ([alice, bob]) => { - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - - await alice.v1.accounts.select(bobId).follow(); - let relationship = await alice.v1.accounts.select(bobId).pin(); + return sessions.use(2, async ([alice, bob]) => { + await alice.rest.v1.accounts.select(bob.id).follow(); + let relationship = await alice.rest.v1.accounts.select(bob.id).pin(); expect(relationship.endorsed).toBe(true); - relationship = await alice.v1.accounts.select(bobId).unpin(); - await alice.v1.accounts.select(bobId).unfollow(); + relationship = await alice.rest.v1.accounts.select(bob.id).unpin(); + await alice.rest.v1.accounts.select(bob.id).unfollow(); expect(relationship.endorsed).toBe(false); }); }); it('mutes / unmute by ID', () => { - return clients.use(2, async ([alice, bob]) => { - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - - let relationship = await alice.v1.accounts.select(bobId).mute(); + return sessions.use(2, async ([alice, bob]) => { + let relationship = await alice.rest.v1.accounts.select(bob.id).mute(); expect(relationship.muting).toBe(true); - relationship = await alice.v1.accounts.select(bobId).unmute(); + relationship = await alice.rest.v1.accounts.select(bob.id).unmute(); expect(relationship.muting).toBe(false); }); }); it('creates a note', () => { - return clients.use(2, async ([alice, bob]) => { - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - + return sessions.use(2, async ([alice, bob]) => { const comment = Math.random().toString(); - const relationship = await alice.v1.accounts - .select(bobId) + const relationship = await alice.rest.v1.accounts + .select(bob.id) .note.create({ comment }); expect(relationship.note).toBe(comment); @@ -121,59 +100,42 @@ describe('account', () => { }); it('fetches relationships', () => { - return clients.use(3, async ([alice, bob, carol]) => { - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - const carolId = await carol.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - - const res = await alice.v1.accounts.relationships.fetch({ - id: [bobId, carolId], + return sessions.use(3, async ([alice, bob, carol]) => { + const res = await alice.rest.v1.accounts.relationships.fetch({ + id: [bob.id, carol.id], }); expect(res).toHaveLength(2); }); }); it('lists followers', () => { - return clients.use(2, async ([alice, bob]) => { - const aliceId = await alice.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - - await alice.v1.accounts.select(bobId).follow(); - const followers = await alice.v1.accounts.select(bobId).followers.list(); - - expect(followers).toContainId(aliceId); - await alice.v1.accounts.select(bobId).unfollow(); + return sessions.use(2, async ([alice, bob]) => { + await alice.rest.v1.accounts.select(bob.id).follow(); + const followers = await alice.rest.v1.accounts + .select(bob.id) + .followers.list(); + + expect(followers).toContainId(alice.id); + await alice.rest.v1.accounts.select(bob.id).unfollow(); }); }); it('lists following', () => { - return clients.use(2, async ([alice, bob]) => { - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - await alice.v1.accounts.select(bobId).follow(); - - const aliceId = await alice.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - const accounts = await alice.v1.accounts.select(aliceId).following.list(); - - expect(accounts).toContainId(bobId); - await alice.v1.accounts.select(bobId).unfollow(); + return sessions.use(2, async ([alice, bob]) => { + await alice.rest.v1.accounts.select(bob.id).follow(); + const accounts = await alice.rest.v1.accounts + .select(alice.id) + .following.list(); + + expect(accounts).toContainId(bob.id); + await alice.rest.v1.accounts.select(bob.id).unfollow(); }); }); it('lists statuses', () => { - return clients.use(async (client) => { - const status = await client.v1.statuses.create({ status: 'Hello' }); - const statuses = await client.v1.accounts + return sessions.use(async (client) => { + const status = await client.rest.v1.statuses.create({ status: 'Hello' }); + const statuses = await client.rest.v1.accounts .select(status.account.id) .statuses.list(); @@ -182,53 +144,54 @@ describe('account', () => { }); it('searches', () => { - return clients.use(async (client) => { - const me = await client.v1.accounts.verifyCredentials.fetch(); - const accounts = await client.v1.accounts.search.list({ q: me.username }); + return sessions.use(async (client) => { + const me = await client.rest.v1.accounts.verifyCredentials.fetch(); + const accounts = await client.rest.v1.accounts.search.list({ + q: me.username, + }); expect(accounts).toContainId(me.id); }); }); it('lists lists', () => { - return clients.use(2, async ([alice, bob]) => { - const bobAccount = await bob.v1.accounts.verifyCredentials.fetch(); - const list = await alice.v1.lists.create({ title: 'title' }); - await alice.v1.accounts.select(bobAccount.id).follow(); + return sessions.use(2, async ([alice, bob]) => { + const list = await alice.rest.v1.lists.create({ title: 'title' }); + await alice.rest.v1.accounts.select(bob.id).follow(); try { - await alice.v1.lists.select(list.id).accounts.create({ - accountIds: [bobAccount.id], + await alice.rest.v1.lists.select(list.id).accounts.create({ + accountIds: [bob.id], }); - const accounts = await alice.v1.accounts - .select(bobAccount.id) + const accounts = await alice.rest.v1.accounts + .select(bob.id) .lists.list(); expect(accounts).toContainId(list.id); } finally { - await alice.v1.lists.select(list.id).remove(); + await alice.rest.v1.lists.select(list.id).remove(); } }); }); it('lists featured tags', () => { - return clients.use(async (client) => { - const me = await client.v1.accounts.verifyCredentials.fetch(); - const featuredTag = await client.v1.featuredTags.create( + return sessions.use(async (client) => { + const featuredTag = await client.rest.v1.featuredTags.create( { name: 'mastodon' }, { encoding: 'multipart-form' }, ); - const tags = await client.v1.accounts.select(me.id).featuredTags.list(); + const tags = await client.rest.v1.accounts + .select(client.id) + .featuredTags.list(); expect(tags).toContainId(featuredTag.id); - await client.v1.featuredTags.select(featuredTag.id).remove(); + await client.rest.v1.featuredTags.select(featuredTag.id).remove(); }); }); it('lists Identity proofs', () => { - return clients.use(async (client) => { - const me = await client.v1.accounts.verifyCredentials.fetch(); - const identityProofs = await client.v1.accounts - .select(me.id) + return sessions.use(async (client) => { + const identityProofs = await client.rest.v1.accounts + .select(client.id) .identityProofs.list(); expect(identityProofs).toEqual(expect.any(Array)); @@ -236,36 +199,28 @@ describe('account', () => { }); it('fetches familiar followers', () => { - return clients.use(async (client) => { - const me = await client.v1.accounts.verifyCredentials.fetch(); - const identityProofs = await client.v1.accounts.familiarFollowers.fetch([ - me.id, - ]); + return sessions.use(async (client) => { + const identityProofs = + await client.rest.v1.accounts.familiarFollowers.fetch([client.id]); expect(identityProofs).toEqual(expect.any(Array)); }); }); it('lookup', () => { - return clients.use(async (client) => { - const me = await client.v1.accounts.verifyCredentials.fetch(); - const account = await client.v1.accounts.lookup.fetch({ acct: me.acct }); - expect(account.id).toBe(me.id); + return sessions.use(async (client) => { + const account = await client.rest.v1.accounts.lookup.fetch({ + acct: client.acct, + }); + expect(account.id).toBe(client.id); }); }); it('removes from followers', () => { - return clients.use(2, async ([alice, bob]) => { - const aliceId = await alice.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((me) => me.id); - - await bob.v1.accounts.select(aliceId).follow(); - await alice.v1.accounts.select(bobId).removeFromFollowers(); - const [rel] = await alice.v1.accounts.relationships.fetch({ - id: [bobId], + return sessions.use(2, async ([alice, bob]) => { + await bob.rest.v1.accounts.select(alice.id).follow(); + await alice.rest.v1.accounts.select(bob.id).removeFromFollowers(); + const [rel] = await alice.rest.v1.accounts.relationships.fetch({ + id: [bob.id], }); expect(rel.followedBy).toBe(false); }); diff --git a/tests/v1/admin/accounts.spec.ts b/tests/v1/admin/accounts.spec.ts index 8fabc9102..2ee163bca 100644 --- a/tests/v1/admin/accounts.spec.ts +++ b/tests/v1/admin/accounts.spec.ts @@ -1,8 +1,9 @@ describe('account', () => { it('fetches an account', async () => { - const me = await admin.v1.accounts.verifyCredentials.fetch(); - const account = await admin.v1.admin.accounts.select(me.id).fetch(); - expect(account.id).toBe(me.id); + return sessions.use(async (session) => { + const account = await admin.v1.admin.accounts.select(session.id).fetch(); + expect(account.id).toBe(session.id); + }); }); it('lists accounts', async () => { @@ -15,61 +16,53 @@ describe('account', () => { test.todo('rejects an account'); it('disables an account', async () => { - return clients.use(async (client) => { - const user = await client.v1.accounts.verifyCredentials.fetch(); - - await admin.v1.admin.accounts.select(user.id).action.create({ + return sessions.use(async (client) => { + await admin.v1.admin.accounts.select(client.id).action.create({ type: 'disable', }); - let account = await admin.v1.admin.accounts.select(user.id).fetch(); + let account = await admin.v1.admin.accounts.select(client.id).fetch(); expect(account.disabled).toBe(true); - account = await admin.v1.admin.accounts.select(user.id).enable(); + account = await admin.v1.admin.accounts.select(client.id).enable(); expect(account.disabled).toBe(false); }); }); it('suspends an account', async () => { - return clients.use(async (client) => { - const user = await client.v1.accounts.verifyCredentials.fetch(); - - await admin.v1.admin.accounts.select(user.id).action.create({ + return sessions.use(async (session) => { + await admin.v1.admin.accounts.select(session.id).action.create({ type: 'suspend', }); - let account = await admin.v1.admin.accounts.select(user.id).fetch(); + let account = await admin.v1.admin.accounts.select(session.id).fetch(); expect(account.suspended).toBe(true); - account = await admin.v1.admin.accounts.select(user.id).unsuspend(); + account = await admin.v1.admin.accounts.select(session.id).unsuspend(); expect(account.suspended).toBe(false); }); }); it('silences an account', async () => { - return clients.use(async (client) => { - const user = await client.v1.accounts.verifyCredentials.fetch(); - - await admin.v1.admin.accounts.select(user.id).action.create({ + return sessions.use(async (session) => { + await admin.v1.admin.accounts.select(session.id).action.create({ type: 'silence', }); - let account = await admin.v1.admin.accounts.select(user.id).fetch(); + let account = await admin.v1.admin.accounts.select(session.id).fetch(); expect(account.silenced).toBe(true); - account = await admin.v1.admin.accounts.select(user.id).unsilence(); + account = await admin.v1.admin.accounts.select(session.id).unsilence(); expect(account.silenced).toBe(false); }); }); it('marks account as sensitive', async () => { - return clients.use(async (client) => { - const user = await client.v1.accounts.verifyCredentials.fetch(); - - await admin.v1.admin.accounts.select(user.id).action.create({ + return sessions.use(async (session) => { + await admin.v1.admin.accounts.select(session.id).action.create({ type: 'sensitive', }); - let account = await admin.v1.admin.accounts.select(user.id).fetch(); + let account = await admin.v1.admin.accounts.select(session.id).fetch(); expect(account.sensitized).toBe(true); - account = await admin.v1.admin.accounts.select(user.id).unsensitive(); + account = await admin.v1.admin.accounts.select(session.id).unsensitive(); expect(account.sensitized).toBe(false); }); }); diff --git a/tests/v1/admin/reports.spec.ts b/tests/v1/admin/reports.spec.ts index 1916cb9f3..f95b16f0e 100644 --- a/tests/v1/admin/reports.spec.ts +++ b/tests/v1/admin/reports.spec.ts @@ -5,16 +5,15 @@ import assert from 'node:assert'; it('handles reports', async () => { const self = await admin.v1.accounts.verifyCredentials.fetch(); - return clients.use(async (client) => { - const user = await client.v1.accounts.verifyCredentials.fetch(); - await admin.v1.reports.create({ accountId: user.id }); + return sessions.use(async (session) => { + await admin.v1.reports.create({ accountId: session.id }); const reports = await admin.v1.admin.reports.list(); - let report = reports.find((r) => r.targetAccount.id === user.id); + let report = reports.find((r) => r.targetAccount.id === session.id); assert(report != undefined); report = await admin.v1.admin.reports.select(report.id).fetch(); - expect(report.targetAccount.id).toBe(user.id); + expect(report.targetAccount.id).toBe(session.id); report = await admin.v1.admin.reports.select(report.id).assignToSelf(); expect(report.assignedAccount?.id).toBe(self.id); diff --git a/tests/v1/apps.spec.ts b/tests/v1/apps.spec.ts index a0967f82e..5352c8861 100644 --- a/tests/v1/apps.spec.ts +++ b/tests/v1/apps.spec.ts @@ -1,7 +1,7 @@ describe('apps', () => { it('creates an app', () => { - return clients.use(async (client) => { - const app = await client.v1.apps.create({ + return sessions.use(async (client) => { + const app = await client.rest.v1.apps.create({ clientName: 'My App', redirectUris: 'https://example.com/oauth/callback', scopes: 'read write', @@ -12,8 +12,8 @@ describe('apps', () => { }); it('verifies an app', () => { - return clients.use(async (client) => { - const app = await client.v1.apps.verifyCredentials.fetch(); + return sessions.use(async (client) => { + const app = await client.rest.v1.apps.verifyCredentials.fetch(); expect(app.name).toEqual(expect.any(String)); }); }); diff --git a/tests/v1/blocks.spec.ts b/tests/v1/blocks.spec.ts index 7205f1586..1744291e5 100644 --- a/tests/v1/blocks.spec.ts +++ b/tests/v1/blocks.spec.ts @@ -1,16 +1,12 @@ describe('blocks', () => { it('lists blocks', () => { - return clients.use(2, async ([alice, bob]) => { - const bobId = await bob.v1.accounts.verifyCredentials - .fetch() - .then((account) => account.id); - + return sessions.use(2, async ([alice, bob]) => { try { - await alice.v1.accounts.select(bobId).block(); - const blocks = await alice.v1.blocks.list(); - expect(blocks).toContainId(bobId); + await alice.rest.v1.accounts.select(bob.id).block(); + const blocks = await alice.rest.v1.blocks.list(); + expect(blocks).toContainId(bob.id); } finally { - await alice.v1.accounts.select(bobId).unblock(); + await alice.rest.v1.accounts.select(bob.id).unblock(); } }); }); diff --git a/tests/v1/bookmarks.spec.ts b/tests/v1/bookmarks.spec.ts index 6cefdf96c..084a5aca1 100644 --- a/tests/v1/bookmarks.spec.ts +++ b/tests/v1/bookmarks.spec.ts @@ -1,14 +1,14 @@ describe('bookmarks', () => { it('lists bookmarks', () => { - return clients.use(async (client) => { - const status = await client.v1.statuses.create({ status: 'status' }); + return sessions.use(async (client) => { + const status = await client.rest.v1.statuses.create({ status: 'status' }); try { - await client.v1.statuses.select(status.id).bookmark(); - const bookmarks = await client.v1.bookmarks.list(); + await client.rest.v1.statuses.select(status.id).bookmark(); + const bookmarks = await client.rest.v1.bookmarks.list(); expect(bookmarks).toContainId(status.id); } finally { - await client.v1.statuses.select(status.id).unbookmark(); + await client.rest.v1.statuses.select(status.id).unbookmark(); } }); }); diff --git a/tests/v1/conversations.spec.ts b/tests/v1/conversations.spec.ts index 7884310fa..3a75bb9c9 100644 --- a/tests/v1/conversations.spec.ts +++ b/tests/v1/conversations.spec.ts @@ -2,16 +2,14 @@ import { delay } from '../../src/utils'; describe('conversations', () => { it('interacts with conversations', () => { - return clients.use(2, async ([alice, bob]) => { - const { acct } = await alice.v1.accounts.verifyCredentials.fetch(); - - const status = await bob.v1.statuses.create({ - status: `@${acct} Hi alice`, + return sessions.use(2, async ([alice, bob]) => { + const status = await bob.rest.v1.statuses.create({ + status: `@${alice.acct} Hi alice`, visibility: 'direct', }); await delay(3000); - const conversations = await alice.v1.conversations.list(); + const conversations = await alice.rest.v1.conversations.list(); const conversation = conversations.find( (c) => c.lastStatus?.id === status.id, ); @@ -22,8 +20,8 @@ describe('conversations', () => { expect(conversation).toBeDefined(); - await alice.v1.conversations.select(conversation.id).read(); - await alice.v1.conversations.select(conversation.id).remove(); + await alice.rest.v1.conversations.select(conversation.id).read(); + await alice.rest.v1.conversations.select(conversation.id).remove(); }); }); }); diff --git a/tests/v1/custom-emojis.spec.ts b/tests/v1/custom-emojis.spec.ts index c170e6cb1..0df279be7 100644 --- a/tests/v1/custom-emojis.spec.ts +++ b/tests/v1/custom-emojis.spec.ts @@ -1,7 +1,7 @@ describe('custom emojis', () => { it('lists custom emojis', () => { - return clients.use(async (client) => { - const emojis = await client.v1.customEmojis.list(); + return sessions.use(async (client) => { + const emojis = await client.rest.v1.customEmojis.list(); expect(emojis).toEqual(expect.any(Array)); }); }); diff --git a/tests/v1/directory.spec.ts b/tests/v1/directory.spec.ts index 481273e31..a770d99e9 100644 --- a/tests/v1/directory.spec.ts +++ b/tests/v1/directory.spec.ts @@ -1,6 +1,6 @@ it('lists directory', () => { - return clients.use(async (client) => { - const directory = await client.v1.directory.list(); + return sessions.use(async (client) => { + const directory = await client.rest.v1.directory.list(); expect(directory).toEqual(expect.any(Array)); }); }); diff --git a/tests/v1/domain-blocks.spec.ts b/tests/v1/domain-blocks.spec.ts index 05ffef4db..e1844c0d3 100644 --- a/tests/v1/domain-blocks.spec.ts +++ b/tests/v1/domain-blocks.spec.ts @@ -1,13 +1,13 @@ it('block a domain', () => { - return clients.use(async (client) => { + return sessions.use(async (client) => { const domain = 'example.domain.to.block.com'; - await client.v1.domainBlocks.create({ domain }); - let domainBlocks = await client.v1.domainBlocks.list(); + await client.rest.v1.domainBlocks.create({ domain }); + let domainBlocks = await client.rest.v1.domainBlocks.list(); expect(domainBlocks).toEqual(expect.arrayContaining([domain])); - await client.v1.domainBlocks.remove({ domain }); - domainBlocks = await client.v1.domainBlocks.list(); + await client.rest.v1.domainBlocks.remove({ domain }); + domainBlocks = await client.rest.v1.domainBlocks.list(); expect(domainBlocks).not.toEqual(expect.arrayContaining([domain])); }); }); diff --git a/tests/v1/endorsements.spec.ts b/tests/v1/endorsements.spec.ts index 7a452f863..148b78bc1 100644 --- a/tests/v1/endorsements.spec.ts +++ b/tests/v1/endorsements.spec.ts @@ -1,16 +1,14 @@ it('lists endorsements', () => { - return clients.use(2, async ([alice, bob]) => { - const bobProfile = await bob.v1.accounts.verifyCredentials.fetch(); - + return sessions.use(2, async ([alice, bob]) => { try { - await alice.v1.accounts.select(bobProfile.id).follow(); - await alice.v1.accounts.select(bobProfile.id).pin(); - const endorsements = await alice.v1.endorsements.list(); + await alice.rest.v1.accounts.select(bob.id).follow(); + await alice.rest.v1.accounts.select(bob.id).pin(); + const endorsements = await alice.rest.v1.endorsements.list(); - expect(endorsements).toContainId(bobProfile.id); + expect(endorsements).toContainId(bob.id); } finally { - await alice.v1.accounts.select(bobProfile.id).unfollow(); - await alice.v1.accounts.select(bobProfile.id).unpin(); + await alice.rest.v1.accounts.select(bob.id).unfollow(); + await alice.rest.v1.accounts.select(bob.id).unpin(); } }); }); diff --git a/tests/v1/favourites.spec.ts b/tests/v1/favourites.spec.ts index d4de7dcd1..b8f0b1d51 100644 --- a/tests/v1/favourites.spec.ts +++ b/tests/v1/favourites.spec.ts @@ -1,13 +1,13 @@ it('list favourites', () => { - return clients.use(async (client) => { - const status = await client.v1.statuses.create({ status: 'test' }); + return sessions.use(async (client) => { + const status = await client.rest.v1.statuses.create({ status: 'test' }); try { - await client.v1.statuses.select(status.id).favourite(); - const statuses = await client.v1.favourites.list(); + await client.rest.v1.statuses.select(status.id).favourite(); + const statuses = await client.rest.v1.favourites.list(); expect(statuses).toContainId(status.id); } finally { - await client.v1.statuses.select(status.id).remove(); + await client.rest.v1.statuses.select(status.id).remove(); } }); }); diff --git a/tests/v1/featured-tags.spec.ts b/tests/v1/featured-tags.spec.ts index 2702a6852..b3e84d2ab 100644 --- a/tests/v1/featured-tags.spec.ts +++ b/tests/v1/featured-tags.spec.ts @@ -1,23 +1,23 @@ it('lists suggestions', () => { - return clients.use(async (client) => { - const suggestions = await client.v1.featuredTags.suggestions.list(); + return sessions.use(async (client) => { + const suggestions = await client.rest.v1.featuredTags.suggestions.list(); expect(suggestions).toEqual(expect.any(Array)); }); }); it('lists featured tags', () => { - return clients.use(async (client) => { + return sessions.use(async (client) => { const name = 'mastodon'; - const featuredTag = await client.v1.featuredTags.create( + const featuredTag = await client.rest.v1.featuredTags.create( { name }, { encoding: 'multipart-form' }, ); try { - const featuredTags = await client.v1.featuredTags.list(); + const featuredTags = await client.rest.v1.featuredTags.list(); expect(featuredTags).toContainEqual(expect.objectContaining({ name })); } finally { - await client.v1.featuredTags.select(featuredTag.id).remove(); + await client.rest.v1.featuredTags.select(featuredTag.id).remove(); } }); }); diff --git a/tests/v1/filters.spec.ts b/tests/v1/filters.spec.ts index 3bb061e64..296e7a328 100644 --- a/tests/v1/filters.spec.ts +++ b/tests/v1/filters.spec.ts @@ -1,24 +1,24 @@ it('lists filters', () => { - return clients.use(async (client) => { - let filter = await client.v1.filters.create({ + return sessions.use(async (client) => { + let filter = await client.rest.v1.filters.create({ phrase: 'test1', context: ['home'], }); try { - await client.v1.filters.select(filter.id).update({ + await client.rest.v1.filters.select(filter.id).update({ phrase: 'test1', context: ['home', 'notifications'], }); - filter = await client.v1.filters.select(filter.id).fetch(); + filter = await client.rest.v1.filters.select(filter.id).fetch(); expect(filter.phrase).toBe('test1'); expect(filter.context).toEqual(['home', 'notifications']); - const filters = await client.v1.filters.list(); + const filters = await client.rest.v1.filters.list(); expect(filters).toContainId(filter.id); } finally { - await client.v1.filters.select(filter.id).remove(); + await client.rest.v1.filters.select(filter.id).remove(); } }); }); diff --git a/tests/v1/follow-requests.spec.ts b/tests/v1/follow-requests.spec.ts index c66f874b8..86c0a65cb 100644 --- a/tests/v1/follow-requests.spec.ts +++ b/tests/v1/follow-requests.spec.ts @@ -1,59 +1,53 @@ it('authorize follow requests', () => { - return clients.use(2, async ([alice, bob]) => { - const aliceProfile = await alice.v1.accounts.verifyCredentials.fetch(); - const bobProfile = await bob.v1.accounts.verifyCredentials.fetch(); - - await alice.v1.accounts.updateCredentials.update( + return sessions.use(2, async ([alice, bob]) => { + await alice.rest.v1.accounts.updateCredentials.update( { locked: true }, { encoding: 'multipart-form' }, ); try { - let relationship = await bob.v1.accounts.select(aliceProfile.id).follow(); + let relationship = await bob.rest.v1.accounts.select(alice.id).follow(); expect(relationship.requested).toBe(true); - const followRequests = await alice.v1.followRequests.list(); - expect(followRequests).toContainId(bobProfile.id); + const followRequests = await alice.rest.v1.followRequests.list(); + expect(followRequests).toContainId(bob.id); - await alice.v1.followRequests.select(bobProfile.id).authorize(); - [relationship] = await bob.v1.accounts.relationships.fetch({ - id: [aliceProfile.id], + await alice.rest.v1.followRequests.select(bob.id).authorize(); + [relationship] = await bob.rest.v1.accounts.relationships.fetch({ + id: [alice.id], }); expect(relationship.following).toBe(true); } finally { - await alice.v1.accounts.updateCredentials.update( + await alice.rest.v1.accounts.updateCredentials.update( { locked: false }, { encoding: 'multipart-form' }, ); - await bob.v1.accounts.select(aliceProfile.id).unfollow(); + await bob.rest.v1.accounts.select(alice.id).unfollow(); } }); }); it('reject follow requests', () => { - return clients.use(2, async ([alice, bob]) => { - const aliceProfile = await alice.v1.accounts.verifyCredentials.fetch(); - const bobProfile = await bob.v1.accounts.verifyCredentials.fetch(); - - await alice.v1.accounts.updateCredentials.update( + return sessions.use(2, async ([alice, bob]) => { + await alice.rest.v1.accounts.updateCredentials.update( { locked: true }, { encoding: 'multipart-form' }, ); try { - let relationship = await bob.v1.accounts.select(aliceProfile.id).follow(); + let relationship = await bob.rest.v1.accounts.select(alice.id).follow(); expect(relationship.requested).toBe(true); - const followRequests = await alice.v1.followRequests.list(); - expect(followRequests).toContainId(bobProfile.id); + const followRequests = await alice.rest.v1.followRequests.list(); + expect(followRequests).toContainId(bob.id); - await alice.v1.followRequests.select(bobProfile.id).reject(); - [relationship] = await bob.v1.accounts.relationships.fetch({ - id: [aliceProfile.id], + await alice.rest.v1.followRequests.select(bob.id).reject(); + [relationship] = await bob.rest.v1.accounts.relationships.fetch({ + id: [alice.id], }); expect(relationship.following).toBe(false); } finally { - await alice.v1.accounts.updateCredentials.update( + await alice.rest.v1.accounts.updateCredentials.update( { locked: false }, { encoding: 'multipart-form' }, ); diff --git a/tests/v1/instance.spec.ts b/tests/v1/instance.spec.ts index 9a21c202e..80b61fc89 100644 --- a/tests/v1/instance.spec.ts +++ b/tests/v1/instance.spec.ts @@ -1,13 +1,13 @@ it('lists peers', () => { - return clients.use(async (client) => { - const peers = await client.v1.instance.peers.list(); + return sessions.use(async (client) => { + const peers = await client.rest.v1.instance.peers.list(); expect(peers).toEqual(expect.any(Array)); }); }); it('lists peers', () => { - return clients.use(async (client) => { - const peers = await client.v1.instance.activity.list(); + return sessions.use(async (client) => { + const peers = await client.rest.v1.instance.activity.list(); expect(peers).toEqual(expect.any(Array)); }); }); diff --git a/tests/v1/lists.spec.ts b/tests/v1/lists.spec.ts index 7382f8ccf..224d42034 100644 --- a/tests/v1/lists.spec.ts +++ b/tests/v1/lists.spec.ts @@ -1,33 +1,34 @@ it('mutates a list', () => { - return clients.use(2, async ([alice, bob]) => { - const bobAccount = await bob.v1.accounts.verifyCredentials.fetch(); - let list = await alice.v1.lists.create({ + return sessions.use(2, async ([alice, bob]) => { + let list = await alice.rest.v1.lists.create({ title: 'Test List', }); - await alice.v1.accounts.select(bobAccount.id).follow(); + await alice.rest.v1.accounts.select(bob.id).follow(); try { - await alice.v1.lists.select(list.id).update({ + await alice.rest.v1.lists.select(list.id).update({ title: 'Test List Updated', }); - list = await alice.v1.lists.select(list.id).fetch(); + list = await alice.rest.v1.lists.select(list.id).fetch(); - const lists = await alice.v1.lists.list(); + const lists = await alice.rest.v1.lists.list(); expect(lists).toContainId(list.id); - await alice.v1.lists + await alice.rest.v1.lists .select(list.id) - .accounts.create({ accountIds: [bobAccount.id] }); + .accounts.create({ accountIds: [bob.id] }); - const accounts = await alice.v1.lists.select(list.id).accounts.list(); - expect(accounts).toContainId(bobAccount.id); + const accounts = await alice.rest.v1.lists + .select(list.id) + .accounts.list(); + expect(accounts).toContainId(bob.id); } finally { - await alice.v1.lists + await alice.rest.v1.lists .select(list.id) - .accounts.remove({ accountIds: [bobAccount.id] }); + .accounts.remove({ accountIds: [bob.id] }); - await alice.v1.lists.select(list.id).remove(); - await alice.v1.accounts.select(bobAccount.id).unfollow(); + await alice.rest.v1.lists.select(list.id).remove(); + await alice.rest.v1.accounts.select(bob.id).unfollow(); } }); }); diff --git a/tests/v1/markers.spec.ts b/tests/v1/markers.spec.ts index 95ccf3371..344068694 100644 --- a/tests/v1/markers.spec.ts +++ b/tests/v1/markers.spec.ts @@ -1,15 +1,15 @@ it('creates a marker', () => { - return clients.use(async (client) => { - const status = await client.v1.statuses.create({ status: 'test' }); - let marker = await client.v1.markers.create({ + return sessions.use(async (client) => { + const status = await client.rest.v1.statuses.create({ status: 'test' }); + let marker = await client.rest.v1.markers.create({ home: { lastReadId: status.id }, }); try { - marker = await client.v1.markers.fetch({ timeline: ['home'] }); + marker = await client.rest.v1.markers.fetch({ timeline: ['home'] }); expect(marker.home.lastReadId).toBe(status.id); } finally { - await client.v1.statuses.select(status.id).remove(); + await client.rest.v1.statuses.select(status.id).remove(); } }); }); diff --git a/tests/v1/mutes.spec.ts b/tests/v1/mutes.spec.ts index 57213db8c..4ea2ed7d2 100644 --- a/tests/v1/mutes.spec.ts +++ b/tests/v1/mutes.spec.ts @@ -1,13 +1,12 @@ it('lists mute', () => { - return clients.use(2, async ([alice, bob]) => { - const bobProfile = await bob.v1.accounts.verifyCredentials.fetch(); - await alice.v1.accounts.select(bobProfile.id).mute(); + return sessions.use(2, async ([alice, bob]) => { + await alice.rest.v1.accounts.select(bob.id).mute(); try { - const mutes = await alice.v1.mutes.list(); - expect(mutes).toContainId(bobProfile.id); + const mutes = await alice.rest.v1.mutes.list(); + expect(mutes).toContainId(bob.id); } finally { - await alice.v1.accounts.select(bobProfile.id).unmute(); + await alice.rest.v1.accounts.select(bob.id).unmute(); } }); }); diff --git a/tests/v1/notifications.spec.ts b/tests/v1/notifications.spec.ts index 003a102ca..40ac923d4 100644 --- a/tests/v1/notifications.spec.ts +++ b/tests/v1/notifications.spec.ts @@ -1,58 +1,55 @@ import { delay } from '../../src/utils'; it('handles notifications', () => { - return clients.use(2, async ([alice, bob]) => { - const aliceAccount = await alice.v1.accounts.verifyCredentials.fetch(); - const status = await bob.v1.statuses.create({ - status: `@${aliceAccount.acct} Hello`, + return sessions.use(2, async ([alice, bob]) => { + const status = await bob.rest.v1.statuses.create({ + status: `@${alice.acct} Hello`, }); try { await delay(2000); - let notifications = await alice.v1.notifications.list(); + let notifications = await alice.rest.v1.notifications.list(); let notification = notifications[0]; - notification = await alice.v1.notifications + notification = await alice.rest.v1.notifications .select(notification.id) .fetch(); expect(notification.status?.id).toBe(status.id); - await alice.v1.notifications.select(notification.id).dismiss(); + await alice.rest.v1.notifications.select(notification.id).dismiss(); - notifications = await alice.v1.notifications.list(); + notifications = await alice.rest.v1.notifications.list(); expect(notifications).not.toContainId(notification.id); } finally { - await bob.v1.statuses.select(status.id).remove(); + await bob.rest.v1.statuses.select(status.id).remove(); } }); }); it('clear notifications', () => { - return clients.use(2, async ([alice, bob]) => { - const aliceAccount = await alice.v1.accounts.verifyCredentials.fetch(); - - const s1 = await bob.v1.statuses.create({ - status: `@${aliceAccount.acct} Hello 1`, + return sessions.use(2, async ([alice, bob]) => { + const s1 = await bob.rest.v1.statuses.create({ + status: `@${alice.acct} Hello 1`, }); - const s2 = await bob.v1.statuses.create({ - status: `@${aliceAccount.acct} Hello 2`, + const s2 = await bob.rest.v1.statuses.create({ + status: `@${alice.acct} Hello 2`, }); - const s3 = await bob.v1.statuses.create({ - status: `@${aliceAccount.acct} Hello 3`, + const s3 = await bob.rest.v1.statuses.create({ + status: `@${alice.acct} Hello 3`, }); try { await delay(2000); - let notifications = await alice.v1.notifications.list(); + let notifications = await alice.rest.v1.notifications.list(); expect(notifications.length >= 3).toBe(true); - await alice.v1.notifications.clear(); - notifications = await alice.v1.notifications.list(); + await alice.rest.v1.notifications.clear(); + notifications = await alice.rest.v1.notifications.list(); expect(notifications).toHaveLength(0); } finally { - await bob.v1.statuses.select(s1.id).remove(); - await bob.v1.statuses.select(s2.id).remove(); - await bob.v1.statuses.select(s3.id).remove(); + await bob.rest.v1.statuses.select(s1.id).remove(); + await bob.rest.v1.statuses.select(s2.id).remove(); + await bob.rest.v1.statuses.select(s3.id).remove(); } }); }); diff --git a/tests/v1/polls.spec.ts b/tests/v1/polls.spec.ts index 4d459ea22..4ca22ac9a 100644 --- a/tests/v1/polls.spec.ts +++ b/tests/v1/polls.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ it('handles poll', () => { - return clients.use(2, async ([alice, bob]) => { - const status = await alice.v1.statuses.create({ + return sessions.use(2, async ([alice, bob]) => { + const status = await alice.rest.v1.statuses.create({ status: `Which fruits do you like?`, poll: { options: ['Apple', 'Banana', 'Orange'], @@ -11,14 +11,14 @@ it('handles poll', () => { }); try { - await bob.v1.polls.select(status.poll!.id).votes.create({ + await bob.rest.v1.polls.select(status.poll!.id).votes.create({ choices: [0, 1], }); - const poll = await bob.v1.polls.select(status.poll!.id).fetch(); + const poll = await bob.rest.v1.polls.select(status.poll!.id).fetch(); expect(poll.votesCount).toBe(2); expect(poll.ownVotes).toEqual([0, 1]); } finally { - await alice.v1.statuses.select(status.id).remove(); + await alice.rest.v1.statuses.select(status.id).remove(); } }); }); diff --git a/tests/v1/preferences.spec.ts b/tests/v1/preferences.spec.ts index ddccf6e63..318522828 100644 --- a/tests/v1/preferences.spec.ts +++ b/tests/v1/preferences.spec.ts @@ -1,6 +1,6 @@ it('shows preferences', () => { - return clients.use(async (client) => { - const preferences = await client.v1.preferences.fetch(); + return sessions.use(async (client) => { + const preferences = await client.rest.v1.preferences.fetch(); expect(preferences['posting:default:language']).toBeDefined(); expect(preferences['posting:default:sensitive']).toBeDefined(); diff --git a/tests/v1/reports.spec.ts b/tests/v1/reports.spec.ts index 50d4ae78d..213415e19 100644 --- a/tests/v1/reports.spec.ts +++ b/tests/v1/reports.spec.ts @@ -2,21 +2,15 @@ import assert from 'node:assert'; /* eslint-disable @typescript-eslint/no-non-null-assertion */ it('creates a report', () => { - return clients.use(2, async ([alice, bob]) => { - const bobProfile = await bob.v1.accounts.verifyCredentials.fetch(); - const report = await alice.v1.reports.create({ - accountId: bobProfile.id, + return sessions.use(2, async ([alice, bob]) => { + await alice.rest.v1.reports.create({ + accountId: bob.id, }); const reports = await admin.v1.admin.reports.list(); - const adminReport = reports.find( - (report) => report.targetAccount.id === bobProfile.id, - ); + const report = reports.find((report) => report.targetAccount.id === bob.id); assert(report != undefined); - assert(adminReport != undefined); - expect(adminReport.id).toEqual(report.id); - expect(report).toBeDefined(); await admin.v1.admin.reports.select(report.id).resolve(); }); diff --git a/tests/v1/scheduled-statuses.spec.ts b/tests/v1/scheduled-statuses.spec.ts index d8cb4688f..58b7cff63 100644 --- a/tests/v1/scheduled-statuses.spec.ts +++ b/tests/v1/scheduled-statuses.spec.ts @@ -1,31 +1,35 @@ describe('scheduled-statuses', () => { it('schedules a status', () => { - return clients.use(async (client) => { + return sessions.use(async (client) => { const tomorrow = new Date(Date.now() + 1000 * 60 * 60 * 24); const scheduledAt = tomorrow.toISOString(); - let schedule = await client.v1.statuses.create({ + let schedule = await client.rest.v1.statuses.create({ status: 'Scheduled status', scheduledAt, }); expect(schedule.params.text).toBe('Scheduled status'); expect(schedule.scheduledAt).toBe(scheduledAt); - schedule = await client.v1.scheduledStatuses.select(schedule.id).fetch(); + schedule = await client.rest.v1.scheduledStatuses + .select(schedule.id) + .fetch(); expect(schedule.params.text).toBe('Scheduled status'); expect(schedule.scheduledAt).toBe(scheduledAt); const dayAfterTomorrow = new Date(Date.now() + 1000 * 60 * 60 * 24 * 2); - schedule = await client.v1.scheduledStatuses.select(schedule.id).update({ - scheduledAt: dayAfterTomorrow.toISOString(), - }); + schedule = await client.rest.v1.scheduledStatuses + .select(schedule.id) + .update({ + scheduledAt: dayAfterTomorrow.toISOString(), + }); expect(schedule.params.text).toBe('Scheduled status'); expect(schedule.scheduledAt).toBe(dayAfterTomorrow.toISOString()); - const scheduledStatuses = await client.v1.scheduledStatuses.list(); + const scheduledStatuses = await client.rest.v1.scheduledStatuses.list(); expect(scheduledStatuses[0].id).toBe(schedule.id); - await client.v1.scheduledStatuses.select(schedule.id).remove(); + await client.rest.v1.scheduledStatuses.select(schedule.id).remove(); }); }); }); diff --git a/tests/v1/statuses.spec.ts b/tests/v1/statuses.spec.ts index fc9c000c7..023cd2d11 100644 --- a/tests/v1/statuses.spec.ts +++ b/tests/v1/statuses.spec.ts @@ -6,44 +6,50 @@ import { delay } from '../../src/utils'; describe('status', () => { it('creates, updates, and removes a status', () => { - return clients.use(async (client) => { + return sessions.use(async (client) => { const random = Math.random().toString(); - const { id } = await client.v1.statuses.create({ + const { id } = await client.rest.v1.statuses.create({ status: random, visibility: 'direct', }); - let status = await client.v1.statuses.select(id).fetch(); + let status = await client.rest.v1.statuses.select(id).fetch(); expect(status.content).toBe(`

${random}

`); - const source = await client.v1.statuses.select(id).source.fetch(); + const source = await client.rest.v1.statuses.select(id).source.fetch(); expect(source.text).toBe(random); const random2 = Math.random().toString(); - status = await client.v1.statuses.select(id).update({ status: random2 }); + status = await client.rest.v1.statuses + .select(id) + .update({ status: random2 }); expect(status.content).toBe(`

${random2}

`); - const history = await client.v1.statuses.select(status.id).history.list(); + const history = await client.rest.v1.statuses + .select(status.id) + .history.list(); expect(history[0]).toEqual( expect.objectContaining({ content: `

${random}

`, }), ); - await client.v1.statuses.select(id).remove(); - await expect(client.v1.statuses.select(id).fetch()).rejects.toThrow(); + await client.rest.v1.statuses.select(id).remove(); + await expect( + client.rest.v1.statuses.select(id).fetch(), + ).rejects.toThrow(); }); }); it('creates a status with an Idempotency-Key', () => { - return clients.use(async (client) => { + return sessions.use(async (client) => { const idempotencyKey = crypto.randomUUID(); - const s1 = await client.v1.statuses.create( + const s1 = await client.rest.v1.statuses.create( { status: 'hello' }, { headers: new Headers({ 'Idempotency-Key': idempotencyKey }) }, ); - const s2 = await client.v1.statuses.create( + const s2 = await client.rest.v1.statuses.create( { status: 'hello' }, { headers: new Headers({ 'Idempotency-Key': idempotencyKey }) }, ); @@ -53,152 +59,152 @@ describe('status', () => { }); it('fetches a status context', () => { - return clients.use(async (client) => { - const s1 = await client.v1.statuses.create({ + return sessions.use(async (client) => { + const s1 = await client.rest.v1.statuses.create({ status: 'Hello', }); - const s2 = await client.v1.statuses.create({ + const s2 = await client.rest.v1.statuses.create({ status: 'Hello 2', inReplyToId: s1.id, }); - const s3 = await client.v1.statuses.create({ + const s3 = await client.rest.v1.statuses.create({ status: 'Hello 3', inReplyToId: s2.id, }); try { - const context = await client.v1.statuses.select(s2.id).context.fetch(); + const context = await client.rest.v1.statuses + .select(s2.id) + .context.fetch(); expect(context.ancestors).toContainId(s1.id); expect(context.descendants).toContainId(s3.id); } finally { - await client.v1.statuses.select(s1.id).remove(); - await client.v1.statuses.select(s2.id).remove(); - await client.v1.statuses.select(s3.id).remove(); + await client.rest.v1.statuses.select(s1.id).remove(); + await client.rest.v1.statuses.select(s2.id).remove(); + await client.rest.v1.statuses.select(s3.id).remove(); } }); }); it('translates a status', () => { - return clients.use(async (client) => { - const instance = await client.v2.instance.fetch(); + return sessions.use(async (session) => { + const instance = await session.rest.v2.instance.fetch(); if (!instance.configuration.translation.enabled) { return; } - const { id } = await client.v1.statuses.create({ + const { id } = await session.rest.v1.statuses.create({ status: 'Hello', }); try { await delay(2000); - const translation = await client.v1.statuses.select(id).translate({ - lang: 'ja', - }); + const translation = await session.rest.v1.statuses + .select(id) + .translate({ lang: 'ja' }); expect(translation.content).toEqual(expect.any(String)); } finally { - await client.v1.statuses.select(id).remove(); + await session.rest.v1.statuses.select(id).remove(); } }); }); it('favourites and unfavourites a status', () => { - return clients.use(2, async ([alice, bob]) => { - const bobAccount = await bob.v1.accounts.verifyCredentials.fetch(); - const { id: statusId } = await alice.v1.statuses.create({ + return sessions.use(2, async ([alice, bob]) => { + const { id: statusId } = await alice.rest.v1.statuses.create({ status: 'status', }); try { - let status = await bob.v1.statuses.select(statusId).favourite(); + let status = await bob.rest.v1.statuses.select(statusId).favourite(); expect(status.favourited).toBe(true); - const favourites = await bob.v1.statuses + const favourites = await bob.rest.v1.statuses .select(statusId) .favouritedBy.list(); - expect(favourites).toContainId(bobAccount.id); + expect(favourites).toContainId(bob.id); - status = await bob.v1.statuses.select(statusId).unfavourite(); + status = await bob.rest.v1.statuses.select(statusId).unfavourite(); expect(status.favourited).toBe(false); } finally { - await alice.v1.statuses.select(statusId).remove(); + await alice.rest.v1.statuses.select(statusId).remove(); } }); }); it('mutes and unmute a status', () => { - return clients.use(async (client) => { - let status = await client.v1.statuses.create({ + return sessions.use(async (client) => { + let status = await client.rest.v1.statuses.create({ status: 'status', visibility: 'direct', }); try { - status = await client.v1.statuses.select(status.id).mute(); + status = await client.rest.v1.statuses.select(status.id).mute(); expect(status.muted).toBe(true); - status = await client.v1.statuses.select(status.id).unmute(); + status = await client.rest.v1.statuses.select(status.id).unmute(); expect(status.muted).toBe(false); } finally { - await client.v1.statuses.select(status.id).remove(); + await client.rest.v1.statuses.select(status.id).remove(); } }); }); it('reblogs and unreblog a status', () => { - return clients.use(2, async ([alice, bob]) => { - const bobAccount = await bob.v1.accounts.verifyCredentials.fetch(); - const { id: statusId } = await alice.v1.statuses.create({ + return sessions.use(2, async ([alice, bob]) => { + const { id: statusId } = await alice.rest.v1.statuses.create({ status: 'status', }); try { - let status = await bob.v1.statuses.select(statusId).reblog(); + let status = await bob.rest.v1.statuses.select(statusId).reblog(); expect(status.reblogged).toBe(true); - const reblogs = await alice.v1.statuses + const reblogs = await alice.rest.v1.statuses .select(statusId) .rebloggedBy.list(); - expect(reblogs).toContainId(bobAccount.id); + expect(reblogs).toContainId(bob.id); - status = await bob.v1.statuses.select(statusId).unreblog(); + status = await bob.rest.v1.statuses.select(statusId).unreblog(); expect(status.reblogged).toBe(false); } finally { - await alice.v1.statuses.select(statusId).remove(); + await alice.rest.v1.statuses.select(statusId).remove(); } }); }); it('pins and unpin a status', () => { - return clients.use(async (client) => { - let status = await client.v1.statuses.create({ + return sessions.use(async (client) => { + let status = await client.rest.v1.statuses.create({ status: 'status', visibility: 'private', }); - status = await client.v1.statuses.select(status.id).pin(); + status = await client.rest.v1.statuses.select(status.id).pin(); expect(status.pinned).toBe(true); - status = await client.v1.statuses.select(status.id).unpin(); + status = await client.rest.v1.statuses.select(status.id).unpin(); expect(status.pinned).toBe(false); - await client.v1.statuses.select(status.id).remove(); + await client.rest.v1.statuses.select(status.id).remove(); }); }); it('bookmarks and unbookmark a status', () => { - return clients.use(async (client) => { - let status = await client.v1.statuses.create({ + return sessions.use(async (client) => { + let status = await client.rest.v1.statuses.create({ status: 'status', visibility: 'direct', }); - status = await client.v1.statuses.select(status.id).bookmark(); + status = await client.rest.v1.statuses.select(status.id).bookmark(); expect(status.bookmarked).toBe(true); - status = await client.v1.statuses.select(status.id).unbookmark(); + status = await client.rest.v1.statuses.select(status.id).unbookmark(); expect(status.bookmarked).toBe(false); - await client.v1.statuses.select(status.id).remove(); + await client.rest.v1.statuses.select(status.id).remove(); }); }); }); diff --git a/tests/v1/suggestions.spec.ts b/tests/v1/suggestions.spec.ts index a6a28cf92..afa7bc167 100644 --- a/tests/v1/suggestions.spec.ts +++ b/tests/v1/suggestions.spec.ts @@ -1,7 +1,7 @@ describe('suggestions', () => { it('returns suggestions', () => { - return clients.use(async (client) => { - const suggestions = await client.v1.suggestions.list(); + return sessions.use(async (client) => { + const suggestions = await client.rest.v1.suggestions.list(); expect(suggestions).toEqual(expect.any(Array)); }); }); diff --git a/tests/v1/timelines.spec.ts b/tests/v1/timelines.spec.ts index d5ed97cb4..d7ee8dc22 100644 --- a/tests/v1/timelines.spec.ts +++ b/tests/v1/timelines.spec.ts @@ -2,34 +2,44 @@ import { delay } from '../../src/utils'; describe('timeline', () => { it('returns home', () => { - return clients.use(async (client) => { - const status = await client.v1.statuses.create({ status: 'own post' }); + return sessions.use(async (client) => { + const status = await client.rest.v1.statuses.create({ + status: 'own post', + }); await delay(3000); - const statuses = await client.v1.timelines.home.list(); + const statuses = await client.rest.v1.timelines.home.list(); expect(statuses).toContainId(status.id); }); }); it('returns public', () => { - return clients.use(2, async ([alice, bob]) => { - const status = await bob.v1.statuses.create({ status: 'public post' }); - const statuses = await alice.v1.timelines.public.list(); + return sessions.use(2, async ([alice, bob]) => { + const status = await bob.rest.v1.statuses.create({ + status: 'public post', + }); + const statuses = await alice.rest.v1.timelines.public.list(); expect(statuses).toContainId(status.id); }); }); it('returns hashtag', () => { - return clients.use(async (client) => { - const status = await client.v1.statuses.create({ status: '#mastodon' }); - const statuses = await client.v1.timelines.tag.select('mastodon').list(); + return sessions.use(async (client) => { + const status = await client.rest.v1.statuses.create({ + status: '#mastodon', + }); + const statuses = await client.rest.v1.timelines.tag + .select('mastodon') + .list(); expect(statuses).toContainId(status.id); }); }); it('returns list', () => { - return clients.use(async (client) => { - const list = await client.v1.lists.create({ title: 'List' }); - const statuses = await client.v1.timelines.list.select(list.id).list(); + return sessions.use(async (client) => { + const list = await client.rest.v1.lists.create({ title: 'List' }); + const statuses = await client.rest.v1.timelines.list + .select(list.id) + .list(); expect(statuses).toEqual([]); }); }); diff --git a/tests/v1/trends.spec.ts b/tests/v1/trends.spec.ts index 5decd3afc..de7047b3b 100644 --- a/tests/v1/trends.spec.ts +++ b/tests/v1/trends.spec.ts @@ -1,21 +1,21 @@ describe('trend', () => { it('returns trend statuses', () => { - return clients.use(async (client) => { - const statuses = await client.v1.trends.statuses.list(); + return sessions.use(async (client) => { + const statuses = await client.rest.v1.trends.statuses.list(); expect(statuses).toEqual(expect.any(Array)); }); }); it('returns trend links', () => { - return clients.use(async (client) => { - const statuses = await client.v1.trends.links.list(); + return sessions.use(async (client) => { + const statuses = await client.rest.v1.trends.links.list(); expect(statuses).toEqual(expect.any(Array)); }); }); it('returns trend tags', () => { - return clients.use(async (client) => { - const statuses = await client.v1.trends.tags.list(); + return sessions.use(async (client) => { + const statuses = await client.rest.v1.trends.tags.list(); expect(statuses).toEqual(expect.any(Array)); }); }); diff --git a/tests/v1/web-push-subscriptions.spec.ts b/tests/v1/web-push-subscriptions.spec.ts index 7666a9887..15883ac98 100644 --- a/tests/v1/web-push-subscriptions.spec.ts +++ b/tests/v1/web-push-subscriptions.spec.ts @@ -2,12 +2,12 @@ import crypto from 'node:crypto'; describe('subscription', () => { it('can subscribe', () => { - return clients.use(async (client) => { + return sessions.use(async (client) => { const ecdh = crypto.createECDH('prime256v1'); const auth = crypto.randomBytes(16).toString('base64'); const p256dh = ecdh.generateKeys().toString('base64'); - const { id } = await client.v1.push.subscription.create({ + const { id } = await client.rest.v1.push.subscription.create({ subscription: { endpoint: 'https://example.com', keys: { @@ -23,14 +23,14 @@ describe('subscription', () => { }, }); - let subscription = await client.v1.push.subscription.fetch(); + let subscription = await client.rest.v1.push.subscription.fetch(); expect(subscription.id).toBe(id); expect(subscription.endpoint).toBe('https://example.com'); expect(subscription.policy).toBe('all'); expect(subscription.alerts.follow).toBe(true); - subscription = await client.v1.push.subscription.update({ + subscription = await client.rest.v1.push.subscription.update({ data: { alerts: { follow: false, @@ -40,7 +40,7 @@ describe('subscription', () => { expect(subscription.alerts.follow).toBe(false); - await client.v1.push.subscription.remove(); + await client.rest.v1.push.subscription.remove(); }); }); }); diff --git a/tests/v2/filters.spec.ts b/tests/v2/filters.spec.ts index f94f55686..69ca0cd54 100644 --- a/tests/v2/filters.spec.ts +++ b/tests/v2/filters.spec.ts @@ -1,93 +1,97 @@ describe('filters', () => { it('creates a filter', () => { - return clients.use(async (client) => { - let filter = await client.v2.filters.create({ + return sessions.use(async (session) => { + let filter = await session.rest.v2.filters.create({ title: 'Filter', context: ['notifications'], keywordsAttributes: [{ keyword: 'test' }], }); try { - await client.v2.filters.select(filter.id).update({ + await session.rest.v2.filters.select(filter.id).update({ title: 'Filter Updated', }); - filter = await client.v2.filters.select(filter.id).fetch(); + filter = await session.rest.v2.filters.select(filter.id).fetch(); expect(filter.title).toBe('Filter Updated'); - const filters = await client.v2.filters.list(); + const filters = await session.rest.v2.filters.list(); expect(filters).toContainId(filter.id); } finally { - await client.v2.filters.select(filter.id).remove(); + await session.rest.v2.filters.select(filter.id).remove(); } }); }); it('handles filter keywords', () => { - return clients.use(async (client) => { - const filter = await client.v2.filters.create({ + return sessions.use(async (session) => { + const filter = await session.rest.v2.filters.create({ title: 'Filter', context: ['notifications'], }); try { - let keyword = await client.v2.filters + let keyword = await session.rest.v2.filters .select(filter.id) .keywords.create({ keyword: 'test', }); - await client.v2.filters.keywords.select(keyword.id).update({ + await session.rest.v2.filters.keywords.select(keyword.id).update({ keyword: 'test2', }); - keyword = await client.v2.filters.keywords.select(keyword.id).fetch(); + keyword = await session.rest.v2.filters.keywords + .select(keyword.id) + .fetch(); expect(keyword.keyword).toBe('test2'); - const keywords = await client.v2.filters + const keywords = await session.rest.v2.filters .select(filter.id) .keywords.list(); expect(keywords).toContainId(keyword.id); - await client.v2.filters.keywords.select(keyword.id).remove(); + await session.rest.v2.filters.keywords.select(keyword.id).remove(); } finally { - await client.v2.filters.select(filter.id).remove(); + await session.rest.v2.filters.select(filter.id).remove(); } }); }); it('handles status filters', () => { - return clients.use(async (client) => { - const filter = await client.v2.filters.create({ + return sessions.use(async (session) => { + const filter = await session.rest.v2.filters.create({ title: 'Filter', context: ['notifications'], }); try { - const status = await client.v1.statuses.create({ status: 'test' }); - let statusFilter = await client.v2.filters + const status = await session.rest.v1.statuses.create({ + status: 'test', + }); + let statusFilter = await session.rest.v2.filters .select(filter.id) .statuses.create({ statusId: status.id, }); - statusFilter = await client.v2.filters.statuses + statusFilter = await session.rest.v2.filters.statuses .select(statusFilter.id) .fetch(); expect(statusFilter.statusId).toBe(status.id); - const statusFilters = await client.v2.filters + const statusFilters = await session.rest.v2.filters .select(filter.id) .statuses.list(); expect(statusFilters).toContainId(statusFilter.id); - await client.v2.filters.statuses.select(statusFilter.id).remove(); + await session.rest.v2.filters.statuses.select(statusFilter.id).remove(); } finally { - await client.v2.filters.select(filter.id).remove(); + await session.rest.v2.filters.select(filter.id).remove(); } }); }); it('removes a filter with _destroy', () => { - return clients.use(async (client) => { - let filter = await client.v2.filters.create({ + return sessions.use(async (session) => { + let filter = await session.rest.v2.filters.create({ title: 'Filter', context: ['notifications'], keywordsAttributes: [{ keyword: 'test' }, { keyword: 'test2' }], @@ -95,12 +99,12 @@ describe('filters', () => { expect(filter.keywords).toHaveLength(2); try { - filter = await client.v2.filters.select(filter.id).update({ + filter = await session.rest.v2.filters.select(filter.id).update({ keywordsAttributes: [{ id: filter.keywords[0].id, _destroy: true }], }); expect(filter.keywords).toHaveLength(1); } finally { - await client.v2.filters.select(filter.id).remove(); + await session.rest.v2.filters.select(filter.id).remove(); } }); }); diff --git a/tests/v2/instance.spec.ts b/tests/v2/instance.spec.ts index 0ebbcd68b..fe7058a9e 100644 --- a/tests/v2/instance.spec.ts +++ b/tests/v2/instance.spec.ts @@ -1,6 +1,6 @@ it('fetches instance', () => { - return clients.use(async (client) => { - const instance = await client.v2.instance.fetch(); + return sessions.use(async (session) => { + const instance = await session.rest.v2.instance.fetch(); expect(instance.domain).toEqual(expect.any(String)); }); }); diff --git a/tests/v2/media.spec.ts b/tests/v2/media.spec.ts index 3a48cc557..d7394913d 100644 --- a/tests/v2/media.spec.ts +++ b/tests/v2/media.spec.ts @@ -30,13 +30,13 @@ describe('media', () => { }); it('creates media attachment without polling', () => { - return clients.use(async (client) => { + return sessions.use(async (session) => { const file = await createFile(); - let media = await client.v2.media.create( + let media = await session.rest.v2.media.create( { file }, { encoding: 'multipart-form' }, ); - media = await waitForMediaAttachment(client, media.id); + media = await waitForMediaAttachment(session.rest, media.id); expect(media.type).toBe('image'); }); }); diff --git a/tests/v2/search.spec.ts b/tests/v2/search.spec.ts index 10f15f11a..a5e8b14e4 100644 --- a/tests/v2/search.spec.ts +++ b/tests/v2/search.spec.ts @@ -1,6 +1,6 @@ it('searches', () => { - return clients.use(async (client) => { - const results = await client.v2.search.fetch({ + return sessions.use(async (session) => { + const results = await session.rest.v2.search.fetch({ q: 'mastodon', }); diff --git a/tests/websocket/events.spec.ts b/tests/websocket/events.spec.ts new file mode 100644 index 000000000..75104181c --- /dev/null +++ b/tests/websocket/events.spec.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert'; + +import { delay } from '../../src/utils'; + +describe('events', () => { + it('streams update, status.update, and delete event', () => { + return sessions.use(async (session) => { + let id!: string; + const connection = await session.ws.connect(); + + try { + const events = connection.subscribe('public:local'); + + setImmediate(async () => { + const status = await session.rest.v1.statuses.create({ + status: 'test', + }); + id = status.id; + await delay(1000); + await session.rest.v1.statuses.select(status.id).update({ + status: 'test2', + }); + await delay(1000); + await session.rest.v1.statuses.select(status.id).remove(); + }); + + const [e1, e2, e3] = await events.take(3).toArray(); + + assert(e1?.event === 'update'); + expect(e1.payload.content).toBe('

test

'); + assert(e2?.event === 'status.update'); + expect(e2.payload.content).toBe('

test2

'); + assert(e3?.event === 'delete'); + expect(e3.payload).toBe(id); + } finally { + connection.unsubscribe('public:local'); + connection.close(); + } + }); + }); + + it('streams filters_changed event', () => { + return sessions.use(async (session) => { + const connection = await session.ws.connect(); + + try { + const events = connection.subscribe('user'); + + setImmediate(async () => { + const filter = await session.rest.v2.filters.create({ + title: 'test', + context: ['public'], + keywordsAttributes: [{ keyword: 'TypeScript' }], + }); + await session.rest.v2.filters.select(filter.id).remove(); + }); + + const [e] = await events.take(1).toArray(); + + assert(e?.event === 'filters_changed'); + expect(e.payload).toBeUndefined(); + } finally { + connection.unsubscribe('user'); + connection.close(); + } + }); + }); + + it('streams notification', () => { + return sessions.use(2, async ([alice, bob]) => { + let id!: string; + const connection = await alice.ws.connect(); + + try { + const events = connection.subscribe('user:notification'); + + setImmediate(async () => { + await bob.rest.v1.accounts.select(alice.id).follow(); + }); + + const [e] = await events.take(1).toArray(); + assert(e?.event === 'notification'); + expect(e.payload.account?.id).toBe(bob.id); + expect(e.payload.status?.id).toBe(id); + } finally { + await bob.rest.v1.accounts.select(alice.id).unfollow(); + connection.unsubscribe('user:notification'); + connection.close(); + } + }); + }); + + it('streams conversation', () => { + return sessions.use(2, async ([alice, bob]) => { + let id!: string; + const connection = await alice.ws.connect(); + + try { + setImmediate(async () => { + const status = await bob.rest.v1.statuses.create({ + status: `@${alice.acct} Hello there`, + visibility: 'direct', + }); + id = status.id; + await delay(1000); + }); + + const events = connection.subscribe('direct'); + const [e] = await events.take(1).toArray(); + + assert(e?.event === 'conversation'); + expect(e.payload.lastStatus?.id).toBe(id); + } finally { + await bob.rest.v1.statuses.select(id).remove(); + connection.unsubscribe('direct'); + connection.close(); + } + }); + }); + + test.todo('announcement'); +}); diff --git a/tests/websocket/timelines.spec.ts b/tests/websocket/timelines.spec.ts new file mode 100644 index 000000000..7a60dc542 --- /dev/null +++ b/tests/websocket/timelines.spec.ts @@ -0,0 +1,301 @@ +import assert from 'node:assert'; + +import type { mastodon } from '../../src'; +import { waitForMediaAttachment } from '../../src/utils'; + +const TRANSPARENT_1X1_PNG = + 'data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +describe('websocket', () => { + it('streams public', () => { + return sessions.use(async (session) => { + let id!: string; + const connection = await session.ws.connect(); + + try { + const events = connection.subscribe('public'); + + setImmediate(async () => { + const status = await session.rest.v1.statuses.create({ + status: 'test', + }); + id = status.id; + }); + + const [event] = await events + .filter((e): e is mastodon.UpdateEvent => e.event === 'update') + .filter((e) => e.payload.id === id) + .take(1) + .toArray(); + + assert(event?.event === 'update'); + expect(event?.payload?.id).toBe(id); + } finally { + connection.unsubscribe('public'); + connection.close(); + await session.rest.v1.statuses.select(id).remove(); + } + }); + }); + + // it('streams public:media', () => { + // return sessions.use(async (session) => { + // let id!: string; + // const connection = await session.ws.connect(); + // try { + // const events = connection.subscribe('public:media'); + // setImmediate(async () => { + // const media = await session.rest.v2.media.create( + // { file: TRANSPARENT_1X1_PNG }, + // { encoding: 'multipart-form' }, + // ); + // await waitForMediaAttachment(session.rest, media.id); + // const status = await session.rest.v1.statuses.create({ + // status: 'test', + // mediaIds: [media.id], + // visibility: 'public', + // }); + // id = status.id; + // }); + // const [event] = await events + // .filter((e): e is mastodon.UpdateEvent => e.event === 'update') + // .filter((e) => e.payload.id === id) + // .take(1) + // .toArray(); + // assert(event?.event === 'update'); + // expect(event?.payload?.id).toBe(id); + // } finally { + // connection.unsubscribe('public:media'); + // connection.close(); + // await session.rest.v1.statuses.select(id).remove(); + // } + // }); + // }); + + it('streams public:local', () => { + return sessions.use(async (session) => { + let id!: string; + const connection = await session.ws.connect(); + + try { + const events = connection.subscribe('public:local'); + + setImmediate(async () => { + const status = await session.rest.v1.statuses.create({ + status: 'test', + visibility: 'public', + }); + id = status.id; + }); + + const [event] = await events + .filter((e): e is mastodon.UpdateEvent => e.event === 'update') + .filter((e) => e.payload.id === id) + .take(1) + .toArray(); + + assert(event?.event === 'update'); + expect(event?.payload?.id).toBe(id); + } finally { + connection.unsubscribe('public:local'); + connection.close(); + await session.rest.v1.statuses.select(id).remove(); + } + }); + }); + + it('streams public:local:media', () => { + return sessions.use(async (session) => { + let id!: string; + const connection = await session.ws.connect(); + + try { + const events = connection.subscribe('public:local:media'); + + setImmediate(async () => { + const media = await session.rest.v2.media.create( + { file: TRANSPARENT_1X1_PNG }, + { encoding: 'multipart-form' }, + ); + + await waitForMediaAttachment(session.rest, media.id); + + const status = await session.rest.v1.statuses.create({ + status: 'test', + mediaIds: [media.id], + visibility: 'public', + }); + + id = status.id; + }); + + const [event] = await events + .filter((e): e is mastodon.UpdateEvent => e.event === 'update') + .filter((e) => e.payload.id === id) + .take(1) + .toArray(); + + assert(event?.event === 'update'); + expect(event?.payload?.id).toBe(id); + } finally { + connection.unsubscribe('public:local:media'); + connection.close(); + await session.rest.v1.statuses.select(id).remove(); + } + }); + }); + + it('streams hashtag', () => { + return sessions.use(async (session) => { + let id!: string; + const connection = await session.ws.connect(); + + try { + const events = connection.subscribe('hashtag', { tag: 'test' }); + + setImmediate(async () => { + const status = await session.rest.v1.statuses.create({ + status: '#test', + }); + id = status.id; + }); + + const [event] = await events + .filter((e): e is mastodon.UpdateEvent => e.event === 'update') + .filter((e) => e.payload.id === id) + .take(1) + .toArray(); + + assert(event?.event === 'update'); + expect(event?.payload?.id).toBe(id); + } finally { + connection.close(); + connection.unsubscribe('hashtag', { tag: 'test' }); + await session.rest.v1.statuses.select(id).remove(); + } + }); + }); + + it('streams hashtag:local', () => { + return sessions.use(async (session) => { + let id!: string; + const connection = await session.ws.connect(); + + try { + const events = connection.subscribe('hashtag:local', { tag: 'test' }); + + setImmediate(async () => { + const status = await session.rest.v1.statuses.create({ + status: '#test', + }); + id = status.id; + }); + + const [event] = await events + .filter((e): e is mastodon.UpdateEvent => e.event === 'update') + .filter((e) => e.payload.id === id) + .take(1) + .toArray(); + + assert(event?.event === 'update'); + expect(event?.payload?.id).toBe(id); + } finally { + connection.unsubscribe('hashtag:local', { tag: 'test' }); + connection.close(); + await session.rest.v1.statuses.select(id).remove(); + } + }); + }); + + it('streams user', () => { + return sessions.use(2, async ([alice, bob]) => { + const connection = await alice.ws.connect(); + + try { + const events = connection.subscribe('user'); + setImmediate(async () => { + await bob.rest.v1.accounts.select(alice.id).follow(); + }); + + const [e1] = await events + .filter( + (e): e is mastodon.NotificationEvent => e.event === 'notification', + ) + .take(1) + .toArray(); + + assert(e1?.event === 'notification'); + expect(e1?.payload?.type).toBe('follow'); + expect(e1?.payload?.account.id).toBe(bob.id); + } finally { + connection.unsubscribe('user'); + connection.close(); + await bob.rest.v1.accounts.select(alice.id).unfollow(); + } + }); + }); + + it('streams user:notification', () => { + return sessions.use(2, async ([alice, bob]) => { + const connection = await alice.ws.connect(); + + try { + const events = connection.subscribe('user:notification'); + + setImmediate(async () => { + await bob.rest.v1.accounts.select(alice.id).follow(); + }); + + const [e1] = await events + .filter( + (e): e is mastodon.NotificationEvent => e.event === 'notification', + ) + .take(1) + .toArray(); + + assert(e1?.event === 'notification'); + expect(e1?.payload?.type).toBe('follow'); + expect(e1?.payload?.account.id).toBe(bob.id); + } finally { + connection.unsubscribe('user:notification'); + connection.close(); + await bob.rest.v1.accounts.select(alice.id).unfollow(); + } + }); + }); + + it('streams list', () => { + return sessions.use(2, async ([alice, bob]) => { + const connection = await alice.ws.connect(); + const list = await alice.rest.v1.lists.create({ title: 'test' }); + + try { + await alice.rest.v1.accounts.select(bob.id).follow(); + const events = connection.subscribe('list', { list: list.id }); + + await alice.rest.v1.lists.select(list.id).accounts.create({ + accountIds: [bob.id], + }); + + setImmediate(async () => { + await bob.rest.v1.statuses.create({ + status: 'a post from bob', + }); + }); + + const [e1] = await events + .filter((e): e is mastodon.UpdateEvent => e.event === 'update') + .take(1) + .toArray(); + + assert(e1?.event === 'update'); + expect(e1?.payload?.account?.id).toBe(bob.id); + } finally { + connection.unsubscribe('list', { list: list.id }); + connection.close(); + await alice.rest.v1.lists.select(list.id).remove(); + await alice.rest.v1.accounts.select(bob.id).unfollow(); + } + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index e4106496b..79f546263 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES6", - "module": "ES6", + "target": "ESNext", + "module": "ESNext", "lib": ["ESNext"], "declaration": false, "esModuleInterop": true, diff --git a/yarn.lock b/yarn.lock index eaade806d..8465134bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3228,6 +3228,11 @@ eventemitter3@^5.0.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.0.tgz#084eb7f5b5388df1451e63f4c2aafd71b217ccb3" integrity sha512-riuVbElZZNXLeLEoprfNYoDSwTBRR44X3mnhdI1YcnENpWTCsTTVZ2zFuqQcpoyqPQIUXdiPEU0ECAq0KQRaHg== +events-to-async@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/events-to-async/-/events-to-async-2.0.0.tgz#3b2987a9c6c9833093d048379fa8640b6c62afb3" + integrity sha512-NiZEr4g51nI4/lz/6NdwMqK/TLIctlnp9TQ3wCJjlRp47VgrthUZE4nrk2UhfZ8VzoQ/Xyth+G6MKioLCt0FVA== + events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"