UNPKG

@fedify/testing

Version:

Testing utilities for Fedify applications

612 lines (608 loc) 18.7 kB
import { trace } from "@opentelemetry/api"; import { RouterError } from "@fedify/fedify/federation"; import { lookupObject, traverseCollection } from "@fedify/fedify/vocab"; import { lookupWebFinger } from "@fedify/fedify/webfinger"; //#region docloader.ts const mockDocumentLoader = async (url) => ({ contextUrl: null, document: {}, documentUrl: url }); //#endregion //#region context.ts function createContext(values) { const { federation, url = new URL("http://example.com/"), canonicalOrigin, data, documentLoader, contextLoader, tracerProvider, clone, getNodeInfoUri, getActorUri, getObjectUri, getOutboxUri, getInboxUri, getFollowingUri, getFollowersUri, getLikedUri, getFeaturedUri, getFeaturedTagsUri, parseUri, getActorKeyPairs, getDocumentLoader, lookupObject: lookupObject$1, traverseCollection: traverseCollection$1, lookupNodeInfo, lookupWebFinger: lookupWebFinger$1, sendActivity, routeActivity } = values; function throwRouteError() { throw new RouterError("Not implemented"); } return { federation, data, origin: url.origin, canonicalOrigin: canonicalOrigin ?? url.origin, host: url.host, hostname: url.hostname, documentLoader: documentLoader ?? mockDocumentLoader, contextLoader: contextLoader ?? mockDocumentLoader, tracerProvider: tracerProvider ?? trace.getTracerProvider(), clone: clone ?? ((data$1) => createContext({ ...values, data: data$1 })), getNodeInfoUri: getNodeInfoUri ?? throwRouteError, getActorUri: getActorUri ?? throwRouteError, getObjectUri: getObjectUri ?? throwRouteError, getOutboxUri: getOutboxUri ?? throwRouteError, getInboxUri: getInboxUri ?? throwRouteError, getFollowingUri: getFollowingUri ?? throwRouteError, getFollowersUri: getFollowersUri ?? throwRouteError, getLikedUri: getLikedUri ?? throwRouteError, getFeaturedUri: getFeaturedUri ?? throwRouteError, getFeaturedTagsUri: getFeaturedTagsUri ?? throwRouteError, parseUri: parseUri ?? ((_uri) => { throw new Error("Not implemented"); }), getDocumentLoader: getDocumentLoader ?? ((_params) => { throw new Error("Not implemented"); }), getActorKeyPairs: getActorKeyPairs ?? ((_handle) => Promise.resolve([])), lookupObject: lookupObject$1 ?? ((uri, options = {}) => { return lookupObject(uri, { documentLoader: options.documentLoader ?? documentLoader ?? mockDocumentLoader, contextLoader: options.contextLoader ?? contextLoader ?? mockDocumentLoader }); }), traverseCollection: traverseCollection$1 ?? ((collection, options = {}) => { return traverseCollection(collection, { documentLoader: options.documentLoader ?? documentLoader ?? mockDocumentLoader, contextLoader: options.contextLoader ?? contextLoader ?? mockDocumentLoader }); }), lookupNodeInfo: lookupNodeInfo ?? ((_params) => { throw new Error("Not implemented"); }), lookupWebFinger: lookupWebFinger$1 ?? ((resource, options = {}) => { return lookupWebFinger(resource, options); }), sendActivity: sendActivity ?? ((_params) => { throw new Error("Not implemented"); }), routeActivity: routeActivity ?? ((_params) => { throw new Error("Not implemented"); }) }; } function createRequestContext(args) { return { ...createContext(args), clone: args.clone ?? ((data) => createRequestContext({ ...args, data })), request: args.request ?? new Request(args.url), url: args.url, getActor: args.getActor ?? (() => Promise.resolve(null)), getObject: args.getObject ?? (() => Promise.resolve(null)), getSignedKey: args.getSignedKey ?? (() => Promise.resolve(null)), getSignedKeyOwner: args.getSignedKeyOwner ?? (() => Promise.resolve(null)), sendActivity: args.sendActivity ?? ((_params) => { throw new Error("Not implemented"); }) }; } function createInboxContext(args) { return { ...createContext(args), clone: args.clone ?? ((data) => createInboxContext({ ...args, data })), recipient: args.recipient ?? null, forwardActivity: args.forwardActivity ?? ((_params) => { throw new Error("Not implemented"); }) }; } //#endregion //#region mock.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, values) { return template.replace(/{([^}]+)}/g, (match, key) => { return values[key] || match; }); } /** * 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 */ var MockFederation = class { sentActivities = []; queueStarted = false; activeQueues = /* @__PURE__ */ new Set(); sentCounter = 0; nodeInfoDispatcher; actorDispatchers = /* @__PURE__ */ new Map(); actorPath; inboxPath; outboxPath; followingPath; followersPath; likedPath; featuredPath; featuredTagsPath; nodeInfoPath; sharedInboxPath; objectPaths = /* @__PURE__ */ new Map(); objectDispatchers = /* @__PURE__ */ new Map(); inboxDispatcher; outboxDispatcher; followingDispatcher; followersDispatcher; likedDispatcher; featuredDispatcher; featuredTagsDispatcher; inboxListeners = /* @__PURE__ */ new Map(); contextData; receivedActivities = []; constructor(options = {}) { this.options = options; this.contextData = options.contextData; } setNodeInfoDispatcher(path, dispatcher) { this.nodeInfoDispatcher = dispatcher; this.nodeInfoPath = path; } setActorDispatcher(path, dispatcher) { this.actorDispatchers.set(path, dispatcher); this.actorPath = path; return { setKeyPairsDispatcher: () => this, mapHandle: () => this, mapAlias: () => this, authorize: () => this }; } setObjectDispatcher(cls, path, dispatcher) { this.objectDispatchers.set(path, dispatcher); this.objectPaths.set(cls.typeId.href, path); return { authorize: () => this }; } setInboxDispatcher(_path, dispatcher) { this.inboxDispatcher = dispatcher; return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } setOutboxDispatcher(path, dispatcher) { this.outboxDispatcher = dispatcher; this.outboxPath = path; return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } setFollowingDispatcher(path, dispatcher) { this.followingDispatcher = dispatcher; this.followingPath = path; return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } setFollowersDispatcher(path, dispatcher) { this.followersDispatcher = dispatcher; this.followersPath = path; return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } setLikedDispatcher(path, dispatcher) { this.likedDispatcher = dispatcher; this.likedPath = path; return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } setFeaturedDispatcher(path, dispatcher) { this.featuredDispatcher = dispatcher; this.featuredPath = path; return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } setFeaturedTagsDispatcher(path, dispatcher) { this.featuredTagsDispatcher = dispatcher; this.featuredTagsPath = path; return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } setInboxListeners(inboxPath, sharedInboxPath) { this.inboxPath = inboxPath; this.sharedInboxPath = sharedInboxPath; const self = this; return { on(type, listener) { const typeName = type.name; if (!self.inboxListeners.has(typeName)) self.inboxListeners.set(typeName, []); self.inboxListeners.get(typeName).push(listener); return this; }, onError() { return this; }, setSharedKeyDispatcher() { return this; } }; } async startQueue(contextData, options) { this.contextData = contextData; this.queueStarted = true; if (options?.queue) this.activeQueues.add(options.queue); else { this.activeQueues.add("inbox"); this.activeQueues.add("outbox"); this.activeQueues.add("fanout"); } } async processQueuedTask(contextData, _message) { this.contextData = contextData; } createContext(baseUrlOrRequest, contextData) { const mockFederation = this; if (baseUrlOrRequest instanceof Request) return createRequestContext({ url: new URL(baseUrlOrRequest.url), request: baseUrlOrRequest, data: contextData, federation: mockFederation, sendActivity: async (sender, recipients, activity, options) => { const tempContext = new MockContext({ url: new URL(baseUrlOrRequest.url), data: contextData, federation: mockFederation }); await tempContext.sendActivity(sender, recipients, activity, options); } }); else return new MockContext({ url: baseUrlOrRequest, data: contextData, federation: mockFederation }); } async fetch(request, options) { 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) { this.receivedActivities.push(activity); const typeName = activity.constructor.name; const listeners = this.inboxListeners.get(typeName) || []; if (listeners.length > 0 && this.contextData === void 0) 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, federation: this }); 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() { this.sentActivities = []; } }; /** * 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 */ var MockContext = class MockContext { origin; canonicalOrigin; host; hostname; data; federation; documentLoader; contextLoader; tracerProvider; sentActivities = []; constructor(options) { 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; this.documentLoader = options.documentLoader ?? (async (url$1) => ({ contextUrl: null, document: {}, documentUrl: url$1 })); this.contextLoader = options.contextLoader ?? this.documentLoader; this.tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); } clone(data) { return new MockContext({ url: new URL(this.origin), data, federation: this.federation, documentLoader: this.documentLoader, contextLoader: this.contextLoader, tracerProvider: this.tracerProvider }); } getNodeInfoUri() { 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) { 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(cls, values) { if (this.federation instanceof MockFederation) { const pathTemplate = this.federation.objectPaths.get(cls.typeId.href); if (pathTemplate) { const path$1 = expandUriTemplate(pathTemplate, values); return new URL(path$1, 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { return Promise.resolve([]); } getDocumentLoader(params) { if ("keyId" in params) return this.documentLoader; return Promise.resolve(this.documentLoader); } lookupObject(_uri, _options) { return Promise.resolve(null); } traverseCollection(_collection, _options) { return { async *[Symbol.asyncIterator]() {} }; } lookupNodeInfo(_url, _options) { return Promise.resolve(void 0); } lookupWebFinger(_resource, _options) { return Promise.resolve(null); } sendActivity(sender, recipients, activity, _options) { this.sentActivities.push({ sender, recipients, activity }); if (this.federation instanceof MockFederation) { const queued = this.federation.queueStarted; this.federation.sentActivities.push({ queued, queue: queued ? "outbox" : void 0, activity, sentOrder: ++this.federation.sentCounter }); } return Promise.resolve(); } routeActivity(_recipient, _activity, _options) { 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() { 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() { this.sentActivities = []; } }; //#endregion export { MockContext, MockFederation };