UNPKG

@fedify/testing

Version:

Testing utilities for Fedify applications

954 lines (899 loc) 27.2 kB
// deno-lint-ignore-file no-explicit-any import type { ActorCallbackSetters, ActorDispatcher, ActorKeyPair, CollectionCallbackSetters, CollectionDispatcher, Context, Federation, FederationFetchOptions, FederationStartQueueOptions, InboxContext, InboxListenerSetters, Message, NodeInfoDispatcher, ObjectCallbackSetters, ObjectDispatcher, ParseUriResult, RequestContext, RouteActivityOptions, SendActivityOptions, SendActivityOptionsForCollection, SenderKeyPair, } from "@fedify/fedify/federation"; import type { JsonValue, NodeInfo } from "@fedify/fedify/nodeinfo"; import type { DocumentLoader } from "@fedify/fedify/runtime"; import type { Activity, Actor, Collection, Hashtag, LookupObjectOptions, Object, Recipient, TraverseCollectionOptions, } from "@fedify/fedify/vocab"; import type { ResourceDescriptor } from "@fedify/fedify/webfinger"; import { trace, type TracerProvider } from "@opentelemetry/api"; import { createInboxContext, createRequestContext } from "./context.ts"; /** * Helper function to expand URI templates with values. * Supports simple placeholders like {identifier}, {handle}, etc. * @param template The URI template pattern * @param values The values to substitute * @returns The expanded URI path */ function expandUriTemplate( template: string, values: Record<string, string>, ): string { return template.replace(/{([^}]+)}/g, (match, key) => { return values[key] || match; }); } /** * Represents a sent activity with metadata about how it was sent. * @since 1.8.0 */ export interface SentActivity { /** Whether the activity was queued or sent immediately. */ queued: boolean; /** Which queue was used (if queued). */ queue?: "inbox" | "outbox" | "fanout"; /** The activity that was sent. */ activity: Activity; /** The order in which the activity was sent (auto-incrementing counter). */ sentOrder: number; } /** * A mock implementation of the {@link Federation} interface for unit testing. * This class provides a way to test Fedify applications without needing * a real federation setup. * * @example * ```typescript * import { Create } from "@fedify/fedify/vocab"; * import { MockFederation } from "@fedify/testing"; * * // Create a mock federation with contextData * const federation = new MockFederation<{ userId: string }>({ * contextData: { userId: "test-user" } * }); * * // Set up inbox listeners * federation * .setInboxListeners("/users/{identifier}/inbox") * .on(Create, async (ctx, activity) => { * console.log("Received:", activity); * }); * * // Simulate receiving an activity * const createActivity = new Create({ * id: new URL("https://example.com/create/1"), * actor: new URL("https://example.com/users/alice") * }); * await federation.receiveActivity(createActivity); * ``` * * @typeParam TContextData The context data to pass to the {@link Context}. * @since 1.8.0 */ export class MockFederation<TContextData> implements Federation<TContextData> { public sentActivities: SentActivity[] = []; public queueStarted = false; private activeQueues: Set<"inbox" | "outbox" | "fanout"> = new Set(); public sentCounter = 0; private nodeInfoDispatcher?: NodeInfoDispatcher<TContextData>; private actorDispatchers: Map<string, ActorDispatcher<TContextData>> = new Map(); public actorPath?: string; public inboxPath?: string; public outboxPath?: string; public followingPath?: string; public followersPath?: string; public likedPath?: string; public featuredPath?: string; public featuredTagsPath?: string; public nodeInfoPath?: string; public sharedInboxPath?: string; public objectPaths: Map<string, string> = new Map(); private objectDispatchers: Map< string, ObjectDispatcher<TContextData, Object, string> > = new Map(); private inboxDispatcher?: CollectionDispatcher< Activity, RequestContext<TContextData>, TContextData, void >; private outboxDispatcher?: CollectionDispatcher< Activity, RequestContext<TContextData>, TContextData, void >; private followingDispatcher?: CollectionDispatcher< Actor | URL, RequestContext<TContextData>, TContextData, void >; private followersDispatcher?: CollectionDispatcher< Recipient, Context<TContextData>, TContextData, URL >; private likedDispatcher?: CollectionDispatcher< Object | URL, RequestContext<TContextData>, TContextData, void >; private featuredDispatcher?: CollectionDispatcher< Object, RequestContext<TContextData>, TContextData, void >; private featuredTagsDispatcher?: CollectionDispatcher< Hashtag, RequestContext<TContextData>, TContextData, void >; private inboxListeners: Map<string, InboxListener<TContextData, Activity>[]> = new Map(); private contextData?: TContextData; private receivedActivities: Activity[] = []; constructor( private options: { contextData?: TContextData; origin?: string; tracerProvider?: TracerProvider; } = {}, ) { this.contextData = options.contextData; } setNodeInfoDispatcher( path: string, dispatcher: NodeInfoDispatcher<TContextData>, ): void { this.nodeInfoDispatcher = dispatcher; this.nodeInfoPath = path; } setActorDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: ActorDispatcher<TContextData>, ): ActorCallbackSetters<TContextData> { this.actorDispatchers.set(path, dispatcher); this.actorPath = path; return { setKeyPairsDispatcher: () => this as any, mapHandle: () => this as any, mapAlias: () => this as any, authorize: () => this as any, }; } setObjectDispatcher<TObject extends Object, TParam extends string>( cls: (new (...args: any[]) => TObject) & { typeId: URL }, path: string, dispatcher: ObjectDispatcher<TContextData, TObject, TParam>, ): ObjectCallbackSetters<TContextData, TObject, TParam> { this.objectDispatchers.set(path, dispatcher); this.objectPaths.set(cls.typeId.href, path); return { authorize: () => this as any, }; } setInboxDispatcher( _path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Activity, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { this.inboxDispatcher = dispatcher; // Note: inboxPath is set in setInboxListeners return { setCounter: () => this as any, setFirstCursor: () => this as any, setLastCursor: () => this as any, authorize: () => this as any, }; } setOutboxDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Activity, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { this.outboxDispatcher = dispatcher; this.outboxPath = path; return { setCounter: () => this as any, setFirstCursor: () => this as any, setLastCursor: () => this as any, authorize: () => this as any, }; } setFollowingDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Actor | URL, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { this.followingDispatcher = dispatcher; this.followingPath = path; return { setCounter: () => this as any, setFirstCursor: () => this as any, setLastCursor: () => this as any, authorize: () => this as any, }; } setFollowersDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Recipient, Context<TContextData>, TContextData, URL >, ): CollectionCallbackSetters<Context<TContextData>, TContextData, URL> { this.followersDispatcher = dispatcher; this.followersPath = path; return { setCounter: () => this as any, setFirstCursor: () => this as any, setLastCursor: () => this as any, authorize: () => this as any, }; } setLikedDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Object | URL, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { this.likedDispatcher = dispatcher; this.likedPath = path; return { setCounter: () => this as any, setFirstCursor: () => this as any, setLastCursor: () => this as any, authorize: () => this as any, }; } setFeaturedDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Object, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { this.featuredDispatcher = dispatcher; this.featuredPath = path; return { setCounter: () => this as any, setFirstCursor: () => this as any, setLastCursor: () => this as any, authorize: () => this as any, }; } setFeaturedTagsDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Hashtag, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { this.featuredTagsDispatcher = dispatcher; this.featuredTagsPath = path; return { setCounter: () => this as any, setFirstCursor: () => this as any, setLastCursor: () => this as any, authorize: () => this as any, }; } setInboxListeners( inboxPath: `${string}{identifier}${string}` | `${string}{handle}${string}`, sharedInboxPath?: string, ): InboxListenerSetters<TContextData> { this.inboxPath = inboxPath; this.sharedInboxPath = sharedInboxPath; // deno-lint-ignore no-this-alias const self = this; return { on<TActivity extends Activity>( type: new (...args: any[]) => TActivity, listener: InboxListener<TContextData, TActivity>, ): InboxListenerSetters<TContextData> { const typeName = type.name; if (!self.inboxListeners.has(typeName)) { self.inboxListeners.set(typeName, []); } self.inboxListeners.get(typeName)!.push( listener as InboxListener<TContextData, Activity>, ); return this; }, onError(): InboxListenerSetters<TContextData> { return this; }, setSharedKeyDispatcher(): InboxListenerSetters<TContextData> { return this; }, }; } // deno-lint-ignore require-await async startQueue( contextData: TContextData, options?: FederationStartQueueOptions, ): Promise<void> { this.contextData = contextData; this.queueStarted = true; // If a specific queue is specified, only activate that one if (options?.queue) { this.activeQueues.add(options.queue); } else { // If no specific queue, activate all three this.activeQueues.add("inbox"); this.activeQueues.add("outbox"); this.activeQueues.add("fanout"); } } // deno-lint-ignore require-await async processQueuedTask( contextData: TContextData, _message: Message, ): Promise<void> { this.contextData = contextData; // no queue in mock type. process immediately } createContext(baseUrl: URL, contextData: TContextData): Context<TContextData>; createContext( request: Request, contextData: TContextData, ): RequestContext<TContextData>; createContext( baseUrlOrRequest: URL | Request, contextData: TContextData, ): Context<TContextData> | RequestContext<TContextData> { // deno-lint-ignore no-this-alias const mockFederation = this; if (baseUrlOrRequest instanceof Request) { // For now, we'll use createRequestContext since MockContext doesn't support Request // But we need to ensure the sendActivity behavior is consistent return createRequestContext({ url: new URL(baseUrlOrRequest.url), request: baseUrlOrRequest, data: contextData, federation: mockFederation as any, sendActivity: (async ( sender: any, recipients: any, activity: any, options: any, ) => { // Create a temporary MockContext to use its sendActivity logic const tempContext = new MockContext({ url: new URL(baseUrlOrRequest.url), data: contextData, federation: mockFederation as any, }); await tempContext.sendActivity( sender, recipients, activity, options, ); }) as any, }); } else { return new MockContext({ url: baseUrlOrRequest, data: contextData, federation: mockFederation as any, }); } } // deno-lint-ignore require-await async fetch( request: Request, options: FederationFetchOptions<TContextData>, ): Promise<Response> { // returning 404 by default if (options.onNotFound) { return options.onNotFound(request); } return new Response("Not Found", { status: 404 }); } /** * Simulates receiving an activity. This method is specific to the mock * implementation and is used for testing purposes. * * @param activity The activity to receive. * @returns A promise that resolves when the activity has been processed. * @since 1.8.0 */ async receiveActivity(activity: Activity): Promise<void> { this.receivedActivities.push(activity); // Find and execute appropriate inbox listeners const typeName = activity.constructor.name; const listeners = this.inboxListeners.get(typeName) || []; // Check if we have listeners but no context data if (listeners.length > 0 && this.contextData === undefined) { throw new Error( "MockFederation.receiveActivity(): contextData is not initialized. " + "Please provide contextData through the constructor or call startQueue() before receiving activities.", ); } for (const listener of listeners) { const context = createInboxContext({ data: this.contextData as TContextData, federation: this as any, }); await listener(context, activity); } } /** * Clears all sent activities from the mock federation. * This method is specific to the mock implementation and is used for * testing purposes. * * @since 1.8.0 */ reset(): void { this.sentActivities = []; } } // Type definitions for inbox listeners interface InboxListener<TContextData, TActivity extends Activity> { ( context: InboxContext<TContextData>, activity: TActivity, ): void | Promise<void>; } /** * A mock implementation of the {@link Context} interface for unit testing. * This class provides a way to test Fedify applications without needing * a real federation context. * * @example * ```typescript * import { Person, Create } from "@fedify/fedify/vocab"; * import { MockContext, MockFederation } from "@fedify/testing"; * * // Create a mock context * const mockFederation = new MockFederation<{ userId: string }>(); * const context = new MockContext({ * url: new URL("https://example.com"), * data: { userId: "test-user" }, * federation: mockFederation * }); * * // Send an activity * const recipient = new Person({ id: new URL("https://example.com/users/bob") }); * const activity = new Create({ * id: new URL("https://example.com/create/1"), * actor: new URL("https://example.com/users/alice") * }); * await context.sendActivity( * { identifier: "alice" }, * recipient, * activity * ); * * // Check sent activities * const sent = context.getSentActivities(); * console.log(sent[0].activity); * ``` * * @typeParam TContextData The context data to pass to the {@link Context}. * @since 1.8.0 */ export class MockContext<TContextData> implements Context<TContextData> { readonly origin: string; readonly canonicalOrigin: string; readonly host: string; readonly hostname: string; readonly data: TContextData; readonly federation: Federation<TContextData>; readonly documentLoader: DocumentLoader; readonly contextLoader: DocumentLoader; readonly tracerProvider: TracerProvider; private sentActivities: Array<{ sender: any; recipients: Recipient | Recipient[] | "followers"; activity: Activity; }> = []; constructor( options: { url?: URL; data: TContextData; federation: Federation<TContextData>; documentLoader?: DocumentLoader; contextLoader?: DocumentLoader; tracerProvider?: TracerProvider; }, ) { const url = options.url ?? new URL("https://example.com"); this.origin = url.origin; this.canonicalOrigin = url.origin; this.host = url.host; this.hostname = url.hostname; this.data = options.data; this.federation = options.federation; // deno-lint-ignore require-await this.documentLoader = options.documentLoader ?? (async (url: string) => ({ contextUrl: null, document: {}, documentUrl: url, })); this.contextLoader = options.contextLoader ?? this.documentLoader; this.tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); } clone(data: TContextData): Context<TContextData> { return new MockContext({ url: new URL(this.origin), data, federation: this.federation, documentLoader: this.documentLoader, contextLoader: this.contextLoader, tracerProvider: this.tracerProvider, }); } getNodeInfoUri(): URL { if ( this.federation instanceof MockFederation && this.federation.nodeInfoPath ) { return new URL(this.federation.nodeInfoPath, this.origin); } return new URL("/nodeinfo/2.0", this.origin); } getActorUri(identifier: string): URL { if ( this.federation instanceof MockFederation && this.federation.actorPath ) { const path = expandUriTemplate(this.federation.actorPath, { identifier, handle: identifier, }); return new URL(path, this.origin); } return new URL(`/users/${identifier}`, this.origin); } getObjectUri<TObject extends Object>( cls: (new (...args: any[]) => TObject) & { typeId: URL }, values: Record<string, string>, ): URL { if (this.federation instanceof MockFederation) { const pathTemplate = this.federation.objectPaths.get(cls.typeId.href); if (pathTemplate) { const path = expandUriTemplate(pathTemplate, values); return new URL(path, this.origin); } } const path = globalThis.Object.entries(values) .map(([key, value]) => `${key}/${value}`) .join("/"); return new URL(`/objects/${cls.name.toLowerCase()}/${path}`, this.origin); } getOutboxUri(identifier: string): URL { if ( this.federation instanceof MockFederation && this.federation.outboxPath ) { const path = expandUriTemplate(this.federation.outboxPath, { identifier, handle: identifier, }); return new URL(path, this.origin); } return new URL(`/users/${identifier}/outbox`, this.origin); } getInboxUri(identifier: string): URL; getInboxUri(): URL; getInboxUri(identifier?: string): URL { if (identifier) { if ( this.federation instanceof MockFederation && this.federation.inboxPath ) { const path = expandUriTemplate(this.federation.inboxPath, { identifier, handle: identifier, }); return new URL(path, this.origin); } return new URL(`/users/${identifier}/inbox`, this.origin); } if ( this.federation instanceof MockFederation && this.federation.sharedInboxPath ) { return new URL(this.federation.sharedInboxPath, this.origin); } return new URL("/inbox", this.origin); } getFollowingUri(identifier: string): URL { if ( this.federation instanceof MockFederation && this.federation.followingPath ) { const path = expandUriTemplate(this.federation.followingPath, { identifier, handle: identifier, }); return new URL(path, this.origin); } return new URL(`/users/${identifier}/following`, this.origin); } getFollowersUri(identifier: string): URL { if ( this.federation instanceof MockFederation && this.federation.followersPath ) { const path = expandUriTemplate(this.federation.followersPath, { identifier, handle: identifier, }); return new URL(path, this.origin); } return new URL(`/users/${identifier}/followers`, this.origin); } getLikedUri(identifier: string): URL { if ( this.federation instanceof MockFederation && this.federation.likedPath ) { const path = expandUriTemplate(this.federation.likedPath, { identifier, handle: identifier, }); return new URL(path, this.origin); } return new URL(`/users/${identifier}/liked`, this.origin); } getFeaturedUri(identifier: string): URL { if ( this.federation instanceof MockFederation && this.federation.featuredPath ) { const path = expandUriTemplate(this.federation.featuredPath, { identifier, handle: identifier, }); return new URL(path, this.origin); } return new URL(`/users/${identifier}/featured`, this.origin); } getFeaturedTagsUri(identifier: string): URL { if ( this.federation instanceof MockFederation && this.federation.featuredTagsPath ) { const path = expandUriTemplate(this.federation.featuredTagsPath, { identifier, handle: identifier, }); return new URL(path, this.origin); } return new URL(`/users/${identifier}/tags`, this.origin); } parseUri(uri: URL): ParseUriResult | null { if (uri.pathname.startsWith("/users/")) { const parts = uri.pathname.split("/"); if (parts.length >= 3) { return { type: "actor", identifier: parts[2], handle: parts[2], }; } } return null; } getActorKeyPairs(_identifier: string): Promise<ActorKeyPair[]> { return Promise.resolve([]); } getDocumentLoader( params: { handle: string } | { identifier: string }, ): Promise<DocumentLoader>; getDocumentLoader( params: { keyId: URL; privateKey: CryptoKey }, ): DocumentLoader; getDocumentLoader(params: any): DocumentLoader | Promise<DocumentLoader> { // return the same document loader if ("keyId" in params) { return this.documentLoader; } return Promise.resolve(this.documentLoader); } lookupObject( _uri: URL | string, _options?: LookupObjectOptions, ): Promise<Object | null> { return Promise.resolve(null); } traverseCollection<TItem, TContext extends Context<TContextData>>( _collection: Collection | URL | null, _options?: TraverseCollectionOptions, ): AsyncIterable<TItem> { // just returning empty async iterable return { async *[Symbol.asyncIterator]() { // yield nothing }, }; } lookupNodeInfo( url: URL | string, options?: { parse?: "strict" | "best-effort" } & any, ): Promise<NodeInfo | undefined>; lookupNodeInfo( url: URL | string, options?: { parse: "none" } & any, ): Promise<JsonValue | undefined>; lookupNodeInfo( _url: URL | string, _options?: any, ): Promise<NodeInfo | JsonValue | undefined> { return Promise.resolve(undefined); } lookupWebFinger( _resource: URL | `acct:${string}@${string}` | string, _options?: any, ): Promise<ResourceDescriptor | null> { return Promise.resolve(null); } sendActivity( sender: | SenderKeyPair | SenderKeyPair[] | { identifier: string } | { username: string } | { handle: string }, recipients: Recipient | Recipient[], activity: Activity, options?: SendActivityOptions, ): Promise<void>; sendActivity( sender: { identifier: string } | { username: string } | { handle: string }, recipients: "followers", activity: Activity, options?: SendActivityOptionsForCollection, ): Promise<void>; sendActivity( sender: | SenderKeyPair | SenderKeyPair[] | { identifier: string } | { username: string } | { handle: string }, recipients: Recipient | Recipient[], activity: Activity, options?: SendActivityOptions, ): Promise<void>; sendActivity( sender: { identifier: string } | { username: string } | { handle: string }, recipients: "followers", activity: Activity, options?: SendActivityOptionsForCollection, ): Promise<void>; sendActivity( sender: | SenderKeyPair | SenderKeyPair[] | { identifier: string } | { username: string } | { handle: string }, recipients: Recipient | Recipient[] | "followers", activity: Activity, _options?: SendActivityOptions | SendActivityOptionsForCollection, ): Promise<void> { this.sentActivities.push({ sender, recipients, activity }); // If this is a MockFederation, also record it there if (this.federation instanceof MockFederation) { const queued = this.federation.queueStarted; this.federation.sentActivities.push({ queued, queue: queued ? "outbox" : undefined, activity, sentOrder: ++this.federation.sentCounter, }); } return Promise.resolve(); } routeActivity( _recipient: string | null, _activity: Activity, _options?: RouteActivityOptions, ): Promise<boolean> { return Promise.resolve(true); } /** * Gets all activities that have been sent through this mock context. * This method is specific to the mock implementation and is used for * testing purposes. * * @returns An array of sent activity records. */ getSentActivities(): Array<{ sender: | SenderKeyPair | SenderKeyPair[] | { identifier: string } | { username: string } | { handle: string }; recipients: Recipient | Recipient[] | "followers"; activity: Activity; }> { return [...this.sentActivities]; } /** * Clears all sent activities from the mock context. * This method is specific to the mock implementation and is used for * testing purposes. */ reset(): void { this.sentActivities = []; } }