@fujocoded/msw-atproto
v0.0.1
Published
MSW-based ATProto testing helpers: mock identity (PLC), record reads/writes, and DNS stubs for vitest setup.
Maintainers
Readme
@fujocoded/msw-atproto
MSW request handlers for tests that talk to ATproto services...or at least believe they do.
What is @fujocoded/msw-atproto?
@fujocoded/msw-atproto uses the power of MSW to give
you(r tests) fake, stateful ATproto accounts that respond to HTTP
requests exactly like real PDSes on the real network would! Set a DID, a handle,
and the records for each of these accounts, then just let your code read and
write through normal ATproto PDS endpoints: it will do so while staying
completely offline. With @fujocoded/msw-atproto your Atproto tests can stay
fast and deterministic, while overrides remain as close as possible to the source
of truth, which makes them easier to reason about.
Thanks to this library, you can (pretend to):
- Serve DID documents from the PLC directory, the public registry for
did:plc:...accounts - Serve
.well-known/atproto-did, the handle lookup URL your code needs to know an account's DID - Serve PDS record endpoints like
listRecords,getRecord,createRecord,putRecord,deleteRecord, andapplyWrites - Serve PDS blob endpoints for uploads and reads, so records can point at image or file data
- Store successful writes in memory, so a later read sees what the test wrote
[!IMPORTANT]
This package currently covers PDS record reads/writes, PDS blob reads, and identity resolution. It does not cover firehose methods like
com.atproto.sync.subscribeRepos, the whole OAuth dance, or AppView endpoints, like anything underapp.bsky.*.
What can you do with @fujocoded/msw-atproto?
- Test code that reads Bluesky posts or custom ATproto records through a
real
AtpAgentorClient, including for long lists that need pagination - Test code that writes to a PDS, such as creating a Bluesky post, updating a profile, or deleting a record. These persist across requests in the same test.
- Test records that point at blobs, such as image refs, sprite sheets, or thumbnails, without hosting a real file server
- Test identity resolution, including handle lookup, PLC lookup, missing DIDs, and handle changes
- Test PLC document updates, such as adding a verification method or moving an account to a new PDS
- Test failure modes like a missing record, a missing blob, a
.well-known404, or one flakygetRecordrequest
What's included in @fujocoded/msw-atproto?
In this package, you'll find:
useMockAtprotoRepo()andcreateMockAtprotoRepo(): Create one fake account with identity, record, and blob handlersuseMockRepoIdentity()andcreateMockRepoIdentity(): Create one fake identity (without record or blob handlers)useMockPlcOperationFlow()andcreateMockPlcOperationFlow(): Create the fake network calls used when code updates a DID documentcreateDnsMock(importActual): Makes handle resolution fall back to the HTTP path MSW can intercept (a must-have when using handles in your tests!)createIdentityPassthrough(): Lets one test mix fake accounts with real handlesFAKE_CID: A placeholder CID for when the exact value does not matterfakeCid(input)andcidForRecord(...): Give you stable CIDs for assertions
Setup (a.k.a. okay, how do I actually do this stuff?)
- Run the following command:
npm add --save-dev @fujocoded/msw-atproto msw- Create one MSW server for your tests:
// __tests__/msw/server.ts
import { setupServer } from "msw/node";
export const server = setupServer();- Start MSW and install the DNS helper in your test setup:
// __tests__/setup.ts
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { server } from "./msw/server.ts";
// THIS IS IMPORTANT!
// MAKE SURE YOU HAVE THIS IF YOU NEED TO RESOLVE HANDLES!
vi.mock("node:dns/promises", async (importActual) => {
const { createDnsMock } = await import("@fujocoded/msw-atproto");
return createDnsMock(importActual);
});
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});[!IMPORTANT]
Keep
onUnhandledRequest: "error"on in your test setup. Missing ATproto fakes should fail the test loudly. Without that flag, MSW lets an unmatched request through and your test may call the real network.If your tests still do real HTTP calls, you may need to allow certain requests to punch through.
- Wire the setup file into Vitest:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
setupFiles: ["./__tests__/setup.ts"],
},
});- In a test, create a fake account, then just use a real client:
// __tests__/example.test.ts
import { AtpAgent } from "@atproto/api";
import { expect, test } from "vitest";
import { useMockAtprotoRepo } from "@fujocoded/msw-atproto";
import { server } from "./msw/server.ts";
test("loads records for one account", async () => {
const repo = useMockAtprotoRepo(server, {
did: "did:plc:bobatan",
handle: "bobatan.fujocoded.com",
records: {
"app.bsky.feed.post": [
{ rkey: "whatever", value: { text: "hello fujin!" } },
],
},
});
// The exact same code as production...
const agent = new AtpAgent({ service: repo.pds });
const { data } = await agent.com.atproto.repo.listRecords({
repo: "did:plc:bobatan",
collection: "app.bsky.feed.post",
});
// ...but our test's own special result!
expect(data.records).toHaveLength(1);
});[!NOTE]
By default,
msw-atprototakes over every ATproto handle in your tests: thecreateDnsMockDNS helper makes all_atproto.<handle>TXT lookups fail withENODATA, to force the ATproto identity library to checkhttps://<handle>/.well-known/atproto-didinstead (which MSW can intercept). If your tests need a mix of fake and real handles together, seeidentity-passthrough.test.ts.
Examples You Can Run (and Copy)
Runnable examples live in
__examples__/.
Run them from the repo root:
npm test --workspace @fujocoded/msw-atproto -- __examples__Pick the file that matches your needs:
01-stateful-repo.test.ts: one fake Bluesky account, realAtpAgentcalls, seeded records, writes, reads, and blobs02-repo-boundaries.test.ts: empty collections, several accounts on one PDS, identity changes, and cursor-based pagination03-empty-repo-and-raw-msw.test.ts: an intentionally empty account, plus raw MSW for server behavior this package does not model04-shared-fixture.test.ts: one shared setup pattern for a suite where every test starts from the same state
Customize Your Fake Account
useMockAtprotoRepo: faking a PDS
useMockAtprotoRepo(server, { did, pds?, handle?, records?, blobs? }) creates
one fake account and registers its handlers with the MSW server.
Config options
did(required): The account DID, likedid:plc:bobatan. This identifies the repo, that is the account's PDS, on every XRPC callpds(optional): The fake PDS URL. Defaults tohttps://pds.fujocoded.test. The returned fake exposes the final value asrepo.pdshandle(optional): The account handle, likebobatan.fujocoded.com. When set, the library will also servehttps://<handle>/.well-known/atproto-did. Record reads and writes also accept this handle in the XRPCrepoparameterrecords(optional): Seed records, grouped by collection NSID, likeapp.bsky.feed.post. Each seed record has{ rkey, value, cid? }. Whencidis omitted, the library derives a stable CID from the repo DID, collection, rkey, and valueblobs(optional): Blob bodies the fake PDS can serve fromcom.atproto.sync.getBlob. Each seed has{ cid, body?, contentType? }
When records is omitted, no collections are declared. A listRecords request
for an undeclared collection fails the test under onUnhandledRequest: "error".
Seed [] when an empty collection is the expected result:
const repo = useMockAtprotoRepo(server, { did: "did:plc:bobatan" });
repo.seed("app.bsky.feed.post", []);MockAtprotoRepo properties
pds: The URL the handlers answer ondidandhandle: The identity values the fake was created withhandlers(): The MSW handlers for manual registration. Includes identity handlers, all six record endpoints, and both blob endpointsrecords(): A snapshot of the current stored recordswrites(): Successful write requests captured so fardeletes(): Successful delete requests captured so farseed(collection, records): Declares a collection and adds or replaces records in it. Pass[]to declare a collection as intentionally emptyseedBlob(blob): Adds or replaces one hosted blobclear(): Clears records, blobs, declared collections, captured writes, captured deletes, queued failures, and generated countersidentity.*: One-off identity handlers and mutators for missing PLC documents, custom DID documents, handle changes, and verification methodsfailOnce.*: One-shot ATproto-shaped failure handles for the next matching endpoint request
useMockRepoIdentity: faking an identity
Use useMockRepoIdentity(server, { did, pds?, handle? }) when your test only
needs account identity. It serves the PLC DID document, and (when you pass a
handle) serves .well-known/atproto-did.
When the test already has a fake account, you can simply use repo.identity
from useMockAtprotoRepo(...) .
MockRepoIdentity properties
pds: The PDS URL advertised by the fake identitydidandhandle: The identity values the fake was created withhandlers(): The MSW handlers for manual registrationplcNotFound(): Makes PLC lookup return404 NotFounddidDocument(doc): Serves a custom DID documentwellKnownNotFound(): Makes handle lookup return404handleResolvesTo(otherDid): Makes the configured handle point at another DIDsetDidDocument(doc): Replaces the stored DID document without registering a new MSW handlerupdateDidDocument(fn): Updates the stored DID documentsetVerificationMethod(name, didKeyOrMultibase): Adds or replaces a DID document verification methodsetHandleDid(nextDid): Changes the DID returned by the configured handlereset(): Restores the original DID document and handle result
useMockPlcOperationFlow: faking updates to DID documents
Use useMockPlcOperationFlow(server, { did, pds?, operation, signedOperation?,
onSign?, onSubmit? }) when code asks a PDS to update a DID document.
Use createMockPlcOperationFlow(...) when you want the flow fake without
registering it right away. It returns the same object, including handlers().
This covers the three network calls involved in an update to the PLC (the
directory that stores the current DID document for did:plc:... accounts).
The flow fake registers three handlers:
- Serves the current PLC operation from the account's audit log
- Serves the PDS signing endpoint and returns
{ operation: signedOperation }, or echoes the submitted body withsigned: true - Accepts the signed operation at the PLC directory and returns
{}
onSign(body) and onSubmit(body) let your test assert on the payload sent to
each step. plcDirectoryUrl defaults to https://plc.directory.
Simulate failures
To return a ATproto-shaped errors, queue a one-shot failure on the fakes:
repo.failOnce.getRecord({ status: 404 });The next matching getRecord request returns:
{ "error": "RecordNotFound", "message": "Record not found" }...then it all goes back to normal.
More failures!
Each failOnce.* method accepts an optional status, error, and message,
plus filters for that endpoint:
listRecordsandcreateRecord=>collectiongetRecord,putRecord, anddeleteRecord=>collectionandrkeygetBlob=>cid
For example, fail the next listRecords request for one collection:
repo.failOnce.listRecords({
collection: "app.bsky.feed.post",
status: 503,
});Fail the next getRecord request for one record:
repo.failOnce.getRecord({
collection: "app.bsky.feed.post",
rkey: "3k2jxqj7m4s2a",
status: 404,
});Fail the next blob read for one CID:
repo.failOnce.getBlob({
cid: avatarCid,
status: 404,
message: "Avatar blob is missing",
});To test a network failure or malformed response (or other, pernicious cases), use raw MSW:
import { http, HttpResponse } from "msw";
server.use(
http.get(`${repo.pds}/xrpc/com.atproto.repo.getRecord`, () =>
HttpResponse.error(),
),
);Faking CIDs
When a seed record has no cid, useMockAtprotoRepo(...) derives one from the
repo DID, collection, rkey, and JSON.stringify(value). If object key order
matters to your test, pass an explicit cid.
- Use
FAKE_CIDwhen a test needs one valid placeholder CID and does not care about content - Use
fakeCid(input)when several records or blobs need stable but different CIDs - Use
cidForRecord({ repo, collection, rkey, value })when a test seeds a record withoutcidand later wants to assert on the CID the fake generated
