@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 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: args.forwardActivity ?? ((_params) => {
123
- throw new Error("Not implemented");
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 with values.
135
- * Supports simple placeholders like {identifier}, etc.
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(/{([^}]+)}/g, (match, key) => {
142
- return values[key] || match;
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: () => this
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: baseUrlOrRequest instanceof Request ? new URL(baseUrlOrRequest.url) : baseUrlOrRequest,
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, _options) {
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 { CryptographicKey, Multikey, lookupObject, traverseCollection } from "@fedify/vocab";
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: args.forwardActivity ?? ((_params) => {
122
- throw new Error("Not implemented");
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 with values.
134
- * Supports simple placeholders like {identifier}, etc.
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(/{([^}]+)}/g, (match, key) => {
141
- return values[key] || match;
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: () => this
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: baseUrlOrRequest instanceof Request ? new URL(baseUrlOrRequest.url) : baseUrlOrRequest,
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, _options) {
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.695.16+7a782334",
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.695.16+7a782334"
53
+ "@fedify/fedify": "^2.2.0-pr.697.18+b776632f"
54
54
  },
55
55
  "dependencies": {
56
56
  "es-toolkit": "1.43.0"