@fedify/testing
Version:
Testing utilities for Fedify applications
1,060 lines (1,059 loc) • 36.3 kB
JavaScript
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;