@fedify/testing
Version:
Testing utilities for Fedify applications
612 lines (608 loc) • 18.7 kB
JavaScript
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 };