@fedify/testing 1.8.1-pr.283.1138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +101 -0
- package/context.ts +154 -0
- package/deno.json +41 -0
- package/dist/mod.d.ts +274 -0
- package/dist/mod.js +612 -0
- package/docloader.ts +8 -0
- package/mock.test.ts +361 -0
- package/mock.ts +953 -0
- package/mod.ts +12 -0
- package/package.json +64 -0
- package/tsdown.config.ts +16 -0
package/mock.ts
ADDED
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
// deno-lint-ignore-file no-explicit-any
|
|
2
|
+
import type {
|
|
3
|
+
ActorCallbackSetters,
|
|
4
|
+
ActorDispatcher,
|
|
5
|
+
ActorKeyPair,
|
|
6
|
+
CollectionCallbackSetters,
|
|
7
|
+
CollectionDispatcher,
|
|
8
|
+
Context,
|
|
9
|
+
Federation,
|
|
10
|
+
FederationFetchOptions,
|
|
11
|
+
FederationStartQueueOptions,
|
|
12
|
+
InboxContext,
|
|
13
|
+
InboxListenerSetters,
|
|
14
|
+
Message,
|
|
15
|
+
NodeInfoDispatcher,
|
|
16
|
+
ObjectCallbackSetters,
|
|
17
|
+
ObjectDispatcher,
|
|
18
|
+
ParseUriResult,
|
|
19
|
+
RequestContext,
|
|
20
|
+
RouteActivityOptions,
|
|
21
|
+
SendActivityOptions,
|
|
22
|
+
SendActivityOptionsForCollection,
|
|
23
|
+
SenderKeyPair,
|
|
24
|
+
} from "@fedify/fedify/federation";
|
|
25
|
+
import type { JsonValue, NodeInfo } from "@fedify/fedify/nodeinfo";
|
|
26
|
+
import type { DocumentLoader } from "@fedify/fedify/runtime";
|
|
27
|
+
import type {
|
|
28
|
+
Activity,
|
|
29
|
+
Actor,
|
|
30
|
+
Collection,
|
|
31
|
+
Hashtag,
|
|
32
|
+
LookupObjectOptions,
|
|
33
|
+
Object,
|
|
34
|
+
Recipient,
|
|
35
|
+
TraverseCollectionOptions,
|
|
36
|
+
} from "@fedify/fedify/vocab";
|
|
37
|
+
import type { ResourceDescriptor } from "@fedify/fedify/webfinger";
|
|
38
|
+
import { trace, type TracerProvider } from "@opentelemetry/api";
|
|
39
|
+
import { createInboxContext, createRequestContext } from "./context.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Helper function to expand URI templates with values.
|
|
43
|
+
* Supports simple placeholders like {identifier}, {handle}, etc.
|
|
44
|
+
* @param template The URI template pattern
|
|
45
|
+
* @param values The values to substitute
|
|
46
|
+
* @returns The expanded URI path
|
|
47
|
+
*/
|
|
48
|
+
function expandUriTemplate(
|
|
49
|
+
template: string,
|
|
50
|
+
values: Record<string, string>,
|
|
51
|
+
): string {
|
|
52
|
+
return template.replace(/{([^}]+)}/g, (match, key) => {
|
|
53
|
+
return values[key] || match;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Represents a sent activity with metadata about how it was sent.
|
|
59
|
+
* @since 1.8.0
|
|
60
|
+
*/
|
|
61
|
+
export interface SentActivity {
|
|
62
|
+
/** Whether the activity was queued or sent immediately. */
|
|
63
|
+
queued: boolean;
|
|
64
|
+
/** Which queue was used (if queued). */
|
|
65
|
+
queue?: "inbox" | "outbox" | "fanout";
|
|
66
|
+
/** The activity that was sent. */
|
|
67
|
+
activity: Activity;
|
|
68
|
+
/** The order in which the activity was sent (auto-incrementing counter). */
|
|
69
|
+
sentOrder: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A mock implementation of the {@link Federation} interface for unit testing.
|
|
74
|
+
* This class provides a way to test Fedify applications without needing
|
|
75
|
+
* a real federation setup.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```typescript
|
|
79
|
+
* import { Create } from "@fedify/fedify/vocab";
|
|
80
|
+
* import { MockFederation } from "@fedify/testing";
|
|
81
|
+
*
|
|
82
|
+
* // Create a mock federation with contextData
|
|
83
|
+
* const federation = new MockFederation<{ userId: string }>({
|
|
84
|
+
* contextData: { userId: "test-user" }
|
|
85
|
+
* });
|
|
86
|
+
*
|
|
87
|
+
* // Set up inbox listeners
|
|
88
|
+
* federation
|
|
89
|
+
* .setInboxListeners("/users/{identifier}/inbox")
|
|
90
|
+
* .on(Create, async (ctx, activity) => {
|
|
91
|
+
* console.log("Received:", activity);
|
|
92
|
+
* });
|
|
93
|
+
*
|
|
94
|
+
* // Simulate receiving an activity
|
|
95
|
+
* const createActivity = new Create({
|
|
96
|
+
* id: new URL("https://example.com/create/1"),
|
|
97
|
+
* actor: new URL("https://example.com/users/alice")
|
|
98
|
+
* });
|
|
99
|
+
* await federation.receiveActivity(createActivity);
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @typeParam TContextData The context data to pass to the {@link Context}.
|
|
103
|
+
* @since 1.8.0
|
|
104
|
+
*/
|
|
105
|
+
export class MockFederation<TContextData> implements Federation<TContextData> {
|
|
106
|
+
public sentActivities: SentActivity[] = [];
|
|
107
|
+
public queueStarted = false;
|
|
108
|
+
private activeQueues: Set<"inbox" | "outbox" | "fanout"> = new Set();
|
|
109
|
+
public sentCounter = 0;
|
|
110
|
+
private nodeInfoDispatcher?: NodeInfoDispatcher<TContextData>;
|
|
111
|
+
private actorDispatchers: Map<string, ActorDispatcher<TContextData>> =
|
|
112
|
+
new Map();
|
|
113
|
+
public actorPath?: string;
|
|
114
|
+
public inboxPath?: string;
|
|
115
|
+
public outboxPath?: string;
|
|
116
|
+
public followingPath?: string;
|
|
117
|
+
public followersPath?: string;
|
|
118
|
+
public likedPath?: string;
|
|
119
|
+
public featuredPath?: string;
|
|
120
|
+
public featuredTagsPath?: string;
|
|
121
|
+
public nodeInfoPath?: string;
|
|
122
|
+
public sharedInboxPath?: string;
|
|
123
|
+
public objectPaths: Map<string, string> = new Map();
|
|
124
|
+
private objectDispatchers: Map<
|
|
125
|
+
string,
|
|
126
|
+
ObjectDispatcher<TContextData, Object, string>
|
|
127
|
+
> = new Map();
|
|
128
|
+
private inboxDispatcher?: CollectionDispatcher<
|
|
129
|
+
Activity,
|
|
130
|
+
RequestContext<TContextData>,
|
|
131
|
+
TContextData,
|
|
132
|
+
void
|
|
133
|
+
>;
|
|
134
|
+
private outboxDispatcher?: CollectionDispatcher<
|
|
135
|
+
Activity,
|
|
136
|
+
RequestContext<TContextData>,
|
|
137
|
+
TContextData,
|
|
138
|
+
void
|
|
139
|
+
>;
|
|
140
|
+
private followingDispatcher?: CollectionDispatcher<
|
|
141
|
+
Actor | URL,
|
|
142
|
+
RequestContext<TContextData>,
|
|
143
|
+
TContextData,
|
|
144
|
+
void
|
|
145
|
+
>;
|
|
146
|
+
private followersDispatcher?: CollectionDispatcher<
|
|
147
|
+
Recipient,
|
|
148
|
+
Context<TContextData>,
|
|
149
|
+
TContextData,
|
|
150
|
+
URL
|
|
151
|
+
>;
|
|
152
|
+
private likedDispatcher?: CollectionDispatcher<
|
|
153
|
+
Object | URL,
|
|
154
|
+
RequestContext<TContextData>,
|
|
155
|
+
TContextData,
|
|
156
|
+
void
|
|
157
|
+
>;
|
|
158
|
+
private featuredDispatcher?: CollectionDispatcher<
|
|
159
|
+
Object,
|
|
160
|
+
RequestContext<TContextData>,
|
|
161
|
+
TContextData,
|
|
162
|
+
void
|
|
163
|
+
>;
|
|
164
|
+
private featuredTagsDispatcher?: CollectionDispatcher<
|
|
165
|
+
Hashtag,
|
|
166
|
+
RequestContext<TContextData>,
|
|
167
|
+
TContextData,
|
|
168
|
+
void
|
|
169
|
+
>;
|
|
170
|
+
private inboxListeners: Map<string, InboxListener<TContextData, Activity>[]> =
|
|
171
|
+
new Map();
|
|
172
|
+
private contextData?: TContextData;
|
|
173
|
+
private receivedActivities: Activity[] = [];
|
|
174
|
+
|
|
175
|
+
constructor(
|
|
176
|
+
private options: {
|
|
177
|
+
contextData?: TContextData;
|
|
178
|
+
origin?: string;
|
|
179
|
+
tracerProvider?: TracerProvider;
|
|
180
|
+
} = {},
|
|
181
|
+
) {
|
|
182
|
+
this.contextData = options.contextData;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
setNodeInfoDispatcher(
|
|
186
|
+
path: string,
|
|
187
|
+
dispatcher: NodeInfoDispatcher<TContextData>,
|
|
188
|
+
): void {
|
|
189
|
+
this.nodeInfoDispatcher = dispatcher;
|
|
190
|
+
this.nodeInfoPath = path;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
setActorDispatcher(
|
|
194
|
+
path: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
195
|
+
dispatcher: ActorDispatcher<TContextData>,
|
|
196
|
+
): ActorCallbackSetters<TContextData> {
|
|
197
|
+
this.actorDispatchers.set(path, dispatcher);
|
|
198
|
+
this.actorPath = path;
|
|
199
|
+
return {
|
|
200
|
+
setKeyPairsDispatcher: () => this as any,
|
|
201
|
+
mapHandle: () => this as any,
|
|
202
|
+
mapAlias: () => this as any,
|
|
203
|
+
authorize: () => this as any,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setObjectDispatcher<TObject extends Object, TParam extends string>(
|
|
208
|
+
cls: (new (...args: any[]) => TObject) & { typeId: URL },
|
|
209
|
+
path: string,
|
|
210
|
+
dispatcher: ObjectDispatcher<TContextData, TObject, TParam>,
|
|
211
|
+
): ObjectCallbackSetters<TContextData, TObject, TParam> {
|
|
212
|
+
this.objectDispatchers.set(path, dispatcher);
|
|
213
|
+
this.objectPaths.set(cls.typeId.href, path);
|
|
214
|
+
return {
|
|
215
|
+
authorize: () => this as any,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setInboxDispatcher(
|
|
220
|
+
_path: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
221
|
+
dispatcher: CollectionDispatcher<
|
|
222
|
+
Activity,
|
|
223
|
+
RequestContext<TContextData>,
|
|
224
|
+
TContextData,
|
|
225
|
+
void
|
|
226
|
+
>,
|
|
227
|
+
): CollectionCallbackSetters<
|
|
228
|
+
RequestContext<TContextData>,
|
|
229
|
+
TContextData,
|
|
230
|
+
void
|
|
231
|
+
> {
|
|
232
|
+
this.inboxDispatcher = dispatcher;
|
|
233
|
+
// Note: inboxPath is set in setInboxListeners
|
|
234
|
+
return {
|
|
235
|
+
setCounter: () => this as any,
|
|
236
|
+
setFirstCursor: () => this as any,
|
|
237
|
+
setLastCursor: () => this as any,
|
|
238
|
+
authorize: () => this as any,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
setOutboxDispatcher(
|
|
243
|
+
path: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
244
|
+
dispatcher: CollectionDispatcher<
|
|
245
|
+
Activity,
|
|
246
|
+
RequestContext<TContextData>,
|
|
247
|
+
TContextData,
|
|
248
|
+
void
|
|
249
|
+
>,
|
|
250
|
+
): CollectionCallbackSetters<
|
|
251
|
+
RequestContext<TContextData>,
|
|
252
|
+
TContextData,
|
|
253
|
+
void
|
|
254
|
+
> {
|
|
255
|
+
this.outboxDispatcher = dispatcher;
|
|
256
|
+
this.outboxPath = path;
|
|
257
|
+
return {
|
|
258
|
+
setCounter: () => this as any,
|
|
259
|
+
setFirstCursor: () => this as any,
|
|
260
|
+
setLastCursor: () => this as any,
|
|
261
|
+
authorize: () => this as any,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setFollowingDispatcher(
|
|
266
|
+
path: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
267
|
+
dispatcher: CollectionDispatcher<
|
|
268
|
+
Actor | URL,
|
|
269
|
+
RequestContext<TContextData>,
|
|
270
|
+
TContextData,
|
|
271
|
+
void
|
|
272
|
+
>,
|
|
273
|
+
): CollectionCallbackSetters<
|
|
274
|
+
RequestContext<TContextData>,
|
|
275
|
+
TContextData,
|
|
276
|
+
void
|
|
277
|
+
> {
|
|
278
|
+
this.followingDispatcher = dispatcher;
|
|
279
|
+
this.followingPath = path;
|
|
280
|
+
return {
|
|
281
|
+
setCounter: () => this as any,
|
|
282
|
+
setFirstCursor: () => this as any,
|
|
283
|
+
setLastCursor: () => this as any,
|
|
284
|
+
authorize: () => this as any,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
setFollowersDispatcher(
|
|
289
|
+
path: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
290
|
+
dispatcher: CollectionDispatcher<
|
|
291
|
+
Recipient,
|
|
292
|
+
Context<TContextData>,
|
|
293
|
+
TContextData,
|
|
294
|
+
URL
|
|
295
|
+
>,
|
|
296
|
+
): CollectionCallbackSetters<Context<TContextData>, TContextData, URL> {
|
|
297
|
+
this.followersDispatcher = dispatcher;
|
|
298
|
+
this.followersPath = path;
|
|
299
|
+
return {
|
|
300
|
+
setCounter: () => this as any,
|
|
301
|
+
setFirstCursor: () => this as any,
|
|
302
|
+
setLastCursor: () => this as any,
|
|
303
|
+
authorize: () => this as any,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
setLikedDispatcher(
|
|
308
|
+
path: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
309
|
+
dispatcher: CollectionDispatcher<
|
|
310
|
+
Object | URL,
|
|
311
|
+
RequestContext<TContextData>,
|
|
312
|
+
TContextData,
|
|
313
|
+
void
|
|
314
|
+
>,
|
|
315
|
+
): CollectionCallbackSetters<
|
|
316
|
+
RequestContext<TContextData>,
|
|
317
|
+
TContextData,
|
|
318
|
+
void
|
|
319
|
+
> {
|
|
320
|
+
this.likedDispatcher = dispatcher;
|
|
321
|
+
this.likedPath = path;
|
|
322
|
+
return {
|
|
323
|
+
setCounter: () => this as any,
|
|
324
|
+
setFirstCursor: () => this as any,
|
|
325
|
+
setLastCursor: () => this as any,
|
|
326
|
+
authorize: () => this as any,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
setFeaturedDispatcher(
|
|
331
|
+
path: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
332
|
+
dispatcher: CollectionDispatcher<
|
|
333
|
+
Object,
|
|
334
|
+
RequestContext<TContextData>,
|
|
335
|
+
TContextData,
|
|
336
|
+
void
|
|
337
|
+
>,
|
|
338
|
+
): CollectionCallbackSetters<
|
|
339
|
+
RequestContext<TContextData>,
|
|
340
|
+
TContextData,
|
|
341
|
+
void
|
|
342
|
+
> {
|
|
343
|
+
this.featuredDispatcher = dispatcher;
|
|
344
|
+
this.featuredPath = path;
|
|
345
|
+
return {
|
|
346
|
+
setCounter: () => this as any,
|
|
347
|
+
setFirstCursor: () => this as any,
|
|
348
|
+
setLastCursor: () => this as any,
|
|
349
|
+
authorize: () => this as any,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
setFeaturedTagsDispatcher(
|
|
354
|
+
path: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
355
|
+
dispatcher: CollectionDispatcher<
|
|
356
|
+
Hashtag,
|
|
357
|
+
RequestContext<TContextData>,
|
|
358
|
+
TContextData,
|
|
359
|
+
void
|
|
360
|
+
>,
|
|
361
|
+
): CollectionCallbackSetters<
|
|
362
|
+
RequestContext<TContextData>,
|
|
363
|
+
TContextData,
|
|
364
|
+
void
|
|
365
|
+
> {
|
|
366
|
+
this.featuredTagsDispatcher = dispatcher;
|
|
367
|
+
this.featuredTagsPath = path;
|
|
368
|
+
return {
|
|
369
|
+
setCounter: () => this as any,
|
|
370
|
+
setFirstCursor: () => this as any,
|
|
371
|
+
setLastCursor: () => this as any,
|
|
372
|
+
authorize: () => this as any,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
setInboxListeners(
|
|
377
|
+
inboxPath: `${string}{identifier}${string}` | `${string}{handle}${string}`,
|
|
378
|
+
sharedInboxPath?: string,
|
|
379
|
+
): InboxListenerSetters<TContextData> {
|
|
380
|
+
this.inboxPath = inboxPath;
|
|
381
|
+
this.sharedInboxPath = sharedInboxPath;
|
|
382
|
+
// deno-lint-ignore no-this-alias
|
|
383
|
+
const self = this;
|
|
384
|
+
return {
|
|
385
|
+
on<TActivity extends Activity>(
|
|
386
|
+
type: new (...args: any[]) => TActivity,
|
|
387
|
+
listener: InboxListener<TContextData, TActivity>,
|
|
388
|
+
): InboxListenerSetters<TContextData> {
|
|
389
|
+
const typeName = type.name;
|
|
390
|
+
if (!self.inboxListeners.has(typeName)) {
|
|
391
|
+
self.inboxListeners.set(typeName, []);
|
|
392
|
+
}
|
|
393
|
+
self.inboxListeners.get(typeName)!.push(
|
|
394
|
+
listener as InboxListener<TContextData, Activity>,
|
|
395
|
+
);
|
|
396
|
+
return this;
|
|
397
|
+
},
|
|
398
|
+
onError(): InboxListenerSetters<TContextData> {
|
|
399
|
+
return this;
|
|
400
|
+
},
|
|
401
|
+
setSharedKeyDispatcher(): InboxListenerSetters<TContextData> {
|
|
402
|
+
return this;
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// deno-lint-ignore require-await
|
|
408
|
+
async startQueue(
|
|
409
|
+
contextData: TContextData,
|
|
410
|
+
options?: FederationStartQueueOptions,
|
|
411
|
+
): Promise<void> {
|
|
412
|
+
this.contextData = contextData;
|
|
413
|
+
this.queueStarted = true;
|
|
414
|
+
|
|
415
|
+
// If a specific queue is specified, only activate that one
|
|
416
|
+
if (options?.queue) {
|
|
417
|
+
this.activeQueues.add(options.queue);
|
|
418
|
+
} else {
|
|
419
|
+
// If no specific queue, activate all three
|
|
420
|
+
this.activeQueues.add("inbox");
|
|
421
|
+
this.activeQueues.add("outbox");
|
|
422
|
+
this.activeQueues.add("fanout");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// deno-lint-ignore require-await
|
|
427
|
+
async processQueuedTask(
|
|
428
|
+
contextData: TContextData,
|
|
429
|
+
_message: Message,
|
|
430
|
+
): Promise<void> {
|
|
431
|
+
this.contextData = contextData;
|
|
432
|
+
// no queue in mock type. process immediately
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
createContext(baseUrl: URL, contextData: TContextData): Context<TContextData>;
|
|
436
|
+
createContext(
|
|
437
|
+
request: Request,
|
|
438
|
+
contextData: TContextData,
|
|
439
|
+
): RequestContext<TContextData>;
|
|
440
|
+
createContext(
|
|
441
|
+
baseUrlOrRequest: URL | Request,
|
|
442
|
+
contextData: TContextData,
|
|
443
|
+
): Context<TContextData> | RequestContext<TContextData> {
|
|
444
|
+
// deno-lint-ignore no-this-alias
|
|
445
|
+
const mockFederation = this;
|
|
446
|
+
|
|
447
|
+
if (baseUrlOrRequest instanceof Request) {
|
|
448
|
+
// For now, we'll use createRequestContext since MockContext doesn't support Request
|
|
449
|
+
// But we need to ensure the sendActivity behavior is consistent
|
|
450
|
+
return createRequestContext({
|
|
451
|
+
url: new URL(baseUrlOrRequest.url),
|
|
452
|
+
request: baseUrlOrRequest,
|
|
453
|
+
data: contextData,
|
|
454
|
+
federation: mockFederation as any,
|
|
455
|
+
sendActivity: (async (
|
|
456
|
+
sender: any,
|
|
457
|
+
recipients: any,
|
|
458
|
+
activity: any,
|
|
459
|
+
options: any,
|
|
460
|
+
) => {
|
|
461
|
+
// Create a temporary MockContext to use its sendActivity logic
|
|
462
|
+
const tempContext = new MockContext({
|
|
463
|
+
url: new URL(baseUrlOrRequest.url),
|
|
464
|
+
data: contextData,
|
|
465
|
+
federation: mockFederation as any,
|
|
466
|
+
});
|
|
467
|
+
await tempContext.sendActivity(
|
|
468
|
+
sender,
|
|
469
|
+
recipients,
|
|
470
|
+
activity,
|
|
471
|
+
options,
|
|
472
|
+
);
|
|
473
|
+
}) as any,
|
|
474
|
+
});
|
|
475
|
+
} else {
|
|
476
|
+
return new MockContext({
|
|
477
|
+
url: baseUrlOrRequest,
|
|
478
|
+
data: contextData,
|
|
479
|
+
federation: mockFederation as any,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// deno-lint-ignore require-await
|
|
485
|
+
async fetch(
|
|
486
|
+
request: Request,
|
|
487
|
+
options: FederationFetchOptions<TContextData>,
|
|
488
|
+
): Promise<Response> {
|
|
489
|
+
// returning 404 by default
|
|
490
|
+
if (options.onNotFound) {
|
|
491
|
+
return options.onNotFound(request);
|
|
492
|
+
}
|
|
493
|
+
return new Response("Not Found", { status: 404 });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Simulates receiving an activity. This method is specific to the mock
|
|
498
|
+
* implementation and is used for testing purposes.
|
|
499
|
+
*
|
|
500
|
+
* @param activity The activity to receive.
|
|
501
|
+
* @returns A promise that resolves when the activity has been processed.
|
|
502
|
+
* @since 1.8.0
|
|
503
|
+
*/
|
|
504
|
+
async receiveActivity(activity: Activity): Promise<void> {
|
|
505
|
+
this.receivedActivities.push(activity);
|
|
506
|
+
|
|
507
|
+
// Find and execute appropriate inbox listeners
|
|
508
|
+
const typeName = activity.constructor.name;
|
|
509
|
+
const listeners = this.inboxListeners.get(typeName) || [];
|
|
510
|
+
|
|
511
|
+
// Check if we have listeners but no context data
|
|
512
|
+
if (listeners.length > 0 && this.contextData === undefined) {
|
|
513
|
+
throw new Error(
|
|
514
|
+
"MockFederation.receiveActivity(): contextData is not initialized. " +
|
|
515
|
+
"Please provide contextData through the constructor or call startQueue() before receiving activities.",
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const listener of listeners) {
|
|
520
|
+
const context = createInboxContext({
|
|
521
|
+
data: this.contextData as TContextData,
|
|
522
|
+
federation: this as any,
|
|
523
|
+
});
|
|
524
|
+
await listener(context, activity);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Clears all sent activities from the mock federation.
|
|
530
|
+
* This method is specific to the mock implementation and is used for
|
|
531
|
+
* testing purposes.
|
|
532
|
+
*
|
|
533
|
+
* @since 1.8.0
|
|
534
|
+
*/
|
|
535
|
+
reset(): void {
|
|
536
|
+
this.sentActivities = [];
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Type definitions for inbox listeners
|
|
541
|
+
interface InboxListener<TContextData, TActivity extends Activity> {
|
|
542
|
+
(
|
|
543
|
+
context: InboxContext<TContextData>,
|
|
544
|
+
activity: TActivity,
|
|
545
|
+
): void | Promise<void>;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* A mock implementation of the {@link Context} interface for unit testing.
|
|
550
|
+
* This class provides a way to test Fedify applications without needing
|
|
551
|
+
* a real federation context.
|
|
552
|
+
*
|
|
553
|
+
* @example
|
|
554
|
+
* ```typescript
|
|
555
|
+
* import { Person, Create } from "@fedify/fedify/vocab";
|
|
556
|
+
* import { MockContext, MockFederation } from "@fedify/testing";
|
|
557
|
+
*
|
|
558
|
+
* // Create a mock context
|
|
559
|
+
* const mockFederation = new MockFederation<{ userId: string }>();
|
|
560
|
+
* const context = new MockContext({
|
|
561
|
+
* url: new URL("https://example.com"),
|
|
562
|
+
* data: { userId: "test-user" },
|
|
563
|
+
* federation: mockFederation
|
|
564
|
+
* });
|
|
565
|
+
*
|
|
566
|
+
* // Send an activity
|
|
567
|
+
* const recipient = new Person({ id: new URL("https://example.com/users/bob") });
|
|
568
|
+
* const activity = new Create({
|
|
569
|
+
* id: new URL("https://example.com/create/1"),
|
|
570
|
+
* actor: new URL("https://example.com/users/alice")
|
|
571
|
+
* });
|
|
572
|
+
* await context.sendActivity(
|
|
573
|
+
* { identifier: "alice" },
|
|
574
|
+
* recipient,
|
|
575
|
+
* activity
|
|
576
|
+
* );
|
|
577
|
+
*
|
|
578
|
+
* // Check sent activities
|
|
579
|
+
* const sent = context.getSentActivities();
|
|
580
|
+
* console.log(sent[0].activity);
|
|
581
|
+
* ```
|
|
582
|
+
*
|
|
583
|
+
* @typeParam TContextData The context data to pass to the {@link Context}.
|
|
584
|
+
* @since 1.8.0
|
|
585
|
+
*/
|
|
586
|
+
export class MockContext<TContextData> implements Context<TContextData> {
|
|
587
|
+
readonly origin: string;
|
|
588
|
+
readonly canonicalOrigin: string;
|
|
589
|
+
readonly host: string;
|
|
590
|
+
readonly hostname: string;
|
|
591
|
+
readonly data: TContextData;
|
|
592
|
+
readonly federation: Federation<TContextData>;
|
|
593
|
+
readonly documentLoader: DocumentLoader;
|
|
594
|
+
readonly contextLoader: DocumentLoader;
|
|
595
|
+
readonly tracerProvider: TracerProvider;
|
|
596
|
+
|
|
597
|
+
private sentActivities: Array<{
|
|
598
|
+
sender: any;
|
|
599
|
+
recipients: Recipient | Recipient[] | "followers";
|
|
600
|
+
activity: Activity;
|
|
601
|
+
}> = [];
|
|
602
|
+
|
|
603
|
+
constructor(
|
|
604
|
+
options: {
|
|
605
|
+
url?: URL;
|
|
606
|
+
data: TContextData;
|
|
607
|
+
federation: Federation<TContextData>;
|
|
608
|
+
documentLoader?: DocumentLoader;
|
|
609
|
+
contextLoader?: DocumentLoader;
|
|
610
|
+
tracerProvider?: TracerProvider;
|
|
611
|
+
},
|
|
612
|
+
) {
|
|
613
|
+
const url = options.url ?? new URL("https://example.com");
|
|
614
|
+
this.origin = url.origin;
|
|
615
|
+
this.canonicalOrigin = url.origin;
|
|
616
|
+
this.host = url.host;
|
|
617
|
+
this.hostname = url.hostname;
|
|
618
|
+
this.data = options.data;
|
|
619
|
+
this.federation = options.federation;
|
|
620
|
+
// deno-lint-ignore require-await
|
|
621
|
+
this.documentLoader = options.documentLoader ?? (async (url: string) => ({
|
|
622
|
+
contextUrl: null,
|
|
623
|
+
document: {},
|
|
624
|
+
documentUrl: url,
|
|
625
|
+
}));
|
|
626
|
+
this.contextLoader = options.contextLoader ?? this.documentLoader;
|
|
627
|
+
this.tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
clone(data: TContextData): Context<TContextData> {
|
|
631
|
+
return new MockContext({
|
|
632
|
+
url: new URL(this.origin),
|
|
633
|
+
data,
|
|
634
|
+
federation: this.federation,
|
|
635
|
+
documentLoader: this.documentLoader,
|
|
636
|
+
contextLoader: this.contextLoader,
|
|
637
|
+
tracerProvider: this.tracerProvider,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
getNodeInfoUri(): URL {
|
|
642
|
+
if (
|
|
643
|
+
this.federation instanceof MockFederation && this.federation.nodeInfoPath
|
|
644
|
+
) {
|
|
645
|
+
return new URL(this.federation.nodeInfoPath, this.origin);
|
|
646
|
+
}
|
|
647
|
+
return new URL("/nodeinfo/2.0", this.origin);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
getActorUri(identifier: string): URL {
|
|
651
|
+
if (
|
|
652
|
+
this.federation instanceof MockFederation && this.federation.actorPath
|
|
653
|
+
) {
|
|
654
|
+
const path = expandUriTemplate(this.federation.actorPath, {
|
|
655
|
+
identifier,
|
|
656
|
+
handle: identifier,
|
|
657
|
+
});
|
|
658
|
+
return new URL(path, this.origin);
|
|
659
|
+
}
|
|
660
|
+
return new URL(`/users/${identifier}`, this.origin);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
getObjectUri<TObject extends Object>(
|
|
664
|
+
cls: (new (...args: any[]) => TObject) & { typeId: URL },
|
|
665
|
+
values: Record<string, string>,
|
|
666
|
+
): URL {
|
|
667
|
+
if (this.federation instanceof MockFederation) {
|
|
668
|
+
const pathTemplate = this.federation.objectPaths.get(cls.typeId.href);
|
|
669
|
+
if (pathTemplate) {
|
|
670
|
+
const path = expandUriTemplate(pathTemplate, values);
|
|
671
|
+
return new URL(path, this.origin);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const path = globalThis.Object.entries(values)
|
|
675
|
+
.map(([key, value]) => `${key}/${value}`)
|
|
676
|
+
.join("/");
|
|
677
|
+
return new URL(`/objects/${cls.name.toLowerCase()}/${path}`, this.origin);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
getOutboxUri(identifier: string): URL {
|
|
681
|
+
if (
|
|
682
|
+
this.federation instanceof MockFederation && this.federation.outboxPath
|
|
683
|
+
) {
|
|
684
|
+
const path = expandUriTemplate(this.federation.outboxPath, {
|
|
685
|
+
identifier,
|
|
686
|
+
handle: identifier,
|
|
687
|
+
});
|
|
688
|
+
return new URL(path, this.origin);
|
|
689
|
+
}
|
|
690
|
+
return new URL(`/users/${identifier}/outbox`, this.origin);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
getInboxUri(identifier: string): URL;
|
|
694
|
+
getInboxUri(): URL;
|
|
695
|
+
getInboxUri(identifier?: string): URL {
|
|
696
|
+
if (identifier) {
|
|
697
|
+
if (
|
|
698
|
+
this.federation instanceof MockFederation && this.federation.inboxPath
|
|
699
|
+
) {
|
|
700
|
+
const path = expandUriTemplate(this.federation.inboxPath, {
|
|
701
|
+
identifier,
|
|
702
|
+
handle: identifier,
|
|
703
|
+
});
|
|
704
|
+
return new URL(path, this.origin);
|
|
705
|
+
}
|
|
706
|
+
return new URL(`/users/${identifier}/inbox`, this.origin);
|
|
707
|
+
}
|
|
708
|
+
if (
|
|
709
|
+
this.federation instanceof MockFederation &&
|
|
710
|
+
this.federation.sharedInboxPath
|
|
711
|
+
) {
|
|
712
|
+
return new URL(this.federation.sharedInboxPath, this.origin);
|
|
713
|
+
}
|
|
714
|
+
return new URL("/inbox", this.origin);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
getFollowingUri(identifier: string): URL {
|
|
718
|
+
if (
|
|
719
|
+
this.federation instanceof MockFederation && this.federation.followingPath
|
|
720
|
+
) {
|
|
721
|
+
const path = expandUriTemplate(this.federation.followingPath, {
|
|
722
|
+
identifier,
|
|
723
|
+
handle: identifier,
|
|
724
|
+
});
|
|
725
|
+
return new URL(path, this.origin);
|
|
726
|
+
}
|
|
727
|
+
return new URL(`/users/${identifier}/following`, this.origin);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
getFollowersUri(identifier: string): URL {
|
|
731
|
+
if (
|
|
732
|
+
this.federation instanceof MockFederation && this.federation.followersPath
|
|
733
|
+
) {
|
|
734
|
+
const path = expandUriTemplate(this.federation.followersPath, {
|
|
735
|
+
identifier,
|
|
736
|
+
handle: identifier,
|
|
737
|
+
});
|
|
738
|
+
return new URL(path, this.origin);
|
|
739
|
+
}
|
|
740
|
+
return new URL(`/users/${identifier}/followers`, this.origin);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
getLikedUri(identifier: string): URL {
|
|
744
|
+
if (
|
|
745
|
+
this.federation instanceof MockFederation && this.federation.likedPath
|
|
746
|
+
) {
|
|
747
|
+
const path = expandUriTemplate(this.federation.likedPath, {
|
|
748
|
+
identifier,
|
|
749
|
+
handle: identifier,
|
|
750
|
+
});
|
|
751
|
+
return new URL(path, this.origin);
|
|
752
|
+
}
|
|
753
|
+
return new URL(`/users/${identifier}/liked`, this.origin);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
getFeaturedUri(identifier: string): URL {
|
|
757
|
+
if (
|
|
758
|
+
this.federation instanceof MockFederation && this.federation.featuredPath
|
|
759
|
+
) {
|
|
760
|
+
const path = expandUriTemplate(this.federation.featuredPath, {
|
|
761
|
+
identifier,
|
|
762
|
+
handle: identifier,
|
|
763
|
+
});
|
|
764
|
+
return new URL(path, this.origin);
|
|
765
|
+
}
|
|
766
|
+
return new URL(`/users/${identifier}/featured`, this.origin);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
getFeaturedTagsUri(identifier: string): URL {
|
|
770
|
+
if (
|
|
771
|
+
this.federation instanceof MockFederation &&
|
|
772
|
+
this.federation.featuredTagsPath
|
|
773
|
+
) {
|
|
774
|
+
const path = expandUriTemplate(this.federation.featuredTagsPath, {
|
|
775
|
+
identifier,
|
|
776
|
+
handle: identifier,
|
|
777
|
+
});
|
|
778
|
+
return new URL(path, this.origin);
|
|
779
|
+
}
|
|
780
|
+
return new URL(`/users/${identifier}/tags`, this.origin);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
parseUri(uri: URL): ParseUriResult | null {
|
|
784
|
+
if (uri.pathname.startsWith("/users/")) {
|
|
785
|
+
const parts = uri.pathname.split("/");
|
|
786
|
+
if (parts.length >= 3) {
|
|
787
|
+
return {
|
|
788
|
+
type: "actor",
|
|
789
|
+
identifier: parts[2],
|
|
790
|
+
handle: parts[2],
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
getActorKeyPairs(_identifier: string): Promise<ActorKeyPair[]> {
|
|
798
|
+
return Promise.resolve([]);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
getDocumentLoader(
|
|
802
|
+
params: { handle: string } | { identifier: string },
|
|
803
|
+
): Promise<DocumentLoader>;
|
|
804
|
+
getDocumentLoader(
|
|
805
|
+
params: { keyId: URL; privateKey: CryptoKey },
|
|
806
|
+
): DocumentLoader;
|
|
807
|
+
getDocumentLoader(params: any): DocumentLoader | Promise<DocumentLoader> {
|
|
808
|
+
// return the same document loader
|
|
809
|
+
if ("keyId" in params) {
|
|
810
|
+
return this.documentLoader;
|
|
811
|
+
}
|
|
812
|
+
return Promise.resolve(this.documentLoader);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
lookupObject(
|
|
816
|
+
_uri: URL | string,
|
|
817
|
+
_options?: LookupObjectOptions,
|
|
818
|
+
): Promise<Object | null> {
|
|
819
|
+
return Promise.resolve(null);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
traverseCollection<TItem, TContext extends Context<TContextData>>(
|
|
823
|
+
_collection: Collection | URL | null,
|
|
824
|
+
_options?: TraverseCollectionOptions,
|
|
825
|
+
): AsyncIterable<TItem> {
|
|
826
|
+
// just returning empty async iterable
|
|
827
|
+
return {
|
|
828
|
+
async *[Symbol.asyncIterator]() {
|
|
829
|
+
// yield nothing
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
lookupNodeInfo(
|
|
835
|
+
url: URL | string,
|
|
836
|
+
options?: { parse?: "strict" | "best-effort" } & any,
|
|
837
|
+
): Promise<NodeInfo | undefined>;
|
|
838
|
+
lookupNodeInfo(
|
|
839
|
+
url: URL | string,
|
|
840
|
+
options?: { parse: "none" } & any,
|
|
841
|
+
): Promise<JsonValue | undefined>;
|
|
842
|
+
lookupNodeInfo(
|
|
843
|
+
_url: URL | string,
|
|
844
|
+
_options?: any,
|
|
845
|
+
): Promise<NodeInfo | JsonValue | undefined> {
|
|
846
|
+
return Promise.resolve(undefined);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
lookupWebFinger(
|
|
850
|
+
_resource: URL | `acct:${string}@${string}` | string,
|
|
851
|
+
_options?: any,
|
|
852
|
+
): Promise<ResourceDescriptor | null> {
|
|
853
|
+
return Promise.resolve(null);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
sendActivity(
|
|
857
|
+
sender:
|
|
858
|
+
| SenderKeyPair
|
|
859
|
+
| SenderKeyPair[]
|
|
860
|
+
| { identifier: string }
|
|
861
|
+
| { username: string }
|
|
862
|
+
| { handle: string },
|
|
863
|
+
recipients: Recipient | Recipient[],
|
|
864
|
+
activity: Activity,
|
|
865
|
+
options?: SendActivityOptions,
|
|
866
|
+
): Promise<void>;
|
|
867
|
+
sendActivity(
|
|
868
|
+
sender: { identifier: string } | { username: string } | { handle: string },
|
|
869
|
+
recipients: "followers",
|
|
870
|
+
activity: Activity,
|
|
871
|
+
options?: SendActivityOptionsForCollection,
|
|
872
|
+
): Promise<void>;
|
|
873
|
+
sendActivity(
|
|
874
|
+
sender:
|
|
875
|
+
| SenderKeyPair
|
|
876
|
+
| SenderKeyPair[]
|
|
877
|
+
| { identifier: string }
|
|
878
|
+
| { username: string }
|
|
879
|
+
| { handle: string },
|
|
880
|
+
recipients: Recipient | Recipient[],
|
|
881
|
+
activity: Activity,
|
|
882
|
+
options?: SendActivityOptions,
|
|
883
|
+
): Promise<void>;
|
|
884
|
+
sendActivity(
|
|
885
|
+
sender: { identifier: string } | { username: string } | { handle: string },
|
|
886
|
+
recipients: "followers",
|
|
887
|
+
activity: Activity,
|
|
888
|
+
options?: SendActivityOptionsForCollection,
|
|
889
|
+
): Promise<void>;
|
|
890
|
+
sendActivity(
|
|
891
|
+
sender:
|
|
892
|
+
| SenderKeyPair
|
|
893
|
+
| SenderKeyPair[]
|
|
894
|
+
| { identifier: string }
|
|
895
|
+
| { username: string }
|
|
896
|
+
| { handle: string },
|
|
897
|
+
recipients: Recipient | Recipient[] | "followers",
|
|
898
|
+
activity: Activity,
|
|
899
|
+
_options?: SendActivityOptions | SendActivityOptionsForCollection,
|
|
900
|
+
): Promise<void> {
|
|
901
|
+
this.sentActivities.push({ sender, recipients, activity });
|
|
902
|
+
|
|
903
|
+
// If this is a MockFederation, also record it there
|
|
904
|
+
if (this.federation instanceof MockFederation) {
|
|
905
|
+
const queued = this.federation.queueStarted;
|
|
906
|
+
this.federation.sentActivities.push({
|
|
907
|
+
queued,
|
|
908
|
+
queue: queued ? "outbox" : undefined,
|
|
909
|
+
activity,
|
|
910
|
+
sentOrder: ++this.federation.sentCounter,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return Promise.resolve();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
routeActivity(
|
|
918
|
+
_recipient: string | null,
|
|
919
|
+
_activity: Activity,
|
|
920
|
+
_options?: RouteActivityOptions,
|
|
921
|
+
): Promise<boolean> {
|
|
922
|
+
return Promise.resolve(true);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Gets all activities that have been sent through this mock context.
|
|
927
|
+
* This method is specific to the mock implementation and is used for
|
|
928
|
+
* testing purposes.
|
|
929
|
+
*
|
|
930
|
+
* @returns An array of sent activity records.
|
|
931
|
+
*/
|
|
932
|
+
getSentActivities(): Array<{
|
|
933
|
+
sender:
|
|
934
|
+
| SenderKeyPair
|
|
935
|
+
| SenderKeyPair[]
|
|
936
|
+
| { identifier: string }
|
|
937
|
+
| { username: string }
|
|
938
|
+
| { handle: string };
|
|
939
|
+
recipients: Recipient | Recipient[] | "followers";
|
|
940
|
+
activity: Activity;
|
|
941
|
+
}> {
|
|
942
|
+
return [...this.sentActivities];
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Clears all sent activities from the mock context.
|
|
947
|
+
* This method is specific to the mock implementation and is used for
|
|
948
|
+
* testing purposes.
|
|
949
|
+
*/
|
|
950
|
+
reset(): void {
|
|
951
|
+
this.sentActivities = [];
|
|
952
|
+
}
|
|
953
|
+
}
|