UNPKG

@fedify/testing

Version:

Testing utilities for Fedify applications

1,060 lines (1,059 loc) 36.3 kB
const { Temporal } = require("@js-temporal/polyfill"); Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); let _fedify_fedify_sig = require("@fedify/fedify/sig"); let _fedify_vocab = require("@fedify/vocab"); let _fedify_fedify_federation = require("@fedify/fedify/federation"); let es_toolkit = require("es-toolkit"); let node_assert_strict = require("node:assert/strict"); //#region src/docloader.ts const mockDocumentLoader = async (url) => ({ contextUrl: null, document: {}, documentUrl: url }); //#endregion //#region src/context.ts const noopTracerProvider$1 = { getTracer: () => ({ startActiveSpan: () => void 0, startSpan: () => void 0 }) }; function createContext(values) { const { federation, url = new URL("http://example.com/"), canonicalOrigin, data, documentLoader, contextLoader, tracerProvider, clone, getNodeInfoUri, getActorUri, getObjectUri, getCollectionUri, getOutboxUri, getInboxUri, getFollowingUri, getFollowersUri, getLikedUri, getFeaturedUri, getFeaturedTagsUri, parseUri, getActorKeyPairs, getDocumentLoader, lookupObject, traverseCollection, lookupNodeInfo, lookupWebFinger, sendActivity, routeActivity } = values; function throwRouterError() { throw new _fedify_fedify_federation.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 ?? noopTracerProvider$1, clone: clone ?? ((data) => createContext({ ...values, data })), getNodeInfoUri: getNodeInfoUri ?? throwRouterError, getActorUri: getActorUri ?? throwRouterError, getObjectUri: getObjectUri ?? throwRouterError, getCollectionUri: getCollectionUri ?? throwRouterError, getOutboxUri: getOutboxUri ?? throwRouterError, getInboxUri: getInboxUri ?? throwRouterError, getFollowingUri: getFollowingUri ?? throwRouterError, getFollowersUri: getFollowersUri ?? throwRouterError, getLikedUri: getLikedUri ?? throwRouterError, getFeaturedUri: getFeaturedUri ?? throwRouterError, getFeaturedTagsUri: getFeaturedTagsUri ?? throwRouterError, parseUri: parseUri ?? ((_uri) => { throw new Error("Not implemented"); }), getDocumentLoader: getDocumentLoader ?? ((_params) => { throw new Error("Not implemented"); }), getActorKeyPairs: getActorKeyPairs ?? ((_handle) => Promise.resolve([])), lookupObject: lookupObject ?? ((uri, options = {}) => { return (0, _fedify_vocab.lookupObject)(uri, { documentLoader: options.documentLoader ?? documentLoader ?? mockDocumentLoader, contextLoader: options.contextLoader ?? contextLoader ?? mockDocumentLoader }); }), traverseCollection: traverseCollection ?? ((collection, options = {}) => { return (0, _fedify_vocab.traverseCollection)(collection, { documentLoader: options.documentLoader ?? documentLoader ?? mockDocumentLoader, contextLoader: options.contextLoader ?? contextLoader ?? mockDocumentLoader }); }), lookupNodeInfo: lookupNodeInfo ?? ((_params) => { throw new Error("Not implemented"); }), lookupWebFinger: lookupWebFinger ?? ((_resource, _options = {}) => { return Promise.resolve(null); }), sendActivity: sendActivity ?? ((_params) => { throw new Error("Not implemented"); }), routeActivity: routeActivity ?? ((_params) => { throw new Error("Not implemented"); }) }; } /** * Creates a RequestContext for testing purposes. * Not exported - used internally only. Public API is in mock.ts * @param args Partial RequestContext properties * @returns A RequestContext instance * @since 1.8.0 */ 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"); }) }; } /** * Creates an InboxContext for testing purposes. * Not exported - used internally only. Public API is in mock.ts * @param args Partial InboxContext properties * @returns An InboxContext instance * @since 1.8.0 */ function createInboxContext(args) { const forwardActivity = args.forwardActivity ?? ((_forwarder, _recipients, _options) => { throw new Error("Not implemented"); }); return { ...createContext(args), clone: args.clone ?? ((data) => createInboxContext({ ...args, data })), recipient: args.recipient ?? null, forwardActivity }; } /** * Creates an OutboxContext for testing purposes. * Not exported - used internally only. Public API is in mock.ts * @param args Partial OutboxContext properties * @returns An OutboxContext instance * @since 2.2.0 */ function createOutboxContext(args) { const forwardActivity = args.forwardActivity ?? ((_forwarder, _recipients, _options) => { throw new Error("Not implemented"); }); return { ...createContext(args), clone: args.clone ?? ((data) => createOutboxContext({ ...args, data })), identifier: args.identifier, hasDeliveredActivity: args.hasDeliveredActivity ?? (() => false), forwardActivity }; } //#endregion //#region src/mock.ts const noopTracerProvider = { getTracer: () => ({ startActiveSpan: () => void 0, startSpan: () => void 0 }) }; /** * Helper function to expand URI templates used by the mock. * Supports the RFC 6570 operators accepted by Fedify's identifier paths. * @param template The URI template pattern * @param values The values to substitute * @returns The expanded URI path */ function expandUriTemplate(template, values) { return template.replace(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g, (match, operator, key) => { const value = values[key]; if (value == null) return match; switch (operator) { case "": return encodeURIComponent(value); case "+": return encodeURI(value); case "#": return `#${encodeURI(value)}`; case ".": return `.${encodeURIComponent(value)}`; case "/": return `/${encodeURIComponent(value)}`; case ";": return `;${key}=${encodeURIComponent(value)}`; case "?": return `?${key}=${encodeURIComponent(value)}`; case "&": return `&${key}=${encodeURIComponent(value)}`; default: return match; } }); } function validateOutboxListenerPath(path, dispatcherPath) { if (!path.startsWith("/")) throw new TypeError("Path must start with a slash."); if (dispatcherPath != null && dispatcherPath !== path) throw new TypeError("Outbox listener path and outbox dispatcher path must match."); const operatorMatches = globalThis.Array.from(path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g)); if (operatorMatches.some((match) => [ "?", "&", "#" ].includes(match[1]) && match[2] === "identifier")) throw new TypeError("Path for outbox cannot use query or fragment expansion for identifier."); const variables = operatorMatches.map((match) => match[2]); if (variables.length !== 1 || variables[0] !== "identifier") throw new TypeError("Path for outbox must have exactly one variable named identifier."); } /** * 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/vocab"; * import { createFederation } from "@fedify/testing"; * * // Create a mock federation with contextData * const federation = createFederation<{ userId: string }>({ * contextData: { userId: "test-user" } * }); * * // Set up inbox listeners * federation * .setInboxListeners("/users/{identifier}/inbox") * .on(Create, async (ctx: any, activity: any) => { * 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); * ``` * * @template 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; webFingerDispatcher; actorDispatchers = /* @__PURE__ */ new Map(); actorKeyPairsDispatcher; actorPath; inboxPath; outboxPath; followingPath; followersPath; likedPath; featuredPath; featuredTagsPath; nodeInfoPath; sharedInboxPath; objectPaths = /* @__PURE__ */ new Map(); objectDispatchers = /* @__PURE__ */ new Map(); inboxDispatcher; outboxDispatcher; outboxAuthorizePredicate; outboxDispatcherAuthorizePredicate; outboxListenerErrorHandler; followingDispatcher; followersDispatcher; likedDispatcher; featuredDispatcher; featuredTagsDispatcher; inboxListeners = /* @__PURE__ */ new Map(); outboxListeners = /* @__PURE__ */ new Map(); outboxListenersInitialized = false; contextData; receivedActivities = []; constructor(options = {}) { this.options = options; this.contextData = options.contextData; } setNodeInfoDispatcher(path, dispatcher) { this.nodeInfoDispatcher = dispatcher; this.nodeInfoPath = path; } setWebFingerLinksDispatcher(dispatcher) { this.webFingerDispatcher = dispatcher; } setActorDispatcher(path, dispatcher) { this.actorDispatchers.set(path, dispatcher); this.actorPath = path; return { setKeyPairsDispatcher: (keyPairsDispatcher) => { this.actorKeyPairsDispatcher = keyPairsDispatcher; return 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) { validateOutboxListenerPath(path, this.outboxListenersInitialized ? this.outboxPath : void 0); this.outboxDispatcher = dispatcher; this.outboxPath = path; return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: (predicate) => { this.outboxDispatcherAuthorizePredicate = predicate; return 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; }, onUnverifiedActivity() { return this; }, setSharedKeyDispatcher() { return this; }, withIdempotency() { return this; } }; } setOutboxListeners(outboxPath) { if (this.outboxListenersInitialized) throw new TypeError("Outbox listeners already set."); validateOutboxListenerPath(outboxPath, this.outboxPath); this.outboxListenersInitialized = true; this.outboxPath = outboxPath; const self = this; return { on(type, listener) { if (self.outboxListeners.has(type)) throw new TypeError("Listener already set for this type."); self.outboxListeners.set(type, listener); return this; }, onError(handler) { self.outboxListenerErrorHandler = handler; return this; }, authorize(predicate) { self.outboxAuthorizePredicate = predicate; return this; } }; } setOutboxPermanentFailureHandler(_handler) {} 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; const request = baseUrlOrRequest instanceof Request ? baseUrlOrRequest : null; return new MockContext({ url: request == null ? baseUrlOrRequest : new URL(request.url), request, 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) await listener(createInboxContext({ data: this.contextData, federation: this }), activity); } /** * Simulates posting an activity to a local actor outbox. * This method is specific to the mock implementation and is used for * testing purposes. * * @param identifier The identifier of the outbox owner. * @param activity The activity to post. * @returns A promise that resolves when the activity has been processed. * @since 2.2.0 */ async postOutboxActivity(identifier, activity) { if (!this.outboxListenersInitialized) throw new Error("MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized."); let ctor = activity.constructor; let listener = this.outboxListeners.get(ctor); while (listener == null && ctor !== _fedify_vocab.Activity) { ctor = globalThis.Object.getPrototypeOf(ctor); listener = this.outboxListeners.get(ctor); } if (listener != null && this.contextData === void 0) throw new Error("MockFederation.postOutboxActivity(): contextData is not initialized. Please provide contextData through the constructor or call startQueue() before posting activities."); const origin = new URL(this.options.origin ?? "https://example.com"); const routingContext = this.createContext(origin, this.contextData); const postedJson = await activity.toJsonLd({ contextLoader: routingContext.contextLoader }); const request = new Request(routingContext.getOutboxUri(identifier), { method: "POST", body: JSON.stringify(postedJson), headers: { "content-type": "application/activity+json" } }); const baseContext = this.createContext(request, this.contextData); const rawActivity = postedJson; const deliveryState = { delivered: false }; const createMockOutboxContext = () => createOutboxContext({ ...baseContext, clone: void 0, federation: this, identifier, hasDeliveredActivity: () => deliveryState.delivered, sendActivity: async (sender, recipients, outboundActivity, options) => { await baseContext.sendActivity(sender, recipients, outboundActivity, options); deliveryState.delivered = true; }, forwardActivity: async (forwarder, recipients, options) => { const hasProof = (0, _fedify_fedify_sig.hasProofLike)(rawActivity); const hasLds = (0, _fedify_fedify_sig.hasSignatureLike)(rawActivity); if (options?.skipIfUnsigned && !hasProof && !hasLds) return; await baseContext.sendActivity(forwarder, recipients, activity, { ...options, rawActivity }); deliveryState.delivered = true; } }); const actor = await baseContext.getActor(identifier); if (actor == null) throw new Error(`Actor ${JSON.stringify(identifier)} not found.`); const authorizePredicate = this.outboxAuthorizePredicate ?? this.outboxDispatcherAuthorizePredicate; if (authorizePredicate != null && !await authorizePredicate(baseContext, identifier)) throw new Error("Unauthorized."); const expectedActorId = actor.id ?? baseContext.getActorUri(identifier); if (activity.actorIds.length < 1) { const error = /* @__PURE__ */ new Error("The posted activity has no actor."); await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error); throw error; } if (!activity.actorIds.every((actorId) => actorId.href === expectedActorId.href)) { const error = /* @__PURE__ */ new Error("The activity actor does not match the outbox owner."); await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error); throw error; } if (listener == null) return; const context = createMockOutboxContext(); try { await listener(context, activity); } catch (error) { await this.outboxListenerErrorHandler?.(context, error); throw error; } } /** * 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 = []; } setCollectionDispatcher(_name, _itemType, _path, _dispatcher) { return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } setOrderedCollectionDispatcher(_name, _itemType, _path, _dispatcher) { return { setCounter: () => this, setFirstCursor: () => this, setLastCursor: () => this, authorize: () => this }; } }; /** * Creates a mock Federation instance for testing purposes. * * @template TContextData The type of context data to use * @param options Optional configuration for the mock federation * @returns A Federation instance that can be used for testing * @since 1.9.1 * * @example * ```typescript * import { Create } from "@fedify/vocab"; * import { createFederation } from "@fedify/testing"; * * // Create a mock federation with contextData * const federation = createFederation<{ 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); * * // Check sent activities * console.log(federation.sentActivities); * ``` */ function createFederation(options = {}) { return new MockFederation(options); } /** * 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. * * Note: This class is not exported from the public API to avoid JSR type * analyzer issues. The MockContext class has complex type dependencies that * can cause JSR's type analyzer to hang during processing (issue #468). * Use {@link MockFederation.createContext}, {@link createContext}, * {@link createRequestContext}, or {@link createInboxContext} instead. * * @example * ```typescript * import { Person, Create } from "@fedify/vocab"; * import { createFederation } from "@fedify/testing"; * * // Create a mock federation and context * const federation = createFederation<{ userId: string }>(); * const context = federation.createContext( * new URL("https://example.com"), * { userId: "test-user" } * ); * * // 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 from the federation * const sent = federation.sentActivities; * console.log(sent[0].activity); * ``` * * @template 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; request; url; 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.url = url; this.request = options.request ?? new Request(url); this.data = options.data; this.federation = options.federation; this.documentLoader = options.documentLoader ?? (async (url) => ({ contextUrl: null, document: {}, documentUrl: url })); this.contextLoader = options.contextLoader ?? this.documentLoader; this.tracerProvider = options.tracerProvider ?? noopTracerProvider; } async getActor(handle) { if (this.federation instanceof MockFederation && this.federation.actorPath) { const dispatcher = this.federation.actorDispatchers.get(this.federation.actorPath); if (dispatcher) return await dispatcher(this, handle); } return null; } async getObject(cls, values) { if (this.federation instanceof MockFederation) { const path = this.federation.objectPaths.get(cls.typeId.href); if (path) { const dispatcher = this.federation.objectDispatchers.get(path); if (dispatcher) return await dispatcher(this, values); } } return null; } getSignedKey() { return Promise.resolve(null); } getSignedKeyOwner() { return Promise.resolve(null); } clone(data) { return new MockContext({ url: this.url, 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 }); 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 = 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) { if (this.federation instanceof MockFederation && this.federation.outboxPath) { const path = expandUriTemplate(this.federation.outboxPath, { 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 }); 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 }); 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 }); 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 }); 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 }); 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 }); return new URL(path, this.origin); } return new URL(`/users/${identifier}/tags`, this.origin); } getCollectionUri(_name, values) { const path = globalThis.Object.entries(values).map(([key, value]) => `${key}/${value}`).join("/"); return new URL(`/collections/${String(_name)}/${path}`, this.origin); } parseUri(uri) { if (uri.pathname.startsWith("/users/")) { const parts = uri.pathname.split("/"); if (parts.length >= 3) return { type: "actor", identifier: parts[2] }; } return null; } async getActorKeyPairs(identifier) { if (this.federation instanceof MockFederation && this.federation.actorKeyPairsDispatcher) { const keyPairs = await this.federation.actorKeyPairsDispatcher(this, identifier); const owner = this.getActorUri(identifier); return keyPairs.map((kp) => ({ ...kp, cryptographicKey: new _fedify_vocab.CryptographicKey({ id: kp.keyId, owner, publicKey: kp.publicKey }), multikey: new _fedify_vocab.Multikey({ id: kp.keyId, controller: owner, publicKey: kp.publicKey }) })); } return []; } 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, rawActivity: options?.rawActivity }); if (this.federation instanceof MockFederation) { const queued = this.federation.queueStarted; this.federation.sentActivities.push({ queued, queue: queued ? "outbox" : void 0, activity, rawActivity: options?.rawActivity, 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 //#region src/mq-tester.ts /** * Tests a {@link MessageQueue} implementation with a standard set of tests. * * This function runs tests for: * - `enqueue()`: Basic message enqueueing * - `enqueue()` with delay: Delayed message enqueueing * - `enqueueMany()`: Bulk message enqueueing * - `enqueueMany()` with delay: Delayed bulk message enqueueing * - Multiple listeners: Ensures messages are processed by only one listener * - Ordering key support (optional): Ensures messages with the same ordering * key are processed in order * * @example * ```typescript ignore * import { test } from "@fedify/fixture"; * import { testMessageQueue } from "@fedify/testing"; * import { MyMessageQueue } from "./my-mq.ts"; * * test("MyMessageQueue", () => * testMessageQueue( * () => new MyMessageQueue(), * async ({ mq1, mq2, controller }) => { * controller.abort(); * await mq1.close(); * await mq2.close(); * }, * { testOrderingKey: true }, // Enable ordering key tests * ) * ); * ``` * * @param getMessageQueue A factory function that creates a new message queue * instance. It should return a new instance each time * to ensure test isolation, but both instances should * share the same underlying storage/channel. * @param onFinally A cleanup function called after all tests complete. * It receives both message queue instances and the abort * controller used for the listeners. * @param options Optional configuration for the test suite. * @returns A promise that resolves when all tests pass. */ async function testMessageQueue(getMessageQueue, onFinally, options = {}) { const mq1 = await getMessageQueue(); const mq2 = await getMessageQueue(); const controller = new AbortController(); try { const messages = []; const listening1 = mq1.listen((message) => { messages.push(message); }, { signal: controller.signal }); const listening2 = mq2.listen((message) => { messages.push(message); }, { signal: controller.signal }); await (0, es_toolkit.delay)(1e3); await mq1.enqueue("Hello, world!"); await waitFor(() => messages.length > 0, 15e3); (0, node_assert_strict.deepStrictEqual)(messages, ["Hello, world!"]); let started = Date.now(); await mq1.enqueue("Delayed message", { delay: Temporal.Duration.from({ seconds: 3 }) }); await waitFor(() => messages.length > 1, 15e3); (0, node_assert_strict.deepStrictEqual)(messages, ["Hello, world!", "Delayed message"]); (0, node_assert_strict.ok)(Date.now() - started >= 3e3, "Delayed message should be delivered after at least 3 seconds"); if (mq1.enqueueMany != null) { while (messages.length > 0) messages.pop(); const batchMessages = [ "First batch message", "Second batch message", "Third batch message" ]; await mq1.enqueueMany(batchMessages); await waitFor(() => messages.length >= batchMessages.length, 15e3); (0, node_assert_strict.deepStrictEqual)(new Set(messages), new Set(batchMessages)); while (messages.length > 0) messages.pop(); started = Date.now(); const delayedBatchMessages = ["Delayed batch 1", "Delayed batch 2"]; await mq1.enqueueMany(delayedBatchMessages, { delay: Temporal.Duration.from({ seconds: 2 }) }); await waitFor(() => messages.length >= delayedBatchMessages.length, 15e3); (0, node_assert_strict.deepStrictEqual)(new Set(messages), new Set(delayedBatchMessages)); (0, node_assert_strict.ok)(Date.now() - started >= 2e3, "Delayed batch messages should be delivered after at least 2 seconds"); } while (messages.length > 0) messages.pop(); const bulkCount = 100; for (let i = 0; i < bulkCount; i++) await mq1.enqueue(`message-${i}`); await waitFor(() => messages.length >= bulkCount, 3e4); const expectedMessages = new Set(Array.from({ length: bulkCount }, (_, i) => `message-${i}`)); (0, node_assert_strict.deepStrictEqual)(new Set(messages), expectedMessages); if (options.testOrderingKey) { while (messages.length > 0) messages.pop(); const orderTracker = { keyA: [], keyB: [], noKey: [] }; controller.abort(); await listening1; await listening2; const orderController = new AbortController(); const orderMessages = []; const orderListening1 = mq1.listen((message) => { orderMessages.push(message); const trackKey = message.key ?? "noKey"; if (trackKey in orderTracker) orderTracker[trackKey].push(message.value); }, { signal: orderController.signal }); const orderListening2 = mq2.listen((message) => { orderMessages.push(message); const trackKey = message.key ?? "noKey"; if (trackKey in orderTracker) orderTracker[trackKey].push(message.value); }, { signal: orderController.signal }); await (0, es_toolkit.delay)(1e3); await mq1.enqueue({ key: "keyA", value: 1 }, { orderingKey: "keyA" }); await mq1.enqueue({ key: "keyB", value: 1 }, { orderingKey: "keyB" }); await mq1.enqueue({ key: "keyA", value: 2 }, { orderingKey: "keyA" }); await mq1.enqueue({ key: "keyB", value: 2 }, { orderingKey: "keyB" }); await mq1.enqueue({ key: "keyA", value: 3 }, { orderingKey: "keyA" }); await mq1.enqueue({ key: "keyB", value: 3 }, { orderingKey: "keyB" }); await mq1.enqueue({ key: null, value: 1 }); await mq1.enqueue({ key: null, value: 2 }); await waitFor(() => orderMessages.length >= 8, 3e4); (0, node_assert_strict.deepStrictEqual)(orderTracker.keyA, [ 1, 2, 3 ], "Messages with orderingKey 'keyA' should be processed in order"); (0, node_assert_strict.deepStrictEqual)(orderTracker.keyB, [ 1, 2, 3 ], "Messages with orderingKey 'keyB' should be processed in order"); (0, node_assert_strict.strictEqual)(orderTracker.noKey.length, 2, "Messages without ordering key should all be received"); (0, node_assert_strict.ok)(orderTracker.noKey.includes(1) && orderTracker.noKey.includes(2), "Messages without ordering key should contain values 1 and 2"); orderController.abort(); await orderListening1; await orderListening2; } else { controller.abort(); await listening1; await listening2; } } finally { await onFinally({ mq1, mq2, controller }); } } async function waitFor(predicate, timeoutMs) { const started = Date.now(); while (!predicate()) { await (0, es_toolkit.delay)(500); if (Date.now() - started > timeoutMs) throw new Error("Timeout"); } } const getRandomKey = (prefix) => `fedify_test_${prefix}_${crypto.randomUUID()}`; //#endregion exports.createContext = createContext; exports.createFederation = createFederation; exports.createInboxContext = createInboxContext; exports.createOutboxContext = createOutboxContext; exports.createRequestContext = createRequestContext; exports.getRandomKey = getRandomKey; exports.testMessageQueue = testMessageQueue; exports.waitFor = waitFor;