Unverified Commit 5db6ad89 authored by Lee ByeongJun's avatar Lee ByeongJun Committed by GitHub
Browse files

feat(test): `MockFederation` and `MockContext` (#283)

parent faaf4c74
Loading
Loading
Loading
Loading
+12 −0
Original line number Diff line number Diff line
@@ -24,6 +24,16 @@ the versioning.
    All packages now follow the same version number and are released together.
    Previously, each package had independent versioning.

 -  Added mock classes for `Federation` and `Context` interfaces to improve
    testability without requiring a real federation server setup. The mock
    classes track all sent activities with metadata and support all standard
    Fedify patterns including custom path registration and multiple activity
    type listeners.  [[#197], [#283] by Lee ByeongJun]

     -  Added `@fedify/testing` package.
     -  Added `MockFederation` class.
     -  Added `MockContext` class.

 -  Key–value stores now optionally support CAS (compare-and-swap) operation
    for atomic updates.  This is useful for implementing optimistic locking
    and preventing lost updates in concurrent environments.
@@ -88,6 +98,7 @@ the versioning.
     -  Added `FedifyModule` for integrating Fedify into NestJS applications.

[#168]: https://github.com/fedify-dev/fedify/issues/168
[#197]: https://github.com/fedify-dev/fedify/issues/197
[#248]: https://github.com/fedify-dev/fedify/issues/248
[#260]: https://github.com/fedify-dev/fedify/issues/260
[#262]: https://github.com/fedify-dev/fedify/issues/262
@@ -96,6 +107,7 @@ the versioning.
[#278]: https://github.com/fedify-dev/fedify/pull/278
[#281]: https://github.com/fedify-dev/fedify/pull/281
[#282]: https://github.com/fedify-dev/fedify/pull/282
[#283]: https://github.com/fedify-dev/fedify/pull/283
[#285]: https://github.com/fedify-dev/fedify/pull/285
[#298]: https://github.com/fedify-dev/fedify/pull/298
[#300]: https://github.com/fedify-dev/fedify/pull/300
+1 −0
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@
    "./h3",
    "./postgres",
    "./redis",
    "./testing",
    "./examples/blog",
    "./examples/cloudflare-workers",
    "./examples/hono-sample"
+1 −0
Original line number Diff line number Diff line
@@ -90,6 +90,7 @@ const REFERENCES = {
    { text: "@fedify/h3", link: "https://jsr.io/@fedify/h3/doc" },
    { text: "@fedify/postgres", link: "https://jsr.io/@fedify/postgres/doc" },
    { text: "@fedify/redis", link: "https://jsr.io/@fedify/redis/doc" },
    { text: "@fedify/testing", link: "https://jsr.io/@fedify/testing/doc" },
  ],
};

+243 −0
Original line number Diff line number Diff line
@@ -143,3 +143,246 @@ const federation = createFederation({
> environment.

[SSRF]: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery


Mocking
-------

*This API is available since Fedify 1.8.0.*

When writing unit tests for your federated server application, you often need
to mock the federation layer to avoid making actual network requests and to
have predictable test behavior.  Fedify provides the `@fedify/testing` package
that includes mock implementations of the `Federation` and `Context` interfaces
specifically designed for testing purposes.

### Installation

You can install the `@fedify/testing` package using your preferred package
manager:

::: code-group

~~~~ bash [Deno]
deno add @fedify/testing
~~~~

~~~~ bash [npm]
npm install @fedify/testing
~~~~

~~~~ bash [pnpm]
pnpm add @fedify/testing
~~~~

~~~~ bash [Yarn]
yarn add @fedify/testing
~~~~

~~~~ bash [Bun]
bun add @fedify/testing
~~~~

:::

### `MockFederation`

The `MockFederation` class provides a mock implementation of the `Federation`
interface that allows you to:

 -  Track sent activities without making network requests
 -  Simulate receiving activities and test inbox listeners
 -  Configure custom URI templates for testing
 -  Test queue-based activity processing

Here's a basic example of using `MockFederation`:

~~~~ typescript twoslash
import { MockFederation } from "@fedify/testing";
import { Create, Note } from "@fedify/fedify/vocab";

// Create a mock federation with context data
const federation = new MockFederation<{ userId: string }>({
  contextData: { userId: "test-user" }
});

// Set up inbox listeners
federation
  .setInboxListeners("/users/{identifier}/inbox")
  .on(Create, async (ctx, activity) => {
    console.log("Received activity:", activity.id);
    // Your inbox logic here
  });

// Simulate receiving an activity
const activity = new Create({
  id: new URL("https://example.com/activities/1"),
  actor: new URL("https://example.com/users/alice"),
  object: new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Hello, world!"
  })
});

await federation.receiveActivity(activity);

// Check sent activities
console.log("Sent activities:", federation.sentActivities);
~~~~

### `MockContext`

The `MockContext` class provides a mock implementation of the `Context`
interface that tracks sent activities and provides mock implementations of
URI generation methods:

~~~~ typescript twoslash
import { MockContext, MockFederation } from "@fedify/testing";
import { Create, Note, Person } from "@fedify/fedify/vocab";

// Create a mock federation and context
const federation = new MockFederation<{ userId: string }>();
const context = new MockContext({
  url: new URL("https://example.com"),
  data: { userId: "test-user" },
  federation: federation
});

// Send an activity
const activity = new Create({
  id: new URL("https://example.com/activities/1"),
  actor: new URL("https://example.com/users/alice"),
  object: new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Hello from MockContext!"
  })
});

await context.sendActivity(
  { identifier: "alice" },
  new Person({ id: new URL("https://example.com/users/bob") }),
  activity
);

// Check sent activities
const sentActivities = context.getSentActivities();
console.log("Context sent activities:", sentActivities);

// Also available in federation
console.log("Federation sent activities:", federation.sentActivities);
~~~~

### Testing URI generation

`MockContext` provides mock implementations of all URI generation methods
that work with the paths configured in your `MockFederation`:

~~~~ typescript twoslash
import { MockContext, MockFederation } from "@fedify/testing";
import { Note } from "@fedify/fedify/vocab";

const federation = new MockFederation();

// Configure paths (similar to real federation setup)
federation.setActorDispatcher("/users/{identifier}", () => null);
federation.setInboxListeners("/users/{identifier}/inbox", "/shared-inbox");
federation.setOutboxDispatcher("/users/{identifier}/outbox", () => null);
federation.setObjectDispatcher(Note, "/notes/{id}", () => null);

const context = federation.createContext(
  new URL("https://example.com"),
  undefined
);

// Test URI generation
console.log(context.getActorUri("alice"));     // https://example.com/users/alice
console.log(context.getInboxUri("alice"));     // https://example.com/users/alice/inbox
console.log(context.getOutboxUri("alice"));    // https://example.com/users/alice/outbox
console.log(context.getObjectUri(Note, { id: "123" })); // https://example.com/notes/123
~~~~

### Tracking sent activities

Both `MockFederation` and `MockContext` track sent activities with detailed
metadata:

~~~~ typescript twoslash
// @noErrors: 2554
import { MockContext, MockFederation } from "@fedify/testing";

const federation = new MockFederation();
const context = new MockContext({
  federation,
  url: new URL("https://example.com/"),
  data: undefined,
});

// Send some activities...
await context.sendActivity(/* ... */);

// Check federation-level tracking
federation.sentActivities.forEach(sent => {
  console.log("Activity:", sent.activity.id);
  console.log("Queued:", sent.queued);
  console.log("Queue type:", sent.queue);
  console.log("Send order:", sent.sentOrder);
});

// Check context-level tracking
context.getSentActivities().forEach(sent => {
  console.log("Sender:", sent.sender);
  console.log("Recipients:", sent.recipients);
  console.log("Activity:", sent.activity);
});
~~~~

### Simulating queue processing

You can test queue-based activity processing by starting the mock queue:

~~~~ typescript twoslash
// @noErrors: 2554
import { MockContext, MockFederation } from "@fedify/testing";

const federation = new MockFederation();

// Start the queue to simulate background processing
await federation.startQueue({ contextData: { userId: "test" } });

// Now sent activities will be marked as queued
const context = new MockContext({
  federation,
  url: new URL("https://example.com/"),
  data: { userId: "test" },
})
await context.sendActivity(/* ... */);

// Check if activities were queued
const queued = federation.sentActivities.filter(a => a.queued);
console.log("Queued activities:", queued.length);
~~~~

### Resetting mock state

Both mock classes provide `reset()` methods to clear tracked activities:

~~~~ typescript twoslash
import { MockContext, MockFederation } from "@fedify/testing";
const federation = new MockFederation();
const context = new MockContext({
  federation,
  url: new URL("https://example.com/"),
  data: undefined,
});
// ---cut-before---
// Clear all sent activities
federation.reset();
context.reset();

console.log(federation.sentActivities.length); // 0
console.log(context.getSentActivities().length); // 0
~~~~

The mocking utilities make it easy to write comprehensive unit tests for your
federated server application without requiring a full federation setup or
making actual network requests.
+2 −1
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@
    "@fedify/nestjs": "workspace:",
    "@fedify/postgres": "workspace:",
    "@fedify/redis": "workspace:",
    "@fedify/testing": "workspace:",
    "@hono/node-server": "^1.13.7",
    "@js-temporal/polyfill": "catalog:",
    "@logtape/file": "catalog:",
@@ -46,7 +47,7 @@
    "x-forwarded-fetch": "^0.2.0"
  },
  "scripts": {
    "dev": "cd ../ && pnpm run --filter '!{docs}' -r build && cd docs/ && vitepress dev",
    "dev": "cd ../ && pnpm run --filter '!{docs}' -r build && cd docs/ && vitepress dev --host",
    "build": "cd ../ && pnpm run --filter '!{docs}' -r build && cd docs/ && vitepress build",
    "preview": "cd ../ && pnpm run --filter '!{docs}' -r build && cd docs/ && vitepress preview"
  }
Loading