@fedify/testing 2.2.0-pr.695.16 → 2.2.0-pr.697.18
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/dist/mod.cjs +171 -12
- package/dist/mod.d.cts +25 -2
- package/dist/mod.d.ts +25 -2
- package/dist/mod.js +172 -14
- package/package.json +2 -2
package/dist/mod.cjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { Temporal } = require("@js-temporal/polyfill");
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
let _fedify_fedify_sig = require("@fedify/fedify/sig");
|
|
3
4
|
let _fedify_vocab = require("@fedify/vocab");
|
|
4
5
|
let _fedify_fedify_federation = require("@fedify/fedify/federation");
|
|
5
6
|
let es_toolkit = require("es-toolkit");
|
|
@@ -112,6 +113,9 @@ function createRequestContext(args) {
|
|
|
112
113
|
* @since 1.8.0
|
|
113
114
|
*/
|
|
114
115
|
function createInboxContext(args) {
|
|
116
|
+
const forwardActivity = args.forwardActivity ?? ((_forwarder, _recipients, _options) => {
|
|
117
|
+
throw new Error("Not implemented");
|
|
118
|
+
});
|
|
115
119
|
return {
|
|
116
120
|
...createContext(args),
|
|
117
121
|
clone: args.clone ?? ((data) => createInboxContext({
|
|
@@ -119,9 +123,29 @@ function createInboxContext(args) {
|
|
|
119
123
|
data
|
|
120
124
|
})),
|
|
121
125
|
recipient: args.recipient ?? null,
|
|
122
|
-
forwardActivity
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
forwardActivity
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Creates an OutboxContext for testing purposes.
|
|
131
|
+
* Not exported - used internally only. Public API is in mock.ts
|
|
132
|
+
* @param args Partial OutboxContext properties
|
|
133
|
+
* @returns An OutboxContext instance
|
|
134
|
+
* @since 2.2.0
|
|
135
|
+
*/
|
|
136
|
+
function createOutboxContext(args) {
|
|
137
|
+
const forwardActivity = args.forwardActivity ?? ((_forwarder, _recipients, _options) => {
|
|
138
|
+
throw new Error("Not implemented");
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
...createContext(args),
|
|
142
|
+
clone: args.clone ?? ((data) => createOutboxContext({
|
|
143
|
+
...args,
|
|
144
|
+
data
|
|
145
|
+
})),
|
|
146
|
+
identifier: args.identifier,
|
|
147
|
+
hasDeliveredActivity: args.hasDeliveredActivity ?? (() => false),
|
|
148
|
+
forwardActivity
|
|
125
149
|
};
|
|
126
150
|
}
|
|
127
151
|
//#endregion
|
|
@@ -131,17 +155,41 @@ const noopTracerProvider = { getTracer: () => ({
|
|
|
131
155
|
startSpan: () => void 0
|
|
132
156
|
}) };
|
|
133
157
|
/**
|
|
134
|
-
* Helper function to expand URI templates
|
|
135
|
-
* Supports
|
|
158
|
+
* Helper function to expand URI templates used by the mock.
|
|
159
|
+
* Supports the RFC 6570 operators accepted by Fedify's identifier paths.
|
|
136
160
|
* @param template The URI template pattern
|
|
137
161
|
* @param values The values to substitute
|
|
138
162
|
* @returns The expanded URI path
|
|
139
163
|
*/
|
|
140
164
|
function expandUriTemplate(template, values) {
|
|
141
|
-
return template.replace(/{([
|
|
142
|
-
|
|
165
|
+
return template.replace(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g, (match, operator, key) => {
|
|
166
|
+
const value = values[key];
|
|
167
|
+
if (value == null) return match;
|
|
168
|
+
switch (operator) {
|
|
169
|
+
case "": return encodeURIComponent(value);
|
|
170
|
+
case "+": return encodeURI(value);
|
|
171
|
+
case "#": return `#${encodeURI(value)}`;
|
|
172
|
+
case ".": return `.${encodeURIComponent(value)}`;
|
|
173
|
+
case "/": return `/${encodeURIComponent(value)}`;
|
|
174
|
+
case ";": return `;${key}=${encodeURIComponent(value)}`;
|
|
175
|
+
case "?": return `?${key}=${encodeURIComponent(value)}`;
|
|
176
|
+
case "&": return `&${key}=${encodeURIComponent(value)}`;
|
|
177
|
+
default: return match;
|
|
178
|
+
}
|
|
143
179
|
});
|
|
144
180
|
}
|
|
181
|
+
function validateOutboxListenerPath(path, dispatcherPath) {
|
|
182
|
+
if (!path.startsWith("/")) throw new TypeError("Path must start with a slash.");
|
|
183
|
+
if (dispatcherPath != null && dispatcherPath !== path) throw new TypeError("Outbox listener path and outbox dispatcher path must match.");
|
|
184
|
+
const operatorMatches = globalThis.Array.from(path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g));
|
|
185
|
+
if (operatorMatches.some((match) => [
|
|
186
|
+
"?",
|
|
187
|
+
"&",
|
|
188
|
+
"#"
|
|
189
|
+
].includes(match[1]) && match[2] === "identifier")) throw new TypeError("Path for outbox cannot use query or fragment expansion for identifier.");
|
|
190
|
+
const variables = operatorMatches.map((match) => match[2]);
|
|
191
|
+
if (variables.length !== 1 || variables[0] !== "identifier") throw new TypeError("Path for outbox must have exactly one variable named identifier.");
|
|
192
|
+
}
|
|
145
193
|
/**
|
|
146
194
|
* A mock implementation of the {@link Federation} interface for unit testing.
|
|
147
195
|
* This class provides a way to test Fedify applications without needing
|
|
@@ -198,12 +246,17 @@ var MockFederation = class {
|
|
|
198
246
|
objectDispatchers = /* @__PURE__ */ new Map();
|
|
199
247
|
inboxDispatcher;
|
|
200
248
|
outboxDispatcher;
|
|
249
|
+
outboxAuthorizePredicate;
|
|
250
|
+
outboxDispatcherAuthorizePredicate;
|
|
251
|
+
outboxListenerErrorHandler;
|
|
201
252
|
followingDispatcher;
|
|
202
253
|
followersDispatcher;
|
|
203
254
|
likedDispatcher;
|
|
204
255
|
featuredDispatcher;
|
|
205
256
|
featuredTagsDispatcher;
|
|
206
257
|
inboxListeners = /* @__PURE__ */ new Map();
|
|
258
|
+
outboxListeners = /* @__PURE__ */ new Map();
|
|
259
|
+
outboxListenersInitialized = false;
|
|
207
260
|
contextData;
|
|
208
261
|
receivedActivities = [];
|
|
209
262
|
constructor(options = {}) {
|
|
@@ -245,13 +298,17 @@ var MockFederation = class {
|
|
|
245
298
|
};
|
|
246
299
|
}
|
|
247
300
|
setOutboxDispatcher(path, dispatcher) {
|
|
301
|
+
validateOutboxListenerPath(path, this.outboxListenersInitialized ? this.outboxPath : void 0);
|
|
248
302
|
this.outboxDispatcher = dispatcher;
|
|
249
303
|
this.outboxPath = path;
|
|
250
304
|
return {
|
|
251
305
|
setCounter: () => this,
|
|
252
306
|
setFirstCursor: () => this,
|
|
253
307
|
setLastCursor: () => this,
|
|
254
|
-
authorize: () =>
|
|
308
|
+
authorize: (predicate) => {
|
|
309
|
+
this.outboxDispatcherAuthorizePredicate = predicate;
|
|
310
|
+
return this;
|
|
311
|
+
}
|
|
255
312
|
};
|
|
256
313
|
}
|
|
257
314
|
setFollowingDispatcher(path, dispatcher) {
|
|
@@ -329,6 +386,28 @@ var MockFederation = class {
|
|
|
329
386
|
}
|
|
330
387
|
};
|
|
331
388
|
}
|
|
389
|
+
setOutboxListeners(outboxPath) {
|
|
390
|
+
if (this.outboxListenersInitialized) throw new TypeError("Outbox listeners already set.");
|
|
391
|
+
validateOutboxListenerPath(outboxPath, this.outboxPath);
|
|
392
|
+
this.outboxListenersInitialized = true;
|
|
393
|
+
this.outboxPath = outboxPath;
|
|
394
|
+
const self = this;
|
|
395
|
+
return {
|
|
396
|
+
on(type, listener) {
|
|
397
|
+
if (self.outboxListeners.has(type)) throw new TypeError("Listener already set for this type.");
|
|
398
|
+
self.outboxListeners.set(type, listener);
|
|
399
|
+
return this;
|
|
400
|
+
},
|
|
401
|
+
onError(handler) {
|
|
402
|
+
self.outboxListenerErrorHandler = handler;
|
|
403
|
+
return this;
|
|
404
|
+
},
|
|
405
|
+
authorize(predicate) {
|
|
406
|
+
self.outboxAuthorizePredicate = predicate;
|
|
407
|
+
return this;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
332
411
|
setOutboxPermanentFailureHandler(_handler) {}
|
|
333
412
|
async startQueue(contextData, options) {
|
|
334
413
|
this.contextData = contextData;
|
|
@@ -345,8 +424,10 @@ var MockFederation = class {
|
|
|
345
424
|
}
|
|
346
425
|
createContext(baseUrlOrRequest, contextData) {
|
|
347
426
|
const mockFederation = this;
|
|
427
|
+
const request = baseUrlOrRequest instanceof Request ? baseUrlOrRequest : null;
|
|
348
428
|
return new MockContext({
|
|
349
|
-
url:
|
|
429
|
+
url: request == null ? baseUrlOrRequest : new URL(request.url),
|
|
430
|
+
request,
|
|
350
431
|
data: contextData,
|
|
351
432
|
federation: mockFederation
|
|
352
433
|
});
|
|
@@ -374,6 +455,81 @@ var MockFederation = class {
|
|
|
374
455
|
}), activity);
|
|
375
456
|
}
|
|
376
457
|
/**
|
|
458
|
+
* Simulates posting an activity to a local actor outbox.
|
|
459
|
+
* This method is specific to the mock implementation and is used for
|
|
460
|
+
* testing purposes.
|
|
461
|
+
*
|
|
462
|
+
* @param identifier The identifier of the outbox owner.
|
|
463
|
+
* @param activity The activity to post.
|
|
464
|
+
* @returns A promise that resolves when the activity has been processed.
|
|
465
|
+
* @since 2.2.0
|
|
466
|
+
*/
|
|
467
|
+
async postOutboxActivity(identifier, activity) {
|
|
468
|
+
if (!this.outboxListenersInitialized) throw new Error("MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized.");
|
|
469
|
+
let ctor = activity.constructor;
|
|
470
|
+
let listener = this.outboxListeners.get(ctor);
|
|
471
|
+
while (listener == null && ctor !== _fedify_vocab.Activity) {
|
|
472
|
+
ctor = globalThis.Object.getPrototypeOf(ctor);
|
|
473
|
+
listener = this.outboxListeners.get(ctor);
|
|
474
|
+
}
|
|
475
|
+
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.");
|
|
476
|
+
const origin = new URL(this.options.origin ?? "https://example.com");
|
|
477
|
+
const routingContext = this.createContext(origin, this.contextData);
|
|
478
|
+
const postedJson = await activity.toJsonLd({ contextLoader: routingContext.contextLoader });
|
|
479
|
+
const request = new Request(routingContext.getOutboxUri(identifier), {
|
|
480
|
+
method: "POST",
|
|
481
|
+
body: JSON.stringify(postedJson),
|
|
482
|
+
headers: { "content-type": "application/activity+json" }
|
|
483
|
+
});
|
|
484
|
+
const baseContext = this.createContext(request, this.contextData);
|
|
485
|
+
const rawActivity = postedJson;
|
|
486
|
+
const deliveryState = { delivered: false };
|
|
487
|
+
const createMockOutboxContext = () => createOutboxContext({
|
|
488
|
+
...baseContext,
|
|
489
|
+
clone: void 0,
|
|
490
|
+
federation: this,
|
|
491
|
+
identifier,
|
|
492
|
+
hasDeliveredActivity: () => deliveryState.delivered,
|
|
493
|
+
sendActivity: async (sender, recipients, outboundActivity, options) => {
|
|
494
|
+
await baseContext.sendActivity(sender, recipients, outboundActivity, options);
|
|
495
|
+
deliveryState.delivered = true;
|
|
496
|
+
},
|
|
497
|
+
forwardActivity: async (forwarder, recipients, options) => {
|
|
498
|
+
const hasProof = (0, _fedify_fedify_sig.hasProofLike)(rawActivity);
|
|
499
|
+
const hasLds = (0, _fedify_fedify_sig.hasSignatureLike)(rawActivity);
|
|
500
|
+
if (options?.skipIfUnsigned && !hasProof && !hasLds) return;
|
|
501
|
+
await baseContext.sendActivity(forwarder, recipients, activity, {
|
|
502
|
+
...options,
|
|
503
|
+
rawActivity
|
|
504
|
+
});
|
|
505
|
+
deliveryState.delivered = true;
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
const actor = await baseContext.getActor(identifier);
|
|
509
|
+
if (actor == null) throw new Error(`Actor ${JSON.stringify(identifier)} not found.`);
|
|
510
|
+
const authorizePredicate = this.outboxAuthorizePredicate ?? this.outboxDispatcherAuthorizePredicate;
|
|
511
|
+
if (authorizePredicate != null && !await authorizePredicate(baseContext, identifier)) throw new Error("Unauthorized.");
|
|
512
|
+
const expectedActorId = actor.id ?? baseContext.getActorUri(identifier);
|
|
513
|
+
if (activity.actorIds.length < 1) {
|
|
514
|
+
const error = /* @__PURE__ */ new Error("The posted activity has no actor.");
|
|
515
|
+
await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error);
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
if (!activity.actorIds.every((actorId) => actorId.href === expectedActorId.href)) {
|
|
519
|
+
const error = /* @__PURE__ */ new Error("The activity actor does not match the outbox owner.");
|
|
520
|
+
await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error);
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
if (listener == null) return;
|
|
524
|
+
const context = createMockOutboxContext();
|
|
525
|
+
try {
|
|
526
|
+
await listener(context, activity);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
await this.outboxListenerErrorHandler?.(context, error);
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
377
533
|
* Clears all sent activities from the mock federation.
|
|
378
534
|
* This method is specific to the mock implementation and is used for
|
|
379
535
|
* testing purposes.
|
|
@@ -502,7 +658,7 @@ var MockContext = class MockContext {
|
|
|
502
658
|
this.host = url.host;
|
|
503
659
|
this.hostname = url.hostname;
|
|
504
660
|
this.url = url;
|
|
505
|
-
this.request = new Request(url);
|
|
661
|
+
this.request = options.request ?? new Request(url);
|
|
506
662
|
this.data = options.data;
|
|
507
663
|
this.federation = options.federation;
|
|
508
664
|
this.documentLoader = options.documentLoader ?? (async (url) => ({
|
|
@@ -671,11 +827,12 @@ var MockContext = class MockContext {
|
|
|
671
827
|
lookupWebFinger(_resource, _options) {
|
|
672
828
|
return Promise.resolve(null);
|
|
673
829
|
}
|
|
674
|
-
sendActivity(sender, recipients, activity,
|
|
830
|
+
sendActivity(sender, recipients, activity, options) {
|
|
675
831
|
this.sentActivities.push({
|
|
676
832
|
sender,
|
|
677
833
|
recipients,
|
|
678
|
-
activity
|
|
834
|
+
activity,
|
|
835
|
+
rawActivity: options?.rawActivity
|
|
679
836
|
});
|
|
680
837
|
if (this.federation instanceof MockFederation) {
|
|
681
838
|
const queued = this.federation.queueStarted;
|
|
@@ -683,6 +840,7 @@ var MockContext = class MockContext {
|
|
|
683
840
|
queued,
|
|
684
841
|
queue: queued ? "outbox" : void 0,
|
|
685
842
|
activity,
|
|
843
|
+
rawActivity: options?.rawActivity,
|
|
686
844
|
sentOrder: ++this.federation.sentCounter
|
|
687
845
|
});
|
|
688
846
|
}
|
|
@@ -894,6 +1052,7 @@ const getRandomKey = (prefix) => `fedify_test_${prefix}_${crypto.randomUUID()}`;
|
|
|
894
1052
|
exports.createContext = createContext;
|
|
895
1053
|
exports.createFederation = createFederation;
|
|
896
1054
|
exports.createInboxContext = createInboxContext;
|
|
1055
|
+
exports.createOutboxContext = createOutboxContext;
|
|
897
1056
|
exports.createRequestContext = createRequestContext;
|
|
898
1057
|
exports.getRandomKey = getRandomKey;
|
|
899
1058
|
exports.testMessageQueue = testMessageQueue;
|
package/dist/mod.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, Federation, InboxContext, RequestContext } from "@fedify/fedify/federation";
|
|
1
|
+
import { Context, Federation, InboxContext, OutboxContext, RequestContext } from "@fedify/fedify/federation";
|
|
2
2
|
import { Activity } from "@fedify/vocab";
|
|
3
3
|
import { MessageQueue } from "@fedify/fedify";
|
|
4
4
|
|
|
@@ -27,6 +27,12 @@ declare function createRequestContext<TContextData>(args: Partial<RequestContext
|
|
|
27
27
|
*/
|
|
28
28
|
type TestInboxContext<TContextData> = InboxContext<TContextData>;
|
|
29
29
|
/**
|
|
30
|
+
* Test-specific OutboxContext type alias.
|
|
31
|
+
* This indirection helps avoid JSR type analyzer issues.
|
|
32
|
+
* @since 2.2.0
|
|
33
|
+
*/
|
|
34
|
+
type TestOutboxContext<TContextData> = OutboxContext<TContextData>;
|
|
35
|
+
/**
|
|
30
36
|
* Creates an InboxContext for testing purposes.
|
|
31
37
|
* Not exported - used internally only. Public API is in mock.ts
|
|
32
38
|
* @param args Partial InboxContext properties
|
|
@@ -39,6 +45,19 @@ declare function createInboxContext<TContextData>(args: Partial<InboxContext<TCo
|
|
|
39
45
|
recipient?: string | null;
|
|
40
46
|
federation: Federation<TContextData>;
|
|
41
47
|
}): TestInboxContext<TContextData>;
|
|
48
|
+
/**
|
|
49
|
+
* Creates an OutboxContext for testing purposes.
|
|
50
|
+
* Not exported - used internally only. Public API is in mock.ts
|
|
51
|
+
* @param args Partial OutboxContext properties
|
|
52
|
+
* @returns An OutboxContext instance
|
|
53
|
+
* @since 2.2.0
|
|
54
|
+
*/
|
|
55
|
+
declare function createOutboxContext<TContextData>(args: Partial<OutboxContext<TContextData>> & {
|
|
56
|
+
url?: URL;
|
|
57
|
+
data: TContextData;
|
|
58
|
+
identifier: string;
|
|
59
|
+
federation: Federation<TContextData>;
|
|
60
|
+
}): TestOutboxContext<TContextData>;
|
|
42
61
|
//#endregion
|
|
43
62
|
//#region src/mock.d.ts
|
|
44
63
|
/**
|
|
@@ -52,6 +71,8 @@ interface SentActivity {
|
|
|
52
71
|
queue?: "inbox" | "outbox" | "fanout";
|
|
53
72
|
/** The activity that was sent. */
|
|
54
73
|
activity: Activity;
|
|
74
|
+
/** The raw forwarded payload, if preserved by the caller. */
|
|
75
|
+
rawActivity?: unknown;
|
|
55
76
|
/** The order in which the activity was sent (auto-incrementing counter). */
|
|
56
77
|
sentOrder: number;
|
|
57
78
|
}
|
|
@@ -66,6 +87,7 @@ interface TestContext<TContextData> extends Omit<Context<TContextData>, "clone">
|
|
|
66
87
|
sender: any;
|
|
67
88
|
recipients: any;
|
|
68
89
|
activity: Activity;
|
|
90
|
+
rawActivity?: unknown;
|
|
69
91
|
}>;
|
|
70
92
|
reset(): void;
|
|
71
93
|
}
|
|
@@ -79,6 +101,7 @@ interface TestFederation<TContextData> extends Omit<Federation<TContextData>, "c
|
|
|
79
101
|
queueStarted: boolean;
|
|
80
102
|
sentCounter: number;
|
|
81
103
|
receiveActivity(activity: Activity): Promise<void>;
|
|
104
|
+
postOutboxActivity(identifier: string, activity: Activity): Promise<void>;
|
|
82
105
|
reset(): void;
|
|
83
106
|
createContext(baseUrlOrRequest: URL | Request, contextData: TContextData): TestContext<TContextData>;
|
|
84
107
|
}
|
|
@@ -194,4 +217,4 @@ declare function testMessageQueue<MQ extends MessageQueue>(getMessageQueue: () =
|
|
|
194
217
|
declare function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void>;
|
|
195
218
|
declare const getRandomKey: (prefix: string) => string;
|
|
196
219
|
//#endregion
|
|
197
|
-
export { type TestMessageQueueOptions, createContext, createFederation, createInboxContext, createRequestContext, getRandomKey, testMessageQueue, waitFor };
|
|
220
|
+
export { type TestMessageQueueOptions, createContext, createFederation, createInboxContext, createOutboxContext, createRequestContext, getRandomKey, testMessageQueue, waitFor };
|
package/dist/mod.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Temporal } from "@js-temporal/polyfill";
|
|
2
2
|
import { Activity } from "@fedify/vocab";
|
|
3
|
-
import { Context, Federation, InboxContext, RequestContext } from "@fedify/fedify/federation";
|
|
3
|
+
import { Context, Federation, InboxContext, OutboxContext, RequestContext } from "@fedify/fedify/federation";
|
|
4
4
|
import { MessageQueue } from "@fedify/fedify";
|
|
5
5
|
|
|
6
6
|
//#region src/context.d.ts
|
|
@@ -28,6 +28,12 @@ declare function createRequestContext<TContextData>(args: Partial<RequestContext
|
|
|
28
28
|
*/
|
|
29
29
|
type TestInboxContext<TContextData> = InboxContext<TContextData>;
|
|
30
30
|
/**
|
|
31
|
+
* Test-specific OutboxContext type alias.
|
|
32
|
+
* This indirection helps avoid JSR type analyzer issues.
|
|
33
|
+
* @since 2.2.0
|
|
34
|
+
*/
|
|
35
|
+
type TestOutboxContext<TContextData> = OutboxContext<TContextData>;
|
|
36
|
+
/**
|
|
31
37
|
* Creates an InboxContext for testing purposes.
|
|
32
38
|
* Not exported - used internally only. Public API is in mock.ts
|
|
33
39
|
* @param args Partial InboxContext properties
|
|
@@ -40,6 +46,19 @@ declare function createInboxContext<TContextData>(args: Partial<InboxContext<TCo
|
|
|
40
46
|
recipient?: string | null;
|
|
41
47
|
federation: Federation<TContextData>;
|
|
42
48
|
}): TestInboxContext<TContextData>;
|
|
49
|
+
/**
|
|
50
|
+
* Creates an OutboxContext for testing purposes.
|
|
51
|
+
* Not exported - used internally only. Public API is in mock.ts
|
|
52
|
+
* @param args Partial OutboxContext properties
|
|
53
|
+
* @returns An OutboxContext instance
|
|
54
|
+
* @since 2.2.0
|
|
55
|
+
*/
|
|
56
|
+
declare function createOutboxContext<TContextData>(args: Partial<OutboxContext<TContextData>> & {
|
|
57
|
+
url?: URL;
|
|
58
|
+
data: TContextData;
|
|
59
|
+
identifier: string;
|
|
60
|
+
federation: Federation<TContextData>;
|
|
61
|
+
}): TestOutboxContext<TContextData>;
|
|
43
62
|
//#endregion
|
|
44
63
|
//#region src/mock.d.ts
|
|
45
64
|
/**
|
|
@@ -53,6 +72,8 @@ interface SentActivity {
|
|
|
53
72
|
queue?: "inbox" | "outbox" | "fanout";
|
|
54
73
|
/** The activity that was sent. */
|
|
55
74
|
activity: Activity;
|
|
75
|
+
/** The raw forwarded payload, if preserved by the caller. */
|
|
76
|
+
rawActivity?: unknown;
|
|
56
77
|
/** The order in which the activity was sent (auto-incrementing counter). */
|
|
57
78
|
sentOrder: number;
|
|
58
79
|
}
|
|
@@ -67,6 +88,7 @@ interface TestContext<TContextData> extends Omit<Context<TContextData>, "clone">
|
|
|
67
88
|
sender: any;
|
|
68
89
|
recipients: any;
|
|
69
90
|
activity: Activity;
|
|
91
|
+
rawActivity?: unknown;
|
|
70
92
|
}>;
|
|
71
93
|
reset(): void;
|
|
72
94
|
}
|
|
@@ -80,6 +102,7 @@ interface TestFederation<TContextData> extends Omit<Federation<TContextData>, "c
|
|
|
80
102
|
queueStarted: boolean;
|
|
81
103
|
sentCounter: number;
|
|
82
104
|
receiveActivity(activity: Activity): Promise<void>;
|
|
105
|
+
postOutboxActivity(identifier: string, activity: Activity): Promise<void>;
|
|
83
106
|
reset(): void;
|
|
84
107
|
createContext(baseUrlOrRequest: URL | Request, contextData: TContextData): TestContext<TContextData>;
|
|
85
108
|
}
|
|
@@ -195,4 +218,4 @@ declare function testMessageQueue<MQ extends MessageQueue>(getMessageQueue: () =
|
|
|
195
218
|
declare function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void>;
|
|
196
219
|
declare const getRandomKey: (prefix: string) => string;
|
|
197
220
|
//#endregion
|
|
198
|
-
export { type TestMessageQueueOptions, createContext, createFederation, createInboxContext, createRequestContext, getRandomKey, testMessageQueue, waitFor };
|
|
221
|
+
export { type TestMessageQueueOptions, createContext, createFederation, createInboxContext, createOutboxContext, createRequestContext, getRandomKey, testMessageQueue, waitFor };
|
package/dist/mod.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
-
import {
|
|
2
|
+
import { hasProofLike, hasSignatureLike } from "@fedify/fedify/sig";
|
|
3
|
+
import { Activity, CryptographicKey, Multikey, lookupObject, traverseCollection } from "@fedify/vocab";
|
|
3
4
|
import { RouterError } from "@fedify/fedify/federation";
|
|
4
5
|
import { delay } from "es-toolkit";
|
|
5
6
|
import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
|
|
@@ -111,6 +112,9 @@ function createRequestContext(args) {
|
|
|
111
112
|
* @since 1.8.0
|
|
112
113
|
*/
|
|
113
114
|
function createInboxContext(args) {
|
|
115
|
+
const forwardActivity = args.forwardActivity ?? ((_forwarder, _recipients, _options) => {
|
|
116
|
+
throw new Error("Not implemented");
|
|
117
|
+
});
|
|
114
118
|
return {
|
|
115
119
|
...createContext(args),
|
|
116
120
|
clone: args.clone ?? ((data) => createInboxContext({
|
|
@@ -118,9 +122,29 @@ function createInboxContext(args) {
|
|
|
118
122
|
data
|
|
119
123
|
})),
|
|
120
124
|
recipient: args.recipient ?? null,
|
|
121
|
-
forwardActivity
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
forwardActivity
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Creates an OutboxContext for testing purposes.
|
|
130
|
+
* Not exported - used internally only. Public API is in mock.ts
|
|
131
|
+
* @param args Partial OutboxContext properties
|
|
132
|
+
* @returns An OutboxContext instance
|
|
133
|
+
* @since 2.2.0
|
|
134
|
+
*/
|
|
135
|
+
function createOutboxContext(args) {
|
|
136
|
+
const forwardActivity = args.forwardActivity ?? ((_forwarder, _recipients, _options) => {
|
|
137
|
+
throw new Error("Not implemented");
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
...createContext(args),
|
|
141
|
+
clone: args.clone ?? ((data) => createOutboxContext({
|
|
142
|
+
...args,
|
|
143
|
+
data
|
|
144
|
+
})),
|
|
145
|
+
identifier: args.identifier,
|
|
146
|
+
hasDeliveredActivity: args.hasDeliveredActivity ?? (() => false),
|
|
147
|
+
forwardActivity
|
|
124
148
|
};
|
|
125
149
|
}
|
|
126
150
|
//#endregion
|
|
@@ -130,17 +154,41 @@ const noopTracerProvider = { getTracer: () => ({
|
|
|
130
154
|
startSpan: () => void 0
|
|
131
155
|
}) };
|
|
132
156
|
/**
|
|
133
|
-
* Helper function to expand URI templates
|
|
134
|
-
* Supports
|
|
157
|
+
* Helper function to expand URI templates used by the mock.
|
|
158
|
+
* Supports the RFC 6570 operators accepted by Fedify's identifier paths.
|
|
135
159
|
* @param template The URI template pattern
|
|
136
160
|
* @param values The values to substitute
|
|
137
161
|
* @returns The expanded URI path
|
|
138
162
|
*/
|
|
139
163
|
function expandUriTemplate(template, values) {
|
|
140
|
-
return template.replace(/{([
|
|
141
|
-
|
|
164
|
+
return template.replace(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g, (match, operator, key) => {
|
|
165
|
+
const value = values[key];
|
|
166
|
+
if (value == null) return match;
|
|
167
|
+
switch (operator) {
|
|
168
|
+
case "": return encodeURIComponent(value);
|
|
169
|
+
case "+": return encodeURI(value);
|
|
170
|
+
case "#": return `#${encodeURI(value)}`;
|
|
171
|
+
case ".": return `.${encodeURIComponent(value)}`;
|
|
172
|
+
case "/": return `/${encodeURIComponent(value)}`;
|
|
173
|
+
case ";": return `;${key}=${encodeURIComponent(value)}`;
|
|
174
|
+
case "?": return `?${key}=${encodeURIComponent(value)}`;
|
|
175
|
+
case "&": return `&${key}=${encodeURIComponent(value)}`;
|
|
176
|
+
default: return match;
|
|
177
|
+
}
|
|
142
178
|
});
|
|
143
179
|
}
|
|
180
|
+
function validateOutboxListenerPath(path, dispatcherPath) {
|
|
181
|
+
if (!path.startsWith("/")) throw new TypeError("Path must start with a slash.");
|
|
182
|
+
if (dispatcherPath != null && dispatcherPath !== path) throw new TypeError("Outbox listener path and outbox dispatcher path must match.");
|
|
183
|
+
const operatorMatches = globalThis.Array.from(path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g));
|
|
184
|
+
if (operatorMatches.some((match) => [
|
|
185
|
+
"?",
|
|
186
|
+
"&",
|
|
187
|
+
"#"
|
|
188
|
+
].includes(match[1]) && match[2] === "identifier")) throw new TypeError("Path for outbox cannot use query or fragment expansion for identifier.");
|
|
189
|
+
const variables = operatorMatches.map((match) => match[2]);
|
|
190
|
+
if (variables.length !== 1 || variables[0] !== "identifier") throw new TypeError("Path for outbox must have exactly one variable named identifier.");
|
|
191
|
+
}
|
|
144
192
|
/**
|
|
145
193
|
* A mock implementation of the {@link Federation} interface for unit testing.
|
|
146
194
|
* This class provides a way to test Fedify applications without needing
|
|
@@ -197,12 +245,17 @@ var MockFederation = class {
|
|
|
197
245
|
objectDispatchers = /* @__PURE__ */ new Map();
|
|
198
246
|
inboxDispatcher;
|
|
199
247
|
outboxDispatcher;
|
|
248
|
+
outboxAuthorizePredicate;
|
|
249
|
+
outboxDispatcherAuthorizePredicate;
|
|
250
|
+
outboxListenerErrorHandler;
|
|
200
251
|
followingDispatcher;
|
|
201
252
|
followersDispatcher;
|
|
202
253
|
likedDispatcher;
|
|
203
254
|
featuredDispatcher;
|
|
204
255
|
featuredTagsDispatcher;
|
|
205
256
|
inboxListeners = /* @__PURE__ */ new Map();
|
|
257
|
+
outboxListeners = /* @__PURE__ */ new Map();
|
|
258
|
+
outboxListenersInitialized = false;
|
|
206
259
|
contextData;
|
|
207
260
|
receivedActivities = [];
|
|
208
261
|
constructor(options = {}) {
|
|
@@ -244,13 +297,17 @@ var MockFederation = class {
|
|
|
244
297
|
};
|
|
245
298
|
}
|
|
246
299
|
setOutboxDispatcher(path, dispatcher) {
|
|
300
|
+
validateOutboxListenerPath(path, this.outboxListenersInitialized ? this.outboxPath : void 0);
|
|
247
301
|
this.outboxDispatcher = dispatcher;
|
|
248
302
|
this.outboxPath = path;
|
|
249
303
|
return {
|
|
250
304
|
setCounter: () => this,
|
|
251
305
|
setFirstCursor: () => this,
|
|
252
306
|
setLastCursor: () => this,
|
|
253
|
-
authorize: () =>
|
|
307
|
+
authorize: (predicate) => {
|
|
308
|
+
this.outboxDispatcherAuthorizePredicate = predicate;
|
|
309
|
+
return this;
|
|
310
|
+
}
|
|
254
311
|
};
|
|
255
312
|
}
|
|
256
313
|
setFollowingDispatcher(path, dispatcher) {
|
|
@@ -328,6 +385,28 @@ var MockFederation = class {
|
|
|
328
385
|
}
|
|
329
386
|
};
|
|
330
387
|
}
|
|
388
|
+
setOutboxListeners(outboxPath) {
|
|
389
|
+
if (this.outboxListenersInitialized) throw new TypeError("Outbox listeners already set.");
|
|
390
|
+
validateOutboxListenerPath(outboxPath, this.outboxPath);
|
|
391
|
+
this.outboxListenersInitialized = true;
|
|
392
|
+
this.outboxPath = outboxPath;
|
|
393
|
+
const self = this;
|
|
394
|
+
return {
|
|
395
|
+
on(type, listener) {
|
|
396
|
+
if (self.outboxListeners.has(type)) throw new TypeError("Listener already set for this type.");
|
|
397
|
+
self.outboxListeners.set(type, listener);
|
|
398
|
+
return this;
|
|
399
|
+
},
|
|
400
|
+
onError(handler) {
|
|
401
|
+
self.outboxListenerErrorHandler = handler;
|
|
402
|
+
return this;
|
|
403
|
+
},
|
|
404
|
+
authorize(predicate) {
|
|
405
|
+
self.outboxAuthorizePredicate = predicate;
|
|
406
|
+
return this;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
331
410
|
setOutboxPermanentFailureHandler(_handler) {}
|
|
332
411
|
async startQueue(contextData, options) {
|
|
333
412
|
this.contextData = contextData;
|
|
@@ -344,8 +423,10 @@ var MockFederation = class {
|
|
|
344
423
|
}
|
|
345
424
|
createContext(baseUrlOrRequest, contextData) {
|
|
346
425
|
const mockFederation = this;
|
|
426
|
+
const request = baseUrlOrRequest instanceof Request ? baseUrlOrRequest : null;
|
|
347
427
|
return new MockContext({
|
|
348
|
-
url:
|
|
428
|
+
url: request == null ? baseUrlOrRequest : new URL(request.url),
|
|
429
|
+
request,
|
|
349
430
|
data: contextData,
|
|
350
431
|
federation: mockFederation
|
|
351
432
|
});
|
|
@@ -373,6 +454,81 @@ var MockFederation = class {
|
|
|
373
454
|
}), activity);
|
|
374
455
|
}
|
|
375
456
|
/**
|
|
457
|
+
* Simulates posting an activity to a local actor outbox.
|
|
458
|
+
* This method is specific to the mock implementation and is used for
|
|
459
|
+
* testing purposes.
|
|
460
|
+
*
|
|
461
|
+
* @param identifier The identifier of the outbox owner.
|
|
462
|
+
* @param activity The activity to post.
|
|
463
|
+
* @returns A promise that resolves when the activity has been processed.
|
|
464
|
+
* @since 2.2.0
|
|
465
|
+
*/
|
|
466
|
+
async postOutboxActivity(identifier, activity) {
|
|
467
|
+
if (!this.outboxListenersInitialized) throw new Error("MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized.");
|
|
468
|
+
let ctor = activity.constructor;
|
|
469
|
+
let listener = this.outboxListeners.get(ctor);
|
|
470
|
+
while (listener == null && ctor !== Activity) {
|
|
471
|
+
ctor = globalThis.Object.getPrototypeOf(ctor);
|
|
472
|
+
listener = this.outboxListeners.get(ctor);
|
|
473
|
+
}
|
|
474
|
+
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.");
|
|
475
|
+
const origin = new URL(this.options.origin ?? "https://example.com");
|
|
476
|
+
const routingContext = this.createContext(origin, this.contextData);
|
|
477
|
+
const postedJson = await activity.toJsonLd({ contextLoader: routingContext.contextLoader });
|
|
478
|
+
const request = new Request(routingContext.getOutboxUri(identifier), {
|
|
479
|
+
method: "POST",
|
|
480
|
+
body: JSON.stringify(postedJson),
|
|
481
|
+
headers: { "content-type": "application/activity+json" }
|
|
482
|
+
});
|
|
483
|
+
const baseContext = this.createContext(request, this.contextData);
|
|
484
|
+
const rawActivity = postedJson;
|
|
485
|
+
const deliveryState = { delivered: false };
|
|
486
|
+
const createMockOutboxContext = () => createOutboxContext({
|
|
487
|
+
...baseContext,
|
|
488
|
+
clone: void 0,
|
|
489
|
+
federation: this,
|
|
490
|
+
identifier,
|
|
491
|
+
hasDeliveredActivity: () => deliveryState.delivered,
|
|
492
|
+
sendActivity: async (sender, recipients, outboundActivity, options) => {
|
|
493
|
+
await baseContext.sendActivity(sender, recipients, outboundActivity, options);
|
|
494
|
+
deliveryState.delivered = true;
|
|
495
|
+
},
|
|
496
|
+
forwardActivity: async (forwarder, recipients, options) => {
|
|
497
|
+
const hasProof = hasProofLike(rawActivity);
|
|
498
|
+
const hasLds = hasSignatureLike(rawActivity);
|
|
499
|
+
if (options?.skipIfUnsigned && !hasProof && !hasLds) return;
|
|
500
|
+
await baseContext.sendActivity(forwarder, recipients, activity, {
|
|
501
|
+
...options,
|
|
502
|
+
rawActivity
|
|
503
|
+
});
|
|
504
|
+
deliveryState.delivered = true;
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
const actor = await baseContext.getActor(identifier);
|
|
508
|
+
if (actor == null) throw new Error(`Actor ${JSON.stringify(identifier)} not found.`);
|
|
509
|
+
const authorizePredicate = this.outboxAuthorizePredicate ?? this.outboxDispatcherAuthorizePredicate;
|
|
510
|
+
if (authorizePredicate != null && !await authorizePredicate(baseContext, identifier)) throw new Error("Unauthorized.");
|
|
511
|
+
const expectedActorId = actor.id ?? baseContext.getActorUri(identifier);
|
|
512
|
+
if (activity.actorIds.length < 1) {
|
|
513
|
+
const error = /* @__PURE__ */ new Error("The posted activity has no actor.");
|
|
514
|
+
await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error);
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
if (!activity.actorIds.every((actorId) => actorId.href === expectedActorId.href)) {
|
|
518
|
+
const error = /* @__PURE__ */ new Error("The activity actor does not match the outbox owner.");
|
|
519
|
+
await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error);
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
if (listener == null) return;
|
|
523
|
+
const context = createMockOutboxContext();
|
|
524
|
+
try {
|
|
525
|
+
await listener(context, activity);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
await this.outboxListenerErrorHandler?.(context, error);
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
376
532
|
* Clears all sent activities from the mock federation.
|
|
377
533
|
* This method is specific to the mock implementation and is used for
|
|
378
534
|
* testing purposes.
|
|
@@ -501,7 +657,7 @@ var MockContext = class MockContext {
|
|
|
501
657
|
this.host = url.host;
|
|
502
658
|
this.hostname = url.hostname;
|
|
503
659
|
this.url = url;
|
|
504
|
-
this.request = new Request(url);
|
|
660
|
+
this.request = options.request ?? new Request(url);
|
|
505
661
|
this.data = options.data;
|
|
506
662
|
this.federation = options.federation;
|
|
507
663
|
this.documentLoader = options.documentLoader ?? (async (url) => ({
|
|
@@ -670,11 +826,12 @@ var MockContext = class MockContext {
|
|
|
670
826
|
lookupWebFinger(_resource, _options) {
|
|
671
827
|
return Promise.resolve(null);
|
|
672
828
|
}
|
|
673
|
-
sendActivity(sender, recipients, activity,
|
|
829
|
+
sendActivity(sender, recipients, activity, options) {
|
|
674
830
|
this.sentActivities.push({
|
|
675
831
|
sender,
|
|
676
832
|
recipients,
|
|
677
|
-
activity
|
|
833
|
+
activity,
|
|
834
|
+
rawActivity: options?.rawActivity
|
|
678
835
|
});
|
|
679
836
|
if (this.federation instanceof MockFederation) {
|
|
680
837
|
const queued = this.federation.queueStarted;
|
|
@@ -682,6 +839,7 @@ var MockContext = class MockContext {
|
|
|
682
839
|
queued,
|
|
683
840
|
queue: queued ? "outbox" : void 0,
|
|
684
841
|
activity,
|
|
842
|
+
rawActivity: options?.rawActivity,
|
|
685
843
|
sentOrder: ++this.federation.sentCounter
|
|
686
844
|
});
|
|
687
845
|
}
|
|
@@ -890,4 +1048,4 @@ async function waitFor(predicate, timeoutMs) {
|
|
|
890
1048
|
}
|
|
891
1049
|
const getRandomKey = (prefix) => `fedify_test_${prefix}_${crypto.randomUUID()}`;
|
|
892
1050
|
//#endregion
|
|
893
|
-
export { createContext, createFederation, createInboxContext, createRequestContext, getRandomKey, testMessageQueue, waitFor };
|
|
1051
|
+
export { createContext, createFederation, createInboxContext, createOutboxContext, createRequestContext, getRandomKey, testMessageQueue, waitFor };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/testing",
|
|
3
|
-
"version": "2.2.0-pr.
|
|
3
|
+
"version": "2.2.0-pr.697.18+b776632f",
|
|
4
4
|
"description": "Testing utilities for Fedify applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fedify",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"package.json"
|
|
51
51
|
],
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@fedify/fedify": "^2.2.0-pr.
|
|
53
|
+
"@fedify/fedify": "^2.2.0-pr.697.18+b776632f"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"es-toolkit": "1.43.0"
|