From 4533c74c96601278393ec43bb60cd52eb1aaaead Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Sun, 31 May 2026 02:08:37 -0700 Subject: [PATCH] Create msw-atproto package --- .../__examples__/04-single-entry/README.md | 8 +- msw-atproto/LICENSE | 21 + msw-atproto/README.md | 386 ++++++++++++ .../__examples__/01-stateful-repo.test.ts | 86 +++ .../__examples__/02-repo-boundaries.test.ts | 142 +++++ .../03-empty-repo-and-raw-msw.test.ts | 72 +++ .../__examples__/04-shared-fixture.test.ts | 70 +++ msw-atproto/__examples__/README.md | 23 + msw-atproto/__examples__/msw/server.ts | 3 + msw-atproto/__examples__/package-lock.json | 155 +++++ msw-atproto/__examples__/package.json | 10 + msw-atproto/__examples__/setup.ts | 18 + msw-atproto/__tests__/cid.test.ts | 14 + msw-atproto/__tests__/dns.test.ts | 136 +++++ .../__tests__/identity-passthrough.test.ts | 97 +++ msw-atproto/__tests__/identity.test.ts | 256 ++++++++ msw-atproto/__tests__/msw/server.ts | 3 + msw-atproto/__tests__/plc.test.ts | 71 +++ .../__tests__/repo-apply-writes.test.ts | 243 ++++++++ msw-atproto/__tests__/repo-failures.test.ts | 320 ++++++++++ msw-atproto/__tests__/repo-records.test.ts | 504 +++++++++++++++ .../__tests__/repo-upload-blob.test.ts | 91 +++ msw-atproto/__tests__/setup.ts | 7 + msw-atproto/__tests__/support.ts | 41 ++ msw-atproto/package.json | 65 ++ msw-atproto/src/cid.ts | 51 ++ msw-atproto/src/dns.ts | 120 ++++ msw-atproto/src/identity/mock.ts | 296 +++++++++ msw-atproto/src/identity/passthrough.ts | 69 +++ msw-atproto/src/index.ts | 25 + msw-atproto/src/repo/failures.ts | 174 ++++++ msw-atproto/src/repo/handlers.ts | 578 ++++++++++++++++++ msw-atproto/src/repo/index.ts | 106 ++++ msw-atproto/src/repo/model.ts | 338 ++++++++++ msw-atproto/src/repo/xrpc.ts | 73 +++ msw-atproto/tsconfig.json | 21 + msw-atproto/tsdown.config.ts | 11 + msw-atproto/vitest.config.ts | 22 + package-lock.json | 30 + 39 files changed, 4752 insertions(+), 4 deletions(-) create mode 100644 msw-atproto/LICENSE create mode 100644 msw-atproto/README.md create mode 100644 msw-atproto/__examples__/01-stateful-repo.test.ts create mode 100644 msw-atproto/__examples__/02-repo-boundaries.test.ts create mode 100644 msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts create mode 100644 msw-atproto/__examples__/04-shared-fixture.test.ts create mode 100644 msw-atproto/__examples__/README.md create mode 100644 msw-atproto/__examples__/msw/server.ts create mode 100644 msw-atproto/__examples__/package-lock.json create mode 100644 msw-atproto/__examples__/package.json create mode 100644 msw-atproto/__examples__/setup.ts create mode 100644 msw-atproto/__tests__/cid.test.ts create mode 100644 msw-atproto/__tests__/dns.test.ts create mode 100644 msw-atproto/__tests__/identity-passthrough.test.ts create mode 100644 msw-atproto/__tests__/identity.test.ts create mode 100644 msw-atproto/__tests__/msw/server.ts create mode 100644 msw-atproto/__tests__/plc.test.ts create mode 100644 msw-atproto/__tests__/repo-apply-writes.test.ts create mode 100644 msw-atproto/__tests__/repo-failures.test.ts create mode 100644 msw-atproto/__tests__/repo-records.test.ts create mode 100644 msw-atproto/__tests__/repo-upload-blob.test.ts create mode 100644 msw-atproto/__tests__/setup.ts create mode 100644 msw-atproto/__tests__/support.ts create mode 100644 msw-atproto/package.json create mode 100644 msw-atproto/src/cid.ts create mode 100644 msw-atproto/src/dns.ts create mode 100644 msw-atproto/src/identity/mock.ts create mode 100644 msw-atproto/src/identity/passthrough.ts create mode 100644 msw-atproto/src/index.ts create mode 100644 msw-atproto/src/repo/failures.ts create mode 100644 msw-atproto/src/repo/handlers.ts create mode 100644 msw-atproto/src/repo/index.ts create mode 100644 msw-atproto/src/repo/model.ts create mode 100644 msw-atproto/src/repo/xrpc.ts create mode 100644 msw-atproto/tsconfig.json create mode 100644 msw-atproto/tsdown.config.ts create mode 100644 msw-atproto/vitest.config.ts diff --git a/astro-atproto-loader/__examples__/04-single-entry/README.md b/astro-atproto-loader/__examples__/04-single-entry/README.md index e89b49c..e8316c6 100644 --- a/astro-atproto-loader/__examples__/04-single-entry/README.md +++ b/astro-atproto-loader/__examples__/04-single-entry/README.md @@ -29,16 +29,16 @@ npm install npm run dev ``` -Then open `http://127.0.0.1:4321` for the static page and `http://127.0.0.1:4321/live` +Then open `http://127.0.0.1:4321` for the static page and `http://127.0.0.1:4321/live` for the live page. > [!WARNING] -> +> > If you haven't done so already, you'll first need to build `astro-atproto-loader` itself. > If you don't remember if you have, don't worry: there's no harm in doing so again. -> +> > From the `astro-atproto-loader` folder—**not this one!**—run: -> +> > 1. `npm install` > 2. `npm run build` > diff --git a/msw-atproto/LICENSE b/msw-atproto/LICENSE new file mode 100644 index 0000000..a48a70a --- /dev/null +++ b/msw-atproto/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 FujoCoded LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/msw-atproto/README.md b/msw-atproto/README.md new file mode 100644 index 0000000..9babb1f --- /dev/null +++ b/msw-atproto/README.md @@ -0,0 +1,386 @@ +# `@fujocoded/msw-atproto` + + + +[MSW](https://mswjs.io/) 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](https://mswjs.io/) 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): + +1. Serve DID documents from the PLC directory, the public registry for + `did:plc:...` accounts +2. Serve `.well-known/atproto-did`, the handle lookup URL your code needs to know + an account's DID +3. Serve PDS record endpoints like `listRecords`, `getRecord`, `createRecord`, + `putRecord`, `deleteRecord`, and `applyWrites` +4. Serve PDS blob endpoints for uploads and reads, so records can point at + image or file data +5. 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 under `app.bsky.*`. + +## What can **you** do with `@fujocoded/msw-atproto`? + +- **Test code that reads Bluesky posts or custom ATproto records** through a + real `AtpAgent` or `Client`, 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-known` + 404, or one flaky `getRecord` request + +## What's included in `@fujocoded/msw-atproto`? + +In this package, you'll find: + +- `useMockAtprotoRepo()` and `createMockAtprotoRepo()`: Create one fake account + with identity, record, and blob handlers +- `useMockRepoIdentity()` and `createMockRepoIdentity()`: Create one fake + identity (without record or blob handlers) +- `useMockPlcOperationFlow()` and `createMockPlcOperationFlow()`: Create the + fake network calls used when code updates a DID document +- `createDnsMock(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 + handles +- `FAKE_CID`: A placeholder CID for when the exact value does not matter +- `fakeCid(input)` and `cidForRecord(...)`: Give you stable CIDs for assertions + +## Setup (a.k.a. okay, how do I _actually_ do this stuff?) + +1. Run the following command: + +```bash +npm add --save-dev @fujocoded/msw-atproto msw +``` + +2. Create one MSW server for your tests: + +```ts +// __tests__/msw/server.ts +import { setupServer } from "msw/node"; + +export const server = setupServer(); +``` + +3. Start MSW and install the DNS helper in your test setup: + +```ts +// __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. + +1. Wire the setup file into Vitest: + +```ts +// vitest.config.ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + setupFiles: ["./__tests__/setup.ts"], + }, +}); +``` + +5. In a test, create a fake account, then just use a real client: + +```ts +// __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-atproto` takes over every ATproto handle in your tests: the +> `createDnsMock` DNS helper makes all `_atproto.` TXT lookups fail with +> `ENODATA`, to force the ATproto identity library to check +> `https:///.well-known/atproto-did` instead (which MSW can intercept). +> If your tests need a mix of fake and real handles together, see +> [`identity-passthrough.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__tests__/identity-passthrough.test.ts). + +## Examples You Can Run (and Copy) + +Runnable examples live in +[`__examples__/`](https://github.com/FujoWebDev/fujocoded-plugins/tree/main/msw-atproto/__examples__). + +Run them from the repo root: + +```sh +npm test --workspace @fujocoded/msw-atproto -- __examples__ +``` + +Pick the file that matches your needs: + +- [`01-stateful-repo.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__examples__/01-stateful-repo.test.ts): + one fake Bluesky account, real `AtpAgent` calls, seeded records, writes, + reads, and blobs +- [`02-repo-boundaries.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__examples__/02-repo-boundaries.test.ts): + empty collections, several accounts on one PDS, identity changes, and + cursor-based pagination +- [`03-empty-repo-and-raw-msw.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts): + an intentionally empty account, plus raw MSW for server behavior this package + does not model +- [`04-shared-fixture.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__examples__/04-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, like `did:plc:bobatan`. This identifies + the repo, that is the account's PDS, on every XRPC call +- **`pds`** (optional): The fake PDS URL. Defaults to + `https://pds.fujocoded.test`. The returned fake exposes the final value as + `repo.pds` +- **`handle`** (optional): The account handle, like + `bobatan.fujocoded.com`. When set, the library will also serve + `https:///.well-known/atproto-did`. Record reads and writes also + accept this handle in the XRPC `repo` parameter +- **`records`** (optional): Seed records, grouped by collection NSID, like + `app.bsky.feed.post`. Each seed record has `{ rkey, value, cid? }`. When `cid` + is omitted, the library derives a stable CID from the repo DID, collection, rkey, + and value +- **`blobs`** (optional): Blob bodies the fake PDS can serve from + `com.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: + +```ts +const repo = useMockAtprotoRepo(server, { did: "did:plc:bobatan" }); +repo.seed("app.bsky.feed.post", []); +``` + +#### `MockAtprotoRepo` properties + +- **`pds`**: The URL the handlers answer on +- **`did`** and **`handle`**: The identity values the fake was created with +- **`handlers()`**: The MSW handlers for manual registration. Includes + identity handlers, all six record endpoints, and both blob endpoints +- **`records()`**: A snapshot of the current stored records +- **`writes()`**: Successful write requests captured so far +- **`deletes()`**: Successful delete requests captured so far +- **`seed(collection, records)`**: Declares a collection and adds or replaces + records in it. Pass `[]` to declare a collection as intentionally empty +- **`seedBlob(blob)`**: Adds or replaces one hosted blob +- **`clear()`**: Clears records, blobs, declared collections, captured writes, + captured deletes, queued failures, and generated counters +- **`identity.*`**: One-off identity handlers and mutators for missing PLC + documents, custom DID documents, handle changes, and verification methods +- **`failOnce.*`**: 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 identity +- **`did`** and **`handle`**: The identity values the fake was created with +- **`handlers()`**: The MSW handlers for manual registration +- **`plcNotFound()`**: Makes PLC lookup return `404 NotFound` +- **`didDocument(doc)`**: Serves a custom DID document +- **`wellKnownNotFound()`**: Makes handle lookup return `404` +- **`handleResolvesTo(otherDid)`**: Makes the configured handle point at another + DID +- **`setDidDocument(doc)`**: Replaces the stored DID document without + registering a new MSW handler +- **`updateDidDocument(fn)`**: Updates the stored DID document +- **`setVerificationMethod(name, didKeyOrMultibase)`**: Adds or replaces a DID + document verification method +- **`setHandleDid(nextDid)`**: Changes the DID returned by the configured handle +- **`reset()`**: 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 with `signed: 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: + +```ts +repo.failOnce.getRecord({ status: 404 }); +``` + +The next matching `getRecord` request returns: + +```json +{ "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: + +- `listRecords` and `createRecord` => `collection` +- `getRecord`, `putRecord`, and `deleteRecord` => `collection` and `rkey` +- `getBlob` => `cid` + +For example, fail the next `listRecords` request for one collection: + +```ts +repo.failOnce.listRecords({ + collection: "app.bsky.feed.post", + status: 503, +}); +``` + +Fail the next `getRecord` request for one record: + +```ts +repo.failOnce.getRecord({ + collection: "app.bsky.feed.post", + rkey: "3k2jxqj7m4s2a", + status: 404, +}); +``` + +Fail the next blob read for one CID: + +```ts +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: + +```ts +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_CID` when 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 without `cid` and later wants to assert on the CID the fake generated diff --git a/msw-atproto/__examples__/01-stateful-repo.test.ts b/msw-atproto/__examples__/01-stateful-repo.test.ts new file mode 100644 index 0000000..d41919a --- /dev/null +++ b/msw-atproto/__examples__/01-stateful-repo.test.ts @@ -0,0 +1,86 @@ +import { AtpAgent } from "@atproto/api"; +import { AtUri } from "@atproto/syntax"; +import { describe, expect, test } from "vitest"; + +import { cidForRecord, useMockAtprotoRepo, FAKE_CID } from "../src/index.ts"; +import { server } from "./msw/server.ts"; + +describe("stateful repo fake", () => { + test("the smallest end-to-end fake of one Bluesky account, real client included", async () => { + const did = "did:plc:bobatan"; + const handle = "bobatan.fujocoded.com"; + const avatarCid = FAKE_CID; + const avatarBytes = new Uint8Array([137, 80, 78, 71]); + + const repo = useMockAtprotoRepo(server, { + did, + handle, + records: { + "app.bsky.feed.post": [ + { rkey: "seeded", value: { text: "already here" } }, + ], + }, + blobs: [ + { + cid: avatarCid, + body: avatarBytes, + contentType: "image/png", + }, + ], + }); + const agent = new AtpAgent({ service: repo.pds }); + + const { data: created } = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: "app.bsky.feed.post", + record: { text: "created during the test" }, + }); + + const { data: listed } = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: "app.bsky.feed.post", + }); + + const createdUri = new AtUri(created.uri); + const { data: fetched } = await agent.com.atproto.repo.getRecord({ + repo: createdUri.hostname, + collection: createdUri.collection, + rkey: createdUri.rkey, + }); + const avatar = await fetch( + `${repo.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent( + did, + )}&cid=${avatarCid}`, + ); + + expect(created).toMatchObject({ + uri: AtUri.make(did, "app.bsky.feed.post", "3kgenerated1").toString(), + cid: cidForRecord({ + repo: did, + collection: "app.bsky.feed.post", + rkey: "3kgenerated1", + value: { text: "created during the test" }, + }), + }); + expect(listed).toMatchObject({ + records: [ + { uri: AtUri.make(did, "app.bsky.feed.post", "seeded").toString() }, + { + uri: AtUri.make(did, "app.bsky.feed.post", "3kgenerated1").toString(), + }, + ], + }); + expect(fetched).toMatchObject({ + value: { text: "created during the test" }, + }); + expect(avatar.headers.get("content-type")).toBe("image/png"); + expect(new Uint8Array(await avatar.arrayBuffer())).toEqual(avatarBytes); + + await expect( + agent.com.atproto.repo.listRecords({ + repo: did, + collection: "app.bsky.graph.follow", + }), + ).rejects.toThrow(); + }); +}); diff --git a/msw-atproto/__examples__/02-repo-boundaries.test.ts b/msw-atproto/__examples__/02-repo-boundaries.test.ts new file mode 100644 index 0000000..4abc5dc --- /dev/null +++ b/msw-atproto/__examples__/02-repo-boundaries.test.ts @@ -0,0 +1,142 @@ +import { AtpAgent } from "@atproto/api"; +import { AtUri } from "@atproto/syntax"; +import { describe, expect, test } from "vitest"; + +import { useMockAtprotoRepo } from "../src/index.ts"; +import { server } from "./msw/server.ts"; + +describe("repo boundaries", () => { + test("use this when tests need to tell 'no records yet' apart from 'I forgot to seed'", async () => { + const did = "did:plc:bobatan"; + const pds = "https://pds.fujocoded.test"; + const repo = useMockAtprotoRepo(server, { did, pds }); + + repo.seed("app.bsky.feed.post", []); + + const agent = new AtpAgent({ service: pds }); + + const { data: empty } = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: "app.bsky.feed.post", + }); + expect(empty).toEqual({ records: [] }); + + await expect( + agent.com.atproto.repo.listRecords({ + repo: did, + collection: "app.bsky.graph.follow", + }), + ).rejects.toThrow(); + }); + + test("use this when code reads from several accounts on one PDS", async () => { + const mainDid = "did:plc:bobatan"; + const altDid = "did:plc:bobatan-alt"; + const pds = "https://pds.fujocoded.test"; + useMockAtprotoRepo(server, { + did: mainDid, + pds, + records: { + "app.bsky.feed.post": [{ rkey: "main-post", value: { text: "main" } }], + }, + }); + useMockAtprotoRepo(server, { + did: altDid, + pds, + records: { + "app.bsky.feed.post": [{ rkey: "alt-post", value: { text: "alt" } }], + }, + }); + + const agent = new AtpAgent({ service: pds }); + + const { data: mainList } = await agent.com.atproto.repo.listRecords({ + repo: mainDid, + collection: "app.bsky.feed.post", + }); + const { data: altList } = await agent.com.atproto.repo.listRecords({ + repo: altDid, + collection: "app.bsky.feed.post", + }); + + expect(mainList).toMatchObject({ + records: [ + { + uri: AtUri.make( + mainDid, + "app.bsky.feed.post", + "main-post", + ).toString(), + }, + ], + }); + expect(altList).toMatchObject({ + records: [ + { + uri: AtUri.make(altDid, "app.bsky.feed.post", "alt-post").toString(), + }, + ], + }); + }); + + test("re-resolving a handle reflects an identity override registered mid-test", async () => { + const handle = "bobatan.fujocoded.com"; + const originalDid = "did:plc:bobatan"; + const newOwnerDid = "did:plc:bobatan-alt"; + const pds = "https://pds.fujocoded.test"; + const repo = useMockAtprotoRepo(server, { did: originalDid, handle, pds }); + + const before = await fetch(`https://${handle}/.well-known/atproto-did`); + expect(await before.text()).toBe(originalDid); + + server.use(repo.identity.handleResolvesTo(newOwnerDid)); + + const after = await fetch(`https://${handle}/.well-known/atproto-did`); + expect(await after.text()).toBe(newOwnerDid); + }); + + test("use this when the code under test walks a multi-page collection by following cursors", async () => { + const did = "did:plc:bobatan"; + const repo = useMockAtprotoRepo(server, { + did, + records: { + "app.bsky.feed.post": [ + { rkey: "post-1", value: { text: "one" } }, + { rkey: "post-2", value: { text: "two" } }, + { rkey: "post-3", value: { text: "three" } }, + { rkey: "post-4", value: { text: "four" } }, + { rkey: "post-5", value: { text: "five" } }, + ], + }, + }); + const agent = new AtpAgent({ service: repo.pds }); + + const pages: string[][] = []; + let cursor: string | undefined; + let lastCursor: string | undefined; + do { + const { data } = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: "app.bsky.feed.post", + limit: 2, + cursor, + }); + pages.push(data.records.map((rec) => rec.uri)); + cursor = data.cursor; + lastCursor = data.cursor; + } while (cursor); + + expect(pages).toEqual([ + [ + AtUri.make(did, "app.bsky.feed.post", "post-1").toString(), + AtUri.make(did, "app.bsky.feed.post", "post-2").toString(), + ], + [ + AtUri.make(did, "app.bsky.feed.post", "post-3").toString(), + AtUri.make(did, "app.bsky.feed.post", "post-4").toString(), + ], + [AtUri.make(did, "app.bsky.feed.post", "post-5").toString()], + ]); + expect(lastCursor).toBeUndefined(); + }); +}); diff --git a/msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts b/msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts new file mode 100644 index 0000000..3b3cbc5 --- /dev/null +++ b/msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts @@ -0,0 +1,72 @@ +import { AtpAgent } from "@atproto/api"; +import { AtUri } from "@atproto/syntax"; +import { http, HttpResponse } from "msw"; +import { describe, expect, test } from "vitest"; + +import { useMockAtprotoRepo, fakeCid } from "../src/index.ts"; +import { server } from "./msw/server.ts"; + +// Use the stateful repo fake for normal ATproto behavior, even when the +// starting state is empty. Use raw MSW only for server behavior this package +// does not model, such as a cursor loop. +describe("empty repo and raw MSW for impossible server behavior", () => { + test("use an empty stateful repo stub when a collection should have no records yet", async () => { + const did = "did:plc:bobatan"; + const collection = "app.bsky.feed.post"; + const repo = useMockAtprotoRepo(server, { did }); + + repo.seed(collection, []); + + const agent = new AtpAgent({ service: repo.pds }); + const { data } = await agent.com.atproto.repo.listRecords({ + repo: did, + collection, + }); + + expect(data).toEqual({ records: [] }); + }); + + test("use raw MSW for impossible pagination behavior such as a cursor loop", async () => { + const did = "did:plc:bobatan"; + const collection = "app.bsky.feed.post"; + const rkey = "duplicate"; + const repo = useMockAtprotoRepo(server, { did }); + const record = { + uri: AtUri.make(did, collection, rkey).toString(), + cid: fakeCid("duplicate-page-record"), + value: { text: "same record on every page" }, + }; + + // Register the normal fake first, then a hand-authored endpoint override. + // The repo fake handles the ordinary cases; raw MSW keeps weird server + // behavior local to the one test that needs it. + server.use( + http.get(`${repo.pds}/xrpc/com.atproto.repo.listRecords`, () => + HttpResponse.json({ + records: [record], + cursor: "again", + }), + ), + ); + + const listUrl = new URL(`${repo.pds}/xrpc/com.atproto.repo.listRecords`); + listUrl.searchParams.set("repo", did); + listUrl.searchParams.set("collection", collection); + + const first = await fetch(listUrl); + const firstBody = await first.json(); + + listUrl.searchParams.set("cursor", firstBody.cursor); + const second = await fetch(listUrl); + const secondBody = await second.json(); + + expect(firstBody).toEqual({ + records: [record], + cursor: "again", + }); + expect(secondBody).toEqual({ + records: [record], + cursor: "again", + }); + }); +}); diff --git a/msw-atproto/__examples__/04-shared-fixture.test.ts b/msw-atproto/__examples__/04-shared-fixture.test.ts new file mode 100644 index 0000000..0cc84c1 --- /dev/null +++ b/msw-atproto/__examples__/04-shared-fixture.test.ts @@ -0,0 +1,70 @@ +import { AtpAgent } from "@atproto/api"; +import { AtUri } from "@atproto/syntax"; +import { beforeEach, describe, expect, test } from "vitest"; + +import { useMockAtprotoRepo } from "../src/index.ts"; +import { server } from "./msw/server.ts"; + +describe("shared fixture", () => { + const did = "did:plc:bobatan"; + // The agent has to be created in `beforeEach`, not at describe time: + // `AtpAgent` captures the `globalThis.fetch` reference it sees during + // construction, and MSW only patches that reference inside `server.listen` + // (which runs in `beforeAll`). An agent built too early holds the + // unpatched fetch and bypasses every handler. + let repo: ReturnType; + let agent: AtpAgent; + + beforeEach(() => { + repo = useMockAtprotoRepo(server, { did }); + repo.seed("app.bsky.feed.post", [ + { rkey: "seed", value: { text: "fresh seed" } }, + ]); + agent = new AtpAgent({ service: repo.pds }); + }); + + test("each test sees the seed and nothing else", async () => { + const { data: listed } = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: "app.bsky.feed.post", + }); + + expect(listed).toMatchObject({ + records: [ + { uri: AtUri.make(did, "app.bsky.feed.post", "seed").toString() }, + ], + }); + }); + + test("writes in one test do not leak into the next", async () => { + await agent.com.atproto.repo.createRecord({ + repo: did, + collection: "app.bsky.feed.post", + record: { text: "written in test A" }, + }); + + const { data: listed } = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: "app.bsky.feed.post", + }); + + expect(listed.records).toHaveLength(2); + expect(repo.writes()).toHaveLength(1); + }); + + test("a fresh write also persists on a clean slate", async () => { + await agent.com.atproto.repo.createRecord({ + repo: did, + collection: "app.bsky.feed.post", + record: { text: "written in test B" }, + }); + + const { data: listed } = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: "app.bsky.feed.post", + }); + + expect(listed.records).toHaveLength(2); + expect(repo.writes()).toHaveLength(1); + }); +}); diff --git a/msw-atproto/__examples__/README.md b/msw-atproto/__examples__/README.md new file mode 100644 index 0000000..5680473 --- /dev/null +++ b/msw-atproto/__examples__/README.md @@ -0,0 +1,23 @@ +# `@fujocoded/msw-atproto` testing examples + +These tests use [Vitest](https://vitest.dev/) and [MSW](https://mswjs.io/) to show +you how to use `@fujocoded/msw-atproto` in your own tests! + +Pick the file that matches your test: + +- [`01-stateful-repo.test.ts`](./01-stateful-repo.test.ts): One fake Bluesky + account, real `AtpAgent` calls, seeded records, writes, reads, and blobs +- [`02-repo-boundaries.test.ts`](./02-repo-boundaries.test.ts): Empty + collections, several accounts on one PDS, identity changes, and cursor-based + pagination +- [`03-empty-repo-and-raw-msw.test.ts`](./03-empty-repo-and-raw-msw.test.ts): + An intentionally empty account, plus raw MSW for server behavior this package + does not model +- [`04-shared-fixture.test.ts`](./04-shared-fixture.test.ts): One shared setup + pattern for a suite where every test starts from the same state + +Run the example suite from the repo root: + +```sh +npm test --workspace @fujocoded/msw-atproto -- __examples__ +``` diff --git a/msw-atproto/__examples__/msw/server.ts b/msw-atproto/__examples__/msw/server.ts new file mode 100644 index 0000000..bd0bda5 --- /dev/null +++ b/msw-atproto/__examples__/msw/server.ts @@ -0,0 +1,3 @@ +import { setupServer } from "msw/node"; + +export const server = setupServer(); diff --git a/msw-atproto/__examples__/package-lock.json b/msw-atproto/__examples__/package-lock.json new file mode 100644 index 0000000..98c9246 --- /dev/null +++ b/msw-atproto/__examples__/package-lock.json @@ -0,0 +1,155 @@ +{ + "name": "msw-atproto-examples", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "msw-atproto-examples", + "version": "0.0.0", + "dependencies": { + "@atproto/api": "^0.19.16", + "@atproto/syntax": "^0.5.4" + } + }, + "node_modules/@atproto/api": { + "version": "0.19.16", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.19.16.tgz", + "integrity": "sha512-OAnY0+JDucUjPuPIrRVNT3h1PMi8txHd+lOdzenCcDAhtEsMRrUEWv4/IpEHRMclUAnX4ekfJDLlYleaOCQcng==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.21", + "@atproto/lexicon": "^0.6.2", + "@atproto/syntax": "^0.5.4", + "@atproto/xrpc": "^0.7.7", + "await-lock": "^2.2.2", + "multiformats": "^9.9.0", + "tlds": "^1.234.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/common-web": { + "version": "0.4.21", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.21.tgz", + "integrity": "sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.0.15", + "@atproto/lex-json": "^0.0.16", + "@atproto/syntax": "^0.5.4", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.15.tgz", + "integrity": "sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.9.0", + "tslib": "^2.8.1", + "uint8arrays": "3.0.0", + "unicode-segmenter": "^0.14.0" + } + }, + "node_modules/@atproto/lex-json": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.16.tgz", + "integrity": "sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.0.15", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz", + "integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.18", + "@atproto/syntax": "^0.5.0", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.4.tgz", + "integrity": "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/xrpc": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.6.0", + "zod": "^3.23.8" + } + }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "license": "MIT" + }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/msw-atproto/__examples__/package.json b/msw-atproto/__examples__/package.json new file mode 100644 index 0000000..2a83e1e --- /dev/null +++ b/msw-atproto/__examples__/package.json @@ -0,0 +1,10 @@ +{ + "name": "msw-atproto-examples", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@atproto/api": "^0.19.16", + "@atproto/syntax": "^0.5.4" + } +} diff --git a/msw-atproto/__examples__/setup.ts b/msw-atproto/__examples__/setup.ts new file mode 100644 index 0000000..f5292c1 --- /dev/null +++ b/msw-atproto/__examples__/setup.ts @@ -0,0 +1,18 @@ +import { afterAll, afterEach, beforeAll, vi } from "vitest"; + +import { server } from "./msw/server.ts"; + +vi.mock("node:dns/promises", async (importActual) => { + // In your own project, replace this relative import with: + // const { createDnsMock } = await import("@fujocoded/msw-atproto"); + const { createDnsMock } = await import("../src/index.ts"); + return createDnsMock(importActual); +}); + +beforeAll(async () => { + server.listen({ onUnhandledRequest: "error" }); +}); +afterEach(async () => { + server.resetHandlers(); +}); +afterAll(() => server.close()); diff --git a/msw-atproto/__tests__/cid.test.ts b/msw-atproto/__tests__/cid.test.ts new file mode 100644 index 0000000..94ae673 --- /dev/null +++ b/msw-atproto/__tests__/cid.test.ts @@ -0,0 +1,14 @@ +import { CID } from "multiformats"; +import { describe, expect, it } from "vitest"; + +import { fakeCid } from "../src/index.ts"; + +describe("fakeCid", () => { + it("returns the same parseable CIDv1 for the same input", () => { + const input = 'did:plc:repo/app.bsky.feed.post/rkey:{"text":"hi"}'; + const cid = fakeCid(input); + + expect(fakeCid(input)).toBe(cid); + expect(CID.parse(cid).version).toBe(1); + }); +}); diff --git a/msw-atproto/__tests__/dns.test.ts b/msw-atproto/__tests__/dns.test.ts new file mode 100644 index 0000000..ef1b02e --- /dev/null +++ b/msw-atproto/__tests__/dns.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; + +import { createDnsMock } from "../src/index.ts"; +import { createDnsStub } from "../src/dns.ts"; + +describe("createDnsStub", () => { + type DnsPromisesModule = typeof import("node:dns/promises"); + + const buildFakeActual = () => { + const topLevelCalls: string[] = []; + const setServersCalls: string[][] = []; + const resolverCalls: string[] = []; + + class FakeResolver { + setServers(servers: readonly string[]) { + setServersCalls.push([...servers]); + } + resolveTxt(hostname: string) { + resolverCalls.push(hostname); + return Promise.resolve([["from-resolver"]]); + } + } + + const resolveTxt = ((hostname: string) => { + topLevelCalls.push(hostname); + return Promise.resolve([["from-top-level"]]); + }) as DnsPromisesModule["resolveTxt"]; + + const actual = { + resolveTxt, + Resolver: FakeResolver, + } as unknown as DnsPromisesModule; + + return { actual, topLevelCalls, setServersCalls, resolverCalls }; + }; + + it("rejects `_atproto.` TXT lookups on the top-level resolveTxt without calling through", async () => { + const { actual, topLevelCalls } = buildFakeActual(); + const stub = createDnsStub(actual); + + await expect( + stub.resolveTxt("_atproto.bobatan.fujocoded.test"), + ).rejects.toMatchObject({ code: "ENODATA" }); + await expect( + stub.default.resolveTxt("_atproto.bobatan.fujocoded.test"), + ).rejects.toMatchObject({ code: "ENODATA" }); + + expect(topLevelCalls).toEqual([]); + }); + + it("normalizes DNS hostnames before deciding whether to intercept", async () => { + const { actual, topLevelCalls } = buildFakeActual(); + const seenHandles: string[] = []; + const stub = createDnsStub(actual, { + shouldInterceptHandle: (handle) => { + seenHandles.push(handle); + return handle === "bobatan.fujocoded.test"; + }, + }); + + await expect( + stub.resolveTxt("_ATPROTO.Bobatan.FujoCoded.Test."), + ).rejects.toMatchObject({ code: "ENODATA" }); + + expect(seenHandles).toEqual(["bobatan.fujocoded.test"]); + expect(topLevelCalls).toEqual([]); + }); + + it("delegates non-atproto TXT lookups to the real resolveTxt", async () => { + const { actual, topLevelCalls } = buildFakeActual(); + const stub = createDnsStub(actual); + + await expect(stub.resolveTxt("fujocoded.test")).resolves.toEqual([ + ["from-top-level"], + ]); + expect(topLevelCalls).toEqual(["fujocoded.test"]); + }); + + it("forwards `_atproto.*` queries to real DNS when shouldInterceptHandle returns false", async () => { + const { actual, topLevelCalls, resolverCalls } = buildFakeActual(); + const stub = createDnsStub(actual, { + shouldInterceptHandle: (handle) => handle.endsWith(".fujocoded.test"), + }); + + await expect( + stub.resolveTxt("_atproto.bobatan.fujocoded.test"), + ).rejects.toMatchObject({ code: "ENODATA" }); + + await expect( + stub.resolveTxt("_atproto.alice.bsky.social"), + ).resolves.toEqual([["from-top-level"]]); + expect(topLevelCalls).toEqual(["_atproto.alice.bsky.social"]); + + const resolver = new stub.Resolver(); + await expect( + resolver.resolveTxt("_atproto.bobatan.fujocoded.test"), + ).rejects.toMatchObject({ code: "ENODATA" }); + await expect( + resolver.resolveTxt("_atproto.alice.bsky.social"), + ).resolves.toEqual([["from-resolver"]]); + expect(resolverCalls).toEqual(["_atproto.alice.bsky.social"]); + }); + + it("rejects `_atproto.*` from a Resolver instance and delegates everything else", async () => { + const { actual, setServersCalls, resolverCalls } = buildFakeActual(); + const stub = createDnsStub(actual); + const resolver = new stub.Resolver(); + + resolver.setServers(["1.1.1.1"]); + expect(setServersCalls).toEqual([["1.1.1.1"]]); + + await expect( + resolver.resolveTxt("_atproto.bobatan.fujocoded.test"), + ).rejects.toMatchObject({ code: "ENODATA" }); + expect(resolverCalls).toEqual([]); + + await expect(resolver.resolveTxt("fujocoded.test")).resolves.toEqual([ + ["from-resolver"], + ]); + expect(resolverCalls).toEqual(["fujocoded.test"]); + }); + + it("exposes an async Vitest mock factory for setup files", async () => { + const { actual, topLevelCalls } = buildFakeActual(); + + const stub = await createDnsMock(async () => actual); + + await expect(stub.resolveTxt("fujocoded.test")).resolves.toEqual([ + ["from-top-level"], + ]); + await expect( + stub.resolveTxt("_atproto.bobatan.fujocoded.test"), + ).rejects.toMatchObject({ code: "ENODATA" }); + expect(topLevelCalls).toEqual(["fujocoded.test"]); + }); +}); diff --git a/msw-atproto/__tests__/identity-passthrough.test.ts b/msw-atproto/__tests__/identity-passthrough.test.ts new file mode 100644 index 0000000..405bab6 --- /dev/null +++ b/msw-atproto/__tests__/identity-passthrough.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; + +import { + createIdentityPassthrough, + createMockRepoIdentity, +} from "../src/index.ts"; +import { DID, HANDLE, PDS } from "./support.ts"; +import { server } from "./msw/server.ts"; + +describe("createIdentityPassthrough", () => { + type DnsPromisesModule = typeof import("node:dns/promises"); + + const buildFakeActual = () => { + class FakeResolver { + getServers() { + return []; + } + setServers() {} + cancel() {} + resolveTxt() { + return Promise.resolve([["from-resolver"]]); + } + } + + const resolveTxt = (() => + Promise.resolve([["from-top-level"]])) as DnsPromisesModule["resolveTxt"]; + + return { + resolveTxt, + Resolver: FakeResolver, + } as unknown as DnsPromisesModule; + }; + + it("flips the DNS-stub predicate so selected handles passthrough and the rest stay mocked", async () => { + const passthrough = createIdentityPassthrough({ + shouldPassthrough: (handle) => handle === "selected.example.test", + }); + const dns = await passthrough.dnsMock(async () => buildFakeActual()); + + await expect( + dns.resolveTxt("_atproto.mocked.example.test"), + ).rejects.toMatchObject({ code: "ENODATA" }); + await expect( + dns.resolveTxt("_atproto.selected.example.test"), + ).resolves.toEqual([["from-top-level"]]); + }); + + it("well-known handler passthroughs selected hostnames and falls through otherwise", async () => { + const passthrough = createIdentityPassthrough({ + shouldPassthrough: (handle) => handle === "selected.invalid", + }); + server.use( + ...passthrough.handlers, + http.get("https://mocked.invalid/.well-known/atproto-did", () => + HttpResponse.text("did:plc:mocked"), + ), + http.get("https://selected.invalid/.well-known/atproto-did", () => + HttpResponse.text("did:plc:selected"), + ), + ); + + await expect( + fetch("https://mocked.invalid/.well-known/atproto-did").then((response) => + response.text(), + ), + ).resolves.toBe("did:plc:mocked"); + await expect( + fetch("https://selected.invalid/.well-known/atproto-did"), + ).rejects.toBeInstanceOf(TypeError); + }); + + it("routes @atproto/identity handle resolution onto the MSW well-known handler", async () => { + vi.resetModules(); + const passthrough = createIdentityPassthrough(); + vi.doMock("node:dns/promises", passthrough.dnsMock); + + try { + const { HandleResolver } = await import("@atproto/identity"); + server.use( + ...passthrough.handlers, + ...createMockRepoIdentity({ + did: DID, + handle: HANDLE, + pds: PDS, + }).handlers(), + ); + + const resolver = new HandleResolver({ timeout: 50 }); + + await expect(resolver.resolve(HANDLE)).resolves.toBe(DID); + } finally { + vi.doUnmock("node:dns/promises"); + vi.resetModules(); + } + }); +}); diff --git a/msw-atproto/__tests__/identity.test.ts b/msw-atproto/__tests__/identity.test.ts new file mode 100644 index 0000000..c39d17d --- /dev/null +++ b/msw-atproto/__tests__/identity.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; + +import { + createMockAtprotoRepo, + createMockRepoIdentity, + useMockAtprotoRepo, + useMockRepoIdentity, +} from "../src/index.ts"; +import { DID, HANDLE, PDS } from "./support.ts"; +import { server } from "./msw/server.ts"; + +describe("createMockRepoIdentity", () => { + it("serves both the PLC doc and well-known DID response", async () => { + const identity = useMockRepoIdentity(server, { + did: DID, + pds: PDS, + handle: HANDLE, + }); + + const plcResponse = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + const didResponse = await fetch( + `https://${HANDLE}/.well-known/atproto-did`, + ); + + expect(plcResponse.ok).toBe(true); + await expect(plcResponse.json()).resolves.toMatchObject({ + id: DID, + alsoKnownAs: [`at://${HANDLE}`], + service: [ + expect.objectContaining({ + serviceEndpoint: PDS, + }), + ], + }); + await expect(didResponse.text()).resolves.toBe(DID); + expect(identity).toMatchObject({ did: DID, pds: PDS, handle: HANDLE }); + }); + + it("synthesizes a default handle and skips well-known when handle is omitted", async () => { + const identity = createMockRepoIdentity({ did: DID, pds: PDS }); + server.use(...identity.handlers()); + const expectedHandle = `${DID.split(":").pop()}.example.test`; + + const plcResponse = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + await expect(plcResponse.json()).resolves.toMatchObject({ + alsoKnownAs: [`at://${expectedHandle}`], + }); + + await expect( + fetch(`https://${expectedHandle}/.well-known/atproto-did`).catch( + (error) => error, + ), + ).resolves.toBeInstanceOf(Error); + }); + + it("exposes identity override handlers that compose through server.use", async () => { + const identity = createMockRepoIdentity({ + did: DID, + pds: PDS, + handle: HANDLE, + }); + server.use(...identity.handlers()); + server.use(identity.wellKnownNotFound()); + + const missingHandle = await fetch( + `https://${HANDLE}/.well-known/atproto-did`, + ); + + expect(missingHandle.status).toBe(404); + + server.use(identity.handleResolvesTo("did:plc:other")); + const changedHandle = await fetch( + `https://${HANDLE}/.well-known/atproto-did`, + ); + + await expect(changedHandle.text()).resolves.toBe("did:plc:other"); + }); +}); + +describe("repo identity controls", () => { + it("includes identity handlers in repo.handlers()", async () => { + useMockAtprotoRepo(server, { did: DID, handle: HANDLE, pds: PDS }); + + const didResponse = await fetch( + `https://${HANDLE}/.well-known/atproto-did`, + ); + + await expect(didResponse.text()).resolves.toBe(DID); + }); + + it("exposes identity override handlers that compose through server.use", async () => { + const repo = createMockAtprotoRepo({ did: DID, handle: HANDLE, pds: PDS }); + server.use(...repo.handlers()); + server.use(repo.identity.plcNotFound()); + + const response = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + + expect(response.status).toBe(404); + + server.use( + repo.identity.didDocument({ + id: DID, + alsoKnownAs: ["at://custom.example.test"], + }), + ); + const custom = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + + await expect(custom.json()).resolves.toEqual({ + id: DID, + alsoKnownAs: ["at://custom.example.test"], + }); + }); + + it("keeps mutable identity baseline across handler resets while temporary overrides reset away", async () => { + const repo = createMockAtprotoRepo({ did: DID, handle: HANDLE, pds: PDS }); + const baselineDocument = { + id: DID, + alsoKnownAs: [`at://${HANDLE}`], + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: PDS, + }, + ], + verificationMethod: [ + { + id: `${DID}#atproto`, + type: "Multikey", + controller: DID, + publicKeyMultibase: "zBaselineKey", + }, + ], + }; + server.resetHandlers(...repo.handlers()); + + repo.identity.setDidDocument(baselineDocument); + server.use(repo.identity.plcNotFound()); + + const override = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + expect(override.status).toBe(404); + + server.resetHandlers(); + const baseline = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + + await expect(baseline.json()).resolves.toEqual(baselineDocument); + }); + + it("updates the current baseline DID document and verification methods", async () => { + const repo = createMockAtprotoRepo({ did: DID, handle: HANDLE, pds: PDS }); + server.use(...repo.handlers()); + + repo.identity.setDidDocument({ + id: DID, + alsoKnownAs: [`at://${HANDLE}`], + verificationMethod: [ + { + id: `${DID}#atproto`, + type: "Multikey", + controller: DID, + publicKeyMultibase: "zOldAtprotoKey", + }, + ], + }); + repo.identity.updateDidDocument((document) => ({ + ...document, + alsoKnownAs: [...(document.alsoKnownAs ?? []), "at://updated.test"], + })); + repo.identity.setVerificationMethod("atproto", "zUpdatedAtprotoKey"); + repo.identity.setVerificationMethod("rotation", "did:key:zRotationKey"); + + const response = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + const document = (await response.json()) as { + alsoKnownAs?: string[]; + verificationMethod?: Array>; + }; + + expect(document.alsoKnownAs).toEqual([ + `at://${HANDLE}`, + "at://updated.test", + ]); + expect(document.verificationMethod).toEqual([ + expect.objectContaining({ + id: `${DID}#atproto`, + publicKeyMultibase: "zUpdatedAtprotoKey", + }), + expect.objectContaining({ + id: `${DID}#rotation`, + type: "Multikey", + controller: DID, + publicKeyMultibase: "zRotationKey", + }), + ]); + }); + + it("updates the well-known handle baseline and resets identity state", async () => { + const repo = createMockAtprotoRepo({ did: DID, handle: HANDLE, pds: PDS }); + server.use(...repo.handlers()); + + repo.identity.setDidDocument({ + id: DID, + alsoKnownAs: ["at://changed.example.test"], + }); + repo.identity.setHandleDid("did:plc:other"); + + const changedDocument = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + const changedHandle = await fetch( + `https://${HANDLE}/.well-known/atproto-did`, + ); + + await expect(changedDocument.json()).resolves.toEqual({ + id: DID, + alsoKnownAs: ["at://changed.example.test"], + }); + await expect(changedHandle.text()).resolves.toBe("did:plc:other"); + + repo.identity.reset(); + const resetDocument = await fetch( + `https://plc.directory/${encodeURIComponent(DID)}`, + ); + const resetHandle = await fetch( + `https://${HANDLE}/.well-known/atproto-did`, + ); + const resetBody = (await resetDocument.json()) as Record; + + expect(resetBody).toMatchObject({ + id: DID, + alsoKnownAs: [`at://${HANDLE}`], + service: [ + expect.objectContaining({ + serviceEndpoint: PDS, + }), + ], + }); + expect(resetBody).not.toMatchObject({ + alsoKnownAs: ["at://changed.example.test"], + }); + await expect(resetHandle.text()).resolves.toBe(DID); + }); +}); diff --git a/msw-atproto/__tests__/msw/server.ts b/msw-atproto/__tests__/msw/server.ts new file mode 100644 index 0000000..bd0bda5 --- /dev/null +++ b/msw-atproto/__tests__/msw/server.ts @@ -0,0 +1,3 @@ +import { setupServer } from "msw/node"; + +export const server = setupServer(); diff --git a/msw-atproto/__tests__/plc.test.ts b/msw-atproto/__tests__/plc.test.ts new file mode 100644 index 0000000..b25901c --- /dev/null +++ b/msw-atproto/__tests__/plc.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; + +import { useMockPlcOperationFlow } from "../src/index.ts"; +import { DID, fetchJson, HANDLE, PDS } from "./support.ts"; +import { server } from "./msw/server.ts"; + +describe("createMockPlcOperationFlow", () => { + it("serves PLC audit state, signs via the PDS, and captures PLC submission", async () => { + const onSign = vi.fn(); + const onSubmit = vi.fn(); + const operation = { + verificationMethods: { atproto: "did:key:zAtproto" }, + rotationKeys: ["did:key:zRotation"], + alsoKnownAs: [`at://${HANDLE}`], + services: { atproto_pds: { type: "AtprotoPersonalDataServer" } }, + }; + const signedOperation = { type: "plc_operation", sig: "signed" }; + const flow = useMockPlcOperationFlow(server, { + did: DID, + pds: PDS, + operation, + signedOperation, + onSign, + onSubmit, + }); + + const audit = await fetchJson(`https://plc.directory/${DID}/log/audit`); + const signed = await fetchJson( + `${PDS}/xrpc/com.atproto.identity.signPlcOperation`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + token: "123456", + verificationMethods: { + atproto: "did:key:zAtproto", + attestations: "did:key:zAttestation", + }, + rotationKeys: operation.rotationKeys, + alsoKnownAs: operation.alsoKnownAs, + services: operation.services, + }), + }, + ); + const submitted = await fetch(`https://plc.directory/${DID}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(signedOperation), + }); + + expect(audit.body).toEqual([{ operation }]); + expect(flow).toMatchObject({ + did: DID, + pds: PDS, + plcDirectoryUrl: "https://plc.directory", + }); + expect(signed.body).toEqual({ operation: signedOperation }); + expect(submitted.ok).toBe(true); + expect(onSign).toHaveBeenCalledWith({ + token: "123456", + verificationMethods: { + atproto: "did:key:zAtproto", + attestations: "did:key:zAttestation", + }, + rotationKeys: operation.rotationKeys, + alsoKnownAs: operation.alsoKnownAs, + services: operation.services, + }); + expect(onSubmit).toHaveBeenCalledWith(signedOperation); + }); +}); diff --git a/msw-atproto/__tests__/repo-apply-writes.test.ts b/msw-atproto/__tests__/repo-apply-writes.test.ts new file mode 100644 index 0000000..8d17257 --- /dev/null +++ b/msw-atproto/__tests__/repo-apply-writes.test.ts @@ -0,0 +1,243 @@ +import { AtUri } from "@atproto/syntax"; +import { describe, expect, it } from "vitest"; + +import { + COLLECTION, + DID, + expectedRepoCid, + fetchJson, + HANDLE, + OTHER_COLLECTION, + PDS, + setupRepo, +} from "./support.ts"; + +type ApplyWritesResult = + | { + $type: "com.atproto.repo.applyWrites#createResult"; + uri: string; + cid: string; + } + | { + $type: "com.atproto.repo.applyWrites#updateResult"; + uri: string; + cid: string; + } + | { $type: "com.atproto.repo.applyWrites#deleteResult" }; + +type ApplyWritesBody = { results: ApplyWritesResult[] }; + +const applyWrites = (repo: string, writes: Array>) => + fetchJson(`${PDS}/xrpc/com.atproto.repo.applyWrites`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ repo, writes }), + }); + +describe("applyWrites", () => { + it("creates a record from a single #create entry and exposes it through reads", async () => { + const repo = setupRepo({ did: DID, pds: PDS }); + const value = { text: "hello" }; + + const response = await applyWrites(DID, [ + { + $type: "com.atproto.repo.applyWrites#create", + collection: COLLECTION, + value, + }, + ]); + + expect(response.body.results).toHaveLength(1); + const [result] = response.body.results; + expect(result).toMatchObject({ + $type: "com.atproto.repo.applyWrites#createResult", + }); + if (result?.$type !== "com.atproto.repo.applyWrites#createResult") { + throw new Error("expected createResult"); + } + const rkey = new AtUri(result.uri).rkey; + expect(result.cid).toBe(expectedRepoCid(rkey, value)); + expect(repo.records()).toEqual([ + { + collection: COLLECTION, + rkey, + uri: result.uri, + cid: result.cid, + value, + }, + ]); + }); + + it("honors an explicit rkey on #create", async () => { + setupRepo({ did: DID, pds: PDS }); + + const response = await applyWrites(DID, [ + { + $type: "com.atproto.repo.applyWrites#create", + collection: COLLECTION, + rkey: "explicit", + value: { text: "explicit" }, + }, + ]); + + const [result] = response.body.results; + if (result?.$type !== "com.atproto.repo.applyWrites#createResult") { + throw new Error("expected createResult"); + } + expect(result.uri).toBe(`at://${DID}/${COLLECTION}/explicit`); + }); + + it("updates an existing record through #update and overwrites its value", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { [COLLECTION]: [{ rkey: "seeded", value: { text: "old" } }] }, + }); + + const response = await applyWrites(DID, [ + { + $type: "com.atproto.repo.applyWrites#update", + collection: COLLECTION, + rkey: "seeded", + value: { text: "new" }, + }, + ]); + + const [result] = response.body.results; + expect(result).toEqual({ + $type: "com.atproto.repo.applyWrites#updateResult", + uri: `at://${DID}/${COLLECTION}/seeded`, + cid: expectedRepoCid("seeded", { text: "new" }), + }); + expect(repo.records()).toEqual([ + { + collection: COLLECTION, + rkey: "seeded", + uri: `at://${DID}/${COLLECTION}/seeded`, + cid: expectedRepoCid("seeded", { text: "new" }), + value: { text: "new" }, + }, + ]); + }); + + it("removes a record through #delete and returns deleteResult", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { [COLLECTION]: [{ rkey: "seeded", value: { text: "old" } }] }, + }); + + const response = await applyWrites(DID, [ + { + $type: "com.atproto.repo.applyWrites#delete", + collection: COLLECTION, + rkey: "seeded", + }, + ]); + + expect(response.body.results).toEqual([ + { $type: "com.atproto.repo.applyWrites#deleteResult" }, + ]); + expect(repo.records()).toEqual([]); + }); + + it("applies a mixed batch (create + update + delete) in order and reports results in matching order", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [ + { rkey: "to-update", value: { text: "old" } }, + { rkey: "to-delete", value: { text: "doomed" } }, + ], + }, + }); + + const response = await applyWrites(DID, [ + { + $type: "com.atproto.repo.applyWrites#create", + collection: OTHER_COLLECTION, + rkey: "new", + value: { text: "fresh" }, + }, + { + $type: "com.atproto.repo.applyWrites#update", + collection: COLLECTION, + rkey: "to-update", + value: { text: "updated" }, + }, + { + $type: "com.atproto.repo.applyWrites#delete", + collection: COLLECTION, + rkey: "to-delete", + }, + ]); + + expect(response.body.results.map((r) => r.$type)).toEqual([ + "com.atproto.repo.applyWrites#createResult", + "com.atproto.repo.applyWrites#updateResult", + "com.atproto.repo.applyWrites#deleteResult", + ]); + const remainingKeys = repo + .records() + .map( + (r: { collection: string; rkey: string }) => + `${r.collection}/${r.rkey}`, + ) + .sort(); + expect(remainingKeys).toEqual( + [`${COLLECTION}/to-update`, `${OTHER_COLLECTION}/new`].sort(), + ); + }); + + it("captures every applyWrites entry as a write or delete on the repo log", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { [COLLECTION]: [{ rkey: "stay", value: {} }] }, + }); + + await applyWrites(DID, [ + { + $type: "com.atproto.repo.applyWrites#create", + collection: COLLECTION, + rkey: "added", + value: { text: "added" }, + }, + { + $type: "com.atproto.repo.applyWrites#delete", + collection: COLLECTION, + rkey: "stay", + }, + ]); + + expect(repo.writes()).toEqual([ + { + action: "create", + uri: `at://${DID}/${COLLECTION}/added`, + cid: expectedRepoCid("added", { text: "added" }), + record: { text: "added" }, + }, + ]); + expect(repo.deletes()).toEqual([{ uri: `at://${DID}/${COLLECTION}/stay` }]); + }); + + it("accepts handle as repo identifier", async () => { + setupRepo({ did: DID, handle: HANDLE, pds: PDS }); + + const response = await applyWrites(HANDLE, [ + { + $type: "com.atproto.repo.applyWrites#create", + collection: COLLECTION, + rkey: "via-handle", + value: { text: "by handle" }, + }, + ]); + + const [result] = response.body.results; + if (result?.$type !== "com.atproto.repo.applyWrites#createResult") { + throw new Error("expected createResult"); + } + expect(result.uri).toBe(`at://${DID}/${COLLECTION}/via-handle`); + }); +}); diff --git a/msw-atproto/__tests__/repo-failures.test.ts b/msw-atproto/__tests__/repo-failures.test.ts new file mode 100644 index 0000000..17c919e --- /dev/null +++ b/msw-atproto/__tests__/repo-failures.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; + +import { FAKE_CID } from "../src/index.ts"; +import { + COLLECTION, + DID, + expectedRepoCid, + fetchJson, + OTHER_CID, + OTHER_COLLECTION, + PDS, + setupRepo, +} from "./support.ts"; + +const listRecordsUrl = (collection = COLLECTION) => + `${PDS}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent( + DID, + )}&collection=${collection}`; + +const getRecordUrl = (collection = COLLECTION, rkey = "seeded") => + `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent( + DID, + )}&collection=${collection}&rkey=${rkey}`; + +const getBlobUrl = (cid = FAKE_CID) => + `${PDS}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent( + DID, + )}&cid=${cid}`; + +const createRecord = (collection = COLLECTION) => + fetch(`${PDS}/xrpc/com.atproto.repo.createRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + repo: DID, + collection, + record: + collection === COLLECTION + ? { text: "created" } + : { subject: "created" }, + }), + }); + +const putRecord = (collection = COLLECTION, rkey = "seeded") => + fetch(`${PDS}/xrpc/com.atproto.repo.putRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + repo: DID, + collection, + rkey, + record: + collection === COLLECTION + ? { text: "updated" } + : { subject: "updated" }, + }), + }); + +const deleteRecord = (collection = COLLECTION, rkey = "seeded") => + fetch(`${PDS}/xrpc/com.atproto.repo.deleteRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ repo: DID, collection, rkey }), + }); + +const setupFailureRepo = () => + setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "seeded", value: { text: "seeded" } }], + [OTHER_COLLECTION]: [{ rkey: "like", value: { subject: "seeded" } }], + }, + blobs: [ + { cid: FAKE_CID, body: new Uint8Array([1]) }, + { cid: OTHER_CID, body: new Uint8Array([2]) }, + ], + }); + +describe("failOnce error shapes", () => { + const failureCases = [ + { + name: "listRecords returns AuthRequired for 401", + queue: (repo: ReturnType) => + repo.failOnce.listRecords({ status: 401 }), + request: () => fetchJson(listRecordsUrl()), + expected: { + response: { status: 401 }, + body: { error: "AuthRequired", message: "Authentication required" }, + }, + }, + { + name: "getRecord returns RecordNotFound for 404", + queue: (repo: ReturnType) => + repo.failOnce.getRecord({ status: 404 }), + request: () => fetchJson(getRecordUrl()), + expected: { + response: { status: 404 }, + body: { error: "RecordNotFound", message: "Record not found" }, + }, + }, + { + name: "getBlob returns NotFound for 404", + queue: (repo: ReturnType) => + repo.failOnce.getBlob({ status: 404 }), + request: () => fetchJson(getBlobUrl()), + expected: { + response: { status: 404 }, + body: { error: "NotFound", message: "Blob not found" }, + }, + }, + { + name: "createRecord returns RateLimitExceeded for 429", + queue: (repo: ReturnType) => + repo.failOnce.createRecord({ status: 429 }), + request: () => + fetchJson(`${PDS}/xrpc/com.atproto.repo.createRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + repo: DID, + collection: COLLECTION, + record: { text: "failed create" }, + }), + }), + expected: { + response: { status: 429 }, + body: { error: "RateLimitExceeded", message: "Rate limit exceeded" }, + }, + }, + { + name: "putRecord returns InternalServerError for 503", + queue: (repo: ReturnType) => + repo.failOnce.putRecord({ status: 503 }), + request: () => + fetchJson(`${PDS}/xrpc/com.atproto.repo.putRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + repo: DID, + collection: COLLECTION, + rkey: "seeded", + record: { text: "failed put" }, + }), + }), + expected: { + response: { status: 503 }, + body: { + error: "InternalServerError", + message: "Internal server error", + }, + }, + }, + { + name: "deleteRecord allows a custom error and message", + queue: (repo: ReturnType) => + repo.failOnce.deleteRecord({ + status: 418, + error: "Teapot", + message: "Short and stout", + }), + request: () => + fetchJson(`${PDS}/xrpc/com.atproto.repo.deleteRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + repo: DID, + collection: COLLECTION, + rkey: "seeded", + }), + }), + expected: { + response: { status: 418 }, + body: { error: "Teapot", message: "Short and stout" }, + }, + }, + ]; + + for (const { name, queue, request, expected } of failureCases) { + it(name, async () => { + const repo = setupFailureRepo(); + queue(repo); + + await expect(request()).resolves.toMatchObject(expected); + }); + } + + it("does not record failed writes in writes(), deletes(), or records()", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "seeded", value: { text: "seeded" } }], + }, + }); + + repo.failOnce.createRecord({ status: 429 }); + repo.failOnce.putRecord({ status: 503 }); + repo.failOnce.deleteRecord({ status: 418 }); + + await createRecord(); + await putRecord(); + await deleteRecord(); + + expect(repo.writes()).toEqual([]); + expect(repo.deletes()).toEqual([]); + expect(repo.records()).toEqual([ + { + collection: COLLECTION, + rkey: "seeded", + uri: `at://${DID}/${COLLECTION}/seeded`, + cid: expectedRepoCid("seeded", { text: "seeded" }), + value: { text: "seeded" }, + }, + ]); + }); + + it("defaults failOnce failures to 500 InternalServerError", async () => { + const repo = setupFailureRepo(); + repo.failOnce.getRecord(); + + const failed = await fetchJson(getRecordUrl()); + + expect(failed).toMatchObject({ + response: { status: 500 }, + body: { + error: "InternalServerError", + message: "Internal server error", + }, + }); + }); +}); + +describe("failOnce filters", () => { + it("filters by collection", async () => { + const repo = setupFailureRepo(); + + repo.failOnce.listRecords({ collection: OTHER_COLLECTION }); + repo.failOnce.createRecord({ collection: OTHER_COLLECTION }); + + const listPost = await fetch(listRecordsUrl()); + const listLike = await fetch(listRecordsUrl(OTHER_COLLECTION)); + const createPost = await createRecord(); + const createLike = await createRecord(OTHER_COLLECTION); + + expect(listPost.status).toBe(200); + expect(listLike.status).toBe(500); + expect(createPost.status).toBe(200); + expect(createLike.status).toBe(500); + }); + + it("filters by rkey", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [ + { rkey: "post", value: { text: "post" } }, + { rkey: "other", value: { text: "other" } }, + ], + }, + }); + + repo.failOnce.getRecord({ rkey: "post" }); + repo.failOnce.putRecord({ rkey: "post" }); + repo.failOnce.deleteRecord({ rkey: "post" }); + + const getOther = await fetch(getRecordUrl(COLLECTION, "other")); + const getPost = await fetch(getRecordUrl(COLLECTION, "post")); + const putOther = await putRecord(COLLECTION, "other"); + const putPost = await putRecord(COLLECTION, "post"); + const deleteOther = await deleteRecord(COLLECTION, "other"); + const deletePost = await deleteRecord(COLLECTION, "post"); + + expect(getOther.status).toBe(200); + expect(getPost.status).toBe(500); + expect(putOther.status).toBe(200); + expect(putPost.status).toBe(500); + expect(deleteOther.status).toBe(200); + expect(deletePost.status).toBe(500); + }); + + it("filters getBlob by cid", async () => { + const repo = setupFailureRepo(); + + repo.failOnce.getBlob({ cid: OTHER_CID }); + + const defaultBlob = await fetch(getBlobUrl()); + const otherBlob = await fetch(getBlobUrl(OTHER_CID)); + + expect(defaultBlob.status).toBe(200); + expect(otherBlob.status).toBe(500); + }); +}); + +describe("failOnce consumption", () => { + it("clears queued failOnce failures", async () => { + const repo = setupRepo({ did: DID, pds: PDS }); + repo.failOnce.createRecord({ status: 500 }); + + repo.clear(); + const afterClear = await createRecord(); + + expect(afterClear.status).toBe(200); + }); + + it("consumes at request entry so only one concurrent match fails", async () => { + const repo = setupFailureRepo(); + repo.failOnce.getRecord({ status: 500 }); + + const responses = await Promise.all([ + fetch(getRecordUrl()), + fetch(getRecordUrl()), + ]); + + expect( + responses.map((response) => response.status).sort((a, b) => a - b), + ).toEqual([200, 500]); + }); +}); diff --git a/msw-atproto/__tests__/repo-records.test.ts b/msw-atproto/__tests__/repo-records.test.ts new file mode 100644 index 0000000..f48b2b0 --- /dev/null +++ b/msw-atproto/__tests__/repo-records.test.ts @@ -0,0 +1,504 @@ +import { AtUri } from "@atproto/syntax"; +import { CID } from "multiformats"; +import { describe, expect, it } from "vitest"; + +import { createMockAtprotoRepo, FAKE_CID } from "../src/index.ts"; +import { + COLLECTION, + DID, + expectedRepoCid, + fetchJson, + HANDLE, + OTHER_CID, + OTHER_COLLECTION, + PDS, + setupRepo, +} from "./support.ts"; +import { server } from "./msw/server.ts"; + +type RecordBody = { + uri: string; + cid: string; + value: Record; +}; + +type ListRecordsBody = { + records: RecordBody[]; + cursor?: string; +}; + +type WriteRecordBody = { + uri: string; + cid: string; +}; + +const listRecordsUrl = (repo = DID, collection = COLLECTION) => + `${PDS}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent( + repo, + )}&collection=${collection}`; + +const getRecordUrl = (repo = DID, collection = COLLECTION, rkey = "seeded") => + `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent( + repo, + )}&collection=${collection}&rkey=${rkey}`; + +const getBlobUrl = (did = DID, cid = FAKE_CID) => + `${PDS}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent( + did, + )}&cid=${cid}`; + +const createRecord = ( + repo = DID, + record: Record = { text: "created" }, + collection = COLLECTION, +) => + fetchJson(`${PDS}/xrpc/com.atproto.repo.createRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ repo, collection, record }), + }); + +const putRecord = ( + repo = DID, + rkey = "seeded", + record: Record = { text: "updated" }, + collection = COLLECTION, +) => + fetchJson(`${PDS}/xrpc/com.atproto.repo.putRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ repo, collection, rkey, record }), + }); + +const deleteRecord = (repo = DID, rkey = "seeded", collection = COLLECTION) => + fetch(`${PDS}/xrpc/com.atproto.repo.deleteRecord`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ repo, collection, rkey }), + }); + +describe("createMockAtprotoRepo record reads", () => { + it("serves seeded records through listRecords", async () => { + const value = { text: "hi" }; + setupRepo({ + did: DID, + handle: HANDLE, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "first", value }], + }, + }); + const cid = expectedRepoCid("first", value); + + const list = await fetchJson(listRecordsUrl()); + + expect(list.body).toEqual({ + records: [{ uri: `at://${DID}/${COLLECTION}/first`, cid, value }], + }); + expect(CID.parse(list.body.records[0]!.cid).version).toBe(1); + }); + + it("uses explicit fixture CIDs as-is", async () => { + const explicitCid = + "bafkreics4felvw3rnpwriv7avyvb3qdfutobmovlrskpbxshlmjkers6b4"; + setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [ + { rkey: "explicit", value: { text: "fixture" }, cid: explicitCid }, + ], + }, + }); + + const list = await fetchJson(listRecordsUrl()); + + expect(list.body.records).toMatchObject([{ cid: explicitCid }]); + }); + + it("serves seeded records through getRecord", async () => { + const value = { text: "hi" }; + setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "first", value }], + }, + }); + const cid = expectedRepoCid("first", value); + + const get = await fetchJson( + getRecordUrl(DID, COLLECTION, "first"), + ); + + expect(get.body).toEqual({ + uri: `at://${DID}/${COLLECTION}/first`, + cid, + value, + }); + }); + + it("exposes read seeded records through repo.records()", () => { + const value = { text: "hi" }; + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "first", value }], + }, + }); + + const records = repo.records(); + const cid = expectedRepoCid("first", value); + + expect(records).toEqual([ + { + collection: COLLECTION, + rkey: "first", + uri: `at://${DID}/${COLLECTION}/first`, + cid, + value, + }, + ]); + }); +}); + +describe("stateful writes", () => { + it("mutates state through createRecord and exposes created records through reads", async () => { + setupRepo({ did: DID, pds: PDS }); + const value = { text: "created" }; + + const create = await createRecord(DID, value); + const uri = create.body.uri; + const rkey = new AtUri(uri).rkey; + const cid = expectedRepoCid(rkey, value); + const list = await fetchJson(listRecordsUrl()); + const get = await fetchJson( + getRecordUrl(DID, COLLECTION, rkey), + ); + + expect(rkey).toBe("3kgenerated1"); + expect(create.body).toMatchObject({ uri, cid }); + expect(list.body).toMatchObject({ records: [{ uri, cid, value }] }); + expect(get.body).toMatchObject({ uri, cid, value }); + }); + + it("captures successful create calls with rkey, uri, and cid", async () => { + const repo = setupRepo({ did: DID, handle: HANDLE, pds: PDS }); + + const created = await createRecord(HANDLE, { text: "created" }); + const createdRkey = new AtUri(created.body.uri).rkey; + + expect(repo.writes()).toEqual([ + { + action: "create", + uri: `at://${DID}/${COLLECTION}/${createdRkey}`, + cid: created.body.cid, + record: { text: "created" }, + }, + ]); + }); + + it("captures successful put calls and updates records", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "seeded", value: { text: "seeded" } }], + }, + }); + + const put = await putRecord(DID, "seeded", { text: "updated" }); + + expect(repo.records()).toEqual([ + { + collection: COLLECTION, + rkey: "seeded", + uri: `at://${DID}/${COLLECTION}/seeded`, + cid: expectedRepoCid("seeded", { text: "updated" }), + value: { text: "updated" }, + }, + ]); + expect(repo.writes()).toEqual([ + { + action: "put", + uri: `at://${DID}/${COLLECTION}/seeded`, + cid: put.body.cid, + record: { text: "updated" }, + }, + ]); + }); + + it("captures successful delete calls and removes records", async () => { + const repo = setupRepo({ + did: DID, + handle: HANDLE, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "seeded", value: { text: "seeded" } }], + }, + }); + + await deleteRecord(HANDLE, "seeded"); + + expect(repo.records()).toEqual([]); + expect(repo.deletes()).toEqual([ + { uri: `at://${DID}/${COLLECTION}/seeded` }, + ]); + }); + + it("preserves listRecords insertion order when putRecord overwrites an existing record", async () => { + setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [ + { rkey: "first", value: { text: "first version" } }, + { rkey: "second", value: { text: "second" } }, + ], + }, + }); + + await putRecord(DID, "first", { text: "updated first" }); + const listed = await fetchJson(listRecordsUrl()); + + expect(listed.body).toMatchObject({ + records: [ + { + uri: `at://${DID}/${COLLECTION}/first`, + value: { text: "updated first" }, + }, + { uri: `at://${DID}/${COLLECTION}/second`, value: { text: "second" } }, + ], + }); + }); +}); + +describe("DID and handle matching", () => { + it("matches stateful reads by configured DID or handle", async () => { + setupRepo({ + did: DID, + handle: HANDLE, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "seeded", value: { text: "old" } }], + }, + }); + + const listedByHandle = await fetchJson( + listRecordsUrl(HANDLE), + ); + const fetchedByHandle = await fetchJson( + getRecordUrl(HANDLE, COLLECTION, "seeded"), + ); + + expect(listedByHandle.body).toMatchObject({ + records: [{ uri: `at://${DID}/${COLLECTION}/seeded` }], + }); + expect(fetchedByHandle.body).toMatchObject({ + uri: `at://${DID}/${COLLECTION}/seeded`, + value: { text: "old" }, + }); + }); + + it("matches stateful writes by configured DID or handle", async () => { + setupRepo({ + did: DID, + handle: HANDLE, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "seeded", value: { text: "old" } }], + }, + }); + + const createdByHandle = await createRecord(HANDLE, { text: "created" }); + const createdByHandleRkey = new AtUri(createdByHandle.body.uri).rkey; + await putRecord(HANDLE, "seeded", { text: "updated" }); + await deleteRecord(HANDLE, createdByHandleRkey); + const listedByDid = await fetchJson(listRecordsUrl(DID)); + const deletedByDid = await fetchJson( + getRecordUrl(DID, COLLECTION, createdByHandleRkey), + ); + + expect(createdByHandle.body).toMatchObject({ + uri: `at://${DID}/${COLLECTION}/${createdByHandleRkey}`, + }); + expect(listedByDid.body).toMatchObject({ + records: [ + { uri: `at://${DID}/${COLLECTION}/seeded`, value: { text: "updated" } }, + ], + }); + expect(deletedByDid.response.status).toBe(404); + }); +}); + +describe("repo boundaries", () => { + it("keeps unknown repos loud", async () => { + setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "post", value: { text: "hi" } }], + }, + }); + + await expect( + fetch(listRecordsUrl("did:plc:other", COLLECTION)), + ).rejects.toThrow(); + }); + + it("keeps undeclared collections loud", async () => { + setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "post", value: { text: "hi" } }], + }, + }); + + await expect( + fetch(listRecordsUrl(DID, "app.unseeded.collection")), + ).rejects.toThrow(); + }); + + it("allows declared empty collections", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "post", value: { text: "hi" } }], + }, + }); + repo.seed(OTHER_COLLECTION, []); + + const posts = await fetchJson(listRecordsUrl()); + const empty = await fetchJson( + listRecordsUrl(DID, OTHER_COLLECTION), + ); + + expect(posts.body).toMatchObject({ + records: [{ uri: `at://${DID}/${COLLECTION}/post` }], + }); + expect(empty.body).toEqual({ records: [] }); + }); + + it("routes same-PDS writes to the matching repo fixture", async () => { + const firstRepo = createMockAtprotoRepo({ did: DID, pds: PDS }); + const secondDid = "did:plc:secondrepo"; + const secondRepo = createMockAtprotoRepo({ did: secondDid, pds: PDS }); + server.use(...firstRepo.handlers(), ...secondRepo.handlers()); + + const created = await createRecord(secondDid, { text: "second" }); + const firstList = await fetch(listRecordsUrl(DID)).catch((error) => error); + const secondList = await fetchJson( + listRecordsUrl(secondDid), + ); + const createdRkey = new AtUri(created.body.uri).rkey; + + expect(created.body).toMatchObject({ + uri: `at://${secondDid}/${COLLECTION}/${createdRkey}`, + }); + expect(firstList).toBeInstanceOf(Error); + expect(secondList.body).toMatchObject({ + records: [{ uri: `at://${secondDid}/${COLLECTION}/${createdRkey}` }], + }); + }); +}); + +describe("clear()", () => { + it("resets records, writes, and deletes", async () => { + const repo = setupRepo({ + did: DID, + pds: PDS, + records: { + [COLLECTION]: [{ rkey: "existing", value: { text: "old" } }], + }, + }); + + await putRecord(DID, "existing", { text: "new" }); + await deleteRecord(DID, "existing"); + repo.seed(COLLECTION, [{ rkey: "after-delete", value: { text: "again" } }]); + repo.clear(); + + expect(repo.records()).toEqual([]); + expect(repo.writes()).toEqual([]); + expect(repo.deletes()).toEqual([]); + }); + + it("removes declared empty collections", async () => { + const repo = setupRepo({ did: DID, pds: PDS }); + repo.seed(OTHER_COLLECTION, []); + + const beforeClear = await fetchJson( + listRecordsUrl(DID, OTHER_COLLECTION), + ); + repo.clear(); + + await expect( + fetch(listRecordsUrl(DID, OTHER_COLLECTION)), + ).rejects.toThrow(); + expect(beforeClear.body).toEqual({ records: [] }); + }); + + it("resets generated rkeys", async () => { + const repo = setupRepo({ did: DID, pds: PDS }); + const beforeClear = await createRecord(DID, { text: "before clear" }); + const beforeClearRkey = new AtUri(beforeClear.body.uri).rkey; + + repo.clear(); + const afterClear = await createRecord(DID, { text: "after clear" }); + const afterClearRkey = new AtUri(afterClear.body.uri).rkey; + + expect(afterClearRkey).toBe(beforeClearRkey); + }); +}); + +describe("blobs", () => { + it("serves seeded blobs through getBlob", async () => { + const bytes = new Uint8Array([1, 2, 3]); + setupRepo({ + did: DID, + pds: PDS, + blobs: [{ cid: FAKE_CID, body: bytes, contentType: "image/png" }], + }); + + const response = await fetch(getBlobUrl()); + + expect(response.headers.get("content-type")).toBe("image/png"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes); + }); + + it("supports adding blobs after the repo fixture is created", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const repo = setupRepo({ did: DID, pds: PDS }); + repo.seedBlob({ cid: FAKE_CID, body: bytes, contentType: "image/jpeg" }); + + const response = await fetch(getBlobUrl()); + + expect(response.headers.get("content-type")).toBe("image/jpeg"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes); + }); + + it("keeps blob reads partitioned by repo DID when fakes share one PDS", async () => { + const firstBlob = new Uint8Array([1]); + const secondBlob = new Uint8Array([2]); + const firstRepo = createMockAtprotoRepo({ + did: DID, + pds: PDS, + blobs: [{ cid: FAKE_CID, body: firstBlob }], + }); + const secondDid = "did:plc:secondrepo"; + const secondRepo = createMockAtprotoRepo({ + did: secondDid, + pds: PDS, + blobs: [{ cid: OTHER_CID, body: secondBlob }], + }); + server.use(...firstRepo.handlers(), ...secondRepo.handlers()); + + const first = await fetch(getBlobUrl(DID, FAKE_CID)); + const second = await fetch(getBlobUrl(secondDid, OTHER_CID)); + + expect(new Uint8Array(await first.arrayBuffer())).toEqual(firstBlob); + expect(new Uint8Array(await second.arrayBuffer())).toEqual(secondBlob); + }); +}); diff --git a/msw-atproto/__tests__/repo-upload-blob.test.ts b/msw-atproto/__tests__/repo-upload-blob.test.ts new file mode 100644 index 0000000..7eaf289 --- /dev/null +++ b/msw-atproto/__tests__/repo-upload-blob.test.ts @@ -0,0 +1,91 @@ +import { CID } from "multiformats"; +import { describe, expect, it } from "vitest"; + +import { DID, fetchJson, PDS, setupRepo } from "./support.ts"; + +type BlobRef = { + $type: "blob"; + ref: { $link: string }; + mimeType: string; + size: number; +}; + +type UploadBlobBody = { blob: BlobRef }; + +const uploadBlob = (body: BodyInit, contentType: string) => + fetchJson(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, { + method: "POST", + headers: { "content-type": contentType }, + body, + }); + +const getBlobUrl = (cid: string, did = DID) => + `${PDS}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent( + did, + )}&cid=${cid}`; + +describe("uploadBlob", () => { + it("returns a parseable CIDv1 BlobRef and reports the byte size", async () => { + setupRepo({ did: DID, pds: PDS }); + const bytes = new Uint8Array([1, 2, 3, 4, 5]); + + const upload = await uploadBlob(bytes, "image/png"); + + expect(upload.body.blob).toMatchObject({ + $type: "blob", + mimeType: "image/png", + size: bytes.byteLength, + }); + expect(upload.body.blob.ref.$link).toMatch(/^baf/); + expect(CID.parse(upload.body.blob.ref.$link).version).toBe(1); + }); + + it("makes the uploaded blob retrievable through com.atproto.sync.getBlob", async () => { + setupRepo({ did: DID, pds: PDS }); + const bytes = new Uint8Array([10, 20, 30]); + + const upload = await uploadBlob(bytes, "application/octet-stream"); + const cid = upload.body.blob.ref.$link; + + const response = await fetch(getBlobUrl(cid)); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/octet-stream", + ); + const fetched = new Uint8Array(await response.arrayBuffer()); + expect(Array.from(fetched)).toEqual([10, 20, 30]); + }); + + it("produces stable CIDs for identical content and contentType", async () => { + setupRepo({ did: DID, pds: PDS }); + const bytes = new Uint8Array([7, 7, 7]); + + const first = await uploadBlob(bytes, "image/jpeg"); + const second = await uploadBlob(new Uint8Array([7, 7, 7]), "image/jpeg"); + + expect(second.body.blob.ref.$link).toBe(first.body.blob.ref.$link); + }); + + it("produces different CIDs when the contentType differs", async () => { + setupRepo({ did: DID, pds: PDS }); + const bytes = new Uint8Array([1, 2, 3]); + + const png = await uploadBlob(bytes, "image/png"); + const jpeg = await uploadBlob(bytes, "image/jpeg"); + + expect(jpeg.body.blob.ref.$link).not.toBe(png.body.blob.ref.$link); + }); + + it("defaults to application/octet-stream when no content-type is sent", async () => { + setupRepo({ did: DID, pds: PDS }); + const bytes = new Uint8Array([42]); + + const response = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, { + method: "POST", + body: bytes, + }); + const body = (await response.json()) as UploadBlobBody; + + expect(body.blob.mimeType).toBe("application/octet-stream"); + }); +}); diff --git a/msw-atproto/__tests__/setup.ts b/msw-atproto/__tests__/setup.ts new file mode 100644 index 0000000..f549c24 --- /dev/null +++ b/msw-atproto/__tests__/setup.ts @@ -0,0 +1,7 @@ +import { afterAll, afterEach, beforeAll } from "vitest"; + +import { server } from "./msw/server.ts"; + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/msw-atproto/__tests__/support.ts b/msw-atproto/__tests__/support.ts new file mode 100644 index 0000000..730afaa --- /dev/null +++ b/msw-atproto/__tests__/support.ts @@ -0,0 +1,41 @@ +import { cidForRecord, createMockAtprotoRepo } from "../src/index.ts"; + +import { server } from "./msw/server.ts"; + +export const DID = "did:plc:mswtest123456789012345"; +export const HANDLE = "msw.example.test"; +export const PDS = "https://pds.fujocoded.test"; +export const COLLECTION = "app.bsky.feed.post"; +export const OTHER_COLLECTION = "app.bsky.feed.like"; +export const OTHER_CID = + "bafkreics4felvw3rnpwriv7avyvb3qdfutobmovlrskpbxshlmjkers6b4"; + +export const expectedRepoCid = ( + rkey: string, + value: Record, +): string => + cidForRecord({ + repo: DID, + collection: COLLECTION, + rkey, + value, + }); + +export const fetchJson = async ( + input: string, + init?: RequestInit, +) => { + const response = await fetch(input, init); + return { + response, + body: (await response.json()) as Body, + }; +}; + +export const setupRepo = ( + options: Parameters[0], +) => { + const repo = createMockAtprotoRepo(options); + server.use(...repo.handlers()); + return repo; +}; diff --git a/msw-atproto/package.json b/msw-atproto/package.json new file mode 100644 index 0000000..a66a1b2 --- /dev/null +++ b/msw-atproto/package.json @@ -0,0 +1,65 @@ +{ + "name": "@fujocoded/msw-atproto", + "version": "0.0.1", + "description": "MSW-based ATProto testing helpers: mock identity (PLC), record reads/writes, and DNS stubs for vitest setup.", + "keywords": [ + "atproto", + "bluesky", + "mock", + "msw", + "testing", + "vitest" + ], + "homepage": "https://github.com/FujoWebDev/fujocoded-plugins#readme", + "bugs": { + "url": "https://github.com/FujoWebDev/fujocoded-plugins/issues" + }, + "license": "MIT", + "author": "FujoCoded LLC", + "repository": { + "type": "git", + "url": "git+https://github.com/FujoWebDev/fujocoded-plugins.git" + }, + "files": [ + "dist", + "LICENSE", + "README.md", + "package.json" + ], + "type": "module", + "sideEffects": false, + "module": "dist/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch src/", + "test": "vitest --run", + "typecheck": "tsc --noEmit", + "validate": "npx publint" + }, + "dependencies": { + "@atproto/common-web": "^0.4.7", + "@atproto/crypto": "^0.4.5", + "@atproto/syntax": "^0.4.2", + "multiformats": "^13.4.2" + }, + "devDependencies": { + "msw": "^2.13.4", + "tsdown": "^0.14.2", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "msw": "^2.0.0", + "vitest": "^3.0.0 || ^4.0.0 || ^5.0.0" + } +} diff --git a/msw-atproto/src/cid.ts b/msw-atproto/src/cid.ts new file mode 100644 index 0000000..b9bdfa2 --- /dev/null +++ b/msw-atproto/src/cid.ts @@ -0,0 +1,51 @@ +import { CID } from "multiformats/cid"; +import * as raw from "multiformats/codecs/raw"; +import { create as createDigest } from "multiformats/hashes/digest"; +import { sha256 } from "multiformats/hashes/sha2"; +import { createHash } from "node:crypto"; + +/** + * A valid CIDv1 string that parses with `multiformats/cid`. + * + * Use this when a test needs a single placeholder CID and does not care about + * content. Use `fakeCid(input)` instead when several values need stable but + * different CIDs. + */ +export const FAKE_CID = + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"; + +/** + * Builds a stable, parseable CIDv1 string from a test input. + * + * The same input always returns the same CID, so two records seeded with the + * same content get the same CID. Use this when test assertions compare CIDs + * across reads, or when fake records need to look different from one another. + */ +export const fakeCid = (input: string): string => + CID.createV1( + raw.code, + createDigest(sha256.code, createHash("sha256").update(input).digest()), + ).toString(); + +/** + * Returns the same CID `createMockAtprotoRepo` derives for any record with no + * explicit `cid`, whether seeded up front or written during the test via + * `createRecord`, `putRecord`, or `applyWrites`. Use this in assertions that + * compare against the CID the fake generates. + * + * Two records with the same `{ repo, collection, rkey, value }` get the same + * CID, so a follow-up read can be matched without round-tripping the CID + * through the fake first. + */ +export const cidForRecord = ({ + repo, + collection, + rkey, + value, +}: { + repo: string; + collection: string; + rkey: string; + value: Record; +}): string => + fakeCid([repo, collection, rkey, JSON.stringify(value)].join("\n")); diff --git a/msw-atproto/src/dns.ts b/msw-atproto/src/dns.ts new file mode 100644 index 0000000..f8c486e --- /dev/null +++ b/msw-atproto/src/dns.ts @@ -0,0 +1,120 @@ +export type DnsPromisesModule = typeof import("node:dns/promises"); +export type ImportActualDnsPromises = () => Promise; + +/** + * `_atproto.` is the only DNS query `@atproto/identity` makes for + * handle resolution. Failing just that query (rather than every DNS call) + * keeps real DNS available for everything else the test process might do. + */ +const ATPROTO_HANDLE_TXT_PREFIX = "_atproto."; + +const handleLookupEnodata = () => + Object.assign(new Error("ENODATA (atproto handle lookup stub)"), { + code: "ENODATA", + }); + +export type CreateDnsStubOptions = { + /** + * Low-level hook deciding whether a given `_atproto.` TXT query + * should be failed with `ENODATA` or forwarded to real DNS. Receives just + * the handle, with the `_atproto.` prefix already stripped. Defaults to + * intercepting every `_atproto.*` query. + * + * Use `createIdentityPassthrough` instead when one test mixes fake accounts + * with real handles. It pairs the DNS bypass with the matching + * `.well-known/atproto-did` HTTP passthrough under one predicate, so a handle + * cannot end up half real and half fake. + */ + shouldInterceptHandle?: (handle: string) => boolean; +}; + +/** + * Returns a partial `node:dns/promises` mock that fails the + * `_atproto.` TXT lookup for handles your predicate selects, and + * passes every other DNS call through to the real module. + * `@atproto/identity` checks DNS before falling back to the HTTP + * `.well-known/atproto-did` path, so failing that single query is enough to + * route handle resolution onto HTTP where MSW can intercept it. Most Vitest + * suites should use `createDnsMock(importActual)` instead. + * + * Default behavior intercepts every `_atproto.*` query. See + * `CreateDnsStubOptions.shouldInterceptHandle` for narrowing the scope when + * a test mixes fake accounts with real handles. + */ +export const createDnsStub = ( + actual: DnsPromisesModule, + { shouldInterceptHandle = () => true }: CreateDnsStubOptions = {}, +) => { + const normalizeHostname = (hostname: string): string => + hostname.endsWith(".") + ? hostname.slice(0, -1).toLowerCase() + : hostname.toLowerCase(); + + const handleToIntercept = (hostname: string): string | null => { + const normalizedHostname = normalizeHostname(hostname); + if (!normalizedHostname.startsWith(ATPROTO_HANDLE_TXT_PREFIX)) return null; + const handle = normalizedHostname.slice(ATPROTO_HANDLE_TXT_PREFIX.length); + return shouldInterceptHandle(handle) ? handle : null; + }; + + const resolveTxtWith = ( + actualResolveTxt: (...args: unknown[]) => Promise, + ) => { + return (hostname: string, ...rest: unknown[]) => { + if (handleToIntercept(hostname) !== null) { + return Promise.reject(handleLookupEnodata()); + } + return actualResolveTxt(hostname, ...rest); + }; + }; + + const resolveTxt = resolveTxtWith( + actual.resolveTxt as (...args: unknown[]) => Promise, + ) as DnsPromisesModule["resolveTxt"]; + + // Compose instead of extending: `Resolver.resolveTxt` is typed as a property + // in `@types/node` for `dns/promises`, which makes `class extends` reject the + // override. Constructing the real Resolver and patching the one method keeps + // every other surface (`cancel`, `setServers`, `getServers`, ...) intact. + const StubResolver = function StubResolver( + options?: ConstructorParameters[0], + ) { + const resolver = new actual.Resolver(options); + const original = resolver.resolveTxt.bind(resolver) as ( + ...args: unknown[] + ) => Promise; + resolver.resolveTxt = resolveTxtWith( + original, + ) as typeof resolver.resolveTxt; + return resolver; + } as unknown as typeof actual.Resolver; + + const actualDefault = + (actual as { default?: DnsPromisesModule }).default ?? actual; + + return { + ...actual, + resolveTxt, + Resolver: StubResolver, + default: { + ...actualDefault, + resolveTxt, + Resolver: StubResolver, + }, + }; +}; + +/** + * Vitest-friendly DNS factory that intercepts every `_atproto.` query. + * Use `createIdentityPassthrough` instead when a test mixes fake accounts with + * real handles that should hit real DNS and real HTTP. + * + * ```ts + * vi.mock("node:dns/promises", async (importActual) => { + * const { createDnsMock } = await import("@fujocoded/msw-atproto"); + * return createDnsMock(importActual); + * }); + * ``` + */ +export const createDnsMock = async (importActual: ImportActualDnsPromises) => + createDnsStub(await importActual()); diff --git a/msw-atproto/src/identity/mock.ts b/msw-atproto/src/identity/mock.ts new file mode 100644 index 0000000..429d1b7 --- /dev/null +++ b/msw-atproto/src/identity/mock.ts @@ -0,0 +1,296 @@ +import type { DidDocument } from "@atproto/common-web"; +import { P256Keypair } from "@atproto/crypto"; +import { AtUri } from "@atproto/syntax"; +import { http, HttpResponse, type HttpHandler } from "msw"; + +export type { DidDocument }; + +export type RepoIdentity = { + did: string; + /** + * PDS URL advertised in the DID document, like `https://pds.fujocoded.test`. + * Defaults to the package's built-in example PDS URL. + */ + pds?: string; + /** + * Optional handle served from `https:///.well-known/atproto-did`. + */ + handle?: string; +}; + +export type MockPlcOperationFlowConfig = RepoIdentity & { + /** + * PLC directory base URL. Defaults to `https://plc.directory`. + */ + plcDirectoryUrl?: string; + operation: { + verificationMethods?: Record; + rotationKeys?: string[]; + alsoKnownAs?: string[]; + services?: Record; + }; + /** + * Signed operation returned by the fake PDS. Defaults to the submitted body + * with `signed: true`. + */ + signedOperation?: Record; + /** + * Runs when the client asks the PDS to sign a PLC operation. + */ + onSign?: (body: Record) => void; + /** + * Runs when the client submits the signed operation to the PLC directory. + */ + onSubmit?: (body: Record) => void; +}; + +export type MockRepoIdentity = Required> & + Pick & { + handlers(): HttpHandler[]; + } & ReturnType["controls"]; + +export type MockPlcOperationFlow = Required< + Pick +> & { + handlers(): HttpHandler[]; +}; + +// `@atproto/identity` insists on a well-formed DID document: signingKey, +// handle, and pds are all required even when callers only care about pds. +const createDidDocument = async ({ + did, + pds = "https://pds.fujocoded.test", + handle, +}: RepoIdentity): Promise => { + const advertisedHandle = handle ?? `${did.split(":").pop()}.example.test`; + const keypair = await P256Keypair.create(); + const publicKeyMultibase = keypair.did().slice("did:key:".length); + + return { + id: did, + alsoKnownAs: [AtUri.make(advertisedHandle).origin], + verificationMethod: [ + { + id: `${did}#atproto`, + type: "Multikey", + controller: did, + publicKeyMultibase, + }, + ], + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: pds, + }, + ], + }; +}; + +const definePlcDidDocumentRoute = ( + did: string, + resolveDocument: () => DidDocument | Promise, +): HttpHandler => + http.get(`https://plc.directory/${encodeURIComponent(did)}`, async () => + HttpResponse.json(await resolveDocument()), + ); + +export const createMutableRepoIdentity = ({ + did, + pds = "https://pds.fujocoded.test", + handle, +}: RepoIdentity) => { + const initialDidDocument = createDidDocument({ did, pds, handle }); + let didDocument = initialDidDocument.then((document) => + structuredClone(document), + ); + let handleDid = did; + + return { + handlers: (): HttpHandler[] => { + const handlers: HttpHandler[] = [ + definePlcDidDocumentRoute(did, () => didDocument), + ]; + + if (handle) { + handlers.push( + http.get(`https://${handle}/.well-known/atproto-did`, () => + HttpResponse.text(handleDid), + ), + ); + } + + return handlers; + }, + controls: { + plcNotFound: (): HttpHandler => + http.get(`https://plc.directory/${encodeURIComponent(did)}`, () => + HttpResponse.json( + { error: "NotFound", message: "DID not found" }, + { status: 404 }, + ), + ), + didDocument: (document: DidDocument): HttpHandler => + http.get(`https://plc.directory/${encodeURIComponent(did)}`, () => + HttpResponse.json(document), + ), + wellKnownNotFound: (): HttpHandler => { + if (!handle) { + throw new Error("wellKnownNotFound requires a repo handle"); + } + return http.get(`https://${handle}/.well-known/atproto-did`, () => + HttpResponse.text("Not found", { status: 404 }), + ); + }, + handleResolvesTo: (otherDid: string): HttpHandler => { + if (!handle) { + throw new Error("handleResolvesTo requires a repo handle"); + } + return http.get(`https://${handle}/.well-known/atproto-did`, () => + HttpResponse.text(otherDid), + ); + }, + setDidDocument(document: DidDocument) { + didDocument = Promise.resolve(structuredClone(document)); + }, + updateDidDocument(update: (document: DidDocument) => DidDocument) { + didDocument = didDocument.then((document) => + structuredClone(update(structuredClone(document))), + ); + }, + setVerificationMethod(name: string, didKeyOrMultibase: string) { + didDocument = didDocument.then((document) => { + const next = structuredClone(document); + const methods = Array.isArray(next.verificationMethod) + ? next.verificationMethod + : []; + const id = `${did}#${name}`; + const existing = methods.find((method) => method.id === id); + const publicKeyMultibase = didKeyOrMultibase.startsWith("did:key:") + ? didKeyOrMultibase.slice("did:key:".length) + : didKeyOrMultibase; + + if (existing) { + existing.publicKeyMultibase = publicKeyMultibase; + next.verificationMethod = methods; + return next; + } + + next.verificationMethod = [ + ...methods, + { id, type: "Multikey", controller: did, publicKeyMultibase }, + ]; + return next; + }); + }, + setHandleDid(nextDid: string) { + if (!handle) { + throw new Error(`setHandleDid requires a repo handle`); + } + handleDid = nextDid; + }, + reset() { + didDocument = initialDidDocument.then((document) => + structuredClone(document), + ); + handleDid = did; + }, + }, + }; +}; + +/** + * Builds one fake ATproto identity for tests that only need DID or handle + * lookup. + * + * Use `createMockAtprotoRepo` when the test also reads or writes records. That + * helper includes these identity handlers, the PDS handlers, and the same + * identity controls for changing behavior mid-test. + */ +export const createMockRepoIdentity = ({ + did, + pds = "https://pds.fujocoded.test", + handle, +}: RepoIdentity): MockRepoIdentity => { + const identity = createMutableRepoIdentity({ did, pds, handle }); + + return { + did, + pds, + handle, + handlers: identity.handlers, + ...identity.controls, + }; +}; + +export const useMockRepoIdentity = ( + server: { use(...handlers: HttpHandler[]): void }, + config: RepoIdentity, +): MockRepoIdentity => { + const identity = createMockRepoIdentity(config); + server.use(...identity.handlers()); + return identity; +}; + +/** + * Builds the fake flow used when code asks a PDS to update a DID document. + * + * The flow runs in order: + * + * 1. The PDS reads the previous op from the PLC audit log + * 2. The PDS signs the proposed op + * 3. The signed op is submitted back to the PLC directory + * + * `onSign` and `onSubmit` fire so the test can assert on each payload. + * `signedOperation` lets the test choose the value the fake PDS returns from + * step 2. When omitted, the fake echoes the submitted body with + * `signed: true` stamped on. + */ +export const createMockPlcOperationFlow = ({ + did, + pds = "https://pds.fujocoded.test", + plcDirectoryUrl = "https://plc.directory", + operation, + signedOperation, + onSign, + onSubmit, +}: MockPlcOperationFlowConfig): MockPlcOperationFlow => ({ + did, + pds, + plcDirectoryUrl, + handlers: () => [ + http.get( + ({ request }) => request.url === `${plcDirectoryUrl}/${did}/log/audit`, + () => HttpResponse.json([{ operation }]), + ), + http.post( + `${pds}/xrpc/com.atproto.identity.signPlcOperation`, + async ({ request }) => { + const body = (await request.json()) as Record; + onSign?.(body); + + return HttpResponse.json({ + operation: signedOperation ?? { ...body, signed: true }, + }); + }, + ), + http.post( + ({ request }) => request.url === `${plcDirectoryUrl}/${did}`, + async ({ request }) => { + const body = (await request.json()) as Record; + onSubmit?.(body); + + return HttpResponse.json({}); + }, + ), + ], +}); + +export const useMockPlcOperationFlow = ( + server: { use(...handlers: HttpHandler[]): void }, + config: MockPlcOperationFlowConfig, +): MockPlcOperationFlow => { + const flow = createMockPlcOperationFlow(config); + server.use(...flow.handlers()); + return flow; +}; diff --git a/msw-atproto/src/identity/passthrough.ts b/msw-atproto/src/identity/passthrough.ts new file mode 100644 index 0000000..30e51c7 --- /dev/null +++ b/msw-atproto/src/identity/passthrough.ts @@ -0,0 +1,69 @@ +import { http, passthrough, type HttpHandler } from "msw"; + +import type { DnsPromisesModule, ImportActualDnsPromises } from "../dns.ts"; +import { createDnsStub } from "../dns.ts"; + +export type CreateIdentityPassthroughOptions = { + /** + * Returns `true` for handles that should be left alone end-to-end: their + * `_atproto.` DNS query runs against real DNS, and their + * `https:///.well-known/atproto-did` HTTP follow-up is passed + * through MSW to the real server. Handles for which this returns `false` + * (the default) are intercepted: DNS fails with `ENODATA` and the + * `.well-known` request must be answered by an MSW handler. + * + * Use this when a single test mixes mocked accounts with one or two real + * handles (e.g. a real `*.bsky.social` while everything else is fake): + * + * ```ts + * createIdentityPassthrough({ + * shouldPassthrough: (handle) => handle.endsWith(".bsky.social"), + * }); + * ``` + */ + shouldPassthrough?: (handle: string) => boolean; +}; + +/** + * Pairs the DNS bypass and the `.well-known/atproto-did` HTTP passthrough + * under a single predicate. A handle is either fully mocked (DNS returns + * `ENODATA`, MSW expects a handler) or fully real (real DNS, real HTTP); + * the two layers always move together. + */ +export const createIdentityPassthrough = ({ + shouldPassthrough = () => false, +}: CreateIdentityPassthroughOptions = {}): { + /** + * Vitest-friendly DNS module factory. Pass to `vi.mock("node:dns/promises")`: + * + * ```ts + * vi.mock("node:dns/promises", passthrough.dnsMock); + * ``` + */ + dnsMock: ( + importActual: ImportActualDnsPromises, + ) => Promise; + /** + * MSW handlers that passthrough `.well-known/atproto-did` for handles the + * predicate selects. Register before any per-test repo handlers so + * mocked handles still match first: + * + * ```ts + * server.use(...passthrough.handlers); + * ``` + */ + handlers: HttpHandler[]; +} => ({ + dnsMock: async (importActual) => + createDnsStub(await importActual(), { + shouldInterceptHandle: (handle) => !shouldPassthrough(handle), + }), + handlers: [ + http.get("https://*/.well-known/atproto-did", ({ request }) => { + const { hostname } = new URL(request.url); + if (shouldPassthrough(hostname)) return passthrough(); + // Fall through to other handlers (per-test fake repo, etc.). + return; + }), + ], +}); diff --git a/msw-atproto/src/index.ts b/msw-atproto/src/index.ts new file mode 100644 index 0000000..06bd062 --- /dev/null +++ b/msw-atproto/src/index.ts @@ -0,0 +1,25 @@ +export { cidForRecord, FAKE_CID, fakeCid } from "./cid.ts"; +export { createDnsMock, createDnsStub } from "./dns.ts"; +export { + createIdentityPassthrough, + type CreateIdentityPassthroughOptions, +} from "./identity/passthrough.ts"; +export { + createMockPlcOperationFlow, + createMockRepoIdentity, + useMockPlcOperationFlow, + useMockRepoIdentity, + type DidDocument, + type MockPlcOperationFlow, + type MockPlcOperationFlowConfig, + type MockRepoIdentity, + type RepoIdentity, +} from "./identity/mock.ts"; +export { + createMockAtprotoRepo, + useMockAtprotoRepo, + type MockAtprotoBlob, + type MockAtprotoRecord, + type MockAtprotoRepoConfig, + type RepoFailureOpts, +} from "./repo/index.ts"; diff --git a/msw-atproto/src/repo/failures.ts b/msw-atproto/src/repo/failures.ts new file mode 100644 index 0000000..bd39b5b --- /dev/null +++ b/msw-atproto/src/repo/failures.ts @@ -0,0 +1,174 @@ +import { HttpResponse, type HttpHandler } from "msw"; + +import type { RepoFailureOpts } from "./index.ts"; +import { defineXrpcRoute, type XrpcRouteContext } from "./xrpc.ts"; + +export type FailOnceEndpoint = + | "listRecords" + | "getRecord" + | "getBlob" + | "createRecord" + | "putRecord" + | "deleteRecord"; + +export type FailOnceConfigByEndpoint = { + listRecords: RepoFailureOpts<"collection">; + getRecord: RepoFailureOpts<"collection" | "rkey">; + getBlob: RepoFailureOpts<"cid">; + createRecord: RepoFailureOpts<"collection">; + putRecord: RepoFailureOpts<"collection" | "rkey">; + deleteRecord: RepoFailureOpts<"collection" | "rkey">; +}; + +export type FailureMatcher = ( + failure: FailOnceConfigByEndpoint[Endpoint], +) => boolean; + +export type FailOnceController = { + clear: () => void; + defineRoute: (config: { + endpoint: Endpoint; + method: string; + httpMethod?: "get" | "post"; + matches: ( + context: XrpcRouteContext, + ) => + | FailureMatcher + | undefined + | Promise | undefined>; + }) => HttpHandler; + queue: ( + endpoint: Endpoint, + config?: FailOnceConfigByEndpoint[Endpoint], + ) => void; +}; + +const defaultFailures = { + invalidRequest: { error: "InvalidRequest", message: "Invalid request" }, + notFound: { error: "NotFound", message: "Blob not found" }, + recordNotFound: { + error: "RecordNotFound", + message: "Record not found", + }, + internalServerError: { + error: "InternalServerError", + message: "Internal server error", + }, +} as const; + +const defaultFailureByStatus: Record< + number, + { error: string; message: string } +> = { + 401: { error: "AuthRequired", message: "Authentication required" }, + 403: { error: "Forbidden", message: "Forbidden" }, + 429: { error: "RateLimitExceeded", message: "Rate limit exceeded" }, +}; + +const defaultAtprotoFailure = (endpoint: FailOnceEndpoint, status: number) => { + if (status === 404) { + return endpoint === "getBlob" + ? defaultFailures.notFound + : defaultFailures.recordNotFound; + } + if (status >= 500 && status <= 599) { + return defaultFailures.internalServerError; + } + return defaultFailureByStatus[status] ?? defaultFailures.invalidRequest; +}; + +const failOnceResponse = ( + endpoint: FailOnceEndpoint, + failure: RepoFailureOpts, +) => { + const status = failure.status ?? 500; + const defaults = defaultAtprotoFailure(endpoint, status); + + return HttpResponse.json( + { + error: failure.error ?? defaults.error, + message: failure.message ?? defaults.message, + }, + { status }, + ); +}; + +export const matchesFields = + (actual: Record) => + (failure: Partial>): boolean => + (Object.entries(actual) as [Fields, string][]).every( + ([field, value]) => + failure[field] === undefined || failure[field] === value, + ); + +export const createFailOnceController = ({ + pds, +}: { + pds: string; +}): FailOnceController => { + const pendingFailures: { + [Endpoint in FailOnceEndpoint]: FailOnceConfigByEndpoint[Endpoint][]; + } = { + listRecords: [], + getRecord: [], + getBlob: [], + createRecord: [], + putRecord: [], + deleteRecord: [], + }; + + const consume = ( + endpoint: Endpoint, + matchesFailure: FailureMatcher, + ) => { + const failures = pendingFailures[endpoint]; + const index = failures.findIndex(matchesFailure); + + if (index === -1) { + return; + } + + return failures.splice(index, 1)[0]; + }; + + return { + clear() { + for (const failures of Object.values(pendingFailures)) { + failures.splice(0); + } + }, + defineRoute({ endpoint, method, httpMethod, matches }) { + const matchedFailures = new WeakMap(); + + return defineXrpcRoute({ + pds, + method, + httpMethod, + async matches(context) { + if (pendingFailures[endpoint].length === 0) { + return false; + } + + const matchesFailure = await matches(context); + if (!matchesFailure) { + return false; + } + + const failure = consume(endpoint, matchesFailure); + if (!failure) { + return false; + } + + matchedFailures.set(context.request, failure); + return true; + }, + resolve({ request }) { + return failOnceResponse(endpoint, matchedFailures.get(request) ?? {}); + }, + }); + }, + queue(endpoint, config = {}) { + pendingFailures[endpoint].push(config); + }, + }; +}; diff --git a/msw-atproto/src/repo/handlers.ts b/msw-atproto/src/repo/handlers.ts new file mode 100644 index 0000000..d131b68 --- /dev/null +++ b/msw-atproto/src/repo/handlers.ts @@ -0,0 +1,578 @@ +import { HttpResponse, type HttpHandler } from "msw"; + +import type { createMutableRepoIdentity } from "../identity/mock.ts"; +import type { FailOnceController } from "./failures.ts"; +import { matchesFields } from "./failures.ts"; +import type { createRepoModel } from "./model.ts"; +import { defineXrpcRoute, readXrpcJsonBody } from "./xrpc.ts"; + +type MutableRepoIdentity = ReturnType; +type RepoModel = ReturnType; + +type WriteRecordBody = { + collection: string; + record: Record; + repo: string; + rkey?: string; +}; + +type DeleteRecordBody = { + collection: string; + repo: string; + rkey: string; +}; + +type ApplyWriteCreate = { + $type: "com.atproto.repo.applyWrites#create"; + collection: string; + rkey?: string; + value: Record; +}; + +type ApplyWriteUpdate = { + $type: "com.atproto.repo.applyWrites#update"; + collection: string; + rkey: string; + value: Record; +}; + +type ApplyWriteDelete = { + $type: "com.atproto.repo.applyWrites#delete"; + collection: string; + rkey: string; +}; + +type ApplyWriteEntry = ApplyWriteCreate | ApplyWriteUpdate | ApplyWriteDelete; + +type ApplyWritesBody = { + repo: string; + writes: ApplyWriteEntry[]; +}; + +const asString = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + +const asRecordObject = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +const parseWriteRecordBody = ({ + body, + requiresRkey, +}: { + body: Record | undefined; + requiresRkey: boolean; +}): WriteRecordBody | undefined => { + const repo = asString(body?.repo); + const collection = asString(body?.collection); + const rkey = asString(body?.rkey); + const record = asRecordObject(body?.record); + + if (!repo || !collection || !record) return; + if (body?.rkey !== undefined && rkey === undefined) return; + if (requiresRkey && !rkey) return; + + return { + repo, + collection, + record: { ...record }, + ...(rkey !== undefined && { rkey }), + }; +}; + +const parseDeleteRecordBody = ( + body: Record | undefined, +): DeleteRecordBody | undefined => { + const repo = asString(body?.repo); + const collection = asString(body?.collection); + const rkey = asString(body?.rkey); + if (!repo || !collection || !rkey) return; + return { repo, collection, rkey }; +}; + +const APPLY_WRITES_TYPES = { + create: "com.atproto.repo.applyWrites#create", + update: "com.atproto.repo.applyWrites#update", + delete: "com.atproto.repo.applyWrites#delete", +} as const; + +const parseApplyWriteEntry = (entry: unknown): ApplyWriteEntry | undefined => { + const obj = asRecordObject(entry); + if (!obj) return; + + const type = asString(obj.$type); + const collection = asString(obj.collection); + if (!type || !collection) return; + + const rkey = asString(obj.rkey); + const value = asRecordObject(obj.value); + + if (type === APPLY_WRITES_TYPES.create) { + if (!value) return; + return { + $type: type, + collection, + value, + ...(rkey !== undefined && { rkey }), + }; + } + + if (type === APPLY_WRITES_TYPES.update) { + if (!rkey || !value) return; + return { $type: type, collection, rkey, value }; + } + + if (type === APPLY_WRITES_TYPES.delete) { + if (!rkey) return; + return { $type: type, collection, rkey }; + } + + return; +}; + +const parseApplyWritesBody = ( + body: Record | undefined, +): ApplyWritesBody | undefined => { + const repo = asString(body?.repo); + if (!repo || !Array.isArray(body?.writes)) return; + + const writes: ApplyWriteEntry[] = []; + for (const raw of body.writes) { + const entry = parseApplyWriteEntry(raw); + if (!entry) return; + writes.push(entry); + } + + return { repo, writes }; +}; + +const readMatchingBody = async ({ + model, + request, +}: { + model: RepoModel; + request: Request; +}) => { + const body = await readXrpcJsonBody(request); + return typeof body?.repo === "string" && model.hasRepo(body.repo) + ? body + : undefined; +}; + +const listRecordsHandler = ({ + pds, + model, +}: { + pds: string; + model: RepoModel; +}): HttpHandler => + defineXrpcRoute({ + pds, + method: "com.atproto.repo.listRecords", + matches({ url }) { + const repo = url.searchParams.get("repo"); + const collection = url.searchParams.get("collection"); + return ( + repo !== null && + collection !== null && + model.hasCollection({ repo, collection }) + ); + }, + resolve({ url }) { + const repo = url.searchParams.get("repo")!; + const collection = url.searchParams.get("collection")!; + const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "", 10); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 50; + const offset = Number.parseInt(url.searchParams.get("cursor") ?? "0", 10); + const records = model.recordsFor({ repo, collection }) ?? []; + const safeOffset = Number.isFinite(offset) && offset > 0 ? offset : 0; + const page = records.slice(safeOffset, safeOffset + limit); + const nextOffset = safeOffset + limit; + + return HttpResponse.json({ + records: page, + ...(nextOffset < records.length && { cursor: String(nextOffset) }), + }); + }, + }); + +const getRecordHandler = ({ + pds, + model, +}: { + pds: string; + model: RepoModel; +}): HttpHandler => + defineXrpcRoute({ + pds, + method: "com.atproto.repo.getRecord", + matches({ url }) { + const repo = url.searchParams.get("repo"); + const collection = url.searchParams.get("collection"); + const rkey = url.searchParams.get("rkey"); + return ( + repo !== null && + collection !== null && + rkey !== null && + model.hasCollection({ repo, collection }) + ); + }, + resolve({ url }) { + const record = model.findRecord({ + repo: url.searchParams.get("repo")!, + collection: url.searchParams.get("collection")!, + rkey: url.searchParams.get("rkey")!, + }); + + return record + ? HttpResponse.json(record) + : HttpResponse.json( + { error: "RecordNotFound", message: "Record not found" }, + { status: 404 }, + ); + }, + }); + +const getBlobHandler = ({ + pds, + model, +}: { + pds: string; + model: RepoModel; +}): HttpHandler => + defineXrpcRoute({ + pds, + method: "com.atproto.sync.getBlob", + matches({ url }) { + const did = url.searchParams.get("did"); + const cid = url.searchParams.get("cid"); + if (!did || !cid) { + return false; + } + + return model.hasBlob({ did, cid }); + }, + resolve({ url }) { + const blob = model.getBlob(url.searchParams.get("cid")!); + return blob + ? new HttpResponse(blob.body, { + headers: { "content-type": blob.contentType }, + }) + : HttpResponse.json( + { error: "NotFound", message: "Blob not found" }, + { status: 404 }, + ); + }, + }); + +const writeRecordHandler = ({ + pds, + model, + method, + action, + requiresRkey, +}: { + pds: string; + model: RepoModel; + method: "com.atproto.repo.createRecord" | "com.atproto.repo.putRecord"; + action: "create" | "put"; + requiresRkey: boolean; +}): HttpHandler => + defineXrpcRoute({ + pds, + method, + httpMethod: "post", + async matches(context) { + const body = await readXrpcJsonBody(context.request); + const write = parseWriteRecordBody({ body, requiresRkey }); + context.parsed = write; + return write !== undefined && model.hasRepo(write.repo); + }, + resolve({ parsed }) { + if (!parsed) { + throw new Error(`Matched ${method} request is missing a valid body`); + } + + return HttpResponse.json( + model.writeRecord({ + action, + uri: { + repo: parsed.repo, + collection: parsed.collection, + rkey: parsed.rkey, + }, + record: parsed.record, + }), + ); + }, + }); + +const deleteRecordHandler = ({ + pds, + model, +}: { + pds: string; + model: RepoModel; +}): HttpHandler => + defineXrpcRoute({ + pds, + method: "com.atproto.repo.deleteRecord", + httpMethod: "post", + async matches(context) { + const body = await readXrpcJsonBody(context.request); + const deletion = parseDeleteRecordBody(body); + + context.parsed = deletion; + return ( + deletion !== undefined && + model.hasCollection({ + repo: deletion.repo, + collection: deletion.collection, + }) + ); + }, + resolve({ parsed }) { + if (!parsed) { + throw new Error( + "Matched com.atproto.repo.deleteRecord request is missing a valid body", + ); + } + + model.deleteRecord(parsed); + + return HttpResponse.json({}); + }, + }); + +const applyWritesHandler = ({ + pds, + model, +}: { + pds: string; + model: RepoModel; +}): HttpHandler => + defineXrpcRoute({ + pds, + method: "com.atproto.repo.applyWrites", + httpMethod: "post", + async matches(context) { + const body = await readXrpcJsonBody(context.request); + const parsed = parseApplyWritesBody(body); + context.parsed = parsed; + return parsed !== undefined && model.hasRepo(parsed.repo); + }, + resolve({ parsed }) { + if (!parsed) { + throw new Error( + "Matched com.atproto.repo.applyWrites request is missing a valid body", + ); + } + + const results = parsed.writes.map((entry) => { + if (entry.$type === APPLY_WRITES_TYPES.delete) { + model.deleteRecord({ + repo: parsed.repo, + collection: entry.collection, + rkey: entry.rkey, + }); + return { $type: "com.atproto.repo.applyWrites#deleteResult" }; + } + + const action = + entry.$type === APPLY_WRITES_TYPES.create ? "create" : "put"; + const { uri, cid } = model.writeRecord({ + action, + uri: { + repo: parsed.repo, + collection: entry.collection, + ...(entry.rkey !== undefined && { rkey: entry.rkey }), + }, + record: entry.value, + }); + + return { + $type: + action === "create" + ? "com.atproto.repo.applyWrites#createResult" + : "com.atproto.repo.applyWrites#updateResult", + uri, + cid, + }; + }); + + return HttpResponse.json({ results }); + }, + }); + +const uploadBlobHandler = ({ + pds, + model, +}: { + pds: string; + model: RepoModel; +}): HttpHandler => + defineXrpcRoute({ + pds, + method: "com.atproto.repo.uploadBlob", + httpMethod: "post", + resolve: async ({ request }) => { + const contentType = + request.headers.get("content-type") ?? "application/octet-stream"; + const buffer = new Uint8Array(await request.arrayBuffer()); + const blob = model.addBlob({ body: buffer, contentType }); + + return HttpResponse.json({ + blob: { + $type: "blob", + ref: { $link: blob.cid }, + mimeType: contentType, + size: buffer.byteLength, + }, + }); + }, + }); + +const createFailureRoutes = ({ + did, + model, + failures, +}: { + did: string; + model: RepoModel; + failures: FailOnceController; +}): HttpHandler[] => [ + failures.defineRoute({ + endpoint: "listRecords", + method: "com.atproto.repo.listRecords", + matches: ({ url }) => { + const repo = url.searchParams.get("repo"); + const collection = url.searchParams.get("collection"); + + if (repo === null || collection === null || !model.hasRepo(repo)) { + return; + } + + return matchesFields({ collection }); + }, + }), + failures.defineRoute({ + endpoint: "getRecord", + method: "com.atproto.repo.getRecord", + matches: ({ url }) => { + const repo = url.searchParams.get("repo"); + const collection = url.searchParams.get("collection"); + const rkey = url.searchParams.get("rkey"); + + if (!repo || !model.hasRepo(repo)) { + return; + } + + if (!collection || !rkey) { + return; + } + + return matchesFields({ collection, rkey }); + }, + }), + failures.defineRoute({ + endpoint: "getBlob", + method: "com.atproto.sync.getBlob", + matches: ({ url }) => { + const requestDid = url.searchParams.get("did"); + const cid = url.searchParams.get("cid"); + + if (requestDid !== did || !cid) { + return; + } + + return matchesFields({ cid }); + }, + }), + failures.defineRoute({ + endpoint: "createRecord", + method: "com.atproto.repo.createRecord", + httpMethod: "post", + matches: async ({ request }) => { + const body = await readMatchingBody({ model, request }); + const collection = body?.collection; + + if (typeof collection !== "string") { + return; + } + + return matchesFields({ collection }); + }, + }), + failures.defineRoute({ + endpoint: "putRecord", + method: "com.atproto.repo.putRecord", + httpMethod: "post", + matches: async ({ request }) => { + const body = await readMatchingBody({ model, request }); + const collection = body?.collection; + const rkey = body?.rkey; + + if (typeof collection !== "string" || typeof rkey !== "string") { + return; + } + + return matchesFields({ collection, rkey }); + }, + }), + failures.defineRoute({ + endpoint: "deleteRecord", + method: "com.atproto.repo.deleteRecord", + httpMethod: "post", + matches: async ({ request }) => { + const body = await readMatchingBody({ model, request }); + const collection = body?.collection; + const rkey = body?.rkey; + + if (typeof collection !== "string" || typeof rkey !== "string") { + return; + } + + return matchesFields({ collection, rkey }); + }, + }), +]; + +export const createRepoHandlers = ({ + did, + identity, + model, + pds, + failures, +}: { + did: string; + identity: MutableRepoIdentity; + model: RepoModel; + pds: string; + failures: FailOnceController; +}): HttpHandler[] => [ + ...identity.handlers(), + // Failure routes must precede success handlers: MSW matches first-to-last, and a + // success handler that responds will prevent the failure route from consuming a queued failOnce. + ...createFailureRoutes({ did, model, failures }), + listRecordsHandler({ pds, model }), + getRecordHandler({ pds, model }), + getBlobHandler({ pds, model }), + writeRecordHandler({ + pds, + model, + method: "com.atproto.repo.createRecord", + action: "create", + requiresRkey: false, + }), + writeRecordHandler({ + pds, + model, + method: "com.atproto.repo.putRecord", + action: "put", + requiresRkey: true, + }), + deleteRecordHandler({ pds, model }), + applyWritesHandler({ pds, model }), + uploadBlobHandler({ pds, model }), +]; diff --git a/msw-atproto/src/repo/index.ts b/msw-atproto/src/repo/index.ts new file mode 100644 index 0000000..bc136e5 --- /dev/null +++ b/msw-atproto/src/repo/index.ts @@ -0,0 +1,106 @@ +import type { HttpHandler } from "msw"; + +import { createMutableRepoIdentity } from "../identity/mock.ts"; +import { createFailOnceController } from "./failures.ts"; +import { createRepoHandlers } from "./handlers.ts"; +import { createRepoModel } from "./model.ts"; + +export type MockAtprotoRecord = { + rkey: string; + value: Record; + cid?: string; +}; + +export type MockAtprotoBlob = { + cid: string; + body?: BodyInit; + contentType?: string; +}; + +/** + * Options accepted by every `failOnce.*` method. + * + * Omit `status` for a `500 InternalServerError`. Pass `status` to pick the + * default ATproto error body for that HTTP status. For example, `401` becomes + * `AuthRequired`, `404` on a record becomes `RecordNotFound`, and `5xx` + * becomes `InternalServerError`. + * + * Pass `error` or `message` to override that body. Endpoint-specific fields + * such as `collection`, `rkey`, or `cid` narrow which request consumes the + * queued failure. + */ +export type RepoFailureOpts = { + status?: number; + error?: string; + message?: string; +} & Partial>; + +export type MockAtprotoRepoConfig = { + did: string; + pds?: string; + handle?: string; + records?: Record; + blobs?: MockAtprotoBlob[]; +}; + +/** + * Builds one stateful fake ATproto account for tests. + * + * Use this first when your test reads or writes records through a real client. + * The returned handlers cover identity resolution, all six record endpoints, + * and both blob endpoints. Writes update the same in-memory store that later + * reads use, so a `createRecord` followed by `listRecords` returns the new + * record. + */ +export const createMockAtprotoRepo = ({ + did, + pds = "https://pds.fujocoded.test", + handle, + records = {}, + blobs = [], +}: MockAtprotoRepoConfig) => { + const identity = createMutableRepoIdentity({ did, pds, handle }); + const model = createRepoModel({ did, handle, records, blobs }); + const failures = createFailOnceController({ pds }); + + return { + did, + handle, + pds, + identity: identity.controls, + + records: model.currentRecords, + writes: model.currentWrites, + deletes: model.currentDeletes, + seed: model.seed, + seedBlob: model.seedBlob, + handlers: () => createRepoHandlers({ did, identity, model, pds, failures }), + failOnce: { + listRecords: (config?: RepoFailureOpts<"collection">) => + failures.queue("listRecords", config), + getRecord: (config?: RepoFailureOpts<"collection" | "rkey">) => + failures.queue("getRecord", config), + getBlob: (config?: RepoFailureOpts<"cid">) => + failures.queue("getBlob", config), + createRecord: (config?: RepoFailureOpts<"collection">) => + failures.queue("createRecord", config), + putRecord: (config?: RepoFailureOpts<"collection" | "rkey">) => + failures.queue("putRecord", config), + deleteRecord: (config?: RepoFailureOpts<"collection" | "rkey">) => + failures.queue("deleteRecord", config), + }, + clear() { + model.clear(); + failures.clear(); + }, + }; +}; + +export const useMockAtprotoRepo = ( + server: { use(...handlers: HttpHandler[]): void }, + config: MockAtprotoRepoConfig, +): ReturnType => { + const repo = createMockAtprotoRepo(config); + server.use(...repo.handlers()); + return repo; +}; diff --git a/msw-atproto/src/repo/model.ts b/msw-atproto/src/repo/model.ts new file mode 100644 index 0000000..aee2a1c --- /dev/null +++ b/msw-atproto/src/repo/model.ts @@ -0,0 +1,338 @@ +import { AtUri } from "@atproto/syntax"; + +import { cidForRecord, fakeCid } from "../cid.ts"; +import type { MockAtprotoBlob, MockAtprotoRecord } from "./index.ts"; + +type RepoWrite = { + action: "create" | "put"; + uri: string; + cid: string; + record: Record; +}; + +type RepoDelete = { + uri: string; +}; + +type StoredAtprotoRecord = { + uri: AtUri; + cid: string; + value: Record; + createdAtOrdinal: number; +}; + +type RecordUriParts = { + repo: string; + collection: string; + rkey?: string; +}; + +type RecordUriInput = string | AtUri | RecordUriParts; + +const GENERATED_RKEY_PREFIX = "3kgenerated"; + +const byCreatedAtOrdinal = ( + left: StoredAtprotoRecord, + right: StoredAtprotoRecord, +) => left.createdAtOrdinal - right.createdAtOrdinal; + +const toAtUri = (input: RecordUriInput): AtUri => + input instanceof AtUri + ? input + : typeof input === "string" + ? new AtUri(input) + : AtUri.make(input.repo, input.collection, input.rkey); + +const createBlobStore = ({ blobs = [] }: { blobs?: MockAtprotoBlob[] }) => { + const blobStore = new Map(); + + const seedBlob = ({ + cid, + body = new Uint8Array(), + contentType = "application/octet-stream", + }: MockAtprotoBlob) => { + blobStore.set(cid, { body, contentType }); + }; + + for (const blob of blobs) { + seedBlob(blob); + } + + return { + clear: () => blobStore.clear(), + getBlob: (cid: string) => blobStore.get(cid), + hasBlob: ({ + did, + expectedDid, + cid, + }: { + did: string; + expectedDid: string; + cid: string; + }) => did === expectedDid && blobStore.has(cid), + seedBlob, + }; +}; + +const createRecordStore = ({ did }: { did: string }) => { + const records = new Map(); + const declaredCollections = new Set(); + let ordinal = 0; + let generatedRkey = 0; + + const writeRecord = ({ + uri, + value, + cid, + }: { + uri: AtUri; + value: Record; + cid?: string; + }) => { + const key = uri.toString(); + const existing = records.get(key); + declaredCollections.add(uri.collection); + const data: StoredAtprotoRecord = { + uri, + cid: + cid ?? + cidForRecord({ + repo: uri.host, + collection: uri.collection, + rkey: uri.rkey, + value, + }), + value, + createdAtOrdinal: existing?.createdAtOrdinal ?? ordinal++, + }; + records.set(key, data); + return data; + }; + + return { + clear() { + records.clear(); + declaredCollections.clear(); + ordinal = 0; + generatedRkey = 0; + }, + delete: (uri: AtUri) => { + records.delete(uri.toString()); + return uri; + }, + find: (uri: AtUri) => { + if (!declaredCollections.has(uri.collection)) return; + return records.get(uri.toString()); + }, + hasCollection: (collection: string) => declaredCollections.has(collection), + listAll: () => Array.from(records.values()).sort(byCreatedAtOrdinal), + listIn: (collection: string) => { + if (!declaredCollections.has(collection)) return null; + return Array.from(records.values()) + .filter((record) => record.uri.collection === collection) + .sort(byCreatedAtOrdinal); + }, + nextGeneratedRkey: () => `${GENERATED_RKEY_PREFIX}${++generatedRkey}`, + seed: (collection: string, entries: MockAtprotoRecord[]) => { + declaredCollections.add(collection); + for (const entry of entries) { + writeRecord({ + uri: AtUri.make(did, collection, entry.rkey), + value: entry.value, + cid: entry.cid, + }); + } + }, + writeRecord, + }; +}; + +type RecordStore = ReturnType; +type BlobStore = ReturnType; + +const createLifecycleApi = ({ + recordStore, + blobStore, + writes, + deletes, +}: { + recordStore: RecordStore; + blobStore: BlobStore; + writes: RepoWrite[]; + deletes: RepoDelete[]; +}) => ({ + clear() { + recordStore.clear(); + blobStore.clear(); + writes.splice(0); + deletes.splice(0); + }, + currentRecords: () => + recordStore.listAll().map((record) => ({ + collection: record.uri.collection, + rkey: record.uri.rkey, + uri: record.uri.toString(), + cid: record.cid, + value: record.value, + })), + currentWrites: () => writes.map((write) => ({ ...write })), + currentDeletes: () => deletes.map((deleted) => ({ ...deleted })), +}); + +const createRecordApi = ({ + did, + recordStore, + writes, + deletes, + handle, +}: { + did: string; + recordStore: RecordStore; + writes: RepoWrite[]; + deletes: RepoDelete[]; + handle?: string; +}) => { + const hasRepo = (repo: string) => repo === did || repo === handle; + + const normalizeRecordUri = ( + input: RecordUriInput, + { generateRkey = false }: { generateRkey?: boolean } = {}, + ): AtUri | undefined => { + try { + const uri = toAtUri(input); + if (!hasRepo(uri.host) || !uri.collection) return; + const rkey = + uri.rkey || (generateRkey ? recordStore.nextGeneratedRkey() : ""); + if (!rkey) return; + return AtUri.make(did, uri.collection, rkey); + } catch { + return; + } + }; + + return { + hasRepo, + hasCollection: ({ + repo, + collection, + }: { + repo: string; + collection: string; + }) => hasRepo(repo) && recordStore.hasCollection(collection), + nextGeneratedRkey: recordStore.nextGeneratedRkey, + seed: recordStore.seed, + findRecord(uri: RecordUriInput) { + const recordUri = normalizeRecordUri(uri); + if (!recordUri) return; + const record = recordStore.find(recordUri); + return record + ? { + uri: record.uri.toString(), + cid: record.cid, + value: record.value, + } + : undefined; + }, + recordsFor({ repo, collection }: { repo: string; collection: string }) { + if (!hasRepo(repo)) return null; + const list = recordStore.listIn(collection); + return list + ? list.map((record) => ({ + uri: record.uri.toString(), + cid: record.cid, + value: record.value, + })) + : null; + }, + writeRecord({ + action, + uri, + record, + }: { + action: RepoWrite["action"]; + uri: RecordUriInput; + record: Record; + }) { + const recordUri = normalizeRecordUri(uri, { generateRkey: true }); + if (!recordUri) { + throw new Error("Cannot write a record for an unknown repo or URI"); + } + const stored = recordStore.writeRecord({ uri: recordUri, value: record }); + const uriString = stored.uri.toString(); + writes.push({ action, uri: uriString, cid: stored.cid, record }); + return { uri: uriString, cid: stored.cid }; + }, + deleteRecord(input: RecordUriInput) { + const recordUri = normalizeRecordUri(input); + if (!recordUri) return; + const uri = recordStore.delete(recordUri); + deletes.push({ uri: uri.toString() }); + }, + }; +}; + +const bodyToString = (body: BodyInit): string => { + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return Buffer.from(body).toString("base64"); + if (body instanceof ArrayBuffer) + return Buffer.from(new Uint8Array(body)).toString("base64"); + if (ArrayBuffer.isView(body)) + return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString( + "base64", + ); + if (body instanceof URLSearchParams) return body.toString(); + throw new Error( + "Cannot derive a stable blob CID from a Blob, FormData, or stream body. Pass an explicit `cid` when seeding this blob.", + ); +}; + +const createBlobApi = ({ + did, + blobStore, +}: { + did: string; + blobStore: BlobStore; +}) => ({ + getBlob: blobStore.getBlob, + hasBlob: ({ did: requestDid, cid }: { did: string; cid: string }) => + blobStore.hasBlob({ did: requestDid, expectedDid: did, cid }), + seedBlob: blobStore.seedBlob, + addBlob: ({ + body, + contentType = "application/octet-stream", + }: { + body: BodyInit; + contentType?: string; + }) => { + const cid = fakeCid(`blob:${contentType}:${bodyToString(body)}`); + blobStore.seedBlob({ cid, body, contentType }); + return { cid }; + }, +}); + +export const createRepoModel = ({ + did, + handle, + records = {}, + blobs = [], +}: { + did: string; + handle?: string; + records?: Record; + blobs?: MockAtprotoBlob[]; +}) => { + const recordStore = createRecordStore({ did }); + const blobStore = createBlobStore({ blobs }); + const writes: RepoWrite[] = []; + const deletes: RepoDelete[] = []; + + for (const [collection, seedRecords] of Object.entries(records)) { + recordStore.seed(collection, seedRecords); + } + + return { + ...createLifecycleApi({ recordStore, blobStore, writes, deletes }), + ...createRecordApi({ did, handle, recordStore, writes, deletes }), + ...createBlobApi({ did, blobStore }), + }; +}; diff --git a/msw-atproto/src/repo/xrpc.ts b/msw-atproto/src/repo/xrpc.ts new file mode 100644 index 0000000..88576b7 --- /dev/null +++ b/msw-atproto/src/repo/xrpc.ts @@ -0,0 +1,73 @@ +import { http, type HttpHandler } from "msw"; + +export type XrpcRouteContext = { + request: Request; + url: URL; + parsed?: Parsed; +}; + +/** + * Defines one XRPC MSW route on a PDS. + * + * The handler first checks origin and path, then lets the route-specific + * matcher inspect query params or JSON body. Returning `false` from the matcher + * lets another handler for the same PDS and endpoint answer instead. + */ +export const defineXrpcRoute = ({ + pds, + method, + httpMethod = "get", + matches, + resolve, +}: { + pds: string; + method: string; + httpMethod?: "get" | "post"; + matches?: (context: XrpcRouteContext) => boolean | Promise; + resolve: (context: XrpcRouteContext) => Response | Promise; +}): HttpHandler => { + const matchedContexts = new WeakMap>(); + + return http[httpMethod]( + async ({ request }) => { + const url = new URL(request.url); + const endpoint = new URL(`/xrpc/${method}`, pds); + + if ( + url.origin !== endpoint.origin || + url.pathname !== endpoint.pathname + ) { + return false; + } + + const context = { request, url }; + const didMatch = await (matches?.(context) ?? true); + if (didMatch) { + matchedContexts.set(request, context); + } + + return didMatch; + }, + ({ request }) => { + const context = matchedContexts.get(request); + if (!context) { + throw new Error(`Missing matched XRPC context for ${method}`); + } + + return resolve(context); + }, + ); +}; + +export const readXrpcJsonBody = async ( + request: Request, +): Promise | undefined> => { + try { + const body = await request.clone().json(); + return typeof body === "object" && body !== null && !Array.isArray(body) + ? (body as Record) + : undefined; + } catch { + return; + } +}; diff --git a/msw-atproto/tsconfig.json b/msw-atproto/tsconfig.json new file mode 100644 index 0000000..085d28e --- /dev/null +++ b/msw-atproto/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "lib": ["es2022", "DOM"], + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "outDir": "dist", + "sourceMap": true, + "declaration": true, + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/msw-atproto/tsdown.config.ts b/msw-atproto/tsdown.config.ts new file mode 100644 index 0000000..4031f72 --- /dev/null +++ b/msw-atproto/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig([ + { + name: "msw-atproto", + dts: true, + clean: true, + unbundle: true, + entry: ["src/index.ts"], + }, +]); diff --git a/msw-atproto/vitest.config.ts b/msw-atproto/vitest.config.ts new file mode 100644 index 0000000..41c5072 --- /dev/null +++ b/msw-atproto/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: "tests", + include: ["__tests__/**/*.test.ts"], + setupFiles: ["./__tests__/setup.ts"], + }, + }, + { + test: { + name: "examples", + include: ["__examples__/**/*.test.ts"], + setupFiles: ["./__examples__/setup.ts"], + }, + }, + ], + }, +}); diff --git a/package-lock.json b/package-lock.json index d75d05d..a7e8997 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3080,6 +3080,10 @@ "resolved": "expressive-code-output", "link": true }, + "node_modules/@fujocoded/msw-atproto": { + "resolved": "msw-atproto", + "link": true + }, "node_modules/@fujocoded/rehype-code-params": { "resolved": "rehype-code-params", "link": true @@ -19781,6 +19785,32 @@ "url": "https://github.com/sponsors/wooorm" } }, + "msw-atproto": { + "name": "@fujocoded/msw-atproto", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.7", + "@atproto/crypto": "^0.4.5", + "@atproto/syntax": "^0.4.2", + "multiformats": "^13.4.2" + }, + "devDependencies": { + "msw": "^2.13.4", + "tsdown": "^0.14.2", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "msw": "^2.0.0", + "vitest": "^3.0.0" + } + }, + "msw-atproto/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, "rehype-code-params": { "name": "@fujocoded/rehype-code-params", "version": "0.0.5",