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