From b2b72296434f600296154634797b7ae4180817d9 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 12 Oct 2026 08:16:42 -0500 Subject: [PATCH 1/9] chore: add docs-snippet test runner --- .env-example | 4 + AGENTS.md | 5 +- __tests__/docs-snippets/README.md | 89 ++++ .../docs-snippets-agent-prompt.md | 121 ++++++ __tests__/docs-snippets/docs-snippets-todo.md | 170 ++++++++ .../docs-snippets/helpers/channel-types.ts | 21 + __tests__/docs-snippets/helpers/cleanup.ts | 103 +++++ __tests__/docs-snippets/helpers/clients.ts | 15 + __tests__/docs-snippets/helpers/grants.ts | 30 ++ __tests__/docs-snippets/helpers/ids.ts | 35 ++ __tests__/docs-snippets/helpers/leaks.ts | 406 ++++++++++++++++++ __tests__/docs-snippets/helpers/push.ts | 12 + __tests__/docs-snippets/helpers/server.ts | 113 +++++ __tests__/docs-snippets/helpers/snapshot.ts | 63 +++ __tests__/docs-snippets/helpers/users.ts | 27 ++ __tests__/docs-snippets/helpers/wait.ts | 96 +++++ __tests__/docs-snippets/learnings.md | 16 + __tests__/docs-snippets/maintenance/sweep.ts | 60 +++ .../01-backend_quickstart.test.ts | 168 ++++++++ __tests__/docs-snippets/setup.ts | 97 +++++ .../docs-snippets/sync/docs-sync.test.ts | 153 +++++++ __tests__/docs-snippets/sync/parse.ts | 318 ++++++++++++++ __tests__/docs-snippets/tsconfig.json | 12 + __tests__/docs-snippets/vitest.config.mts | 31 ++ .../docs-snippets/vitest.sweep.config.mts | 14 + .../docs-snippets/vitest.sync.config.mts | 11 + __tests__/{ => integration}/agent.test.ts | 2 +- .../{ => integration}/assets/test-file.pdf | Bin .../{ => integration}/assets/test-image.jpg | Bin .../{ => integration}/block-lists.test.ts | 4 +- .../{ => integration}/call-members.test.ts | 6 +- .../{ => integration}/call-types.test.ts | 4 +- __tests__/{ => integration}/call.test.ts | 4 +- .../{ => integration}/channel-types.test.ts | 2 +- __tests__/{ => integration}/channel.test.ts | 4 +- __tests__/{ => integration}/command.test.ts | 2 +- .../{ => integration}/create-test-client.ts | 2 +- .../{ => integration}/create-token.test.ts | 2 +- .../{ => integration}/date-transform.test.ts | 4 +- .../{ => integration}/devices-push.test.ts | 4 +- .../external-storage.test.ts | 2 +- .../{ => integration}/file-uploads.test.ts | 4 +- __tests__/{ => integration}/messages.test.ts | 4 +- __tests__/{ => integration}/multipart.test.ts | 6 +- .../permissions-app-settings.test.ts | 4 +- .../{ => integration}/query-params.test.ts | 2 +- .../{ => integration}/rate-limit.test.ts | 2 +- __tests__/{ => integration}/ring-call.test.ts | 4 +- __tests__/{ => integration}/teams.test.ts | 6 +- .../{ => integration}/user-compat.test.ts | 2 +- __tests__/{ => integration}/users.test.ts | 4 +- .../webhook-compression.test.ts | 4 +- __tests__/{ => integration}/webhook.test.ts | 2 +- eslint.config.mjs | 2 + package.json | 4 + vite.config.mts | 2 +- 56 files changed, 2238 insertions(+), 46 deletions(-) create mode 100644 __tests__/docs-snippets/README.md create mode 100644 __tests__/docs-snippets/docs-snippets-agent-prompt.md create mode 100644 __tests__/docs-snippets/docs-snippets-todo.md create mode 100644 __tests__/docs-snippets/helpers/channel-types.ts create mode 100644 __tests__/docs-snippets/helpers/cleanup.ts create mode 100644 __tests__/docs-snippets/helpers/clients.ts create mode 100644 __tests__/docs-snippets/helpers/grants.ts create mode 100644 __tests__/docs-snippets/helpers/ids.ts create mode 100644 __tests__/docs-snippets/helpers/leaks.ts create mode 100644 __tests__/docs-snippets/helpers/push.ts create mode 100644 __tests__/docs-snippets/helpers/server.ts create mode 100644 __tests__/docs-snippets/helpers/snapshot.ts create mode 100644 __tests__/docs-snippets/helpers/users.ts create mode 100644 __tests__/docs-snippets/helpers/wait.ts create mode 100644 __tests__/docs-snippets/learnings.md create mode 100644 __tests__/docs-snippets/maintenance/sweep.ts create mode 100644 __tests__/docs-snippets/pages/node/01-quick_start/01-backend_quickstart.test.ts create mode 100644 __tests__/docs-snippets/setup.ts create mode 100644 __tests__/docs-snippets/sync/docs-sync.test.ts create mode 100644 __tests__/docs-snippets/sync/parse.ts create mode 100644 __tests__/docs-snippets/tsconfig.json create mode 100644 __tests__/docs-snippets/vitest.config.mts create mode 100644 __tests__/docs-snippets/vitest.sweep.config.mts create mode 100644 __tests__/docs-snippets/vitest.sync.config.mts rename __tests__/{ => integration}/agent.test.ts (98%) rename __tests__/{ => integration}/assets/test-file.pdf (100%) rename __tests__/{ => integration}/assets/test-image.jpg (100%) rename __tests__/{ => integration}/block-lists.test.ts (92%) rename __tests__/{ => integration}/call-members.test.ts (96%) rename __tests__/{ => integration}/call-types.test.ts (98%) rename __tests__/{ => integration}/call.test.ts (98%) rename __tests__/{ => integration}/channel-types.test.ts (95%) rename __tests__/{ => integration}/channel.test.ts (97%) rename __tests__/{ => integration}/command.test.ts (95%) rename __tests__/{ => integration}/create-test-client.ts (79%) rename __tests__/{ => integration}/create-token.test.ts (99%) rename __tests__/{ => integration}/date-transform.test.ts (96%) rename __tests__/{ => integration}/devices-push.test.ts (94%) rename __tests__/{ => integration}/external-storage.test.ts (98%) rename __tests__/{ => integration}/file-uploads.test.ts (97%) rename __tests__/{ => integration}/messages.test.ts (98%) rename __tests__/{ => integration}/multipart.test.ts (97%) rename __tests__/{ => integration}/permissions-app-settings.test.ts (95%) rename __tests__/{ => integration}/query-params.test.ts (98%) rename __tests__/{ => integration}/rate-limit.test.ts (97%) rename __tests__/{ => integration}/ring-call.test.ts (94%) rename __tests__/{ => integration}/teams.test.ts (94%) rename __tests__/{ => integration}/user-compat.test.ts (94%) rename __tests__/{ => integration}/users.test.ts (98%) rename __tests__/{ => integration}/webhook-compression.test.ts (99%) rename __tests__/{ => integration}/webhook.test.ts (95%) diff --git a/.env-example b/.env-example index ba87c88..f3f0986 100644 --- a/.env-example +++ b/.env-example @@ -1,3 +1,7 @@ STREAM_API_KEY= STREAM_SECRET= OPENAI_API_KEY= + +# Optional, for the docs snippet tests (__tests__/docs-snippets): Firebase service account JSON on one line. +# Push tests are skipped when it is not set. +DOCS_PUSH_FIREBASE_CREDENTIALS= diff --git a/AGENTS.md b/AGENTS.md index b4e9744..8ddc737 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ src/ StreamFeedsClient.ts # Feeds-specific client StreamModerationClient.ts # Moderation-specific client __tests__/ # Test files - *.test.ts # Test files for each module + integration/*.test.ts # Test files for each module (`yarn test`) + docs-snippets/ # Docs snippet tests (`yarn test:docs`, see its README.md) ``` Use the closest folder's patterns and conventions when editing. @@ -132,7 +133,7 @@ Commit / PR conventions • Ensure public API changes include docs and migration notes. Testing policy -• Add/extend tests in the `__tests__/` folder with .test.ts suffix. +• Add/extend tests in the `__tests__/integration/` folder with .test.ts suffix. • Cover: • Core API clients and models (StreamClient, StreamChatClient, etc.) • Utility functions (token creation, rate limiting) diff --git a/__tests__/docs-snippets/README.md b/__tests__/docs-snippets/README.md new file mode 100644 index 0000000..bb08fce --- /dev/null +++ b/__tests__/docs-snippets/README.md @@ -0,0 +1,89 @@ +# Docs snippet tests + +Typed tests that run the server-side Node.js snippets of the chat docs (the pages of the Node sidebar, +`getstream.io/content/docs/_sidebars/[chat][node].json`) against a real Stream app. +Only fences labelled `Node.js` are in scope (docs fences must always have a label): they are fixed when they are +wrong. `JavaScript` fences (client-side `stream-chat` code) are out of scope and are neither tested nor changed. + +```sh +# credentials: STREAM_API_KEY / STREAM_SECRET in the repo root .env (the node SDK test app, see .env-example) +yarn test:docs # all docs tests +yarn test:docs pages/node/01-quick_start/01-backend_quickstart.test.ts # one file +yarn types:docs # typecheck the docs tests +yarn test:docs:sync # test regions == docs fences (no network) +yarn test:docs:sweep # remove docs-test leftovers older than 30 min (DOCS_SWEEP_DRY_RUN=1 to list only, DOCS_SWEEP_MIN_AGE=0 for all) +yarn eslint __tests__/docs-snippets && npx prettier --check __tests__/docs-snippets # lint +``` + +These tests hit the network and are not part of `yarn test` (which runs `__tests__/integration`). Don't run them +at the same time as `yarn test`: both use the same app, and the drift check would see the other suite's changes. + +## Push credentials (optional) + +`DOCS_PUSH_FIREBASE_CREDENTIALS` in the repo root `.env`: a Firebase service account JSON, on one line. Snippets +that need real push credentials run in `describe.skipIf(!hasFirebaseCredentials)` (`helpers/push.ts`), so the +suite passes without it (those tests are reported as skipped). Push and webhook tests only call the API and +check the response, or read the setting back (`getApp`, `listPushProviders`, ...). They don't check that +notifications or webhook events arrive. + +## Layout + +``` +pages//.test.ts # Node.js snippets, run with the server client +helpers/ # server client, unique ids, cleanup registry, server-side helpers, + # waits, leak check, app snapshot, push credentials +setup.ts # .env loading, per-file leak check and app-settings drift check +sync/ # `yarn test:docs:sync` (docs fence <-> test region check) +maintenance/ # `yarn test:docs:sweep` +docs-snippets-todo.md # pages to cover, their status, blockers and setup notes +docs-snippets-agent-prompt.md # prompt for an agent that covers one page +learnings.md # lessons from earlier agent runs, read before working on a page +``` + +The path mirrors the docs path, e.g. `_default/04-messages/01-send_message.md` maps to +`pages/04-messages/01-send_message.test.ts`. Pages under `chat/node/` go under `pages/node/...`. + +## Conventions + +- Import the SDK from `../../../../index` (relative depth varies) and test helpers from `helpers/`. Use `import type` for types. +- `@stream-io/node-sdk` is aliased to the repo's `index.ts` (`resolve.alias` in `vitest.config.mts`, `paths` in `tsconfig.json`), so a docs fence that starts with `import ... from "@stream-io/node-sdk"` can be a region at module level: declare the values it uses (e.g. `apiKey`) above it. +- The docs call the server client `client`: use `const client = getServerClient()` (`helpers/clients.ts`, 30s timeout) and the same variable names as the docs (`client`, `channel`, ...). `createUserToken(userId)` signs user tokens. +- Snippets act on behalf of users with `user_id` / `created_by_id`: those users must exist (`client.upsertUsers`) and have `uniqueId` ids. +- **Isolation**: the id or name of everything a test creates comes from `uniqueId('name')`: users, channels, channel types, roles, commands, blocklists, segments, campaigns (name), user groups, moderation config keys, predefined filters, push providers. Polls are matched by their creator, so create them as a `uniqueId` user. Never use fixed ids like `"john"` or `"general"`. +- **Cleanup**: register everything you create on a `Cleanup` instance and call `cleanup.run()` in `afterAll`. App-level settings changed by a test must be restored with `cleanup.add(...)`. +- Tests in one file may share data created in `beforeAll`. Nothing may leak across files. +- `cleanup.run()` attempts every step and then throws if anything failed, so leftovers show up as a failed `afterAll` instead of piling up in the app. Server-side hard channel deletion is a background task: `Cleanup` waits for it (`waitForTask`) and retries deleting channel types until their channels are gone. +- **Leak check** (`setup.ts`, runs automatically after each file's own `afterAll`): looks up everything minted by `uniqueId` in that file (users, channels, channel types, commands, roles, custom permissions, blocklists, segments, campaigns, user groups, moderation configs, predefined filters, push providers, polls). Anything still there fails the file with a `LEAK` error listing it, and is then removed. Per-user data (reminders, devices, drafts, ...) goes away with the user. It also snapshots app settings (incl. push and webhook config), pre-existing channel types and retention policies before the file and fails with `DRIFT` if anything changed and wasn't restored. +- Anything created under a non-`uniqueId` name can't be found by the leak check or the sweep. Don't do that. +- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). +- **App settings**: pages are run one at a time, so tests may change app settings and built-in channel types, but must restore them (`cleanup.add`). The check after each file fails with `DRIFT` otherwise. +- `updateChannelType(client, name, patch)` (`helpers/channel-types.ts`) fills in the fields the request requires; `grantMessagingMembers` (`helpers/grants.ts`) adds `messaging` member grants and registers the restore. `helpers/server.ts` has repeated lookups: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy` and `waitForAppSetting`. +- One `it` per docs snippet (or per closely related group of snippets in the same tab group). +- Add basic `expect`s after each snippet to prove it did what the docs claim. + +## Snippet markers and docs sync + +Each docs fence has exactly one marked region in a test. `yarn test:docs:sync` (no network) compares them, so later edits on either side fail until they agree again: + +```ts +// #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Syncing channels" tab="Node.js" index=1 +// COPY: channelId="kung-fu", creatorId="4645" +const channel = client.chat.channel("messaging", channelId); + +// create the channel and set created_by to user id 4645 +await channel.getOrCreate({ data: { created_by_id: creatorId } }); +// #endregion snippet +``` + +- **Fence key**: + - `docs` is the page path relative to `getstream.io/content/docs/chat/`. + - `heading` is the text of the nearest heading above the fence. + - `tab` is the fence `label` (or `unlabelled`). + - `index` is 1-based among fences with the same heading text and tab (default 1). +- **COPY**: `name="literal"` pairs, applied to the region before comparing (identifier `name` becomes the docs literal). Don't use COPY variables as object keys or shorthand properties. +- **Comparison**: both sides are formatted with the same prettier options, so quote style and line wrapping don't matter, but any code or comment change does. +- **Coverage** (checked first): every `Node.js` fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the chat Node sidebar (`content/docs/_sidebars/[chat][node].json`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `JavaScript` fences and comment-only fences are ignored. +- **Labels**: every JS fence on those pages must be labelled `JavaScript` or `Node.js`; unlabelled or otherwise labelled JS fences fail. +- **Excluded pages**: `EXCLUDED_PAGES` in `sync/parse.ts` lists sidebar pages that are deliberately not validated, with a reason. Both checks skip them. +- **`DOCS_SYNC_WRITE= yarn test:docs:sync`** writes the regions of that page into the docs fences (docs prettier style). Use it to apply a fix to the docs. `DOCS_SYNC_WRITE=1` writes all pages. +- **`DOCS_CHAT_DIR`** points at another docs checkout (default: `../getstream.io/content/docs/chat` next to this repo). The check is skipped if the directory doesn't exist. diff --git a/__tests__/docs-snippets/docs-snippets-agent-prompt.md b/__tests__/docs-snippets/docs-snippets-agent-prompt.md new file mode 100644 index 0000000..3cb7d6a --- /dev/null +++ b/__tests__/docs-snippets/docs-snippets-agent-prompt.md @@ -0,0 +1,121 @@ +# Agent prompt: test the docs snippets of one page + +Replace `{{PAGE}}` with a path from `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`, e.g. `chat/_default/04-messages/01-send_message.md`. + +--- + +You are verifying the server-side Node.js code snippets of one Stream Chat docs page by writing typed tests that run them against a real Stream app with the `@stream-io/node-sdk` server client (API key + secret). Only fences labelled `Node.js` are in scope. Docs fences must always have a label: if you find an unlabelled `js` fence, label it (`Node.js` for `@stream-io/node-sdk` code, `JavaScript` for client code) and report it. `JavaScript` fences are out of scope: don't test, add or change them. + +**Page:** `/Users/zitaszupera/Stream/getstream.io/content/docs/{{PAGE}}` + +## Hard rules + +- **Do not commit, push, stash, or create branches** in any repo. Work only in the local working tree. +- **Do not write to `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`.** Read it, and propose additions in your final report. +- Only touch: + - the test files for this page under `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/pages/` + - the docs page itself + - this page's status row (and its entries in the Blockers detail / Setup notes / Known docs bugs / Possible SDK issues sections) in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md` +- Do not edit the helpers, the configs, other pages, or anything in `src/` (`src/gen` is generated from the OpenAPI spec, so hand edits would be lost). If a helper is missing something, write a local helper in your test file and propose the shared helper in your report. If the SDK is wrong (types or behavior), report it under "Possible SDK issues". +- Change existing docs snippets as little as possible, and only when they are actually wrong. +- Never put the API key, secret or push credentials in any file. They are loaded from the repo root `.env` (`STREAM_API_KEY`, `STREAM_SECRET`, optional `DOCS_PUSH_FIREBASE_CREDENTIALS`). +- In getstream.io, never run `npm run ci` or `npm run lint` for the whole repo. They rewrite thousands of files, including image assets. Use only the per-file commands listed below. + +## Read first + +1. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`: issues that earlier agents ran into. Follow it. +2. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/node/01-quick_start/01-backend_quickstart.test.ts` as an example. +3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`. Also read the page's entries in the TODO's **Blockers detail**, **Setup notes** and **Known docs bugs** sections, and the **App facts** line. +4. The SDK source for every method a snippet calls: `src/StreamClient.ts`, `src/StreamChatClient.ts`, `src/StreamChannel.ts`, `src/StreamModerationClient.ts` and the generated `src/gen/` clients and models (`src/gen/models/index.ts`), for the method name, its request object and its response type. When it's unclear whether a server-side endpoint exists or what it accepts, check the OpenAPI spec: https://github.com/GetStream/protocol/tree/main/openapi/v2 (`chat-serverside-api.json`). + +## What to build + +- **Test** (`__tests__/docs-snippets/pages/
/.test.ts`): run each `Node.js` snippet with `const client = getServerClient()` (`helpers/clients.ts`), named `client` as in the docs. + - Snippets act on behalf of users through `user_id` / `created_by_id` / `user`: create those users first (`client.upsertUsers`) with `uniqueId` ids. + - If a `Node.js` snippet is client-side code (it needs a connected user or a WebSocket, or calls a method the node SDK doesn't have, e.g. `connectUser`, `channel.watch()`, `client.on(...)`), it doesn't belong in a `Node.js` tab. Don't invent a server equivalent: put it in `it.skip('BLOCKED: client-side code: ...')` if it still typechecks, otherwise make the smallest docs fix that turns it into the server-side call the node SDK has, and report it either way. + - If no `Node.js` fence of the page can be tested, don't write a test file; record why in the report and the TODO. +- **Mirror paths.** The test paths mirror the docs path without the `chat/_default/` prefix. Pages under `chat/node/` go under `pages/node/...`. +- **Test structure:** + - `describe` per page. + - `beforeAll` creates the shared users and channels with `uniqueId(...)` ids and registers them on a `Cleanup`. + - `afterAll` runs `cleanup.run()`. + - One `it` per snippet, or per tab group when the snippets in it depend on each other. +- **Snippet markers.** Every `Node.js` fence on the page needs exactly one marker in a test (`JavaScript` fences are ignored). `yarn test:docs:sync` checks the format and the coverage. + - Wrap the docs code like this: + ```ts + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 + // COPY: channelId="general", userId="john" + ...docs code... + // #endregion snippet + ``` + - `docs` is the page path relative to `content/docs/chat/`. + - `heading` is the exact text of the nearest heading above the fence. + - `tab` is the fence label, or `unlabelled`. + - `index` (default 1) counts fences with the same heading text and tab on the page. If a key is wrong, the sync check lists the page's fence keys. + - Everything between the markers must be the docs code. Setup goes before the region and assertions after it. + - **COPY** (optional, first line(s) of the region): `name="docs literal"` pairs. Where the docs use fixed values ("john", "general", "messageID", `"{{ api_key }}"`), use variables in the test, and the sync check replaces each variable with its literal before comparing. Don't use COPY variables as object keys or in shorthand properties (`{ userId }`). + - Comment-only fences are skipped by the sync check and need no marker. Every other `Node.js` fence gets a real region: blocked or untestable snippets go inside `it.skip('BLOCKED: ...')` so they are still typechecked. A fence that isn't code (e.g. a bare data shape) is a docs bug: rewrite it as code that runs (e.g. reading and logging the fields), and report it. +- **Assertions.** After each snippet, add basic `expect`s proving that it did what the docs say. For example, check that the response contains the new field, or re-query and check that the state changed. Don't over-assert on volatile fields. +- **Naming**: the id or name of every resource you create comes from `uniqueId(...)` (campaigns: `name`; polls: create them as a `uniqueId` user). The automatic leak check after each file only finds resources named this way. If it reports a `LEAK`, fix your cleanup; don't work around the check. +- **App-level side effects** (app settings, built-in channel types, grants, roles, retention policies, multi-tenancy, push config, webhook URLs, ...): pages are run **one at a time**, so changing them is allowed. + - Read the original value first and restore it with `cleanup.add(...)`. + - The check after each file fails with `DRIFT` if app settings or pre-existing channel types differ from before the file ran. + - Prefer `uniqueId` channel types or `config_overrides` when the snippet allows it. +- **Push notifications and webhooks**: don't test that notifications or webhook events arrive. Calling the API and asserting the response (where there is one) is enough; for a setting, read it back (`getApp()`, `listPushProviders()`, `getPushTemplates`, ...). + - Snippets that need real push credentials (creating a push provider, `firebase_config`, ...) go in `describe.skipIf(!hasFirebaseCredentials)(...)` (`helpers/push.ts`), so the suite still passes without `DOCS_PUSH_FIREBASE_CREDENTIALS`. Pass `firebaseCredentials` through a COPY variable that maps to the docs placeholder (e.g. `credentials="your service worker config"`). Mark the page 🔑 in the TODO. + - Snippets that work without credentials (listing providers, push templates, `checkPush` with a payload, ...) run unconditionally. + - Webhook URL settings: set them to an obvious placeholder (e.g. `https://example.com/...`), assert them with `getApp()`, and restore the original value. +- **Channel types are eventually consistent.** After `createChannelType` or `updateChannelType`, API nodes can disagree for up to ~30s. + - Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after the create/update, outside the snippet region. + - Push the name to `cleanup.channelTypes` straight after `createChannelType`. `Cleanup` deletes it after the channels are gone. Never delete channel types in your own cleanup code. +- **Blockers** (the page's Blockers cell in the TODO): + - ⛔ snippets go in `it.skip('BLOCKED: ', ...)` so they are still typechecked. + - If you find a new blocker, use `it.skip('BLOCKED: ')`, mark the page `[~]` and report it, with the exact API error. +- **Partial snippets** (fragments like `filters = {...}` or `...` placeholders): wrap them in the minimal code needed to compile and run. The region still holds exactly the docs text. + +## Editing the docs page + +- Write fixes from the test with `DOCS_SYNC_WRITE= yarn test:docs:sync`. This writes every region of that page into its fence, applying the COPY values and the docs prettier style (double quotes). Don't paste snippets by hand. +- Follow `/Users/zitaszupera/Stream/getstream.io/content/docs/AGENTS.md`. In particular, no em-dashes in prose or comments. +- Then run, from `/Users/zitaszupera/Stream/getstream.io/content/docs`: + - `npx prettier --write ` + - `npx markdownlint-cli2 --config .markdownlint-cli2.cjs ` + +## Faulty docs snippets + +If a docs snippet is wrong (wrong method name or signature, a type error against the node SDK, wrong request or response shape, a runtime API error that isn't caused by test setup): + +1. Make the smallest fix that makes it correct, in the test region. +2. Write it to the docs page with `DOCS_SYNC_WRITE= yarn test:docs:sync`, then check `git diff` in getstream.io: only the intended fence should change. +3. Record it for the report as before/after with the reason and the evidence (error message or type error). + +If the snippet is correct but the node SDK types reject it or the SDK misbehaves, don't change the snippet or `src/`: put it in `it.skip('BLOCKED: SDK issue: ...')` and report it under "Possible SDK issues". + +If you're not sure whether the snippet or your setup is wrong, don't edit the docs. Mark it as an open question in the report instead. + +## Gates (all must pass before you finish) + +Run these from `/Users/zitaszupera/Stream/stream-node`: + +```sh +yarn test:docs # green, with no `LEAK` or `DRIFT` error +yarn types:docs # no type errors (strict TS, no `any` escapes, no `!` non-null assertions) +yarn eslint __tests__/docs-snippets # zero warnings +npx prettier --check __tests__/docs-snippets +yarn test:docs:sync # your page: every Node.js fence covered, every JS fence labelled JavaScript/Node.js, every region identical to its docs fence +``` + +`yarn test:docs:sync`'s coverage check also lists pages nobody has done yet: only your page must be absent from it. + +Don't use `@ts-ignore`/`@ts-expect-error` to silence a docs snippet. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (report it). + +Finally, update the page's row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`: `[x]` when done, `[~]` when there are open questions. + +## Final report (your last message) + +1. **Files created/changed**: test files, plus the docs page with a one-line summary. +2. **Docs snippet corrections**: for each one, a before/after diff, the reason, and the evidence. These need human review. +3. **Skipped / untestable snippets**: for each one, the snippet and why (including 🔑 push snippets skipped without credentials, and whether you ran them with credentials). +4. **Client-side code in Node.js snippets**: each fence or part of a fence that needs a connected user, with the error or the reason. +5. **Possible SDK issues**: type or runtime problems that look like node SDK bugs rather than docs bugs (not fixed). +6. **Proposed learnings** for `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`: a numbered list of short, reusable lessons that would have saved you time. Skip page-specific trivia. The user will approve or reject each one. diff --git a/__tests__/docs-snippets/docs-snippets-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md new file mode 100644 index 0000000..36aed75 --- /dev/null +++ b/__tests__/docs-snippets/docs-snippets-todo.md @@ -0,0 +1,170 @@ +# Docs snippet tests: todo + +Pages of the chat Node sidebar (`getstream.io/content/docs/_sidebars/[chat][node].json`) that contain `Node.js` snippets. Give each page to an agent with `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-agent-prompt.md`. **Run pages one at a time**: tests may change app settings (they must restore them). + +- Only server-side snippets are in scope: fences labelled `Node.js`. Unlabelled `js` fences get a label (`JavaScript` or `Node.js`). `JavaScript` fences (client-side) are out of scope. +- **Node.js / Unlabelled**: number of `js` fences labelled `Node.js` / with no label (before the docs were changed). +- **Test**: `yes` when the page gets a test under `__tests__/docs-snippets/pages/...`; `-` when nothing on it can be tested. + +**Blockers** (details below; empty = fully runnable, maybe with setup notes): + +- ⛔ **Blocked**: the app doesn't have the feature. The snippet goes in `it.skip('BLOCKED: ...')` (still typechecked). +- ◐ **Partial**: only some snippets of the page are affected. +- 🔑 **Push credentials**: needs `DOCS_PUSH_FIREBASE_CREDENTIALS`; the snippets run in `describe.skipIf(!hasFirebaseCredentials)`. +- **Excluded**: not validated, leave as is. + +Status: `[ ]` todo, `[x]` done, `[~]` done with open questions. + +App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `firebase:firebase` (not a docs-test one, leave it), permissions v2, multi-tenancy off, `disable_auth_checks` off, auto-translation off, campaigns on (segments, user groups and polls endpoints answer), no webhook / SQS / SNS / custom action handler URLs set (`webhook_url`, `custom_action_handler_url`, `sqs_url`, `sns_topic_arn` empty), one entry in `event_hooks`, no retention policies. Channel types: `livestream`, `messaging`, `team`, `agent`, `commerce`, `gaming`, plus `streamnodetest*` types left by the integration tests. Not probed yet: feature availability per page (each agent probes what its page needs and records it here). + +## chat/node + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | --------------------------------------------------- | ------- | ---------- | ---- | -------- | +| [x] | `chat/node/01-quick_start/01-backend_quickstart.md` | 5 | 0 | yes | | + +## chat/\_default/02-init_and_users + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | --------------------------------------------------- | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/02-init_and_users/04-user_groups.md` | 8 | 0 | | | + +## chat/\_default/03-channels + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ----------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/03-channels/01-creating_channels.md` | 2 | 0 | | | +| [ ] | `chat/_default/03-channels/02-query_channels.md` | 11 | 0 | | | +| [ ] | `chat/_default/03-channels/08-get_channel.md` | 1 | 0 | | | +| [ ] | `chat/_default/03-channels/03-channel_update.md` | 2 | 0 | | | +| [ ] | `chat/_default/03-channels/04-query_members.md` | 2 | 0 | | | +| [ ] | `chat/_default/03-channels/05-channel_pagination.md` | 2 | 0 | | | +| [ ] | `chat/_default/03-channels/06-channel_members.md` | 10 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/08-archiving.md` | 1 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/09-pinning.md` | 1 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/10-muting.md` | 3 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/11-hiding.md` | 1 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/12-disabling.md` | 1 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/13-deleting.md` | 3 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/14-freezing.md` | 3 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/15-truncating.md` | 2 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/16-channel_invites.md` | 6 | 0 | | | +| [ ] | `chat/_default/03-channels/07-channel_management/17-batch-updates.md` | 3 | 0 | | | + +## chat/\_default/04-messages + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ------------------------------------------------------ | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/04-messages/01-send_message.md` | 9 | 0 | | | +| [ ] | `chat/_default/04-messages/02-file_uploads.md` | 4 | 0 | | | +| [ ] | `chat/_default/04-messages/03-threads.md` | 9 | 0 | | | +| [ ] | `chat/_default/04-messages/04-send_reaction.md` | 5 | 0 | | | +| [ ] | `chat/_default/04-messages/05-pinned_messages.md` | 3 | 0 | | | +| [ ] | `chat/_default/04-messages/06-search.md` | 2 | 0 | | | +| [ ] | `chat/_default/04-messages/07-silent_messages.md` | 2 | 0 | | | +| [ ] | `chat/_default/04-messages/08-unread_reminders.md` | 1 | 0 | | | +| [ ] | `chat/_default/04-messages/09-message_reminders.md` | 7 | 0 | | | +| [ ] | `chat/_default/04-messages/10-message_receipts.md` | 4 | 0 | | | +| [ ] | `chat/_default/04-messages/11-ai-message-streaming.md` | 2 | 0 | | | + +## chat/\_default/05-features + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ------------------------------------------------------------------------ | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/05-features/02-events.md` | 2 | 0 | | | +| [ ] | `chat/_default/05-features/03-unread.md` | 7 | 0 | | | +| [ ] | `chat/_default/05-features/06-campaign_api.md` | 16 | 0 | | | +| [ ] | `chat/_default/05-features/07-polls_api.md` | 16 | 0 | | | +| [ ] | `chat/_default/05-features/08-location_sharing.md` | 5 | 0 | | | +| [ ] | `chat/_default/05-features/09-translation.md` | 3 | 0 | | | +| [ ] | `chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md` | 1 | 0 | | | +| [ ] | `chat/_default/05-features/10-advanced/12-drafts.md` | 4 | 0 | | | +| [ ] | `chat/_default/05-features/10-advanced/13-private_messaging.md` | 1 | 0 | | | +| [ ] | `chat/_default/05-features/10-advanced/14-user_average_response_time.md` | 1 | 0 | | | +| [ ] | `chat/_default/05-features/10-advanced/15-pending_messages.md` | 6 | 0 | | | +| [ ] | `chat/_default/05-features/10-advanced/16-dynamic_partitioning.md` | 4 | 0 | | | +| [ ] | `chat/_default/05-features/10-advanced/17-audit_logs.md` | 5 | 0 | | | +| [ ] | `chat/_default/05-features/10-advanced/18-data_retention_policy.md` | 4 | 0 | | | + +## chat/\_default/06-app_and_channel_settings + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | -------------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/06-app_and_channel_settings/01-channel_types.md` | 9 | 0 | | | +| [ ] | `chat/_default/06-app_and_channel_settings/02-app_settings.md` | 3 | 0 | | | +| [ ] | `chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md` | 11 | 0 | | | +| [ ] | `chat/_default/06-app_and_channel_settings/06-channel-level_settings.md` | 3 | 0 | | | + +## chat/\_default/07-push + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ------------------------------------------------ | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/07-push/07-legacy_push_system.md` | 2 | 0 | | 🔑 | + +## chat/\_default/08-webhooks + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ---------------------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md` | 3 | 0 | | | +| [ ] | `chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md` | 8 | 0 | | | + +## chat/\_default/10-migrating + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ----------------------------------------------------- | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/10-migrating/03-exporting_channels.md` | 5 | 0 | | | + +## chat/\_default/12-best_practices + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ------------------------------------------------------------------ | ------- | ---------- | ---- | -------- | +| [ ] | `chat/_default/12-best_practices/02-moderation.md` | 13 | 0 | | | +| [ ] | `chat/_default/12-best_practices/05-marketplace_best_practices.md` | 5 | 0 | | | + +## Pages without Node.js snippets + +These sidebar pages have no `Node.js` (or unlabelled) JS fences: nothing to test. The sync coverage check still reads them, so a `Node.js` fence added later shows up as uncovered. + +- `chat/_default/01-quick_start/02-architecture_and_benchmark.md` +- `chat/_default/01-quick_start/03-roadmap_and_changelog.md` +- `chat/_default/03-channels/07-channel_management/07-overview.md` +- `chat/_default/05-features/01-overview.md` +- `chat/_default/06-app_and_channel_settings/05-permissions_reference.md` +- `chat/_default/07-push/02-push_template.md` +- `chat/_default/08-webhooks/05-webhook_events.md` +- `chat/_default/10-migrating/01-migration_guide.md` +- `chat/_default/10-migrating/02-import.md` +- `chat/_default/11-debugging_and_cli/09-datadog_integration.md` +- `chat/_default/12-best_practices/09-fair_usage_limits.md` +- `chat/_default/11-debugging_and_cli/08-rate_limits.md` +- `chat/_default/11-debugging_and_cli/10-api_budget.md` +- `chat/_default/12-best_practices/08-storage_and_bandwidth.md` +- `chat/_default/12-best_practices/01-best_practices.md` +- `chat/_default/12-best_practices/06-livestream_best_practices.md` + +## Blockers detail + +None found yet. + +## Setup notes (not blockers) + +- **node/01-quick_start/01-backend_quickstart**: The first fence creates a client from `"{{ api_key }}"` / `"{{ api_secret }}"` template placeholders: COPY them from the real credentials (`apiKey="{{ api_key }}"`). +- **07-push/07-legacy_push_system**: Push provider / `firebase_config` snippets need real credentials (🔑). Name providers with `uniqueId`; `updateApp({ firebase_config })` changes app-level push config: save it with `getApp()` first and restore it (the DRIFT check compares `push_notifications`). +- **08-webhooks/01-webhooks_overview/\***: Configure the hook URL (`before_message_send_hook_url`, `custom_action_handler_url`, ...) with `updateApp`, assert it with `getApp()`, and restore the original (empty) value. Don't test that the webhook is called. +- **All pages that create/update channel types**: Wait 30s (`waitForChannelTypePropagation()`) after `createChannelType` / `updateChannelType` before using the type. + +## Known docs bugs + +Found while testing; agents confirm and fix them (minimal change, listed in the final report). + +None yet. + +## Possible SDK issues + +Runtime or type problems that look like node SDK bugs rather than docs bugs. Not fixed (no `src/` changes in this project). + +None yet. + +## Notes + +None yet. diff --git a/__tests__/docs-snippets/helpers/channel-types.ts b/__tests__/docs-snippets/helpers/channel-types.ts new file mode 100644 index 0000000..fee8dd1 --- /dev/null +++ b/__tests__/docs-snippets/helpers/channel-types.ts @@ -0,0 +1,21 @@ +import type { StreamClient, UpdateChannelTypeRequest } from '../../../index'; + +/** + * Updates a channel type with only the fields in `patch`. The node-sdk request requires + * `automod`, `automod_behavior` and `max_message_length`, so their current values are + * read and passed through unchanged. Call `waitForChannelTypePropagation()` after it. + */ +export const updateChannelType = async ( + serverClient: StreamClient, + name: string, + patch: Partial, +) => { + const current = await serverClient.chat.getChannelType({ name }); + return serverClient.chat.updateChannelType({ + name, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + ...patch, + }); +}; diff --git a/__tests__/docs-snippets/helpers/cleanup.ts b/__tests__/docs-snippets/helpers/cleanup.ts new file mode 100644 index 0000000..a0306da --- /dev/null +++ b/__tests__/docs-snippets/helpers/cleanup.ts @@ -0,0 +1,103 @@ +import type { StreamClient } from '../../../index'; +import { hardDeleteUsers } from './users'; +import { + CHANNEL_TYPE_PROPAGATION_MS, + isChannelTypeMissing, + retry, + retryOnRateLimit, + waitForTask, +} from './wait'; + +/** + * Collects everything a test file creates and removes it in `afterAll`. + * + * ```ts + * const cleanup = new Cleanup(getServerClient()); + * cleanup.users.push(userId); + * cleanup.add(() => serverClient.updateApp(originalSettings)); + * afterAll(() => cleanup.run()); + * ``` + * + * Everything is attempted even if a step fails; failures are thrown together at the + * end so leftovers (especially channel types, which count against an app limit) don't + * pile up silently. + */ +export class Cleanup { + users: string[] = []; + /** Channel cids (`type:id`). */ + channels: string[] = []; + channelTypes: string[] = []; + private custom: Array<() => Promise> = []; + + constructor(private serverClient: StreamClient) {} + + /** Extra cleanup steps (restore app settings, delete blocklists, ...). Run first, in reverse order. */ + add(fn: () => Promise) { + this.custom.push(fn); + } + + async run() { + const errors: unknown[] = []; + const attempt = async (fn: () => Promise) => { + try { + await fn(); + } catch (error) { + errors.push(error); + } + }; + + for (const fn of [...this.custom].reverse()) await attempt(fn); + if (this.channels.length) await attempt(() => this.deleteChannels()); + // A channel type can only be deleted once all of its channels are gone. + for (const type of this.channelTypes) { + await attempt(() => this.deleteChannelType(type)); + } + if (this.users.length) { + await attempt(() => hardDeleteUsers(this.serverClient, this.users)); + } + + if (errors.length) { + throw new AggregateError( + errors, + '[docs-snippets] cleanup failed, see `errors`', + ); + } + } + + private async deleteChannelType(type: string) { + // "channels of that type exist": the channel delete task hasn't caught up yet. + // "does not exist": either already deleted (e.g. by the snippet) or, right after + // creation, this API node doesn't know the type yet - keep trying for a while and + // treat it as gone after that. + const startedAt = Date.now(); + try { + await retry( + () => this.serverClient.chat.deleteChannelType({ name: type }), + { + timeout: 60000, + interval: 2000, + retryIf: (error) => + error instanceof Error && + (/channels of that type exist/.test(error.message) || + (isChannelTypeMissing(error) && + Date.now() - startedAt < CHANNEL_TYPE_PROPAGATION_MS + 5000)), + }, + ); + } catch (error) { + if (!isChannelTypeMissing(error)) throw error; + } + } + + private async deleteChannels() { + // Server-side hard delete runs as a background task: wait for it so the channel + // types can be deleted afterwards. + const response = await retryOnRateLimit(() => + this.serverClient.chat.deleteChannels({ + cids: this.channels, + hard_delete: true, + }), + ); + if (response.task_id) + await waitForTask(this.serverClient, response.task_id); + } +} diff --git a/__tests__/docs-snippets/helpers/clients.ts b/__tests__/docs-snippets/helpers/clients.ts new file mode 100644 index 0000000..70ad977 --- /dev/null +++ b/__tests__/docs-snippets/helpers/clients.ts @@ -0,0 +1,15 @@ +import { StreamClient } from '../../../index'; + +const apiKey = () => process.env.STREAM_API_KEY as string; +const apiSecret = () => process.env.STREAM_SECRET as string; + +/** + * Server-side client (API key + secret), the `client` of the docs snippets. Also used for + * setup, cleanup and assertions. The default 3s timeout is too short for some endpoints. + */ +export const getServerClient = () => + new StreamClient(apiKey(), apiSecret(), { timeout: 30000 }); + +/** User token for `userId`, signed with the API secret. */ +export const createUserToken = (userId: string) => + getServerClient().generateUserToken({ user_id: userId }); diff --git a/__tests__/docs-snippets/helpers/grants.ts b/__tests__/docs-snippets/helpers/grants.ts new file mode 100644 index 0000000..75c0fd5 --- /dev/null +++ b/__tests__/docs-snippets/helpers/grants.ts @@ -0,0 +1,30 @@ +import type { StreamClient } from '../../../index'; +import { updateChannelType } from './channel-types'; +import type { Cleanup } from './cleanup'; +import { waitForChannelTypePropagation } from './wait'; + +/** + * Adds permissions to the `channel_member` grants of the built-in `messaging` type and + * registers a cleanup step that restores the original grants. Call + * `waitForChannelTypePropagation()` after it, before using the new grants. + */ +export const grantMessagingMembers = async ( + serverClient: StreamClient, + cleanup: Cleanup, + permissions: string[], +) => { + const { grants = {} } = await serverClient.chat.getChannelType({ + name: 'messaging', + }); + const original = grants.channel_member ?? []; + cleanup.add(async () => { + await updateChannelType(serverClient, 'messaging', { + grants: { channel_member: original }, + }); + // Let the restore reach every API node before the DRIFT check reads it. + await waitForChannelTypePropagation(); + }); + await updateChannelType(serverClient, 'messaging', { + grants: { channel_member: [...new Set([...original, ...permissions])] }, + }); +}; diff --git a/__tests__/docs-snippets/helpers/ids.ts b/__tests__/docs-snippets/helpers/ids.ts new file mode 100644 index 0000000..e932a12 --- /dev/null +++ b/__tests__/docs-snippets/helpers/ids.ts @@ -0,0 +1,35 @@ +import { randomBytes } from 'node:crypto'; + +// Every id created by a docs test starts with this prefix, so leftovers from +// crashed runs are easy to find and sweep. +export const DOCS_TEST_PREFIX = 'docs-test'; + +/** + * One id per test file run (each test file gets its own module instance). + * Starts with `Date.now()` in base36 (8 chars) so the sweep can tell how old an id is. + */ +export const runId = `${Date.now().toString(36)}${randomBytes(2).toString('hex')}`; +let counter = 0; + +/** Every id handed out by `uniqueId` in this test file; the leak check looks these up. */ +export const mintedIds = new Set(); + +/** + * Returns an id that is unique across runs, e.g. `docs-test-john-lx3k9a1f-1`. + * Use it for the id or name of everything a test creates: users, channels, channel + * types, roles, commands, blocklists, segments, campaigns, user groups, moderation + * configs, predefined filters, ... Anything named this way is found by the leak check. + */ +export const uniqueId = (name: string) => { + const id = `${DOCS_TEST_PREFIX}-${name}-${runId}-${++counter}`; + mintedIds.add(id); + return id; +}; + +const RUN_ID_RE = /-([0-9a-z]{8})[0-9a-f]{4}-\d+$/; + +/** When the run that minted `id` started, or undefined if `id` wasn't made by `uniqueId`. */ +export const createdAtFromId = (id: string) => { + const match = id.includes(DOCS_TEST_PREFIX) ? RUN_ID_RE.exec(id) : null; + return match ? new Date(parseInt(match[1], 36)) : undefined; +}; diff --git a/__tests__/docs-snippets/helpers/leaks.ts b/__tests__/docs-snippets/helpers/leaks.ts new file mode 100644 index 0000000..e438601 --- /dev/null +++ b/__tests__/docs-snippets/helpers/leaks.ts @@ -0,0 +1,406 @@ +import type { StreamClient } from '../../../index'; +import { DOCS_TEST_PREFIX } from './ids'; +import { hardDeleteUsers } from './users'; +import { retry, retryOnRateLimit, waitForTask } from './wait'; + +/** Something a docs test created and didn't remove. */ +export type Leftover = { + kind: string; + id: string; + /** Used by the sweep to skip resources of runs that may still be in progress. */ + createdAt?: Date | string; + remove: () => Promise; + /** + * Removes many leftovers of this kind in one call (e.g. users in one `deleteUsers`). + * `removeLeftovers` prefers it over `remove`, to stay under the rate limits. + */ + removeMany?: (ids: string[]) => Promise; +}; + +/** + * Decides which resources are leftovers. `matches` gets ids / names (and a poll's + * `created_by_id`); `channelIds` is needed because channels can't be searched by prefix. + */ +export type LeftoverFilter = { + matches: (idOrName: string) => boolean; + channelIds?: string[]; +}; + +/** Hard deletes channels and waits for the background delete task. */ +const hardDeleteChannels = async (client: StreamClient, cids: string[]) => { + const { task_id } = await client.chat.deleteChannels({ + cids, + hard_delete: true, + }); + if (task_id) await waitForTask(client, task_id); +}; + +/** + * Polls can only be queried server-side "as" a user, and they survive their + * creator's deletion, so a permanent helper user (outside the docs-test prefix) is + * used for poll queries / deletes. + */ +const POLL_QUERY_USER_ID = 'docs-snippets-leak-checker'; + +type Scanner = ( + client: StreamClient, + filter: LeftoverFilter, +) => Promise; + +const chunk = (items: T[], size: number) => + Array.from({ length: Math.ceil(items.length / size) }, (_, i) => + items.slice(i * size, i * size + size), + ); + +const scanUsers = async ( + client: StreamClient, + prefix: string, + matches: LeftoverFilter['matches'], +) => { + const found: Leftover[] = []; + for (let offset = 0; ; offset += 100) { + const { users } = await client.queryUsers({ + payload: { + filter_conditions: { id: { $autocomplete: prefix } }, + sort: [{ field: 'id', direction: 1 }], + limit: 100, + offset, + }, + }); + for (const user of users) { + if (!matches(user.id)) continue; + found.push({ + kind: 'user', + id: user.id, + createdAt: user.created_at, + remove: () => hardDeleteUsers(client, [user.id]), + removeMany: (ids) => hardDeleteUsers(client, ids), + }); + } + if (users.length < 100) return found; + } +}; + +const scanners: Record = { + async users(client, { matches }) { + const found: Leftover[] = []; + // Guest users get their id rewritten to `guest--`, so they need + // a second query. + for (const prefix of [DOCS_TEST_PREFIX, 'guest']) { + found.push(...(await scanUsers(client, prefix, matches))); + } + return found; + }, + + async channels(client, { channelIds = [] }) { + const found: Leftover[] = []; + for (const ids of chunk(channelIds, 100)) { + const { channels } = await client.chat.queryChannels({ + filter_conditions: { id: { $in: ids } }, + limit: 100, + state: false, + }); + for (const { channel } of channels) { + if (!channel) continue; + found.push({ + kind: 'channel', + id: channel.cid, + createdAt: channel.created_at, + remove: () => hardDeleteChannels(client, [channel.cid]), + }); + } + } + return found; + }, + + async channelTypes(client, { matches }) { + const { channel_types } = await client.chat.listChannelTypes(); + return Object.entries(channel_types) + .filter(([name]) => matches(name)) + .map(([name, type]) => ({ + kind: 'channel type', + id: name, + createdAt: type.created_at, + remove: async () => { + // Its channels must be gone first; they are only findable by type. + const { channels } = await client.chat.queryChannels({ + filter_conditions: { type: name }, + limit: 100, + state: false, + }); + const cids = channels.flatMap(({ channel }) => + channel ? [channel.cid] : [], + ); + if (cids.length) await hardDeleteChannels(client, cids); + await retry(() => client.chat.deleteChannelType({ name }), { + interval: 2000, + retryIf: (error) => + error instanceof Error && + /channels of that type exist/.test(error.message), + }); + }, + })); + }, + + async commands(client, { matches }) { + const { commands } = await client.chat.listCommands(); + return commands + .filter((command) => matches(command.name)) + .map((command) => ({ + kind: 'command', + id: command.name, + createdAt: command.created_at, + remove: () => client.chat.deleteCommand({ name: command.name }), + })); + }, + + async roles(client, { matches }) { + const { roles } = await client.listRoles(); + return roles + .filter((role) => role.custom && matches(role.name)) + .map((role) => ({ + kind: 'role', + id: role.name, + createdAt: role.created_at, + remove: () => client.deleteRole({ name: role.name }), + })); + }, + + async permissions(client, { matches }) { + const { permissions } = await client.listPermissions(); + return permissions + .filter((permission) => permission.custom && matches(permission.id)) + .map((permission) => ({ + kind: 'custom permission', + id: permission.id, + remove: () => client.deletePermission({ id: permission.id }), + })); + }, + + async blocklists(client, { matches }) { + const { blocklists } = await client.listBlockLists(); + return blocklists + .filter((blocklist) => matches(blocklist.name)) + .map((blocklist) => ({ + kind: 'blocklist', + id: blocklist.name, + createdAt: blocklist.created_at, + remove: () => client.deleteBlockList({ name: blocklist.name }), + })); + }, + + async segments(client, { matches }) { + const found: Leftover[] = []; + let next: string | undefined; + do { + const response = await client.chat.querySegments({ + filter: {}, + limit: 100, + next, + }); + for (const segment of response.segments) { + if (!matches(segment.id) && !matches(segment.name ?? '')) continue; + found.push({ + kind: 'segment', + id: segment.id, + createdAt: segment.created_at, + remove: () => client.chat.deleteSegment({ id: segment.id }), + }); + } + next = response.next; + } while (next); + return found; + }, + + async campaigns(client, { matches }) { + const found: Leftover[] = []; + let next: string | undefined; + do { + const response = await client.chat.queryCampaigns({ limit: 100, next }); + for (const campaign of response.campaigns) { + if (!matches(campaign.id) && !matches(campaign.name)) continue; + found.push({ + kind: 'campaign', + id: `${campaign.id} (${campaign.name})`, + createdAt: campaign.created_at, + remove: () => client.chat.deleteCampaign({ id: campaign.id }), + }); + } + next = response.next; + } while (next); + return found; + }, + + async userGroups(client, { matches }) { + const found: Leftover[] = []; + let idGt: string | undefined; + for (;;) { + const { user_groups } = await client.listUserGroups({ + limit: 100, + id_gt: idGt, + }); + for (const group of user_groups) { + if (!matches(group.id) && !matches(group.name)) continue; + found.push({ + kind: 'user group', + id: group.id, + createdAt: group.created_at, + remove: () => client.deleteUserGroup({ id: group.id }), + }); + } + if (user_groups.length < 100) return found; + idGt = user_groups[user_groups.length - 1].id; + } + }, + + async moderationConfigs(client, { matches }) { + const { configs } = await client.moderation.queryModerationConfigs({ + limit: 100, + }); + return configs + .filter((config) => matches(config.key)) + .map((config) => ({ + kind: 'moderation config', + id: config.key, + createdAt: config.created_at, + remove: () => client.moderation.deleteConfig({ key: config.key }), + })); + }, + + async predefinedFilters(client, { matches }) { + const { predefined_filters } = await client.chat.getPredefinedFilters(); + return predefined_filters + .filter((filter) => matches(filter.name)) + .map((filter) => ({ + kind: 'predefined filter', + id: filter.name, + createdAt: filter.created_at, + remove: () => client.chat.deletePredefinedFilter({ name: filter.name }), + })); + }, + + async pushProviders(client, { matches }) { + const { push_providers } = await client.listPushProviders(); + return push_providers + .filter((provider) => matches(provider.name)) + .map((provider) => ({ + kind: 'push provider', + id: `${provider.type}:${provider.name}`, + createdAt: provider.created_at, + remove: () => + client.deletePushProvider({ + type: provider.type, + name: provider.name, + }), + })); + }, + + async polls(client, { matches }) { + await client.upsertUsers([ + { id: POLL_QUERY_USER_ID, name: 'Docs snippets leak checker' }, + ]); + const found: Leftover[] = []; + let next: string | undefined; + do { + const response = await client.queryPolls({ + user_id: POLL_QUERY_USER_ID, + limit: 100, + next, + }); + for (const poll of response.polls) { + if (!matches(poll.created_by_id) && !matches(poll.name)) continue; + found.push({ + kind: 'poll', + id: `${poll.id} (by ${poll.created_by_id})`, + createdAt: poll.created_at, + remove: () => + client.deletePoll({ + poll_id: poll.id, + user_id: POLL_QUERY_USER_ID, + }), + }); + } + next = response.next; + } while (next); + return found; + }, +}; + +/** Resource kinds that are checked. Anything else (reminders, devices, drafts, ...) is per-user and goes away with the user. */ +export const SCANNED_KINDS = Object.keys(scanners); + +/** + * Finds docs-test resources matching `filter` across every scanned resource kind. + * Scanner failures (e.g. a feature not enabled on the app) are returned as `errors` + * instead of failing the whole scan. + */ +export const findLeftovers = async ( + client: StreamClient, + filter: LeftoverFilter, +) => { + const leftovers: Leftover[] = []; + const errors: string[] = []; + for (const [kind, scan] of Object.entries(scanners)) { + try { + leftovers.push(...(await scan(client, filter))); + } catch (error) { + errors.push( + `${kind}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return { leftovers, errors }; +}; + +/** + * Removes leftovers in dependency order (campaigns/polls before users, channels + * before channel types) and returns the ones that couldn't be removed. + */ +export const removeLeftovers = async (leftovers: Leftover[]) => { + const order = [ + 'campaign', + 'poll', + 'segment', + 'channel', + 'channel type', + 'user', + 'moderation config', + ]; + const rank = (kind: string) => + order.includes(kind) ? order.indexOf(kind) : order.length; + const failed: Array<{ leftover: Leftover; error: string }> = []; + const describe = (error: unknown) => + error instanceof Error ? error.message : String(error); + // Leftovers that can be removed together are batched per kind (one call for all + // users instead of one each), the rest one by one. Both retry on 429. + const sorted = [...leftovers].sort((a, b) => rank(a.kind) - rank(b.kind)); + for (const kind of [...new Set(sorted.map((leftover) => leftover.kind))]) { + const ofKind = sorted.filter((leftover) => leftover.kind === kind); + const removeMany = ofKind.find( + (leftover) => leftover.removeMany, + )?.removeMany; + if (removeMany) { + try { + await removeMany(ofKind.map((leftover) => leftover.id)); + } catch (error) { + failed.push( + ...ofKind.map((leftover) => ({ leftover, error: describe(error) })), + ); + } + continue; + } + for (const leftover of ofKind) { + try { + await retryOnRateLimit(() => leftover.remove()); + } catch (error) { + failed.push({ leftover, error: describe(error) }); + } + } + } + return failed; +}; + +export const describeLeftovers = (leftovers: Leftover[]) => + leftovers + .map((leftover) => ` - ${leftover.kind}: ${leftover.id}`) + .join('\n'); diff --git a/__tests__/docs-snippets/helpers/push.ts b/__tests__/docs-snippets/helpers/push.ts new file mode 100644 index 0000000..1baf246 --- /dev/null +++ b/__tests__/docs-snippets/helpers/push.ts @@ -0,0 +1,12 @@ +/** + * Optional push provider credentials (repo root .env). Push snippets that need real + * credentials go in `describe.skipIf(!hasFirebaseCredentials)(...)`, so the suite still + * runs without them. The tests only call the API and check the response / settings; + * they don't check that notifications arrive. + */ + +/** Firebase service account JSON (the file contents, as one line). */ +export const firebaseCredentials = + process.env.DOCS_PUSH_FIREBASE_CREDENTIALS ?? ''; + +export const hasFirebaseCredentials = firebaseCredentials !== ''; diff --git a/__tests__/docs-snippets/helpers/server.ts b/__tests__/docs-snippets/helpers/server.ts new file mode 100644 index 0000000..70e92ab --- /dev/null +++ b/__tests__/docs-snippets/helpers/server.ts @@ -0,0 +1,113 @@ +import type { + MessageRequest, + QueryChannelsRequest, + SendMessageRequest, + StreamClient, +} from '../../../index'; + +/** + * Server-side lookups and actions the tests repeat. Lookups throw unless exactly one + * result matches, so a filter that matches nothing can't make a falsy-field check pass. + */ + +const expectOne = (items: T[], what: string): T => { + if (items.length !== 1) { + throw new Error(`expected exactly one ${what}, got ${items.length}`); + } + return items[0]; +}; + +/** Sends a message as `message.user_id` and returns it. */ +export const sendServerMessage = async ( + serverClient: StreamClient, + cid: string, + message: MessageRequest & { user_id: string }, + options: Omit = {}, +) => { + const [type, id] = cid.split(':'); + const response = await serverClient.chat + .channel(type, id) + .sendMessage({ message, ...options }); + return response.message; +}; + +/** The user with id `userId`. */ +export const getServerUser = async ( + serverClient: StreamClient, + userId: string, +) => { + const { users } = await serverClient.queryUsers({ + payload: { filter_conditions: { id: userId } }, + }); + return expectOne(users, `user ${userId}`); +}; + +/** `userId`'s member row in channel `cid`. */ +export const getServerMember = async ( + serverClient: StreamClient, + cid: string, + userId: string, +) => { + const [type, id] = cid.split(':'); + const { members } = await serverClient.chat + .channel(type, id) + .queryMembers({ payload: { filter_conditions: { user_id: userId } } }); + return expectOne(members, `member ${userId} of ${cid}`); +}; + +/** + * Channel `cid` as returned by `queryChannels` (`{ channel, messages, members, ... }`). + * Pass `user_id` (and `message_limit`) to get that user's view of it. + */ +export const getServerChannel = async ( + serverClient: StreamClient, + cid: string, + options: Omit = {}, +) => { + const { channels } = await serverClient.chat.queryChannels({ + filter_conditions: { cid }, + ...options, + }); + return expectOne(channels, `channel ${cid}`); +}; + +/** Polls survive their creator's deletion: deletes every poll `userId` created. */ +export const deletePollsCreatedBy = async ( + serverClient: StreamClient, + userId: string, +) => { + const { polls } = await serverClient.queryPolls({ + filter: { created_by_id: userId }, + limit: 100, + user_id: userId, + }); + for (const poll of polls) { + await serverClient.deletePoll({ poll_id: poll.id, user_id: userId }); + } +}; + +type AppSettings = Awaited>['app']; + +/** + * App setting changes are eventually consistent across API nodes: polls `getApp()` + * until `agreeing` consecutive reads return `expected` for `key`. + */ +export const waitForAppSetting = async ( + serverClient: StreamClient, + key: K, + expected: AppSettings[K], + { agreeing = 20, attempts = 120, interval = 500 } = {}, +) => { + let streak = 0; + for (let attempt = 0; attempt < attempts && streak < agreeing; attempt++) { + const { app } = await serverClient.getApp(); + streak = app[key] === expected ? streak + 1 : 0; + if (streak === 0) + await new Promise((resolve) => setTimeout(resolve, interval)); + } + if (streak < agreeing) { + throw new Error( + `app setting ${String(key)} never settled on ${JSON.stringify(expected)}`, + ); + } +}; diff --git a/__tests__/docs-snippets/helpers/snapshot.ts b/__tests__/docs-snippets/helpers/snapshot.ts new file mode 100644 index 0000000..8a79895 --- /dev/null +++ b/__tests__/docs-snippets/helpers/snapshot.ts @@ -0,0 +1,63 @@ +import type { StreamClient } from '../../../index'; +import { DOCS_TEST_PREFIX } from './ids'; + +/** + * App-level state that tests may change but can't create under a docs-test name: + * app settings (incl. push config), the built-in / pre-existing channel types, and + * retention policies. + */ +export type AppSnapshot = Record; + +const VOLATILE_KEYS = new Set(['created_at', 'updated_at', 'duration']); + +/** Drops timestamps and sorts keys and lists, so equal state serializes identically. */ +const normalize = (value: unknown): unknown => { + // Some lists (e.g. geofences) come back in a different order on every call. + if (Array.isArray(value)) { + return value + .map(normalize) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + } + // node-sdk decodes timestamps to Dates; compare them by value. + if (value instanceof Date) return value.toISOString(); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !VOLATILE_KEYS.has(key)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => [key, normalize(nested)]), + ); + } + return value; +}; + +export const takeAppSnapshot = async ( + client: StreamClient, +): Promise => { + const snapshot: AppSnapshot = {}; + const { app } = await client.getApp(); + for (const [key, value] of Object.entries(app ?? {})) { + // Also lists docs-test channel types; channel types are compared separately below. + if (key === 'channel_configs') continue; + snapshot[`app.${key}`] = normalize(value); + } + const { channel_types } = await client.chat.listChannelTypes(); + for (const [name, type] of Object.entries(channel_types)) { + if (!name.startsWith(DOCS_TEST_PREFIX)) + snapshot[`channel_type.${name}`] = normalize(type); + } + try { + snapshot.retention_policies = normalize( + (await client.chat.getRetentionPolicy()).policies, + ); + } catch { + // Not available on every plan. + } + return snapshot; +}; + +/** Keys whose value differs between two snapshots. */ +export const diffAppSnapshots = (before: AppSnapshot, after: AppSnapshot) => + [...new Set([...Object.keys(before), ...Object.keys(after)])] + .filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key])) + .sort(); diff --git a/__tests__/docs-snippets/helpers/users.ts b/__tests__/docs-snippets/helpers/users.ts new file mode 100644 index 0000000..f857cce --- /dev/null +++ b/__tests__/docs-snippets/helpers/users.ts @@ -0,0 +1,27 @@ +import type { StreamClient } from '../../../index'; +import { retryOnRateLimit, waitForTask } from './wait'; + +/** `deleteUsers` accepts at most 100 ids per call. */ +const DELETE_USERS_BATCH_SIZE = 100; + +/** + * Hard deletes users (with their messages and conversations) in as few `deleteUsers` + * calls as possible, retrying on 429, and waits for each delete task to finish. + */ +export const hardDeleteUsers = async ( + client: StreamClient, + userIds: string[], +) => { + for (let i = 0; i < userIds.length; i += DELETE_USERS_BATCH_SIZE) { + const batch = userIds.slice(i, i + DELETE_USERS_BATCH_SIZE); + const { task_id } = await retryOnRateLimit(() => + client.deleteUsers({ + user_ids: batch, + user: 'hard', + messages: 'hard', + conversations: 'hard', + }), + ); + await waitForTask(client, task_id); + } +}; diff --git a/__tests__/docs-snippets/helpers/wait.ts b/__tests__/docs-snippets/helpers/wait.ts new file mode 100644 index 0000000..609502f --- /dev/null +++ b/__tests__/docs-snippets/helpers/wait.ts @@ -0,0 +1,96 @@ +import type { StreamClient } from '../../../index'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export type RetryOptions = { + /** Give up after this many ms and rethrow the last error. */ + timeout?: number; + interval?: number; + /** Only retry errors this returns true for; anything else is thrown immediately. */ + retryIf?: (error: unknown) => boolean; +}; + +/** Calls `fn` until it resolves (or a non-retryable error / the timeout is hit). */ +export const retry = async ( + fn: () => Promise, + { timeout = 60000, interval = 1000, retryIf = () => true }: RetryOptions = {}, +): Promise => { + const deadline = Date.now() + timeout; + for (;;) { + try { + return await fn(); + } catch (error) { + if (!retryIf(error) || Date.now() + interval > deadline) throw error; + await sleep(interval); + } + } +}; + +const errorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +/** + * 429 "Too many requests" (code 9): the app's rate limit for that endpoint was hit. + * node-sdk errors carry the HTTP status in `metadata.responseCode`. + */ +export const isRateLimited = (error: unknown) => + typeof error === 'object' && + error !== null && + 'metadata' in error && + typeof error.metadata === 'object' && + error.metadata !== null && + 'responseCode' in error.metadata && + error.metadata.responseCode === 429; + +/** + * Retries `fn` while it is rate limited. Rate limits are per minute, so this keeps + * trying for up to 90s (inside the 180s hook timeout). + */ +export const retryOnRateLimit = (fn: () => Promise) => + retry(fn, { timeout: 90000, interval: 5000, retryIf: isRateLimited }); + +/** ": channel type does not exist" - returned until a new channel type has propagated. */ +export const isChannelTypeMissing = (error: unknown) => + errorMessage(error).includes('channel type does not exist'); + +/** How long channel type changes take to reach every API node. */ +export const CHANNEL_TYPE_PROPAGATION_MS = 30000; + +/** + * Channel type changes are eventually consistent: after `createChannelType` or + * `updateChannelType`, API nodes can disagree for up to ~30s. A `channel.create()` can + * succeed and the next `sendMessage` still fail with "channel type does not exist" (or + * still see the old settings). Call this right after creating/updating a channel type, + * before using it. Polling doesn't help because different nodes answer differently. + */ +export const waitForChannelTypePropagation = () => + sleep(CHANNEL_TYPE_PROPAGATION_MS); + +/** + * Retries `fn` while it fails with "channel type does not exist". Only a fallback on + * top of `waitForChannelTypePropagation` if a test is still flaky. + */ +export const retryWhileChannelTypePropagates = (fn: () => Promise) => + retry(fn, { timeout: 60000, interval: 2000, retryIf: isChannelTypeMissing }); + +/** Polls a background task (e.g. from server-side `deleteChannels`) until it finishes. */ +export const waitForTask = async ( + client: StreamClient, + taskId: string, + timeout = 60000, +) => { + const task = await retry( + async () => { + const response = await client.getTask({ id: taskId }); + if (response.status !== 'completed' && response.status !== 'failed') { + throw new Error(`task ${taskId} is still ${response.status}`); + } + return response; + }, + { timeout, interval: 1000 }, + ); + if (task.status === 'failed') { + throw new Error(`task ${taskId} failed: ${JSON.stringify(task.error)}`); + } + return task; +}; diff --git a/__tests__/docs-snippets/learnings.md b/__tests__/docs-snippets/learnings.md new file mode 100644 index 0000000..3f4b4e1 --- /dev/null +++ b/__tests__/docs-snippets/learnings.md @@ -0,0 +1,16 @@ +# Docs snippet tests: learnings + +Lessons collected from agent runs. Read this before working on a page. +Entries are added only after the user approves them (agents propose them in their final report). + +## Setup and tooling + +## Server client (`@stream-io/node-sdk`) + +## API and SDK behavior + +## Server-side vs client-side + +## Docs conventions + +## Isolation and cleanup diff --git a/__tests__/docs-snippets/maintenance/sweep.ts b/__tests__/docs-snippets/maintenance/sweep.ts new file mode 100644 index 0000000..eb60647 --- /dev/null +++ b/__tests__/docs-snippets/maintenance/sweep.ts @@ -0,0 +1,60 @@ +import { expect, it } from 'vitest'; +import { getServerClient } from '../helpers/clients'; +import { createdAtFromId, DOCS_TEST_PREFIX } from '../helpers/ids'; +import { + describeLeftovers, + findLeftovers, + removeLeftovers, +} from '../helpers/leaks'; +import type { Leftover } from '../helpers/leaks'; + +// Finds (and by default removes) everything with the docs-test prefix, e.g. after a +// crashed run. Run with `yarn test:docs:sweep`. +// DOCS_SWEEP_DRY_RUN=1 only list what would be removed +// DOCS_SWEEP_MIN_AGE=0 minutes; skip younger resources so runs in progress are left alone (default 30) +const dryRun = process.env.DOCS_SWEEP_DRY_RUN === '1'; +const minAgeMinutes = Number(process.env.DOCS_SWEEP_MIN_AGE ?? 30); + +const isOldEnough = (leftover: Leftover) => { + const createdAt = leftover.createdAt + ? new Date(leftover.createdAt) + : createdAtFromId(leftover.id.split(' ')[0]); + // Unknown age: only sweep it when explicitly asked to ignore age. + if (!createdAt || Number.isNaN(createdAt.getTime())) + return minAgeMinutes === 0; + return Date.now() - createdAt.getTime() >= minAgeMinutes * 60_000; +}; + +it('sweeps docs-test leftovers', async () => { + const serverClient = getServerClient(); + const { leftovers, errors } = await findLeftovers(serverClient, { + // `includes`: guest user ids are rewritten to `guest--docs-test-...`. + matches: (id) => id.includes(DOCS_TEST_PREFIX), + // Channels can't be searched by id prefix. Channels of docs-test users go away with + // the users (conversations: 'hard') and channels of docs-test types with the types; + // anything else must be found by the per-file leak check. + channelIds: [], + }); + + if (errors.length) console.warn(`could not scan:\n ${errors.join('\n ')}`); + const toRemove = leftovers.filter(isOldEnough); + const skipped = leftovers.length - toRemove.length; + + console.log( + toRemove.length + ? `${dryRun ? 'Would remove' : 'Removing'} ${toRemove.length} leftover(s):\n${describeLeftovers(toRemove)}` + : 'No docs-test leftovers found.', + ); + if (skipped) { + console.log( + `Skipped ${skipped} younger than ${minAgeMinutes} min (DOCS_SWEEP_MIN_AGE=0 to include).`, + ); + } + if (dryRun) return; + + const failed = await removeLeftovers(toRemove); + for (const { leftover, error } of failed) { + console.error(`failed to remove ${leftover.kind} ${leftover.id}: ${error}`); + } + expect(failed).toEqual([]); +}); diff --git a/__tests__/docs-snippets/pages/node/01-quick_start/01-backend_quickstart.test.ts b/__tests__/docs-snippets/pages/node/01-quick_start/01-backend_quickstart.test.ts new file mode 100644 index 0000000..4bb378f --- /dev/null +++ b/__tests__/docs-snippets/pages/node/01-quick_start/01-backend_quickstart.test.ts @@ -0,0 +1,168 @@ +import jwt from 'jsonwebtoken'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerMember, getServerUser } from '../../../helpers/server'; + +// Values the module-level region below uses (it must come after them: TDZ). +const apiKey = process.env.STREAM_API_KEY as string; +const apiSecret = process.env.STREAM_SECRET as string; +const johnId = uniqueId('john'); + +// #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Generating user tokens" tab="Node.js" index=1 +// COPY: apiKey="{{ api_key }}", apiSecret="{{ api_secret }}", johnId="john" +// npm install @stream-io/node-sdk +import { StreamClient } from '@stream-io/node-sdk'; +// or: const { StreamClient } = require("@stream-io/node-sdk"); + +// instantiate your stream client using the API key and secret +// the secret is only used server side and gives you full access to the API +const client = new StreamClient(apiKey, apiSecret); + +// generate a token for the user with id 'john' +const token = client.generateUserToken({ user_id: johnId }); +// next, hand this token to the client in your login or registration response +// #endregion snippet + +describe('node/01-quick_start/01-backend_quickstart.md', () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const creatorId = uniqueId('creator'); + const thierryId = uniqueId('thierry'); + const joshId = uniqueId('josh'); + const tommasoId = uniqueId('tommaso'); + const channelId = uniqueId('kung-fu'); + const cid = `messaging:${channelId}`; + + beforeAll(async () => { + cleanup.users.push(johnId, creatorId, thierryId, joshId, tommasoId); + cleanup.channels.push(cid); + await serverClient.upsertUsers( + [johnId, creatorId, thierryId, joshId, tommasoId].map((id) => ({ id })), + ); + }); + + afterAll(() => cleanup.run()); + + /** The page's channel (whether or not "Syncing channels" ran first), with `memberIds` added. */ + const getChannel = async (memberIds: string[]) => { + const channel = client.chat.channel('messaging', channelId); + await channel.getOrCreate({ data: { created_by_id: creatorId } }); + // `getOrCreate` only applies `members` when it creates the channel. + await channel.update({ + add_members: memberIds.map((user_id) => ({ user_id })), + }); + return channel; + }; + + it('generates a user token', () => { + expect(client).toBeInstanceOf(StreamClient); + const payload = jwt.verify(token, apiSecret) as jwt.JwtPayload; + expect(payload.user_id).toBe(johnId); + }); + + it('syncs users', async () => { + const userID = uniqueId('user'); + cleanup.users.push(userID); + + // #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Syncing users" tab="Node.js" index=1 + const response = await client.upsertUsers([ + { + id: userID, + role: 'admin', + custom: { mycustomfield: '123' }, + }, + ]); + // #endregion snippet + + expect(response.users[userID]?.role).toBe('admin'); + const user = await getServerUser(serverClient, userID); + expect(user.role).toBe('admin'); + expect(user.custom.mycustomfield).toBe('123'); + }); + + it('syncs channels', async () => { + // #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Syncing channels" tab="Node.js" index=1 + // COPY: channelId="kung-fu", creatorId="4645" + const channel = client.chat.channel('messaging', channelId); + + // create the channel and set created_by to user id 4645 + await channel.getOrCreate({ data: { created_by_id: creatorId } }); + + const update = await channel.update({ + data: { + custom: { + name: 'myspecialchannel', + image: 'imageurl', + mycustomfield: '123', + }, + }, + }); + // #endregion snippet + + expect(update.channel?.created_by?.id).toBe(creatorId); + expect(update.channel?.custom).toMatchObject({ + name: 'myspecialchannel', + image: 'imageurl', + mycustomfield: '123', + }); + }); + + it('adds members and moderators', async () => { + const channel = await getChannel([tommasoId]); + await getServerMember(serverClient, cid, tommasoId); + + // #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Adding members or moderators" tab="Node.js" index=1 + // COPY: thierryId="thierry", joshId="josh", tommasoId="tommaso" + await channel.update({ + add_members: [{ user_id: thierryId }, { user_id: joshId }], + }); + await channel.update({ remove_members: [tommasoId] }); + + await channel.update({ add_moderators: [thierryId] }); + await channel.update({ demote_moderators: [thierryId] }); + // #endregion snippet + + const { members } = await channel.queryMembers({ payload: {} }); + const memberIds = members.map((member) => member.user_id); + expect(memberIds).toEqual(expect.arrayContaining([thierryId, joshId])); + expect(memberIds).not.toContain(tommasoId); + // Promoted and then demoted again. + const thierry = await getServerMember(serverClient, cid, thierryId); + expect(thierry.channel_role).toBe('channel_member'); + }); + + it('sends a message', async () => { + const channel = await getChannel([johnId, joshId]); + + // #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Sending messages" tab="Node.js" index=1 + // COPY: johnId="john", joshId="josh" + const { message } = await channel.sendMessage({ + message: { + text: '@Josh I told them I was pesca-pescatarian. Which is one who eats solely fish who eat other fish.', + user_id: johnId, + mentioned_users: [joshId], + attachments: [ + { + type: 'image', + asset_url: 'https://bit.ly/2K74TaG', + thumb_url: 'https://bit.ly/2Uumxti', + custom: { myCustomField: 123 }, + }, + ], + custom: { anotherCustomField: 234 }, + }, + }); + // #endregion snippet + + expect(message.user?.id).toBe(johnId); + expect(message.mentioned_users.map((user) => user.id)).toEqual([joshId]); + expect(message.attachments[0]).toMatchObject({ + type: 'image', + asset_url: 'https://bit.ly/2K74TaG', + }); + expect(message.attachments[0].custom.myCustomField).toBe(123); + expect(message.custom.anotherCustomField).toBe(234); + }); +}); diff --git a/__tests__/docs-snippets/setup.ts b/__tests__/docs-snippets/setup.ts new file mode 100644 index 0000000..d05893f --- /dev/null +++ b/__tests__/docs-snippets/setup.ts @@ -0,0 +1,97 @@ +import { config } from 'dotenv'; +import path from 'node:path'; +import { afterAll, beforeAll } from 'vitest'; +import { getServerClient } from './helpers/clients'; +import { mintedIds, runId } from './helpers/ids'; +import { + describeLeftovers, + findLeftovers, + removeLeftovers, +} from './helpers/leaks'; +import { diffAppSnapshots, takeAppSnapshot } from './helpers/snapshot'; +import type { AppSnapshot } from './helpers/snapshot'; +import { retry } from './helpers/wait'; + +// The node SDK's test app, shared with the integration tests (repo root .env). +config({ path: path.join(__dirname, '../../.env'), quiet: true }); + +if (!process.env.STREAM_API_KEY || !process.env.STREAM_SECRET) { + throw new Error( + 'Missing STREAM_API_KEY / STREAM_SECRET. Copy .env-example to .env in the repo root and fill it in.', + ); +} + +// These hooks are registered before the test file's own hooks, so `afterAll` below +// runs after the file's cleanup (vitest runs after-hooks in reverse order). + +let snapshot: AppSnapshot | undefined; + +beforeAll(async () => { + snapshot = await takeAppSnapshot(getServerClient()); +}); + +afterAll(async () => { + const serverClient = getServerClient(); + + let leakError: Error | undefined; + + // Leak check: everything this file created through `uniqueId` must be gone. + if (mintedIds.size) { + const filter = { + matches: (id: string) => mintedIds.has(id) || id.includes(runId), + channelIds: [...mintedIds], + }; + let scan = await findLeftovers(serverClient, filter); + if (scan.leftovers.length) { + // Deletes are eventually consistent: only report what is still there after a while. + let latest = scan; + await retry( + async () => { + latest = await findLeftovers(serverClient, filter); + if (latest.leftovers.length) throw new Error('leftovers still there'); + }, + { timeout: 15000, interval: 3000 }, + ).catch(() => undefined); + scan = latest; + } + if (scan.errors.length) { + console.warn( + `[docs-snippets] leak check could not scan:\n ${scan.errors.join('\n ')}`, + ); + } + if (scan.leftovers.length) { + // Thrown at the end, together with a possible drift error. + const failed = await removeLeftovers(scan.leftovers); + leakError = new Error( + `[docs-snippets] LEAK: this test file left resources behind (they have now been removed` + + `${failed.length ? `, except ${failed.length} that failed - run \`yarn test:docs:sweep\`` : ''}).\n` + + `Register them on \`Cleanup\` (cleanup.users / channels / channelTypes, or cleanup.add(...)):\n` + + describeLeftovers(scan.leftovers), + ); + } + } + + // App-level drift: settings a test changed must be restored (cleanup.add). Pages are + // run one at a time, so any drift was caused by this file. + let driftError: Error | undefined; + if (snapshot) { + const drift = diffAppSnapshots( + snapshot, + await takeAppSnapshot(serverClient), + ); + if (drift.length) { + driftError = new Error( + `[docs-snippets] DRIFT: app settings changed by this file were not restored. Save the original values and restore them with cleanup.add(...):\n ${drift.join('\n ')}`, + ); + } + } + + if (leakError && driftError) { + throw new AggregateError( + [leakError, driftError], + `${leakError.message}\n\n${driftError.message}`, + ); + } + const error = leakError ?? driftError; + if (error) throw error; +}); diff --git a/__tests__/docs-snippets/sync/docs-sync.test.ts b/__tests__/docs-snippets/sync/docs-sync.test.ts new file mode 100644 index 0000000..9a835ae --- /dev/null +++ b/__tests__/docs-snippets/sync/docs-sync.test.ts @@ -0,0 +1,153 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + ALLOWED_JS_LABELS, + applyCopy, + DOCS_CHAT_DIR, + EXCLUDED_PAGES, + fenceId, + findSidebarPages, + findTestFiles, + formatForDocs, + NODE_TAB, + normalize, + parseDocsFences, + parseTestRegions, + readDocsPage, +} from './parse'; +import type { DocsFence, TestRegion } from './parse'; + +// `yarn test:docs:sync`, in this order: +// 1. Every `Node.js` fence (except comment-only fences) on the pages of the chat Node +// sidebar has a test region. `JavaScript` (client-side) fences are out of scope. +// 2. Every JS fence on those pages is labelled `JavaScript` or `Node.js`. +// 3. Every marked test region points at an existing fence and matches it (after +// applying COPY values and formatting both sides the same way). +// Pages in `EXCLUDED_PAGES` (parse.ts) are skipped. +// DOCS_SYNC_WRITE= overwrite that page's fences with the test code instead of +// comparing (page path as in the markers, e.g. +// _default/04-messages/01-send_message.md; `1` = all pages) +// DOCS_CHAT_DIR=... path to getstream.io/content/docs/chat (default: sibling checkout) + +const writeTarget = process.env.DOCS_SYNC_WRITE; +const shouldWrite = (docs: string) => + writeTarget === '1' || writeTarget === docs; +const testsRoot = path.join(__dirname, '..'); + +const regions: TestRegion[] = findTestFiles().flatMap((file) => + parseTestRegions( + path.relative(testsRoot, file), + fs.readFileSync(file, 'utf8'), + ), +); +const regionPages = [...new Set(regions.map((region) => region.docs))].sort(); + +it('collects the test regions', () => { + // Marker syntax errors already throw while the regions are parsed above. + console.log( + `${regions.length} snippet region(s) on ${regionPages.length} page(s)`, + ); +}); + +// Without a docs checkout there is nothing to compare against: skip (vitest still runs +// the describe body to collect tests, so it must not read the docs either). +const hasDocs = fs.existsSync(DOCS_CHAT_DIR); + +describe.skipIf(!hasDocs)(`docs sync (${DOCS_CHAT_DIR})`, () => { + // Pages with tests count even if they are no longer in the sidebar. + const pages = [ + ...new Set([...(hasDocs ? findSidebarPages() : []), ...regionPages]), + ] + .filter((docs) => !(docs in EXCLUDED_PAGES)) + .sort(); + const pageFences = (hasDocs ? pages : []).map((docs) => { + const { file, markdown } = readDocsPage(docs); + return { docs, file, fences: parseDocsFences(docs, markdown) }; + }); + const where = (file: string, fence: DocsFence) => + `${fenceId(fence)} (${file}:${fence.line})`; + + it('every Node.js snippet has a test region', () => { + const covered = new Set(regions.map(fenceId)); + const missing = pageFences.flatMap(({ file, fences }) => + fences + .filter( + (fence) => + fence.tab === NODE_TAB && + !fence.commentOnly && + !covered.has(fenceId(fence)), + ) + .map((fence) => where(file, fence)), + ); + expect( + missing, + 'snippets without a test region; add one (in it.skip if it cannot run), or add the page to EXCLUDED_PAGES in sync/parse.ts with a reason', + ).toEqual([]); + }); + + it(`every JS snippet is labelled ${ALLOWED_JS_LABELS.join(' or ')}`, () => { + const mislabelled = pageFences.flatMap(({ file, fences }) => + fences + .filter((fence) => !ALLOWED_JS_LABELS.includes(fence.tab)) + .map((fence) => `${where(file, fence)} [${fence.lang}]`), + ); + expect( + mislabelled, + `JS fences need label="${ALLOWED_JS_LABELS.join('" or label="')}" (client-side code or @stream-io/node-sdk code)`, + ).toEqual([]); + }); + + describe.each(hasDocs ? regionPages : [])('%s', (docs) => { + const { file: docsFile, markdown } = readDocsPage(docs); + const fences = parseDocsFences(docs, markdown); + const pageRegions = regions.filter((region) => region.docs === docs); + const fenceFor = (region: TestRegion) => + fences.find((fence) => fenceId(fence) === fenceId(region)); + + it('markers point at existing fences', () => { + const unknown = pageRegions.filter((region) => !fenceFor(region)); + expect( + unknown.map( + (region) => `${fenceId(region)} (${region.file}:${region.line})`, + ), + `no such fence. Fences on this page:\n${fences + .filter((fence) => fence.tab === NODE_TAB) + .map((fence) => ` ${fenceId(fence)}`) + .join('\n')}\n`, + ).toEqual([]); + }); + + it.each(pageRegions.map((region) => [fenceId(region), region] as const))( + '%s', + async (_, region) => { + const fence = fenceFor(region); + if (!fence) return; // reported above + const expected = applyCopy(region.code, region.copy); + if (shouldWrite(docs)) { + writeFence(docsFile, fence, await formatForDocs(expected, docsFile)); + return; + } + expect( + await normalize(expected), + `${region.file}:${region.line} differs from ${docsFile}:${fence.line}. Update the test (or the docs), or run DOCS_SYNC_WRITE=${docs} to copy the test into the docs.`, + ).toBe(await normalize(fence.code)); + }, + ); + }); +}); + +/** Replaces one fence body; re-reads the file so several writes to one page compose. */ +const writeFence = (docsFile: string, fence: DocsFence, code: string) => { + const docs = path.relative(DOCS_CHAT_DIR, docsFile); + const current = parseDocsFences(docs, fs.readFileSync(docsFile, 'utf8')).find( + (candidate) => fenceId(candidate) === fenceId(fence), + ); + if (!current) throw new Error(`fence disappeared: ${fenceId(fence)}`); + const lines = fs.readFileSync(docsFile, 'utf8').split('\n'); + const body = code + .split('\n') + .map((line) => (line ? fence.indent + line : line)); + lines.splice(current.bodyStart, current.bodyEnd - current.bodyStart, ...body); + fs.writeFileSync(docsFile, lines.join('\n')); +}; diff --git a/__tests__/docs-snippets/sync/parse.ts b/__tests__/docs-snippets/sync/parse.ts new file mode 100644 index 0000000..24c9020 --- /dev/null +++ b/__tests__/docs-snippets/sync/parse.ts @@ -0,0 +1,318 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { format } from 'prettier'; +import type { Options } from 'prettier'; + +/** `content/docs/chat` in a getstream.io checkout (override with DOCS_CHAT_DIR). */ +export const DOCS_CHAT_DIR = path.resolve( + process.env.DOCS_CHAT_DIR ?? + path.join(__dirname, '../../../../getstream.io/content/docs/chat'), +); + +/** Identifies one code fence on a docs page. */ +export type FenceKey = { + /** Page path relative to `content/docs/chat`, e.g. `_default/04-messages/01-send_message.md`. */ + docs: string; + /** Text of the nearest heading above the fence (`""` before the first heading). */ + heading: string; + /** The fence's `label="..."`, or `unlabelled`. */ + tab: string; + /** 1-based position among the fences with the same heading text and tab on the page. */ + index: number; +}; + +export type DocsFence = FenceKey & { + /** The fence language as written (`js`, `javascript`, `ts`, `typescript`). */ + lang: string; + /** Fence body, de-indented. */ + code: string; + /** 1-based line of the opening ``` line. */ + line: number; + /** 0-based line range of the body in the file (end exclusive), plus the fence indent. */ + bodyStart: number; + bodyEnd: number; + indent: string; + /** The body is only comments (e.g. a "server-side only" placeholder): nothing to test. */ + commentOnly: boolean; +}; + +export type TestRegion = FenceKey & { + /** Code between the markers, de-indented, without marker / COPY lines. */ + code: string; + /** Values the test uses in place of the docs literals: identifier -> JS literal. */ + copy: Record; + file: string; + line: number; +}; + +export const fenceId = (key: FenceKey) => + `${key.docs} > "${key.heading}" > ${key.tab} #${key.index}`; + +const JS_LANGS = new Set(['js', 'javascript', 'ts', 'typescript']); +/** Out of scope (client-side `stream-chat` code). */ +export const JS_TAB = 'JavaScript'; +/** The tested tab (`@stream-io/node-sdk` code). */ +export const NODE_TAB = 'Node.js'; +/** Every JS fence must carry one of these labels. */ +export const ALLOWED_JS_LABELS = [JS_TAB, NODE_TAB]; + +/** Node sidebar of the chat docs: the pages whose `Node.js` fences are tested. */ +const SIDEBAR_FILE = '[chat][node].json'; + +/** + * Pages in the Node sidebar that are deliberately not validated (page path + * relative to `content/docs/chat`, reason). + */ +export const EXCLUDED_PAGES: Record = {}; + +const dedent = (lines: string[]) => { + const indents = lines + .filter((line) => line.trim()) + .map((line) => /^\s*/.exec(line)?.[0].length ?? 0); + const min = indents.length ? Math.min(...indents) : 0; + return lines + .map((line) => line.slice(min)) + .join('\n') + .trim(); +}; + +/** True when the code has comments only (line and block comments), no statements. */ +export const isCommentOnly = (code: string) => + code.trim() !== '' && + code + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .every((line) => !line.trim() || line.trim().startsWith('//')); + +/** + * All JS fences (`js`/`javascript`/`ts`/`typescript`) of a docs page, keyed the same way + * as the test markers. Includes `JavaScript` and unlabelled fences (for the label check); + * only `Node.js` fences are tested. + */ +export const parseDocsFences = ( + docs: string, + markdown: string, +): DocsFence[] => { + const lines = markdown.split('\n'); + const fences: DocsFence[] = []; + const counts = new Map(); + let heading = ''; + for (let i = 0; i < lines.length; i++) { + const open = /^(\s*)(`{3,})(\S*)(.*)$/.exec(lines[i]); + if (!open) { + const h = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(lines[i]); + if (h) heading = h[1]; + continue; + } + const [, indent, ticks, lang, rest] = open; + let end = i + 1; + while ( + end < lines.length && + !new RegExp(`^\\s*${ticks}\\s*$`).test(lines[end]) + ) + end++; + const tab = /label="([^"]*)"/.exec(rest)?.[1] ?? 'unlabelled'; + if (JS_LANGS.has(lang.toLowerCase())) { + const countKey = `${heading}\u0000${tab}`; + const index = (counts.get(countKey) ?? 0) + 1; + counts.set(countKey, index); + const code = dedent(lines.slice(i + 1, end)); + fences.push({ + docs, + heading, + tab, + index, + lang, + code, + commentOnly: isCommentOnly(code), + line: i + 1, + bodyStart: i + 1, + bodyEnd: end, + indent, + }); + } + i = end; + } + return fences; +}; + +const ATTR_RE = /(\w+)=("(?:[^"\\]|\\.)*"|\d+)/g; + +const parseAttrs = (text: string) => { + const attrs: Record = {}; + for (const [, key, raw] of text.matchAll(ATTR_RE)) { + attrs[key] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw; + } + return attrs; +}; + +/** `// COPY: channelId="general", limit=10` -> { channelId: '"general"', limit: '10' } */ +export const parseCopy = (text: string) => { + const copy: Record = {}; + for (const [, key, raw] of text.matchAll( + /(\w+)=("(?:[^"\\]|\\.)*"|[^,\s]+)/g, + )) { + copy[key] = raw; + } + return copy; +}; + +const keyFromAttrs = ( + attrs: Record, + where: string, +): FenceKey => { + for (const required of ['docs', 'heading', 'tab']) { + if (attrs[required] === undefined) { + throw new Error(`${where}: marker is missing ${required}="..."`); + } + } + return { + docs: attrs.docs, + heading: attrs.heading, + tab: attrs.tab, + index: attrs.index ? Number(attrs.index) : 1, + }; +}; + +/** + * Snippet regions of a test file: + * + * ```ts + * // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 + * // COPY: channelId="general", userId="john" + * ...docs code... + * // #endregion snippet + * ``` + */ +export const parseTestRegions = ( + file: string, + source: string, +): TestRegion[] => { + const lines = source.split('\n'); + const regions: TestRegion[] = []; + for (let i = 0; i < lines.length; i++) { + const where = `${file}:${i + 1}`; + const start = /^\s*\/\/ #region snippet\s+(.*)$/.exec(lines[i]); + if (!start) continue; + const key = keyFromAttrs(parseAttrs(start[1]), where); + let end = i + 1; + while ( + end < lines.length && + !/^\s*\/\/ #endregion snippet/.test(lines[end]) + ) + end++; + if (end === lines.length) + throw new Error(`${where}: missing // #endregion snippet`); + const body = lines.slice(i + 1, end); + const copy: Record = {}; + while (body.length && /^\s*\/\/ COPY:/.test(body[0])) { + Object.assign( + copy, + parseCopy((body.shift() as string).replace(/^\s*\/\/ COPY:/, '')), + ); + } + regions.push({ + ...key, + code: dedent(body), + copy, + file, + line: i + 1, + }); + i = end; + } + return regions; +}; + +/** + * Applies the COPY values: every identifier `name` that is not a property access + * (`x.name`) or an object key (`name:`) is replaced with its docs literal. + * Don't use COPY identifiers in shorthand properties (`{ userId }`). + */ +export const applyCopy = (code: string, copy: Record) => + Object.entries(copy).reduce( + (result, [name, literal]) => + result.replace( + new RegExp(`(? { + try { + return (await format(code, COMPARE_OPTIONS)).trim(); + } catch { + return code + .replace(/'/g, '"') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') + .replace(/,(\s*[)\]}])/g, '$1'); + } +}; + +/** Formats a snippet the way the docs repo would (its .prettierrc.json, double quotes). */ +export const formatForDocs = async (code: string, markdownFile: string) => { + const { resolveConfig } = await import('prettier'); + const config = (await resolveConfig(markdownFile)) ?? {}; + try { + return (await format(code, { ...config, parser: 'typescript' })).trim(); + } catch { + return code; + } +}; + +type SidebarItem = { markdown?: string; children?: SidebarItem[] }; + +/** + * Pages of the chat Node sidebar (paths relative to `content/docs/chat`), minus + * `EXCLUDED_PAGES`. + */ +export const findSidebarPages = () => { + const file = path.join(DOCS_CHAT_DIR, '..', '_sidebars', SIDEBAR_FILE); + const { items } = JSON.parse(fs.readFileSync(file, 'utf8')) as { + items: SidebarItem[]; + }; + const pages = new Set(); + const walk = (entries: SidebarItem[] = []) => { + for (const entry of entries) { + if (entry.markdown?.startsWith('chat/')) + pages.add(entry.markdown.slice('chat/'.length)); + walk(entry.children); + } + }; + walk(items); + return [...pages].filter((page) => !(page in EXCLUDED_PAGES)).sort(); +}; + +export const readDocsPage = (docs: string) => { + const file = path.join(DOCS_CHAT_DIR, docs); + return { file, markdown: fs.readFileSync(file, 'utf8') }; +}; + +/** Every *.test.ts under pages/. */ +export const findTestFiles = (root = path.join(__dirname, '..')) => { + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.test.ts')) files.push(full); + } + }; + if (fs.existsSync(path.join(root, 'pages'))) walk(path.join(root, 'pages')); + return files.sort(); +}; diff --git a/__tests__/docs-snippets/tsconfig.json b/__tests__/docs-snippets/tsconfig.json new file mode 100644 index 0000000..dad6fa6 --- /dev/null +++ b/__tests__/docs-snippets/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "rootDir": "../..", + "types": ["node"], + // Docs snippets import the package by name: point it at src, not the dist build. + "paths": { "@stream-io/node-sdk": ["../../index.ts"] } + }, + "include": ["./**/*.ts", "./*.mts", "../../src/**/*", "../../index.ts"] +} diff --git a/__tests__/docs-snippets/vitest.config.mts b/__tests__/docs-snippets/vitest.config.mts new file mode 100644 index 0000000..8e8d3e7 --- /dev/null +++ b/__tests__/docs-snippets/vitest.config.mts @@ -0,0 +1,31 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +// Runs the docs snippet tests against a real Stream app (credentials in the repo root .env). +// Separate from the integration test config so `yarn test` never runs them. +export default defineConfig({ + resolve: { + // Docs snippets import the package by name (`from '@stream-io/node-sdk'`): run them + // against src, not this package's own (possibly stale) dist build. Keep in sync with + // tsconfig `paths`. + alias: [ + { + find: /^@stream-io\/node-sdk$/, + replacement: path.resolve(import.meta.dirname, "../../index.ts"), + }, + ], + }, + test: { + root: import.meta.dirname, + include: ["./pages/**/*.test.ts"], + setupFiles: ["./setup.ts"], + // Shows console output (leak / drift warnings) for passing tests too. + reporters: ["verbose"], + // Generous because some app-level changes are eventually consistent: a new channel + // type can take ~30s to become usable, and cleanup waits for background delete tasks. + testTimeout: 90000, + hookTimeout: 180000, + // All files share one app: run them one at a time to stay clear of rate limits. + fileParallelism: false, + }, +}); diff --git a/__tests__/docs-snippets/vitest.sweep.config.mts b/__tests__/docs-snippets/vitest.sweep.config.mts new file mode 100644 index 0000000..705146b --- /dev/null +++ b/__tests__/docs-snippets/vitest.sweep.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +// `yarn test:docs:sweep`: removes docs-test leftovers from the app (see maintenance/sweep.ts). +export default defineConfig({ + test: { + root: import.meta.dirname, + include: ["./maintenance/sweep.ts"], + setupFiles: ["./setup.ts"], + // Shows console output (leak / drift warnings) for passing tests too. + reporters: ["verbose"], + testTimeout: 600000, + hookTimeout: 180000, + }, +}); diff --git a/__tests__/docs-snippets/vitest.sync.config.mts b/__tests__/docs-snippets/vitest.sync.config.mts new file mode 100644 index 0000000..a91117f --- /dev/null +++ b/__tests__/docs-snippets/vitest.sync.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// `yarn test:docs:sync`: compares the test snippet regions with the docs fences. +// No network and no credentials needed. +export default defineConfig({ + test: { + root: import.meta.dirname, + include: ["./sync/**/*.test.ts"], + reporters: ["verbose"], + }, +}); diff --git a/__tests__/agent.test.ts b/__tests__/integration/agent.test.ts similarity index 98% rename from __tests__/agent.test.ts rename to __tests__/integration/agent.test.ts index d725f1f..fe9c859 100644 --- a/__tests__/agent.test.ts +++ b/__tests__/integration/agent.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'crypto'; import { vi, describe, expect, it } from 'vitest'; import { createTestClient } from './create-test-client.js'; -import { StreamClient } from '../src/StreamClient.js'; +import { StreamClient } from '../../src/StreamClient.js'; const openAiApiKey = process.env.OPENAI_API_KEY!; const enableDebugLogging = false; diff --git a/__tests__/assets/test-file.pdf b/__tests__/integration/assets/test-file.pdf similarity index 100% rename from __tests__/assets/test-file.pdf rename to __tests__/integration/assets/test-file.pdf diff --git a/__tests__/assets/test-image.jpg b/__tests__/integration/assets/test-image.jpg similarity index 100% rename from __tests__/assets/test-image.jpg rename to __tests__/integration/assets/test-image.jpg diff --git a/__tests__/block-lists.test.ts b/__tests__/integration/block-lists.test.ts similarity index 92% rename from __tests__/block-lists.test.ts rename to __tests__/integration/block-lists.test.ts index 20eb5e4..19da9d3 100644 --- a/__tests__/block-lists.test.ts +++ b/__tests__/integration/block-lists.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { CreateBlockListRequest } from '../src/gen/models'; +import { StreamClient } from '../../src/StreamClient'; +import { CreateBlockListRequest } from '../../src/gen/models'; describe('block lists CRUD API', () => { let client: StreamClient; diff --git a/__tests__/call-members.test.ts b/__tests__/integration/call-members.test.ts similarity index 96% rename from __tests__/call-members.test.ts rename to __tests__/integration/call-members.test.ts index 20fbf20..97fca22 100644 --- a/__tests__/call-members.test.ts +++ b/__tests__/integration/call-members.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamCall } from '../src/StreamCall'; -import { StreamClient } from '../src/StreamClient'; -import { OwnCapability } from '../src/gen/models'; +import { StreamCall } from '../../src/StreamCall'; +import { StreamClient } from '../../src/StreamClient'; +import { OwnCapability } from '../../src/gen/models'; describe('call members API', () => { let client: StreamClient; diff --git a/__tests__/call-types.test.ts b/__tests__/integration/call-types.test.ts similarity index 98% rename from __tests__/call-types.test.ts rename to __tests__/integration/call-types.test.ts index 705e606..784e1be 100644 --- a/__tests__/call-types.test.ts +++ b/__tests__/integration/call-types.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { OwnCapability } from '../src/gen/models'; +import { StreamClient } from '../../src/StreamClient'; +import { OwnCapability } from '../../src/gen/models'; describe('call types CRUD API', () => { let client: StreamClient; diff --git a/__tests__/call.test.ts b/__tests__/integration/call.test.ts similarity index 98% rename from __tests__/call.test.ts rename to __tests__/integration/call.test.ts index 156e285..a33cbb3 100644 --- a/__tests__/call.test.ts +++ b/__tests__/integration/call.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamCall } from '../src/StreamCall'; -import { StreamClient } from '../src/StreamClient'; +import { StreamCall } from '../../src/StreamCall'; +import { StreamClient } from '../../src/StreamClient'; describe('call API', () => { let client: StreamClient; diff --git a/__tests__/channel-types.test.ts b/__tests__/integration/channel-types.test.ts similarity index 95% rename from __tests__/channel-types.test.ts rename to __tests__/integration/channel-types.test.ts index 53e48ea..3f31b48 100644 --- a/__tests__/channel-types.test.ts +++ b/__tests__/integration/channel-types.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; describe('channel types CRUD API', () => { let client: StreamClient; diff --git a/__tests__/channel.test.ts b/__tests__/integration/channel.test.ts similarity index 97% rename from __tests__/channel.test.ts rename to __tests__/integration/channel.test.ts index 87715cb..989641e 100644 --- a/__tests__/channel.test.ts +++ b/__tests__/integration/channel.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { StreamChannel } from '../src/StreamChannel'; +import { StreamClient } from '../../src/StreamClient'; +import { StreamChannel } from '../../src/StreamChannel'; describe('channel API', () => { let client: StreamClient; diff --git a/__tests__/command.test.ts b/__tests__/integration/command.test.ts similarity index 95% rename from __tests__/command.test.ts rename to __tests__/integration/command.test.ts index 42f5e8e..d3f0ad1 100644 --- a/__tests__/command.test.ts +++ b/__tests__/integration/command.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; describe('commands CRUD API', () => { let client: StreamClient; diff --git a/__tests__/create-test-client.ts b/__tests__/integration/create-test-client.ts similarity index 79% rename from __tests__/create-test-client.ts rename to __tests__/integration/create-test-client.ts index aa99dc6..2f3f54f 100644 --- a/__tests__/create-test-client.ts +++ b/__tests__/integration/create-test-client.ts @@ -1,5 +1,5 @@ import 'dotenv/config'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; const apiKey = process.env.STREAM_API_KEY!; const secret = process.env.STREAM_SECRET!; diff --git a/__tests__/create-token.test.ts b/__tests__/integration/create-token.test.ts similarity index 99% rename from __tests__/create-token.test.ts rename to __tests__/integration/create-token.test.ts index 2fb65d4..bd6ec28 100644 --- a/__tests__/create-token.test.ts +++ b/__tests__/integration/create-token.test.ts @@ -1,7 +1,7 @@ import 'dotenv/config'; import jwt from 'jsonwebtoken'; import { beforeAll, describe, expect, it } from 'vitest'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; import { createTestClient } from './create-test-client'; const secret = process.env.STREAM_SECRET!; diff --git a/__tests__/date-transform.test.ts b/__tests__/integration/date-transform.test.ts similarity index 96% rename from __tests__/date-transform.test.ts rename to __tests__/integration/date-transform.test.ts index 526f46a..0eb59d8 100644 --- a/__tests__/date-transform.test.ts +++ b/__tests__/integration/date-transform.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { UserRequest } from '../src/gen/models'; +import { StreamClient } from '../../src/StreamClient'; +import { UserRequest } from '../../src/gen/models'; describe('Date conversion', () => { let client: StreamClient; diff --git a/__tests__/devices-push.test.ts b/__tests__/integration/devices-push.test.ts similarity index 94% rename from __tests__/devices-push.test.ts rename to __tests__/integration/devices-push.test.ts index c043821..30aad6f 100644 --- a/__tests__/devices-push.test.ts +++ b/__tests__/integration/devices-push.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { CreateDeviceRequest, PushProvider } from '../src/gen/models'; +import { StreamClient } from '../../src/StreamClient'; +import { CreateDeviceRequest, PushProvider } from '../../src/gen/models'; describe('devices and push', () => { let client: StreamClient; diff --git a/__tests__/external-storage.test.ts b/__tests__/integration/external-storage.test.ts similarity index 98% rename from __tests__/external-storage.test.ts rename to __tests__/integration/external-storage.test.ts index 1beee26..3584b32 100644 --- a/__tests__/external-storage.test.ts +++ b/__tests__/integration/external-storage.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; describe('external storage CRUD API', () => { let client: StreamClient; diff --git a/__tests__/file-uploads.test.ts b/__tests__/integration/file-uploads.test.ts similarity index 97% rename from __tests__/file-uploads.test.ts rename to __tests__/integration/file-uploads.test.ts index 14256c2..b59faf3 100644 --- a/__tests__/file-uploads.test.ts +++ b/__tests__/integration/file-uploads.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, it, expect } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { StreamChannel } from '../src/StreamChannel'; +import { StreamClient } from '../../src/StreamClient'; +import { StreamChannel } from '../../src/StreamChannel'; import fs from 'fs'; import path from 'path'; import { File } from 'buffer'; diff --git a/__tests__/messages.test.ts b/__tests__/integration/messages.test.ts similarity index 98% rename from __tests__/messages.test.ts rename to __tests__/integration/messages.test.ts index 2fe689f..e6fa2db 100644 --- a/__tests__/messages.test.ts +++ b/__tests__/integration/messages.test.ts @@ -1,8 +1,8 @@ import 'dotenv/config'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestClient } from './create-test-client'; -import { StreamChannel } from '../src/StreamChannel'; -import { StreamClient } from '../src/StreamClient'; +import { StreamChannel } from '../../src/StreamChannel'; +import { StreamClient } from '../../src/StreamClient'; import { randomUUID } from 'crypto'; describe('messages API', () => { diff --git a/__tests__/multipart.test.ts b/__tests__/integration/multipart.test.ts similarity index 97% rename from __tests__/multipart.test.ts rename to __tests__/integration/multipart.test.ts index 114f73e..e7e387d 100644 --- a/__tests__/multipart.test.ts +++ b/__tests__/integration/multipart.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ApiClient } from '../src/ApiClient'; -import { StreamChatClient } from '../src/StreamChatClient'; -import { StreamClient } from '../src/StreamClient'; +import { ApiClient } from '../../src/ApiClient'; +import { StreamChatClient } from '../../src/StreamChatClient'; +import { StreamClient } from '../../src/StreamClient'; const createApiClient = () => new ApiClient({ diff --git a/__tests__/permissions-app-settings.test.ts b/__tests__/integration/permissions-app-settings.test.ts similarity index 95% rename from __tests__/permissions-app-settings.test.ts rename to __tests__/integration/permissions-app-settings.test.ts index 6cfc6b5..df0d3c3 100644 --- a/__tests__/permissions-app-settings.test.ts +++ b/__tests__/integration/permissions-app-settings.test.ts @@ -1,8 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { CreateRoleRequest, OwnCapability } from '../src/gen/models'; +import { StreamClient } from '../../src/StreamClient'; +import { CreateRoleRequest, OwnCapability } from '../../src/gen/models'; describe('permissions and app settings API', () => { let client: StreamClient; diff --git a/__tests__/query-params.test.ts b/__tests__/integration/query-params.test.ts similarity index 98% rename from __tests__/query-params.test.ts rename to __tests__/integration/query-params.test.ts index 2346f89..62e0ae0 100644 --- a/__tests__/query-params.test.ts +++ b/__tests__/integration/query-params.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ApiClient } from '../src/ApiClient'; +import { ApiClient } from '../../src/ApiClient'; const createApiClient = () => new ApiClient({ diff --git a/__tests__/rate-limit.test.ts b/__tests__/integration/rate-limit.test.ts similarity index 97% rename from __tests__/rate-limit.test.ts rename to __tests__/integration/rate-limit.test.ts index 935f18a..425a3d7 100644 --- a/__tests__/rate-limit.test.ts +++ b/__tests__/integration/rate-limit.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; describe('rate limit', () => { let client: StreamClient; diff --git a/__tests__/ring-call.test.ts b/__tests__/integration/ring-call.test.ts similarity index 94% rename from __tests__/ring-call.test.ts rename to __tests__/integration/ring-call.test.ts index f0a696f..6fb3348 100644 --- a/__tests__/ring-call.test.ts +++ b/__tests__/integration/ring-call.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { createTestClient } from './create-test-client'; -import { StreamCall } from '../src/StreamCall'; -import { StreamClient } from '../src/StreamClient'; +import { StreamCall } from '../../src/StreamCall'; +import { StreamClient } from '../../src/StreamClient'; import { randomUUID } from 'crypto'; describe('ring call API', () => { diff --git a/__tests__/teams.test.ts b/__tests__/integration/teams.test.ts similarity index 94% rename from __tests__/teams.test.ts rename to __tests__/integration/teams.test.ts index 129f4ea..cd53a0a 100644 --- a/__tests__/teams.test.ts +++ b/__tests__/integration/teams.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { StreamCall } from '../src/StreamCall'; -import { UserRequest } from '../src/gen/models'; +import { StreamClient } from '../../src/StreamClient'; +import { StreamCall } from '../../src/StreamCall'; +import { UserRequest } from '../../src/gen/models'; // Skipping to avoid issues when tests run in parallel describe.skip('teams', () => { diff --git a/__tests__/user-compat.test.ts b/__tests__/integration/user-compat.test.ts similarity index 94% rename from __tests__/user-compat.test.ts rename to __tests__/integration/user-compat.test.ts index 5d1be74..267acd1 100644 --- a/__tests__/user-compat.test.ts +++ b/__tests__/integration/user-compat.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; describe('user-video compatibility API', () => { let client: StreamClient; diff --git a/__tests__/users.test.ts b/__tests__/integration/users.test.ts similarity index 98% rename from __tests__/users.test.ts rename to __tests__/integration/users.test.ts index 71aca06..98bc9c7 100644 --- a/__tests__/users.test.ts +++ b/__tests__/integration/users.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; -import { StreamClient } from '../src/StreamClient'; -import { UserRequest } from '../src/gen/models'; +import { StreamClient } from '../../src/StreamClient'; +import { UserRequest } from '../../src/gen/models'; describe('user API', () => { let client: StreamClient; diff --git a/__tests__/webhook-compression.test.ts b/__tests__/integration/webhook-compression.test.ts similarity index 99% rename from __tests__/webhook-compression.test.ts rename to __tests__/integration/webhook-compression.test.ts index 6b88477..f2cce5e 100644 --- a/__tests__/webhook-compression.test.ts +++ b/__tests__/integration/webhook-compression.test.ts @@ -3,7 +3,7 @@ import zlib from 'zlib'; import { beforeEach, describe, expect, it } from 'vitest'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; import { decodeSnsPayload, decodeSqsPayload, @@ -15,7 +15,7 @@ import { parseSqs, verifyAndParseWebhook, verifySignature, -} from '../src/utils/webhook'; +} from '../../src/utils/webhook'; const JSON_BODY = '{"type":"user.updated","created_at":"2026-04-08T14:23:35.879421674Z","user":{"id":"u-1"}}'; diff --git a/__tests__/webhook.test.ts b/__tests__/integration/webhook.test.ts similarity index 95% rename from __tests__/webhook.test.ts rename to __tests__/integration/webhook.test.ts index 29351bc..1a94ae4 100644 --- a/__tests__/webhook.test.ts +++ b/__tests__/integration/webhook.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { StreamClient } from '../src/StreamClient'; +import { StreamClient } from '../../src/StreamClient'; import { createTestClient } from './create-test-client'; describe('webhooks', () => { diff --git a/eslint.config.mjs b/eslint.config.mjs index a3ce320..d7e1fd1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -50,6 +50,8 @@ export default tseslint.config( { // These read raw, untyped JSON: API error payloads and test fixtures. files: ["src/ApiClient.ts", "__tests__/**/*.ts"], + // The docs snippet tests keep the strict rules. + ignores: ["__tests__/docs-snippets/**"], rules: { "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-member-access": "off", diff --git a/package.json b/package.json index 0b4c060..3970f11 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "prepack": "yarn build", "test": "vitest", "test:bun": "bun run vitest", + "test:docs": "vitest run --config __tests__/docs-snippets/vitest.config.mts", + "test:docs:sync": "vitest run --config __tests__/docs-snippets/vitest.sync.config.mts", + "test:docs:sweep": "vitest run --config __tests__/docs-snippets/vitest.sweep.config.mts", + "types:docs": "tsc -p __tests__/docs-snippets/tsconfig.json", "start": "vite build --watch", "build": "rm -rf dist && vite build && tsc --emitDeclarationOnly -p tsconfig.json", "generate:open-api": "./generate-openapi.sh", diff --git a/vite.config.mts b/vite.config.mts index cf56164..b6a8cf9 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -40,7 +40,7 @@ export default defineConfig({ reporter: ["lcov"], }, testTimeout: 60000, - include: ["__tests__/**/*.test.ts"], + include: ["__tests__/integration/**/*.test.ts"], includeSource: ["src/**/*.ts"], retry: 3, }, From 453efded6fe4ef28a110dab6d062d7aa73170d59 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 13 Oct 2026 10:55:25 -0500 Subject: [PATCH 2/9] test: test server-side pages --- __tests__/docs-snippets/README.md | 11 +- .../docs-snippets-agent-prompt.md | 6 +- __tests__/docs-snippets/docs-snippets-todo.md | 141 +-- __tests__/docs-snippets/env.ts | 11 + __tests__/docs-snippets/global-setup.ts | 84 ++ __tests__/docs-snippets/helpers/leaks.ts | 54 +- __tests__/docs-snippets/helpers/retention.ts | 40 + __tests__/docs-snippets/helpers/server.ts | 37 +- __tests__/docs-snippets/helpers/wait.ts | 37 +- __tests__/docs-snippets/learnings.md | 156 ++++ __tests__/docs-snippets/maintenance/sweep.ts | 29 +- .../02-init_and_users/04-user_groups.test.ts | 256 ++++++ .../03-channels/01-creating_channels.test.ts | 80 ++ .../03-channels/02-query_channels.test.ts | 266 ++++++ .../03-channels/03-channel_update.test.ts | 106 +++ .../03-channels/04-query_members.test.ts | 157 ++++ .../03-channels/05-channel_pagination.test.ts | 113 +++ .../03-channels/06-channel_members.test.ts | 346 +++++++ .../08-archiving.test.ts | 86 ++ .../07-channel_management/09-pinning.test.ts | 103 +++ .../07-channel_management/10-muting.test.ts | 140 +++ .../07-channel_management/11-hiding.test.ts | 96 ++ .../12-disabling.test.ts | 76 ++ .../07-channel_management/13-deleting.test.ts | 186 ++++ .../07-channel_management/14-freezing.test.ts | 145 +++ .../15-truncating.test.ts | 192 ++++ .../16-channel_invites.test.ts | 161 ++++ .../17-batch-updates.test.ts | 207 +++++ .../pages/03-channels/08-get_channel.test.ts | 89 ++ .../pages/04-messages/01-send_message.test.ts | 351 +++++++ .../02-file_uploads.standalone.test.ts | 62 ++ .../pages/04-messages/02-file_uploads.test.ts | 225 +++++ .../pages/04-messages/03-threads.test.ts | 344 +++++++ .../04-messages/04-send_reaction.test.ts | 221 +++++ .../04-messages/05-pinned_messages.test.ts | 181 ++++ .../pages/04-messages/06-search.test.ts | 178 ++++ .../04-messages/07-silent_messages.test.ts | 104 +++ .../04-messages/08-unread_reminders.test.ts | 74 ++ .../04-messages/09-message_reminders.test.ts | 404 +++++++++ .../04-messages/10-message_receipts.test.ts | 177 ++++ .../11-ai-message-streaming.test.ts | 97 ++ .../pages/05-features/02-events.test.ts | 75 ++ .../pages/05-features/03-unread.test.ts | 261 ++++++ .../pages/05-features/06-campaign_api.test.ts | 854 ++++++++++++++++++ .../pages/05-features/07-polls_api.test.ts | 435 +++++++++ .../05-features/08-location_sharing.test.ts | 320 +++++++ .../pages/05-features/09-translation.test.ts | 115 +++ .../11-slow_mode_and_throttling.test.ts | 58 ++ .../05-features/10-advanced/12-drafts.test.ts | 191 ++++ .../10-advanced/13-private_messaging.test.ts | 71 ++ .../14-user_average_response_time.test.ts | 76 ++ .../10-advanced/15-pending_messages.test.ts | 307 +++++++ .../16-dynamic_partitioning.test.ts | 211 +++++ .../10-advanced/17-audit_logs.test.ts | 150 +++ .../18-data_retention_policy.test.ts | 89 ++ .../01-channel_types.test.ts | 322 +++++++ .../02-app_settings.test.ts | 243 +++++ .../03-chat_permission_policies.test.ts | 413 +++++++++ .../06-channel-level_settings.test.ts | 100 ++ .../07-push/07-legacy_push_system.test.ts | 115 +++ .../02-before_message_send_webhook.test.ts | 115 +++ .../03-custom_commands_webhook.test.ts | 260 ++++++ .../03-exporting_channels.test.ts | 174 ++++ .../12-best_practices/02-moderation.test.ts | 538 +++++++++++ .../05-marketplace_best_practices.test.ts | 253 ++++++ __tests__/docs-snippets/setup.ts | 12 +- __tests__/docs-snippets/sync/parse.ts | 3 + __tests__/docs-snippets/vitest.config.mts | 2 + .../docs-snippets/vitest.sweep.config.mts | 5 +- __tests__/integration/stream-error.test.ts | 20 + index.ts | 1 + src/gen/models/index.ts | 4 +- 72 files changed, 11518 insertions(+), 104 deletions(-) create mode 100644 __tests__/docs-snippets/env.ts create mode 100644 __tests__/docs-snippets/global-setup.ts create mode 100644 __tests__/docs-snippets/helpers/retention.ts create mode 100644 __tests__/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/01-creating_channels.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/02-query_channels.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/03-channel_update.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/04-query_members.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/05-channel_pagination.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/06-channel_members.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/07-channel_management/17-batch-updates.test.ts create mode 100644 __tests__/docs-snippets/pages/03-channels/08-get_channel.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/01-send_message.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/02-file_uploads.standalone.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/02-file_uploads.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/03-threads.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/04-send_reaction.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/05-pinned_messages.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/06-search.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/07-silent_messages.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/08-unread_reminders.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/09-message_reminders.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/10-message_receipts.test.ts create mode 100644 __tests__/docs-snippets/pages/04-messages/11-ai-message-streaming.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/02-events.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/03-unread.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/06-campaign_api.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/07-polls_api.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/08-location_sharing.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/09-translation.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/10-advanced/14-user_average_response_time.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/10-advanced/16-dynamic_partitioning.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/10-advanced/17-audit_logs.test.ts create mode 100644 __tests__/docs-snippets/pages/05-features/10-advanced/18-data_retention_policy.test.ts create mode 100644 __tests__/docs-snippets/pages/06-app_and_channel_settings/01-channel_types.test.ts create mode 100644 __tests__/docs-snippets/pages/06-app_and_channel_settings/02-app_settings.test.ts create mode 100644 __tests__/docs-snippets/pages/06-app_and_channel_settings/03-chat_permission_policies.test.ts create mode 100644 __tests__/docs-snippets/pages/06-app_and_channel_settings/06-channel-level_settings.test.ts create mode 100644 __tests__/docs-snippets/pages/07-push/07-legacy_push_system.test.ts create mode 100644 __tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts create mode 100644 __tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts create mode 100644 __tests__/docs-snippets/pages/10-migrating/03-exporting_channels.test.ts create mode 100644 __tests__/docs-snippets/pages/12-best_practices/02-moderation.test.ts create mode 100644 __tests__/docs-snippets/pages/12-best_practices/05-marketplace_best_practices.test.ts create mode 100644 __tests__/integration/stream-error.test.ts diff --git a/__tests__/docs-snippets/README.md b/__tests__/docs-snippets/README.md index bb08fce..c91c90a 100644 --- a/__tests__/docs-snippets/README.md +++ b/__tests__/docs-snippets/README.md @@ -11,7 +11,7 @@ yarn test:docs # all do yarn test:docs pages/node/01-quick_start/01-backend_quickstart.test.ts # one file yarn types:docs # typecheck the docs tests yarn test:docs:sync # test regions == docs fences (no network) -yarn test:docs:sweep # remove docs-test leftovers older than 30 min (DOCS_SWEEP_DRY_RUN=1 to list only, DOCS_SWEEP_MIN_AGE=0 for all) +yarn test:docs:sweep # remove docs-test leftovers older than 30 min, incl. channels, and turns off retention policies (DOCS_SWEEP_DRY_RUN=1 to list only, DOCS_SWEEP_MIN_AGE=0 for all) yarn eslint __tests__/docs-snippets && npx prettier --check __tests__/docs-snippets # lint ``` @@ -32,7 +32,9 @@ notifications or webhook events arrive. pages//.test.ts # Node.js snippets, run with the server client helpers/ # server client, unique ids, cleanup registry, server-side helpers, # waits, leak check, app snapshot, push credentials -setup.ts # .env loading, per-file leak check and app-settings drift check +env.ts # .env loading (fails without STREAM_API_KEY / STREAM_SECRET) +setup.ts # per-file leak check and app-settings drift check +global-setup.ts # end-of-run sweep: removes leftovers of files that failed while loading sync/ # `yarn test:docs:sync` (docs fence <-> test region check) maintenance/ # `yarn test:docs:sweep` docs-snippets-todo.md # pages to cover, their status, blockers and setup notes @@ -54,10 +56,11 @@ The path mirrors the docs path, e.g. `_default/04-messages/01-send_message.md` m - Tests in one file may share data created in `beforeAll`. Nothing may leak across files. - `cleanup.run()` attempts every step and then throws if anything failed, so leftovers show up as a failed `afterAll` instead of piling up in the app. Server-side hard channel deletion is a background task: `Cleanup` waits for it (`waitForTask`) and retries deleting channel types until their channels are gone. - **Leak check** (`setup.ts`, runs automatically after each file's own `afterAll`): looks up everything minted by `uniqueId` in that file (users, channels, channel types, commands, roles, custom permissions, blocklists, segments, campaigns, user groups, moderation configs, predefined filters, push providers, polls). Anything still there fails the file with a `LEAK` error listing it, and is then removed. Per-user data (reminders, devices, drafts, ...) goes away with the user. It also snapshots app settings (incl. push and webhook config), pre-existing channel types and retention policies before the file and fails with `DRIFT` if anything changed and wasn't restored. +- **End-of-run sweep** (`global-setup.ts`, after every `yarn test:docs`): a file that throws while loading runs no hooks, so its cleanup and leak check never run. The sweep removes every `docs-test` resource created since the run started (channels are found by listing every channel created since then) and fails the run with a `LEAK` list. It also puts back the retention policies the run started with and fails with `DRIFT` if it had to. Don't run two `yarn test:docs` at once: the sweep would remove the other run's resources. - Anything created under a non-`uniqueId` name can't be found by the leak check or the sweep. Don't do that. -- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). +- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). Background tasks: `waitForTask(client, taskId)`, and `waitForBatchTask` for channel batch updates (their top-level status completes too early). - **App settings**: pages are run one at a time, so tests may change app settings and built-in channel types, but must restore them (`cleanup.add`). The check after each file fails with `DRIFT` otherwise. -- `updateChannelType(client, name, patch)` (`helpers/channel-types.ts`) fills in the fields the request requires; `grantMessagingMembers` (`helpers/grants.ts`) adds `messaging` member grants and registers the restore. `helpers/server.ts` has repeated lookups: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy` and `waitForAppSetting`. +- `updateChannelType(client, name, patch)` (`helpers/channel-types.ts`) fills in the fields the request requires; `grantMessagingMembers` (`helpers/grants.ts`) adds `messaging` member grants and registers the restore. `helpers/server.ts` has repeated lookups: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy`, `waitForAppSetting` and `waitForTeamIdSupport` (multi-tenancy toggles). - One `it` per docs snippet (or per closely related group of snippets in the same tab group). - Add basic `expect`s after each snippet to prove it did what the docs claim. diff --git a/__tests__/docs-snippets/docs-snippets-agent-prompt.md b/__tests__/docs-snippets/docs-snippets-agent-prompt.md index 3cb7d6a..7217aac 100644 --- a/__tests__/docs-snippets/docs-snippets-agent-prompt.md +++ b/__tests__/docs-snippets/docs-snippets-agent-prompt.md @@ -15,7 +15,7 @@ You are verifying the server-side Node.js code snippets of one Stream Chat docs - Only touch: - the test files for this page under `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/pages/` - the docs page itself - - this page's status row (and its entries in the Blockers detail / Setup notes / Known docs bugs / Possible SDK issues sections) in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md` + - this page's status row (and its entries in the Blockers detail / Setup notes / Open docs bugs / Possible SDK issues sections). Fixed docs bugs go in the report only; record a docs bug in **Open docs bugs** only if you leave it unfixed in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md` - Do not edit the helpers, the configs, other pages, or anything in `src/` (`src/gen` is generated from the OpenAPI spec, so hand edits would be lost). If a helper is missing something, write a local helper in your test file and propose the shared helper in your report. If the SDK is wrong (types or behavior), report it under "Possible SDK issues". - Change existing docs snippets as little as possible, and only when they are actually wrong. - Never put the API key, secret or push credentials in any file. They are loaded from the repo root `.env` (`STREAM_API_KEY`, `STREAM_SECRET`, optional `DOCS_PUSH_FIREBASE_CREDENTIALS`). @@ -25,7 +25,7 @@ You are verifying the server-side Node.js code snippets of one Stream Chat docs 1. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`: issues that earlier agents ran into. Follow it. 2. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/node/01-quick_start/01-backend_quickstart.test.ts` as an example. -3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`. Also read the page's entries in the TODO's **Blockers detail**, **Setup notes** and **Known docs bugs** sections, and the **App facts** line. +3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`. Also read the page's entries in the TODO's **Blockers detail** and **Setup notes** sections, and the **App facts** line. 4. The SDK source for every method a snippet calls: `src/StreamClient.ts`, `src/StreamChatClient.ts`, `src/StreamChannel.ts`, `src/StreamModerationClient.ts` and the generated `src/gen/` clients and models (`src/gen/models/index.ts`), for the method name, its request object and its response type. When it's unclear whether a server-side endpoint exists or what it accepts, check the OpenAPI spec: https://github.com/GetStream/protocol/tree/main/openapi/v2 (`chat-serverside-api.json`). ## What to build @@ -107,7 +107,7 @@ yarn test:docs:sync # your page: every Node.js f `yarn test:docs:sync`'s coverage check also lists pages nobody has done yet: only your page must be absent from it. -Don't use `@ts-ignore`/`@ts-expect-error` to silence a docs snippet. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (report it). +Don't use `@ts-ignore`/`@ts-expect-error` or `eslint-disable` to silence a docs snippet. The one lint-only docs change allowed: a fence that assigns a value it never reads, which the test can't read either (block-scoped), gets a `console.log` of the field the page talks about (see `learnings.md`); report it as a docs correction. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (report it). Finally, update the page's row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`: `[x]` when done, `[~]` when there are open questions. diff --git a/__tests__/docs-snippets/docs-snippets-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md index 36aed75..38c75fb 100644 --- a/__tests__/docs-snippets/docs-snippets-todo.md +++ b/__tests__/docs-snippets/docs-snippets-todo.md @@ -15,7 +15,7 @@ Pages of the chat Node sidebar (`getstream.io/content/docs/_sidebars/[chat][node Status: `[ ]` todo, `[x]` done, `[~]` done with open questions. -App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `firebase:firebase` (not a docs-test one, leave it), permissions v2, multi-tenancy off, `disable_auth_checks` off, auto-translation off, campaigns on (segments, user groups and polls endpoints answer), no webhook / SQS / SNS / custom action handler URLs set (`webhook_url`, `custom_action_handler_url`, `sqs_url`, `sns_topic_arn` empty), one entry in `event_hooks`, no retention policies. Channel types: `livestream`, `messaging`, `team`, `agent`, `commerce`, `gaming`, plus `streamnodetest*` types left by the integration tests. Not probed yet: feature availability per page (each agent probes what its page needs and records it here). +App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `firebase:firebase` (not a docs-test one, leave it), permissions v2, multi-tenancy off, `disable_auth_checks` off, auto-translation off, campaigns on (segments, user groups and polls endpoints answer), no webhook / SQS / SNS / custom action handler URLs set (`webhook_url`, `custom_action_handler_url`, `sqs_url`, `sns_topic_arn` empty), one entry in `event_hooks`, no retention policies. Channel types: `livestream`, `messaging`, `team`, `agent`, `commerce`, `gaming`, plus `streamnodetest*` types left by the integration tests. Delivery receipts: `delivery_events: true` is accepted on create/update channel type without support enablement (2026-10-12). Location sharing: enabled (2026-10-12); channels still need `shared_locations` (`config_overrides` or channel type), otherwise `sendMessage` with `shared_location` fails with code 17 "location sharing is not enabled for this channel". Translation: `translateMessage` works with auto-translation off; `updateApp({ auto_translation_enabled: true })` is accepted (restored after, 2026-10-12). Restricted visibility messages: enabled (`restricted_visibility` on `sendMessage` works, 2026-10-12). User response time: `updateApp({ user_response_time_enabled: true })` is accepted and `avg_response_time` appears on the replying user within seconds (restored after, 2026-10-12). Dynamic partitioning: `partition_size` / `partition_ttl` accepted on create/update channel type without support enablement (2026-10-12). Message history (audit logs): enabled; `queryMessageHistory` is rate limited to 6 requests per minute (2026-10-12). Not probed yet: feature availability per page (each agent probes what its page needs and records it here). ## chat/node @@ -27,99 +27,99 @@ App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `fir | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | --------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/02-init_and_users/04-user_groups.md` | 8 | 0 | | | +| [x] | `chat/_default/02-init_and_users/04-user_groups.md` | 8 | 0 | yes | | ## chat/\_default/03-channels | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | ----------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/03-channels/01-creating_channels.md` | 2 | 0 | | | -| [ ] | `chat/_default/03-channels/02-query_channels.md` | 11 | 0 | | | -| [ ] | `chat/_default/03-channels/08-get_channel.md` | 1 | 0 | | | -| [ ] | `chat/_default/03-channels/03-channel_update.md` | 2 | 0 | | | -| [ ] | `chat/_default/03-channels/04-query_members.md` | 2 | 0 | | | -| [ ] | `chat/_default/03-channels/05-channel_pagination.md` | 2 | 0 | | | -| [ ] | `chat/_default/03-channels/06-channel_members.md` | 10 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/08-archiving.md` | 1 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/09-pinning.md` | 1 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/10-muting.md` | 3 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/11-hiding.md` | 1 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/12-disabling.md` | 1 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/13-deleting.md` | 3 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/14-freezing.md` | 3 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/15-truncating.md` | 2 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/16-channel_invites.md` | 6 | 0 | | | -| [ ] | `chat/_default/03-channels/07-channel_management/17-batch-updates.md` | 3 | 0 | | | +| [x] | `chat/_default/03-channels/01-creating_channels.md` | 2 | 0 | yes | | +| [x] | `chat/_default/03-channels/02-query_channels.md` | 11 | 0 | yes | | +| [x] | `chat/_default/03-channels/08-get_channel.md` | 1 | 0 | yes | | +| [x] | `chat/_default/03-channels/03-channel_update.md` | 2 | 0 | yes | | +| [x] | `chat/_default/03-channels/04-query_members.md` | 2 | 0 | yes | | +| [x] | `chat/_default/03-channels/05-channel_pagination.md` | 2 | 0 | yes | | +| [x] | `chat/_default/03-channels/06-channel_members.md` | 10 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/08-archiving.md` | 1 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/09-pinning.md` | 1 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/10-muting.md` | 3 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/11-hiding.md` | 1 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/12-disabling.md` | 1 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/13-deleting.md` | 3 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/14-freezing.md` | 3 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/15-truncating.md` | 2 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/16-channel_invites.md` | 6 | 0 | yes | | +| [x] | `chat/_default/03-channels/07-channel_management/17-batch-updates.md` | 3 | 0 | yes | | ## chat/\_default/04-messages | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | ------------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/04-messages/01-send_message.md` | 9 | 0 | | | -| [ ] | `chat/_default/04-messages/02-file_uploads.md` | 4 | 0 | | | -| [ ] | `chat/_default/04-messages/03-threads.md` | 9 | 0 | | | -| [ ] | `chat/_default/04-messages/04-send_reaction.md` | 5 | 0 | | | -| [ ] | `chat/_default/04-messages/05-pinned_messages.md` | 3 | 0 | | | -| [ ] | `chat/_default/04-messages/06-search.md` | 2 | 0 | | | -| [ ] | `chat/_default/04-messages/07-silent_messages.md` | 2 | 0 | | | -| [ ] | `chat/_default/04-messages/08-unread_reminders.md` | 1 | 0 | | | -| [ ] | `chat/_default/04-messages/09-message_reminders.md` | 7 | 0 | | | -| [ ] | `chat/_default/04-messages/10-message_receipts.md` | 4 | 0 | | | -| [ ] | `chat/_default/04-messages/11-ai-message-streaming.md` | 2 | 0 | | | +| [x] | `chat/_default/04-messages/01-send_message.md` | 9 | 0 | yes | | +| [x] | `chat/_default/04-messages/02-file_uploads.md` | 4 | 0 | yes | | +| [x] | `chat/_default/04-messages/03-threads.md` | 9 | 0 | yes | | +| [x] | `chat/_default/04-messages/04-send_reaction.md` | 5 | 0 | yes | | +| [x] | `chat/_default/04-messages/05-pinned_messages.md` | 3 | 0 | yes | | +| [x] | `chat/_default/04-messages/06-search.md` | 2 | 0 | yes | | +| [x] | `chat/_default/04-messages/07-silent_messages.md` | 2 | 0 | yes | | +| [x] | `chat/_default/04-messages/08-unread_reminders.md` | 1 | 0 | yes | | +| [~] | `chat/_default/04-messages/09-message_reminders.md` | 7 | 0 | yes | | +| [x] | `chat/_default/04-messages/10-message_receipts.md` | 4 | 0 | yes | | +| [x] | `chat/_default/04-messages/11-ai-message-streaming.md` | 2 | 0 | yes | | ## chat/\_default/05-features | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | ------------------------------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/05-features/02-events.md` | 2 | 0 | | | -| [ ] | `chat/_default/05-features/03-unread.md` | 7 | 0 | | | -| [ ] | `chat/_default/05-features/06-campaign_api.md` | 16 | 0 | | | -| [ ] | `chat/_default/05-features/07-polls_api.md` | 16 | 0 | | | -| [ ] | `chat/_default/05-features/08-location_sharing.md` | 5 | 0 | | | -| [ ] | `chat/_default/05-features/09-translation.md` | 3 | 0 | | | -| [ ] | `chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md` | 1 | 0 | | | -| [ ] | `chat/_default/05-features/10-advanced/12-drafts.md` | 4 | 0 | | | -| [ ] | `chat/_default/05-features/10-advanced/13-private_messaging.md` | 1 | 0 | | | -| [ ] | `chat/_default/05-features/10-advanced/14-user_average_response_time.md` | 1 | 0 | | | -| [ ] | `chat/_default/05-features/10-advanced/15-pending_messages.md` | 6 | 0 | | | -| [ ] | `chat/_default/05-features/10-advanced/16-dynamic_partitioning.md` | 4 | 0 | | | -| [ ] | `chat/_default/05-features/10-advanced/17-audit_logs.md` | 5 | 0 | | | -| [ ] | `chat/_default/05-features/10-advanced/18-data_retention_policy.md` | 4 | 0 | | | +| [x] | `chat/_default/05-features/02-events.md` | 2 | 0 | yes | | +| [x] | `chat/_default/05-features/03-unread.md` | 7 | 0 | yes | | +| [x] | `chat/_default/05-features/06-campaign_api.md` | 16 | 0 | yes | | +| [x] | `chat/_default/05-features/07-polls_api.md` | 16 | 0 | yes | | +| [x] | `chat/_default/05-features/08-location_sharing.md` | 5 | 0 | yes | | +| [x] | `chat/_default/05-features/09-translation.md` | 3 | 0 | yes | | +| [x] | `chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md` | 1 | 0 | yes | | +| [x] | `chat/_default/05-features/10-advanced/12-drafts.md` | 4 | 0 | yes | | +| [x] | `chat/_default/05-features/10-advanced/13-private_messaging.md` | 1 | 0 | yes | | +| [x] | `chat/_default/05-features/10-advanced/14-user_average_response_time.md` | 1 | 0 | yes | | +| [x] | `chat/_default/05-features/10-advanced/15-pending_messages.md` | 6 | 0 | yes | | +| [x] | `chat/_default/05-features/10-advanced/16-dynamic_partitioning.md` | 4 | 0 | yes | | +| [~] | `chat/_default/05-features/10-advanced/17-audit_logs.md` | 5 | 0 | yes | | +| [x] | `chat/_default/05-features/10-advanced/18-data_retention_policy.md` | 4 | 0 | yes | | ## chat/\_default/06-app_and_channel_settings | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | -------------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/06-app_and_channel_settings/01-channel_types.md` | 9 | 0 | | | -| [ ] | `chat/_default/06-app_and_channel_settings/02-app_settings.md` | 3 | 0 | | | -| [ ] | `chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md` | 11 | 0 | | | -| [ ] | `chat/_default/06-app_and_channel_settings/06-channel-level_settings.md` | 3 | 0 | | | +| [x] | `chat/_default/06-app_and_channel_settings/01-channel_types.md` | 9 | 0 | yes | | +| [x] | `chat/_default/06-app_and_channel_settings/02-app_settings.md` | 3 | 0 | yes | | +| [x] | `chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md` | 11 | 0 | yes | | +| [~] | `chat/_default/06-app_and_channel_settings/06-channel-level_settings.md` | 3 | 0 | yes | | ## chat/\_default/07-push | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | ------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/07-push/07-legacy_push_system.md` | 2 | 0 | | 🔑 | +| [~] | `chat/_default/07-push/07-legacy_push_system.md` | 2 | 0 | yes | 🔑 | ## chat/\_default/08-webhooks | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | ---------------------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md` | 3 | 0 | | | -| [ ] | `chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md` | 8 | 0 | | | +| [x] | `chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md` | 3 | 0 | yes | | +| [~] | `chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md` | 8 | 0 | yes | | ## chat/\_default/10-migrating | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | ----------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/10-migrating/03-exporting_channels.md` | 5 | 0 | | | +| [x] | `chat/_default/10-migrating/03-exporting_channels.md` | 5 | 0 | yes | | ## chat/\_default/12-best_practices | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | ------------------------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [ ] | `chat/_default/12-best_practices/02-moderation.md` | 13 | 0 | | | -| [ ] | `chat/_default/12-best_practices/05-marketplace_best_practices.md` | 5 | 0 | | | +| [x] | `chat/_default/12-best_practices/02-moderation.md` | 13 | 0 | yes | | +| [x] | `chat/_default/12-best_practices/05-marketplace_best_practices.md` | 5 | 0 | yes | | ## Pages without Node.js snippets @@ -144,26 +144,45 @@ These sidebar pages have no `Node.js` (or unlabelled) JS fences: nothing to test ## Blockers detail -None found yet. +None. ## Setup notes (not blockers) - **node/01-quick_start/01-backend_quickstart**: The first fence creates a client from `"{{ api_key }}"` / `"{{ api_secret }}"` template placeholders: COPY them from the real credentials (`apiKey="{{ api_key }}"`). -- **07-push/07-legacy_push_system**: Push provider / `firebase_config` snippets need real credentials (🔑). Name providers with `uniqueId`; `updateApp({ firebase_config })` changes app-level push config: save it with `getApp()` first and restore it (the DRIFT check compares `push_notifications`). +- **07-push/07-legacy_push_system**: Push provider / `firebase_config` snippets need real credentials (🔑). Name providers with `uniqueId`; `updateApp({ firebase_config })` changes app-level push config: save it with `getApp()` first and restore it (the DRIFT check compares `push_notifications`). Without real credentials both `upsertPushProvider` and `updateApp({ firebase_config })` (even templates only) fail with code 4 "firebase credentials are invalid" (2026-10-12), so both tests are skipped. Not yet run with credentials: the `firebase_config` restore (templates + `disabled: !enabled`, the app starts with legacy Firebase `enabled: false`) and whether the API accepts the placeholder templates (`"your-template"`) are unverified. - **08-webhooks/01-webhooks_overview/\***: Configure the hook URL (`before_message_send_hook_url`, `custom_action_handler_url`, ...) with `updateApp`, assert it with `getApp()`, and restore the original (empty) value. Don't test that the webhook is called. +- **02-init_and_users/04-user_groups**: The get/list/search/delete snippets pass `team_id`, which the API rejects without multi-tenancy (`team_id is not supported when multi-tenancy is not enabled`) and requires with it. The test enables `multi_tenant_enabled` for those four snippets (on a `uniqueId` team) and restores it. `waitForAppSetting` is not enough: the user group endpoints of some API nodes keep the old setting for a few seconds more, so the test also waits until 20 calls in a row accept (or reject) `team_id`. +- **03-channels/02-query_channels**: The predefined filter snippets query a filter created by the test (`uniqueId` name, template `{ type: "messaging", members: { $in: ["{{user_id}}"] } }`, COPY-mapped to `"user_messaging_channels"`), deleted in cleanup. The "Filter Best Practices" and "Bad Pattern" regions (and the shared setup) run at module level: their one-line `queryChannels({ filter_conditions: { type: "messaging" } })` would be wrapped by prettier at test indentation, and the sync check keeps the wrapping. +- **03-channels/07-channel_management/13-deleting**: `skip_truncate` only works on distinct channels, so the "Preserving Message History" test runs on a distinct channel (its id COPY-mapped to `"general"`) and recreates it to check the message is back. +- **03-channels/07-channel_management/17-batch-updates**: The docs filter on every `messaging` / `team` channel of the app; the test COPY-maps those type names to two `uniqueId` channel types so only its own channels are touched. Wait on the inner `result.status` / `result.finished_at` of the task (the top-level `status` completes first). A batch started right after another one takes ~60s, so the tests have 240s timeouts. +- **04-messages/01-send_message**: Server-side, URL enrichment runs after `sendMessage` returns: the response has no attachment, and it appears on the stored message about a second later. The test polls `getMessage` for it. The "Mentions" snippet's group id maps to a `uniqueId` user group created in `beforeAll`. +- **04-messages/02-file_uploads**: The "Uploading Files to a Channel" and "Uploading Standalone Files" fences start with `import`s, so they run at module level, and in two files (`02-file_uploads.test.ts`, `02-file_uploads.standalone.test.ts`) because both import `readFile` / `File`. The local paths (`./image1.jpg`, `./report.pdf`, `./avatar.jpg`, ...) are COPY-mapped to a 4x4 JPEG and a minimal PDF written to a temp dir. Server-side uploads need `user: { id }` (code 4 "bad user id" without it). +- **04-messages/09-message_reminders**: Reminders need `user_message_reminders` on the channel (`config_overrides`) or channel type; the "Enabling Reminders" snippet runs on a `uniqueId` type (COPY-mapped to `messaging`). `remind_at` must be at least one minute in the future (create and update, code 4), so the "overdue" filter test uses a reminder created at the start of `beforeAll` with `remind_at` ~62s ahead and waits for it to pass. A user can have only one reminder per message. +- **05-features/06-campaign_api**: Campaigns and segments use `uniqueId` ids and names; the docs segment filters ("created after 2020", `country: "USA"`) are COPY-mapped to the test start time and a `uniqueId` country so only the file's users are targeted. A campaign can be started only once (code 4 "cannot be started" after it ran), and one started "now" reads `scheduled` for a moment before `in_progress` / `completed`; `scheduled_for` must be in the future. A running campaign can't be deleted ("is in progress") and a completed one can't be stopped: cleanup retries stop + delete. Campaign channels get template ids (`{{receiver.id}}-{{sender.id}}`, keep user ids short for the 64-char limit), so cleanup finds them by member. `createSegment` rejects `filter: {}` (code 4 "filter must contain at least 1 item"). `team` in `channel_template` works with multi-tenancy off. +- **08-webhooks/01-webhooks_overview/03-custom_commands_webhook**: Commands are app-wide: the docs' `"ticket"` is COPY-mapped to a `uniqueId` name (hyphens are accepted). A command in use by a channel type can't be deleted (code 4 "command in use by channel type"), so cleanup first sets the type's `commands` to `[]`, then deletes the command. The intro fence (sending `/ticket ...`) runs last on a `uniqueId` type with the command (COPY-mapped to `"messaging"`), after the handler URL is set to the example.com placeholder: Stream calls it, it fails, and `sendMessage` rejects with code 45, which the test records through a spy and asserts. - **All pages that create/update channel types**: Wait 30s (`waitForChannelTypePropagation()`) after `createChannelType` / `updateChannelType` before using the type. +- **05-features/07-polls_api**: Polls work on the built-in `messaging` type with no config change. All polls are created as one `uniqueId` user (john) and removed with `deletePollsCreatedBy` before the user. Votes need the poll sent in a message first (`sendMessage` with `poll_id`). The "Querying polls" filter (`is_closed: true`) matches every closed poll of the app, so the test asserts only on the order of its own two closed polls. +- **05-features/10-advanced/17-audit_logs**: Message history is enabled on the test app. `queryMessageHistory` allows 6 requests per minute (code 9, 429): the test spies on it to retry on 429 so back-to-back runs pass. +- **05-features/10-advanced/15-pending_messages**: Pending messages are enabled on the test app (2026-10-12). The `mark_messages_pending` snippet runs on a `uniqueId` type (COPY-mapped to `messaging`). Server-side, `pending_messages` is only returned for a user: `queryChannels` needs `user_id`, `channel.get` needs `state: true` and `user_id`. The pending message hook snippet adds a hook with an `https://example.com` URL to `event_hooks`; the test restores the original `event_hooks` array. -## Known docs bugs +## Open docs bugs -Found while testing; agents confirm and fix them (minimal change, listed in the final report). +Docs bugs found but not fixed (open questions, or fixes that need a decision). Remove an entry once it is fixed. -None yet. +- **04-messages/09-message_reminders** (open question, API behavior): with the default sort (`remind_at`), `queryReminders` pagination skips reminders without a due date: 2 timed + 2 "saved for later" reminders, `limit: 1`, gives page 1 and page 2 (timed) and then no `next`. With `sort: [{ field: "created_at", direction: 1 }]` all 4 pages come back. The "Pagination" test uses timed reminders only. Check whether this is an API bug or should be documented. +- **05-features/10-advanced/17-audit_logs** (open question, `JSON` response fences, not Node.js): each history entry holds the text before an update, and its `message_updated_by_id` is the user who wrote that text, not the user of the update that replaced it (probed: v1 alice, v2 alice, v3 admin, v4 alice gives entries v1/alice, v2/alice, v3/admin). So after the page's example both entries have `message_updated_by_id: "alice"`, while the docs' JSON shows `admin` for the "+31 6 12345678" entry; the prose ("the user who made the change") reads the same way as the JSON. Both JSON fences are also invalid JSON (`"message_updated_at" "..."` lacks a colon, the second fence lacks a comma after `"is_deleted": false`). The test asserts the observed behavior. Decide whether to fix the JSON/prose or whether the API should record the editor. +- **06-app_and_channel_settings/06-channel-level_settings** (open question, API behavior): The blocklist override is asserted only by reading `channel.config` back. Open question: on this app (`moderation_enabled: true`) a message with the blocklist's word is not blocked, neither server-side nor sent as the user with a user token, and `config_overrides.blocklist` accepts a nonexistent name without error (2026-10-12). Probably blocklists are enforced through moderation configs here, not channel config. `set: { config_overrides: {...} }` replaces all overrides (the later replies fence drops the blocklist override). With `replies: false`, a reply fails with code 19 (400). +- **08-webhooks/01-webhooks_overview/03-custom_commands_webhook** (open question, prose, not Node.js code): "Edit a Command" says fields not sent to the update API stay unchanged, but `updateCommand({ name, description })` resets `args` and `set` to `""` (probed 2026-10-12; the SDK leaves undefined fields out of the body, so it is the API). "Performance considerations" says a 4xx/5xx or slow endpoint lets the message through as usual, but a custom command whose handler returns an error rejects `sendMessage` with code 45 "custom command endpoint returned an error" (and with no handler URL set, code 44 "missing custom command endpoint url"). The paragraph looks copied from the Before Message Send page. Decide whether to fix the prose or the API. ## Possible SDK issues -Runtime or type problems that look like node SDK bugs rather than docs bugs. Not fixed (no `src/` changes in this project). +Runtime or type problems that look like node SDK bugs rather than docs bugs. Not fixed by the agents (no `src/` changes). Remove an entry once it is fixed. -None yet. +- **04-messages/08-unread_reminders**: `CreateChannelTypeRequest` (generated, `src/gen/models`) has no `reminders` field, while `UpdateChannelTypeRequest` has it, so a channel type can't be created with reminders on in one call from typed code. Check whether the OpenAPI spec (`CreateChannelType`) lacks it or the API accepts it. + +- **05-features/06-campaign_api**: `updateCampaign` and `stopCampaign` are typed as returning `CampaignResponse`, but the API returns `{ campaign: CampaignResponse, ... }` (like `StartCampaignResponse`), so `response.campaign` is a TS2339 error and `response.name` etc. are undefined at runtime. Likely the OpenAPI spec's response schema for `PUT /campaigns/{id}` and `POST /campaigns/{id}/stop`. + +- **06-app_and_channel_settings/03-chat_permission_policies**: `UpdateChannelTypeRequest.grants` (and `UpdateAppRequest.grants`) are typed `Record` without `| null`, while the OpenAPI spec marks them `nullable: true` and the docs reset a scope with `grants: null` (TS2322). The API accepts `null` and resets the grants to the defaults. The generator drops `nullable` everywhere: `src/gen/models/index.ts` has no `| null` at all (e.g. `pin_expires?: Date`), so this is a generator change in `../chat` (`tools/openapi`), not a spec fix. **Patched locally** in `src/gen/models/index.ts` (`grants?: Record> | null` on both requests) so the docs code typechecks; a regeneration reverts it until the generator emits `| null`. ## Notes diff --git a/__tests__/docs-snippets/env.ts b/__tests__/docs-snippets/env.ts new file mode 100644 index 0000000..99bc92d --- /dev/null +++ b/__tests__/docs-snippets/env.ts @@ -0,0 +1,11 @@ +import { config } from 'dotenv'; +import path from 'node:path'; + +// The node SDK's test app, shared with the integration tests (repo root .env). +config({ path: path.join(__dirname, '../../.env'), quiet: true }); + +if (!process.env.STREAM_API_KEY || !process.env.STREAM_SECRET) { + throw new Error( + 'Missing STREAM_API_KEY / STREAM_SECRET. Copy .env-example to .env in the repo root and fill it in.', + ); +} diff --git a/__tests__/docs-snippets/global-setup.ts b/__tests__/docs-snippets/global-setup.ts new file mode 100644 index 0000000..e4f52cc --- /dev/null +++ b/__tests__/docs-snippets/global-setup.ts @@ -0,0 +1,84 @@ +import { config } from 'dotenv'; +import path from 'node:path'; +import { getServerClient } from './helpers/clients'; +import { createdAtFromId, DOCS_TEST_PREFIX } from './helpers/ids'; +import { + describeLeftovers, + findLeftovers, + removeLeftovers, +} from './helpers/leaks'; +import type { Leftover } from './helpers/leaks'; +import { + getRetentionPolicies, + restoreRetentionPolicies, +} from './helpers/retention'; + +// Safety net for the per-file leak check in setup.ts: a test file that throws while it +// is loaded (module-level setup or region) runs no hooks, so neither its cleanup nor the +// leak check runs. After the whole run, remove every docs-test resource created since the +// run started, put back the retention policies the run started with (they are app-level +// and delete old data), and fail the run if either was needed. + +/** Server and local clocks can differ a little. */ +const CLOCK_SKEW_MS = 5000; + +const createdAt = (leftover: Leftover) => { + // Campaigns and polls are listed as ` ()`; ids minted by `uniqueId` carry + // their creation time, anything else falls back to the server timestamp. + const fromId = createdAtFromId(leftover.id.split(' ')[0]); + if (fromId) return fromId; + return leftover.createdAt ? new Date(leftover.createdAt) : undefined; +}; + +const loadEnv = () => { + config({ path: path.join(__dirname, '../../.env'), quiet: true }); + return Boolean(process.env.STREAM_API_KEY && process.env.STREAM_SECRET); +}; + +export default async function setup() { + const startedAt = Date.now(); + const retentionPolicies = loadEnv() + ? await getRetentionPolicies(getServerClient()) + : undefined; + + return async function teardown() { + if (!loadEnv()) return; + const client = getServerClient(); + + const restoredPolicies = retentionPolicies + ? await restoreRetentionPolicies(client, retentionPolicies) + : []; + + const { leftovers, errors } = await findLeftovers(client, { + // `includes`: guest user ids are rewritten to `guest--docs-test-...`. + matches: (id) => id.includes(DOCS_TEST_PREFIX), + channelsCreatedSince: new Date(startedAt - CLOCK_SKEW_MS), + }); + if (errors.length) { + console.warn( + `[docs-snippets] end-of-run sweep could not scan:\n ${errors.join('\n ')}`, + ); + } + const fromThisRun = leftovers.filter((leftover) => { + const time = createdAt(leftover); + return time !== undefined && time.getTime() >= startedAt - CLOCK_SKEW_MS; + }); + const problems: string[] = []; + if (restoredPolicies.length) { + problems.push( + `[docs-snippets] DRIFT: this run changed retention policies (${restoredPolicies.join(', ')}). ` + + `They have now been restored to what the app had when the run started.`, + ); + } + if (fromThisRun.length) { + const failed = await removeLeftovers(fromThisRun); + problems.push( + `[docs-snippets] LEAK: this run left resources behind that no per-file leak check ` + + `caught (usually a test file that failed while loading). They have now been removed` + + `${failed.length ? `, except ${failed.length} that failed - run \`yarn test:docs:sweep\`` : ''}:\n` + + describeLeftovers(fromThisRun), + ); + } + if (problems.length) throw new Error(problems.join('\n')); + }; +} diff --git a/__tests__/docs-snippets/helpers/leaks.ts b/__tests__/docs-snippets/helpers/leaks.ts index e438601..2bb807b 100644 --- a/__tests__/docs-snippets/helpers/leaks.ts +++ b/__tests__/docs-snippets/helpers/leaks.ts @@ -19,13 +19,19 @@ export type Leftover = { /** * Decides which resources are leftovers. `matches` gets ids / names (and a poll's - * `created_by_id`); `channelIds` is needed because channels can't be searched by prefix. + * `created_by_id`). Channels can't be searched by id prefix: they are found by + * `channelIds`, and with `channelsCreatedSince` also by listing every channel created + * since then and keeping the ones whose id `matches`. */ export type LeftoverFilter = { matches: (idOrName: string) => boolean; channelIds?: string[]; + channelsCreatedSince?: Date; }; +/** Channel queries can't page past this offset. */ +const MAX_CHANNEL_OFFSET = 1000; + /** Hard deletes channels and waits for the background delete task. */ const hardDeleteChannels = async (client: StreamClient, cids: string[]) => { const { task_id } = await client.chat.deleteChannels({ @@ -92,25 +98,49 @@ const scanners: Record = { return found; }, - async channels(client, { channelIds = [] }) { - const found: Leftover[] = []; + async channels(client, { matches, channelIds = [], channelsCreatedSince }) { + const found = new Map(); + const add = (channel: { cid: string; created_at: Date }) => + found.set(channel.cid, { + kind: 'channel', + id: channel.cid, + createdAt: channel.created_at, + remove: () => hardDeleteChannels(client, [channel.cid]), + }); for (const ids of chunk(channelIds, 100)) { const { channels } = await client.chat.queryChannels({ filter_conditions: { id: { $in: ids } }, limit: 100, state: false, }); - for (const { channel } of channels) { - if (!channel) continue; - found.push({ - kind: 'channel', - id: channel.cid, - createdAt: channel.created_at, - remove: () => hardDeleteChannels(client, [channel.cid]), - }); + for (const { channel } of channels) if (channel) add(channel); + } + if (channelsCreatedSince) { + const limit = 30; + for (let offset = 0; ; offset += limit) { + if (offset > MAX_CHANNEL_OFFSET) { + throw new Error( + `more than ${MAX_CHANNEL_OFFSET} channels created since ${channelsCreatedSince.toISOString()}, the rest were not scanned`, + ); + } + const { channels } = await retryOnRateLimit(() => + client.chat.queryChannels({ + filter_conditions: { + created_at: { $gte: channelsCreatedSince.toISOString() }, + }, + sort: [{ field: 'created_at', direction: 1 }], + limit, + offset, + state: false, + }), + ); + for (const { channel } of channels) { + if (channel && matches(channel.id)) add(channel); + } + if (channels.length < limit) break; } } - return found; + return [...found.values()]; }, async channelTypes(client, { matches }) { diff --git a/__tests__/docs-snippets/helpers/retention.ts b/__tests__/docs-snippets/helpers/retention.ts new file mode 100644 index 0000000..1e7567d --- /dev/null +++ b/__tests__/docs-snippets/helpers/retention.ts @@ -0,0 +1,40 @@ +import type { RetentionPolicy, StreamClient } from '../../../index'; + +export type RetentionPolicyName = 'old-messages' | 'inactive-channels'; +export const RETENTION_POLICY_NAMES: RetentionPolicyName[] = [ + 'old-messages', + 'inactive-channels', +]; + +/** The app's retention policies, or `undefined` if the plan doesn't support them. */ +export const getRetentionPolicies = async (client: StreamClient) => { + try { + return (await client.chat.getRetentionPolicy()).policies; + } catch { + return undefined; + } +}; + +/** Puts back `original` and deletes every policy that wasn't in it. Returns the policies it changed. */ +export const restoreRetentionPolicies = async ( + client: StreamClient, + original: RetentionPolicy[], +) => { + const current = (await client.chat.getRetentionPolicy()).policies; + const changed: string[] = []; + for (const name of RETENTION_POLICY_NAMES) { + const before = original.find((p) => p.policy === name); + const now = current.find((p) => p.policy === name); + if (before && now?.config.max_age_hours !== before.config.max_age_hours) { + await client.chat.setRetentionPolicy({ + policy: name, + max_age_hours: before.config.max_age_hours, + }); + changed.push(name); + } else if (!before && now) { + await client.chat.deleteRetentionPolicy({ policy: name }); + changed.push(name); + } + } + return changed; +}; diff --git a/__tests__/docs-snippets/helpers/server.ts b/__tests__/docs-snippets/helpers/server.ts index 70e92ab..ea32562 100644 --- a/__tests__/docs-snippets/helpers/server.ts +++ b/__tests__/docs-snippets/helpers/server.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util'; import type { MessageRequest, QueryChannelsRequest, @@ -101,7 +102,8 @@ export const waitForAppSetting = async ( let streak = 0; for (let attempt = 0; attempt < attempts && streak < agreeing; attempt++) { const { app } = await serverClient.getApp(); - streak = app[key] === expected ? streak + 1 : 0; + // Deep compare: object and array settings (`file_upload_config`, `event_hooks`) too. + streak = isDeepStrictEqual(app[key], expected) ? streak + 1 : 0; if (streak === 0) await new Promise((resolve) => setTimeout(resolve, interval)); } @@ -111,3 +113,36 @@ export const waitForAppSetting = async ( ); } }; + +/** + * `waitForAppSetting` only proves that `getApp` agrees: other endpoints of some API nodes + * keep the old `multi_tenant_enabled` value for a while longer. Polls `listUserGroups` + * with `team_id` until `agreeing` calls in a row accept it (`supported`) or reject it + * with the multi-tenancy error. Call it after `waitForAppSetting`, both after enabling + * multi-tenancy and after restoring it. + */ +export const waitForTeamIdSupport = async ( + serverClient: StreamClient, + teamId: string, + supported: boolean, + { agreeing = 10, timeout = 90000, interval = 500 } = {}, +) => { + const startedAt = Date.now(); + let streak = 0; + while (streak < agreeing) { + if (Date.now() - startedAt > timeout) { + throw new Error(`team_id support never settled on ${supported}`); + } + let accepted = true; + try { + await serverClient.listUserGroups({ limit: 1, team_id: teamId }); + } catch (error) { + if (!(error instanceof Error && /multi-tenancy/.test(error.message))) + throw error; + accepted = false; + } + streak = accepted === supported ? streak + 1 : 0; + if (streak === 0) + await new Promise((resolve) => setTimeout(resolve, interval)); + } +}; diff --git a/__tests__/docs-snippets/helpers/wait.ts b/__tests__/docs-snippets/helpers/wait.ts index 609502f..e064606 100644 --- a/__tests__/docs-snippets/helpers/wait.ts +++ b/__tests__/docs-snippets/helpers/wait.ts @@ -50,8 +50,14 @@ export const retryOnRateLimit = (fn: () => Promise) => retry(fn, { timeout: 90000, interval: 5000, retryIf: isRateLimited }); /** ": channel type does not exist" - returned until a new channel type has propagated. */ -export const isChannelTypeMissing = (error: unknown) => - errorMessage(error).includes('channel type does not exist'); +export const isChannelTypeMissing = (error: unknown) => { + const message = errorMessage(error); + // Messages differ per endpoint, e.g. deleteChannelType: " is not a defined channel type". + return ( + message.includes('channel type does not exist') || + message.includes('is not a defined channel type') + ); +}; /** How long channel type changes take to reach every API node. */ export const CHANNEL_TYPE_PROPAGATION_MS = 30000; @@ -94,3 +100,30 @@ export const waitForTask = async ( } return task; }; + +/** + * Polls a channel batch update task (`updateChannelsBatch`) until the inner + * `result.status` is `completed` and `result.finished_at` is set: its top-level `status` + * reaches `completed` before the channels are updated, so `waitForTask` isn't enough. + * A batch started right after another one can take ~60s: raise the test's timeout. + */ +export const waitForBatchTask = ( + client: StreamClient, + taskId: string, + timeout = 150000, +) => + retry( + async () => { + const task = await client.getTask({ id: taskId }); + if (task.status === 'failed') { + throw new Error(`task ${taskId} failed: ${JSON.stringify(task)}`); + } + if (task.result?.status !== 'completed' || !task.result?.finished_at) { + throw new Error( + `batch ${taskId} is not finished yet: ${JSON.stringify(task)}`, + ); + } + return task; + }, + { timeout, interval: 1000 }, + ); diff --git a/__tests__/docs-snippets/learnings.md b/__tests__/docs-snippets/learnings.md index 3f4b4e1..0303741 100644 --- a/__tests__/docs-snippets/learnings.md +++ b/__tests__/docs-snippets/learnings.md @@ -5,12 +5,168 @@ Entries are added only after the user approves them (agents propose them in thei ## Setup and tooling +- A fence that creates a channel type and uses it right away: spy on `client.chat.createChannelType` with a `mockImplementation` that awaits the bound original and then `waitForChannelTypePropagation()`, so the region runs as written without "channel type does not exist" from another API node. + +- When a fence's pagination branch (`if (page1.next) { ... }`) needs more results than the docs `limit`, COPY-map the limit to a small number (`pageSize=10` → 1) and create just enough items so the branch runs. + +- Fences that start with `import` statements are module-level regions. If two fences import the same names, put them in separate test files (`.test.ts`, `..test.ts`): duplicate import bindings in one module are a SyntaxError. +- Snippets that read local files (`./image1.jpg`): write small fixtures to an `os.tmpdir()` dir (a base64 JPEG works for image uploads) and COPY-map the paths (`image1Path="./image1.jpg"`). +- If module-level setup or a module-level region throws, the file fails while loading and none of its hooks run (no cleanup, no per-file leak check). The end-of-run sweep (`global-setup.ts`) then removes what it created and fails the run with a `LEAK` list: fix the load-time error, the leftovers are already gone. + +- A spy whose `mockImplementation` calls the bound original must be restored even if the test fails: add `afterEach(() => vi.restoreAllMocks())`. Otherwise the next test binds the old spy, and its own spy recurses until "Maximum call stack size exceeded". +- In zsh, `echo =====` fails ("not found": `=word` is command expansion) and stops the rest of a `;` chain. Use another separator (`echo ----`). BSD `sed` basic regex has no `\|` alternation (`sed '/a\|b/d'` silently matches nothing): use `sed -E '/(a|b)/d'` or `grep -v`. + +- To check state between calls inside a region (e.g. after each of several `hide()` calls), spy on the generated method (`client.chat.hideChannel`) with a `mockImplementation` that awaits the bound original, records a probe query, and returns the response. The region stays as is, and `mock.calls[i][0]` is typed (no cast). + +- To tell an API behavior from a snippet bug, write a throwaway probe test under `pages/` that logs the results of request variants (e.g. with and without a flag). It still runs the leak check, so name and register what it creates; delete the file before finishing. + +- To assert on calls a region awaits without keeping the result, `vi.spyOn(client.chat, '')` (no mock implementation, calls go through) before it and await `spy.mock.results.map((r) => r.value)` after it. `StreamChannel` methods call `client.chat.*`, so the spy sees them too. `mock.results[i].value` is `any`: cast it (`r.value as Promise`); a return-type annotation still fails `no-unsafe-return`. + +- A region whose last call rejects (e.g. "a missing channel returns a 404"): don't wrap it in `try/catch` (its `const`s would be out of scope after it). Spy on the method with `mockImplementation` that calls the original (`client.chat.getChannel.bind(client.chat)`), records the error for that one request and returns `undefined as never`, rethrowing anything else. The region then runs as is and its results are asserted directly after it. Assert on the spy's calls before `mockRestore()` (it clears them). Example: `pages/03-channels/08-get_channel.test.ts`. + +- A snippet that assigns a result it never reads (`const update = await channel.update(...)`) fails `unused-imports/no-unused-vars`: assert on that variable after the region instead of disabling the rule. +- COPY replaces the identifier everywhere in the region that isn't a property access or object key, including comments and strings (`sender="..."` also rewrote `{{sender.id}}`). Pick COPY names that appear nowhere else in the fence (`campaignSender`). +- `DOCS_SYNC_WRITE` keeps line breaks that the test's prettier added because of longer COPY names, and docs prettier doesn't rejoin them. Check `git diff` after writing and rejoin untouched lines by hand (the sync check ignores wrapping). +- To read the result of a call the region doesn't keep, spy on the method and `await` its `mock.results[0].value` (a promise for async methods), e.g. `client.updateLiveLocation`. +- Endpoints with very low rate limits (`queryMessageHistory`: 6/min, code 9 / 429) fail on back-to-back runs: spy on the method with a `mockImplementation` that calls the bound original through `retryOnRateLimit` (`helpers/wait.ts`), so the region stays as written. +- `expect.any(...)` inside `toEqual` / `objectContaining` fails `@typescript-eslint/no-unsafe-assignment`: use `toMatchObject` for the literal fields and `typeof x?.field` checks for the rest. The same goes for `expect.objectContaining` / `expect.stringContaining` on an `unknown` error caught in a spy: use `toMatchObject({ code })` and `expect(String(error)).toContain(...)`. +- A fence before the page's first heading uses `heading=""` in its marker. To COPY-map a docs string that contains a test id (`"/ticket suspicious transaction with id 1234"`), map the whole string to a variable built from the id (`ticketText="..."`). +- Two dependent fences that both declare the same `const` (e.g. `response`) can share one `it`: put the second region in its own `{ ... }` block; it still sees the outer variables. + ## Server client (`@stream-io/node-sdk`) +- `createChannelType` doesn't take every field `updateChannelType` does (e.g. `reminders`, `grants`, TS2353): create with the required fields only, assert the default, then update. To restore an app setting (e.g. `reminders_interval`), save `getApp().app.` and `cleanup.add` an `updateApp` with it followed by `waitForAppSetting(client, '', original)`. + +- Attachments in node SDK requests require `custom` (TS2741 otherwise): `{ type, image_url, custom: {} }`. + +- Invites in setup: `getOrCreate({ data: { invites: [{ user_id }] } })` creates a pending invite; `channel.update({ accept_invite: true, user_id })` / `reject_invite` answers it. `queryChannels({ filter_conditions: { invite: 'pending' | 'accepted' | 'rejected' }, user_id })` returns only the matching channels: assert the exact id list. + +- Adding members to an existing channel in setup: `channel.update({ add_members: [{ user_id }] })`. `getOrCreate({ data: { members } })` only applies `members` when it creates the channel. + +- Failed requests throw `StreamError`: `code` is Stream's error code, the HTTP status is `metadata.responseCode`. Import `StreamError` from the package entry (`index.ts`). + +- Custom data is under `custom` in requests and responses (channel `data.custom.name` in, `channel.custom.name` out; same for users, members, messages, attachments), not at the top level. +- Distinct channels (`client.chat.channel(type)` with no id, then `getOrCreate({ data: { members } })`) get an id hashed from the members, which the leak check can't find. `StreamChannel.getOrCreate` sets `channel.id` from the response, so push `channel.cid` to `cleanup.channels` right after the region, and use `uniqueId` members so the hash is unique per run. +- `ChannelResponse` has no `config_overrides` (TS2339): assert an override through the merged `channel.config.` from `getOrCreate({})`. +- `client.updateUsers({ users: { : { id } } })` uses `id` and ignores the map key: a docs literal key (`userId: { id: "userId" }`) can stay while `id` is COPY-mapped to a `uniqueId`. +- Server-side `getDraft`, `deleteDraft` and `queryDrafts` need `user_id` (code 4 "either user or user_id must be provided when using server side auth"), although the SDK types mark it optional: a Node.js fence without it is a docs bug. +- A fence that builds a request object in a separate `const` and passes it later fails TS2322 when a field is a string-literal union (`callback.mode: "CALLBACK_MODE_REST"` widens to `string`). The plain-JS fix is to move the object inline into the call. +- `updateChannelType` returns `commands` as names (`string[]`), while `getChannelType` / `listChannelTypes` return `Command` objects: compare by `.name`. `commands: ["all"]` expands to every built-in command (giphy, ban, unban, mute, unmute). +- A `vi.spyOn(client, 'updateApp').mockImplementation(...)` must take `request?: UpdateAppRequest` (optional), or it fails TS2345. +- A correct fence that the SDK types reject can't be parked with `it.skip`: skipped bodies are still typechecked, so `yarn types:docs` fails for everyone. Stop and report it as a Possible SDK issue. The generator drops `nullable` everywhere (no `| null` in `src/gen/models`); `grants` on `UpdateChannelTypeRequest` / `UpdateAppRequest` is patched locally to allow `null`. +- `verifyAndParseWebhook` fences: build `req = { rawBody, headers: { 'x-signature': sig } }` before the region, where `sig` is the HMAC-SHA256 hex of the uncompressed JSON keyed with `STREAM_SECRET` and `rawBody` is the JSON or `zlib.gzipSync(json)` (the signature always covers the decompressed bytes). +- Optional response fields (`GetTaskResponse.result`, `MessageResponse.i18n`) aren't narrowed by a status check: a fence reading `response.result.url` fails TS18048, and the docs fix is `?.`. +- `getApp().app.enforce_unique_usernames` is typed `string` but `updateApp` expects `'no' | 'app' | 'team'`: cast the saved value when restoring it. `"team"` is accepted with multi-tenancy off. + ## API and SDK behavior +- Reminders need `user_message_reminders` on the channel (code 4 "reminders are not enabled for this channel"): `updateChannelPartial({ set: { config_overrides: { user_message_reminders: true } } })` on a `messaging` channel works immediately, no channel type change. `remind_at` must be at least one minute in the future on create and update: for an "overdue" filter, create the reminder ~62s ahead at the start of `beforeAll` and put slow setup after it so the wait overlaps. One reminder per user and message. + +- Search indexing is eventually consistent: in setup (before the region), `retry` a `client.chat.search` until it returns the messages the test sent, so the region runs once and can be asserted exactly. Autocomplete matches across fences ("supercali" matches "supercalifragilistic..."): give each search test its own channel. + +- A partial message update with `set: { pinned: true }` keeps an existing `pin_expires`: to pin with no expiry, also set `pin_expires: null`. + +- Reactions: a `score` sent with `sendReaction` replaces the user's earlier score for that type (a clap of 5 then 25 gives 25, also in `reaction_groups..sum_scores`). Each user has at most one reaction per type, so for many reactions on one message (pagination), send different types (`type${i}`) from one user. + +- A thread's `created_by_user_id` (`queryThreads` filters, `ThreadStateResponse`) is the author of its first reply, not of the parent message: map a docs "creator" literal to the first replier. + +- Server-side uploads (`uploadChannelImage`, `uploadChannelFile`, `uploadImage`, `uploadFile`) need `user: { id }` (code 4 "bad user id" without it). +- To prove a delete worked, assert that deleting the same thing again rejects with `{ code: 16, metadata: { responseCode: 404 } }`. + +- Server-side URL enrichment is asynchronous: the `sendMessage` response has `attachments: []`; `retry` a `getMessage` until `attachments[0]` exists before asserting `og_scrape_url`. +- A hard-deleted message is 404 (code 16 "message with id ... doesn't exist") for any later message call. +- Partial message updates address custom fields by bare name: `unset: ["color"]`, `set: { "details.status": ... }` act on `message.custom` (create the message with `custom: { color, details }` to prove it). + +- Channel batch update tasks: the top-level `getTask` status reaches `completed` before the channels are updated, so `waitForTask` isn't enough: wait for `result.status === 'completed'` and `result.finished_at` (`waitForBatchTask` in `helpers/wait.ts`). A batch started right after another can take ~60s: raise that test's timeout. + +- `truncate({ truncated_at })` must be more recent than the channel's previous truncation (code 4 `"truncated_at" field must be more recent than previous one`). A fence that truncates a channel and then truncates it again with a past `truncated_at` is a docs bug: give that call its own channel. + +- A grant snippet (adding a permission to a role of a built-in channel type) only proves something if the role didn't have it: assert `not.toContain(permission)` on the original grants first. `updateChannelType({ grants: { : [...] } })` only changes the roles it names, so save and restore just that role (`cleanup.add`, then `waitForChannelTypePropagation()`, as `grantMessagingMembers` does). + +- A soft-deleted channel is 404 (code 16 "Can't find channel") for single-channel endpoints, e.g. a following `channel.delete({ hard_delete: true })`. Batch `deleteChannels({ hard_delete: true })` works on soft-deleted channels. A fence that soft- then hard-deletes the same channel is a docs bug: give the hard delete its own channel. +- `skip_truncate` (`channel.delete`, `deleteChannels`) only works on distinct channels (`client.chat.channel('messaging').getOrCreate({ data: { members } })`). To prove history was kept, send a message first, then recreate the channel with the same members and `state: true` and check `messages`. +- Server-side `deleteChannels` returns a `task_id` for soft and hard deletes: `waitForTask` on it before asserting. + +- `muteChannel` with one cid in `channel_cids` returns the mute as `channel_mute` (`channel_mutes` is absent): assert on `channel_mute` or `own_user.channel_mutes`. + +- To check what one member sees (`hide_history`, per-member truncation, ...), query as that member: `getServerChannel(client, cid, { user_id, message_limit: 10 })` (`helpers/server.ts`) applies their view, e.g. only messages sent after they joined with `hide_history`. + +- Server-side `channel.getOrCreate` paginates only with `state: true`: without it the response has no messages, `members` ignores `limit`/`offset`, and `watchers` is never returned. A snippet that reads messages or paginates through `getOrCreate` without `state: true` is a docs bug. + +- Server-side sorting on `user_id` uses a collation that ignores punctuation, so it can differ from JS `.sort()` when one id prefix starts another (`uniqueId('tom')` vs `uniqueId('tommaso')`: "tommaso" before "tom-"). In sort assertions, use `uniqueId` prefixes that sort the same both ways (`thierry`, `tomas`, `tommaso`). + +- A full `channel.update({ data: { custom } })` replaces the channel's custom data: fields not in the request are deleted. To prove it, create the channel with an extra custom field and check it's gone from `channel.custom` after the snippet. + +- Server-side `queryChannels` filters on per-user fields (`hidden`, `muted`) need a `user_id` (code 4 "you cannot query for hidden channels without a user_id"). Per-user response fields (`pending_messages`) likewise only come back with a `user_id`: `queryChannels({ ..., user_id })` or `channel.get({ state: true, user_id })` (`get` without `state: true` returns no channel state). A Node.js fence that reads them without `user_id` is a docs bug. +- `queryChannels` returns 10 channels by default: a snippet without `limit` on more than 10 test channels returns only 10, so assert the count, not the full set. +- Predefined filters can be created from the test: `client.chat.createPredefinedFilter({ name: uniqueId(...), operation: 'QueryChannels', filter: { ..., members: { $in: ['{{user_id}}'] } } })`. COPY the name to the docs literal and register `cleanup.add(() => client.chat.deletePredefinedFilter({ name }))`. + +- App setting changes (e.g. `multi_tenant_enabled`) reach API nodes over a few seconds, and `waitForAppSetting` (it only reads `getApp`) can pass while other endpoints still use the old value. After changing and after restoring, also wait until the endpoint you use agrees several times in a row (for multi-tenancy: `waitForTeamIdSupport` in `helpers/server.ts`). +- `team_id` and multi-tenancy: with it off, `team_id` is rejected ("team_id is not supported when multi-tenancy is not enabled", code 4); with it on, it is required. Run the `team_id` snippets in a nested `describe` that turns multi-tenancy on and restores it, and register the team resources' deletion after the restore step (`cleanup.add` runs in reverse, so they are deleted while multi-tenancy is still on). +- Campaigns: a campaign can only be started once (code 4 "cannot be started"), and `scheduled_for` must be in the future. A "start now" campaign shows `scheduled` briefly before `in_progress`/`completed`. A running campaign can't be deleted and a completed one can't be stopped, so cleanup retries stop, then delete. A segment can't be deleted while a campaign uses it: delete campaigns first. +- Polls work on the built-in `messaging` type with no config change. A vote needs the poll sent in a message first (`sendMessage` with `poll_id`, by the poll's creator). A full `updatePoll` resets every field it doesn't send (create with a `description`, assert `''` after); partial `unset: ["custom_property"]` acts on `poll.custom` by bare name. +- An app-level feature that is off fails with code 17 (403) "... is not enabled for this app, please contact support": that's a ⛔ blocker (`it.skip('BLOCKED: ...')`). Probe it with one plain call before writing setup. Channel-level flags for it (`shared_locations: true` in `config_overrides` or on a channel type) are accepted even then, so a passing "enable" test proves nothing on its own. With the feature on, a channel without the flag fails with code 17 "... is not enabled for this channel": assert that before the "enable" fence and the success after it. +- `translateMessage` works with the app's `auto_translation_enabled` off: no app setting change is needed to test it. +- With slow mode off, channel responses may omit `cooldown` instead of returning 0: assert `channel?.cooldown ?? 0`. +- `queryDrafts` returns the newest draft first (default and `sort: [{ field: 'created_at', direction: -1 }]`): create drafts in a known order and assert the exact `channel_cid` order. +- Restricted visibility (`restricted_visibility: [userId]`, enabled on the test app): compare `getServerChannel(client, cid, { user_id, message_limit })` for a listed member and for an unlisted one. Only users without `ReadRestrictedVisibilityMessage` prove the hiding: admins have it by default and always see the message, so the unlisted viewer must be a plain `user`. +- When a page says an app setting adds a field to responses, check the field too, not only the setting read back (push and webhook settings excepted). `user_response_time_enabled`: a member's reply to another user's message sets the replier's `avg_response_time` within a few seconds (`retry` `getServerUser`). +- Pending messages (enabled on the test app): a second `commitMessage` fails with code 4 "is no longer pending", soft-deleting a pending message fails with code 4 "cannot soft delete a pending message", and server-side `getMessage` / `getManyMessages` return pending messages. +- `shared_location` is missing from server-side `getMessage`, from `queryChannels` without `user_id` and from `getOrCreate({ state: true })`. Read it with `getServerChannel(client, cid, { user_id, message_limit })` or `getUserLiveLocations({ user_id })`. +- Message history entries hold the version before each update, attributed to that version's author (`message_updated_by_id`), not to the user whose update replaced it. +- Retention policy run history is empty for a new policy (the first cleanup runs after 24 hours): the run query can only be asserted as an array whose items all match the policy filter. +- A new channel type starts with empty `guest` grants (no `messaging` defaults). To test removing guest grants on a `uniqueId` type, first copy the guest grants from the built-in type (read only), then `waitForChannelTypePropagation()`; with create + update waits, give that `beforeAll` a 180s timeout. +- Uploads are checked by content as well as extension: a tiny CSV (`a,b`) is read as `text/plain` and fails "File type text/plain is not supported" under `allowed_mime_types: ["text/csv"]`. Use a few rows of real CSV. +- To prove `disable_auth_checks`, send a dev token (`
..devtoken`, base64url JSON) to `POST /channels?api_key=...` with `stream-auth-type: jwt`: 201 while checks are off, 401 when on. The 401 message differs per API node ("development tokens are not allowed for this application" or "Token signature is invalid", the latter for over a minute on some nodes): accept both, and `retry` until the status matches. +- `file_upload_config` / `image_upload_config` come back with every list present (`[]`) and `size_limit: 0`: restore the saved original and `waitForAppSetting` (deep compare) for it. +- A new custom role isn't known to every API node right away: `updateUsersPartial({ set: { role } })` just after `createRole` can fail with code 4 `role "..." provided but not defined`. Spy on `client.updateUsersPartial` with a `mockImplementation` that calls the bound original through `retry(..., { retryIf: })`. `deleteRole` right after `createRole` fails the same way with `role "..." does not exist`: retry it like that on `'does not exist'`. +- `updateChannelType({ grants: null })` resets grants to the API defaults, which differ from a new type's initial grants (they add e.g. `create-attachment`, `send-poll`, `create-mention`): assert against the update response. New and built-in types start with `guest: []` and `anonymous: []`: to prove a removal snippet works, give those roles grants first. +- `updateChannelPartial({ set: { config_overrides: {...} } })` replaces the whole overrides object, it doesn't merge: a later override drops the earlier ones. With `config_overrides.replies: false`, a reply (`parent_id`) fails with code 19 (400), a direct check for that override. +- Channel-level `blocklist` overrides aren't validated (a nonexistent name is accepted) and, on this moderation-enabled app, don't block messages: only the config read back can be asserted. `createChannelType` with a just-created blocklist can fail with "invalid block list name" for ~20s. +- Firebase calls validate the credentials on every request: `upsertPushProvider` and `updateApp({ firebase_config })` fail with code 4 "firebase credentials are invalid" without a real service account, even when only a template changes, so the whole test goes in `describe.skipIf(!hasFirebaseCredentials)`. On this push v3 app `getApp().app.push_notifications` legacy Firebase config is just `{ enabled: false }` and credentials are never returned: cleanup can only restore templates and the enabled flag. +- Export tasks (`exportChannels`, `exportUsers`) finish within seconds: `waitForTask` is enough, and `result.url` downloads with plain `fetch` (no auth), so assert the export's contents (v2: one JSON object per line). +- `client.moderation.flag`: a repeat flag by the same user on the same entity raises `flags_count`, but the review item's `flags` keep only the first one (later `reason` / `custom` are dropped). A fence that flags one placeholder id several times from one user with different reasons is a docs bug: give each call its own entity id. Check flags by `retry`ing `client.moderation.queryReviewQueue({ filter: { entity_id } })` until it returns one item (about a second), then assert `items[0].flags[i]`. + ## Server-side vs client-side +- Client-only setup (e.g. creating a draft) can be done with `fetch` as the user: `POST https://chat.stream-io-api.com/channels/{type}/{id}/draft?api_key=...` with headers `Authorization: createUserToken(userId)`, `stream-auth-type: jwt` and body `{ message: { text, parent_id? } }` (example: `pages/05-features/10-advanced/12-drafts.test.ts`). + ## Docs conventions +- A fence that assigns a value it never reads, where the test can't read it either (block-scoped, e.g. `if (page1.next) { const page2 = ... }`), fails `no-unused-vars`. Fix the docs minimally by logging the field the page talks about (`console.log(taskResponse.result?.status)`, `console.log(page2.threads)`). Never use `eslint-disable`. In the test, assert the variables that are in scope after the region directly, and read the block-scoped value from the log: `vi.spyOn(console, 'log').mockImplementation(() => undefined)` before the region, `log.mock.calls[0]` after it (example: `pages/04-messages/03-threads.test.ts`, "Supported Sort Fields"). An unused catch parameter (`catch (err) { // comment }`) fails the same rule (`caughtErrors: "all"`): add `console.error(err)` and, in the test, spy on `console.error`. + +- A fence that runs conflicting operations on one placeholder id (soft- then hard-delete a channel, truncate again with an earlier `truncated_at`, delete a message after hard-deleting it) is a docs bug: give the later call its own id (`otherChannel` / `"random"`, `anotherMessageID`), reusing a name another tab already uses when there is one. + +- A fence that reuses the same placeholder ids for a call that needs a different resource (e.g. `deleteChannels({ cids: [cid1, cid2], skip_truncate: true })` after hard-deleting `cid1`/`cid2`, "distinct channels only") is a docs bug: give that call its own ids (`distinctCid1`, `distinctCid2`). Don't swap request fields in a spy to make it pass. + +- Undefined app-supplied values in a fence (`userId`, `channelId`, ...) aren't docs bugs: define them in the test just before the region and leave the docs as they are. + +- A docs fix is the code change only: don't add explanatory comments to the fence (e.g. why `user_id` is needed). Put the reason in the report. + ## Isolation and cleanup + +- Per-user data (reminders, drafts, ...) is easiest to assert exactly with one `uniqueId` user per test, each COPY-mapped to the docs' `"user-id"` literal. + +- To prove something does NOT happen (e.g. a silent message doesn't raise the unread count), add a positive control: read the member's `unread_messages` from `getServerChannel(client, cid, { user_id })` (`read` array), then send a normal message and `retry` until it goes up by 1, so a 0 can't come from a lagging counter. +- Unread counts: `client.chat.unreadCounts({ user_id }).total_unread_count` is user-wide (across all that user's channels, so give each test its own `uniqueId` user for an exact total), `getServerChannel(client, cid, { user_id }).read` is per channel, and `channel.get({ state: true }).read` lists every member. They update within a second of a send or mark read/unread (`retry` is enough). `markUnread({ message_id, user_id })` marks that message and every later one unread: mark the channel read first, then assert the count equals the messages from that one onwards. + +- A `DRIFT` that lists every built-in channel type with the same change the page never made (e.g. `grants` gaining `anonymous: []`) comes from outside the file: an app-wide change still propagating, or another suite (`yarn test`) on the same app. Check with repeated `listChannelTypes()` calls and rerun once it settles; don't add restore code for settings the page doesn't touch, and report it. + +- Snippets that target every channel of a built-in type (`filter: { types: { $in: ["messaging"] } }`) must not run as written: COPY-map the type name to a `uniqueId` channel type (create it, push it to `cleanup.channelTypes`, `waitForChannelTypePropagation()`). The same applies to snippets that change a built-in type (`updateChannelType({ name: "messaging", ... })`): run them on a `uniqueId` type via COPY (`channelType="messaging"`), so nothing built-in needs restoring. + +- Short-lived state from an earlier test (e.g. the docs' 10-second mute) can expire before a later test uses it: recreate it outside the region in the test that needs it. + +- To prove a sort snippet does the work (e.g. `pinned_at: -1` first), create the item it should put first before the other one: without the sort the newer one comes first, so `toEqual([expected, other])` only passes with the sort. + +- `uniqueId(...)` names must be kebab-case (no spaces or `&`): the leak check passes every minted id to a channel `id: { $in }` query, which fails with "invalid id value" and leaves that scan undone. Put docs literals like `"Design Team"` in a COPY variable. +- Docs pagination cursors (`id_gt: "last-id"`, `name_gt: "abc"`) can filter out the test's own resources: map them to `''` with a COPY variable so the assertions see what the test created. +- Docs segment filters ("all users created after 2020") target the whole app: COPY-map them to something only the test's users match (the test start time, a `uniqueId` custom field value) before a campaign is started. +- Channels the API creates from templates (campaign channels named `{{receiver.id}}-{{sender.id}}`) aren't found by the leak check: find them by member in cleanup and push them to `cleanup.channels`. Keep the `uniqueId` names short so the combined id stays under 64 characters. +- `queryPolls` filters (`is_closed: true`) match every poll in the app. When the fence can't be scoped by COPY, create two matching polls and assert their relative order under the docs sort instead of the full list. +- `event_hooks` changes: save `getApp().app.event_hooks` before the region and `cleanup.add` an `updateApp({ event_hooks: original })` followed by `waitForAppSetting(client, 'event_hooks', original)`; passing the returned hooks back keeps their ids (no DRIFT). +- Tests that change the same channel type in setup and again in the snippet need two `waitForChannelTypePropagation()` waits (~60s): give them an explicit 150s timeout (the default is 90s). +- App-level resources that the leak and drift checks don't track (e.g. retention policies): save the original list in `beforeAll`, and `cleanup.add` a restore that puts the originals back and deletes anything the file added. Check that the app is back to its original state after the run. +- A role can't be deleted while a user has it: in cleanup, set that user's role back to `'user'` before `deleteRole` (`Cleanup` deletes users only after the custom steps). +- Unset optional app settings (`before_message_send_hook_url`, `..._attempt_timeout_ms`) are absent from `getApp()`; restoring with `''` / `0` makes them absent again, so `waitForAppSetting(client, key, undefined)` settles with no DRIFT. +- Custom commands are app-wide: give them `uniqueId` names (hyphens are accepted). A command used by a channel type can't be deleted (code 4 "command in use by channel type ..."), and `Cleanup` runs custom steps before deleting channel types: set the type's `commands` to `[]` first (`updateChannelType` helper), then delete the command, retrying on "in use". +- `queryReviewQueue` filters by `entity_type` match the whole app: flag a fresh message just before the region, assert it is `items[0]` under the docs sort and that every item has that `entity_type`. diff --git a/__tests__/docs-snippets/maintenance/sweep.ts b/__tests__/docs-snippets/maintenance/sweep.ts index eb60647..0728416 100644 --- a/__tests__/docs-snippets/maintenance/sweep.ts +++ b/__tests__/docs-snippets/maintenance/sweep.ts @@ -7,9 +7,13 @@ import { removeLeftovers, } from '../helpers/leaks'; import type { Leftover } from '../helpers/leaks'; +import { + getRetentionPolicies, + restoreRetentionPolicies, +} from '../helpers/retention'; -// Finds (and by default removes) everything with the docs-test prefix, e.g. after a -// crashed run. Run with `yarn test:docs:sweep`. +// Finds (and by default removes) everything with the docs-test prefix, and turns off +// retention policies, e.g. after a crashed run. Run with `yarn test:docs:sweep`. // DOCS_SWEEP_DRY_RUN=1 only list what would be removed // DOCS_SWEEP_MIN_AGE=0 minutes; skip younger resources so runs in progress are left alone (default 30) const dryRun = process.env.DOCS_SWEEP_DRY_RUN === '1'; @@ -30,13 +34,26 @@ it('sweeps docs-test leftovers', async () => { const { leftovers, errors } = await findLeftovers(serverClient, { // `includes`: guest user ids are rewritten to `guest--docs-test-...`. matches: (id) => id.includes(DOCS_TEST_PREFIX), - // Channels can't be searched by id prefix. Channels of docs-test users go away with - // the users (conversations: 'hard') and channels of docs-test types with the types; - // anything else must be found by the per-file leak check. - channelIds: [], + // Channels can't be searched by id prefix: list every channel and keep the docs-test + // ones (they can outlive their creator, e.g. when a file crashed before cleanup). + channelsCreatedSince: new Date(0), }); if (errors.length) console.warn(`could not scan:\n ${errors.join('\n ')}`); + + // Retention policies have fixed names, so a docs-test one can't be told apart from + // another; the docs test app has none (see the todo's App facts), so turn off any that + // are set. They delete old messages and inactive channels on the shared app. + const policies = (await getRetentionPolicies(serverClient)) ?? []; + if (policies.length) { + console.log( + `${dryRun ? 'Would delete' : 'Deleting'} ${policies.length} retention policy(s):\n${policies + .map((p) => ` - ${p.policy}: max_age_hours ${p.config.max_age_hours}`) + .join('\n')}`, + ); + if (!dryRun) await restoreRetentionPolicies(serverClient, []); + } + const toRemove = leftovers.filter(isOldEnough); const skipped = leftovers.length - toRemove.length; diff --git a/__tests__/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts b/__tests__/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts new file mode 100644 index 0000000..51ab538 --- /dev/null +++ b/__tests__/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts @@ -0,0 +1,256 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { waitForAppSetting, waitForTeamIdSupport } from '../../helpers/server'; + +const DOCS = '_default/02-init_and_users/04-user_groups.md'; + +const isNotFound = (error: unknown) => + error instanceof Error && /not.?found|404/i.test(error.message); + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const alice = uniqueId('alice'); + const bob = uniqueId('bob'); + const charlie = uniqueId('charlie'); + const dave = uniqueId('dave'); + const eve = uniqueId('eve'); + const frank = uniqueId('frank'); + const grace = uniqueId('grace'); + const userIds = [alice, bob, charlie, dave, eve, frank, grace]; + + /** The group the "design-team" snippets without team_id act on. */ + const groupId = uniqueId('design-team'); + const groupName = uniqueId('design'); + + /** Registers the deletion of a user group, ignoring "not found" (a snippet may have deleted it). */ + const registerGroup = (id: string, team_id?: string) => + cleanup.add(async () => { + try { + await client.deleteUserGroup({ id, team_id }); + } catch (error) { + if (!isNotFound(error)) throw error; + } + }); + + const memberIdsOf = async (id: string) => { + const { user_group } = await client.getUserGroup({ id }); + return (user_group?.members ?? []).map((m) => m.user_id).sort(); + }; + + beforeAll(async () => { + cleanup.users.push(...userIds); + await client.upsertUsers(userIds.map((id) => ({ id }))); + registerGroup(groupId); + await client.createUserGroup({ id: groupId, name: groupName }); + }); + + afterAll(() => cleanup.run()); + + it('creates a user group', async () => { + const designTeamName = uniqueId('design-team-name'); + + // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Creating a User Group" tab="Node.js" index=1 + // COPY: designTeamName="Design Team", alice="alice", bob="bob", charlie="charlie" + const { user_group } = await client.createUserGroup({ + name: designTeamName, + description: 'Product design team members', + member_ids: [alice, bob, charlie], + }); + // #endregion snippet + + expect(user_group).toBeDefined(); + if (!user_group) return; + registerGroup(user_group.id); + expect(user_group.name).toBe(designTeamName); + expect(user_group.description).toBe('Product design team members'); + expect(await memberIdsOf(user_group.id)).toEqual( + [alice, bob, charlie].sort(), + ); + }); + + it('updates a user group', async () => { + const newName = uniqueId('design-product-team'); + + // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Updating a User Group" tab="Node.js" index=1 + // COPY: groupId="design-team", newName="Design & Product Team" + const { user_group } = await client.updateUserGroup({ + id: groupId, + name: newName, + description: 'Product and design team members', + }); + // #endregion snippet + + expect(user_group?.name).toBe(newName); + expect(user_group?.description).toBe('Product and design team members'); + }); + + it('adds members to a user group', async () => { + // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Adding Members" tab="Node.js" index=1 + // COPY: groupId="design-team", dave="dave", eve="eve", frank="frank", grace="grace" + // Add members as regular members + const { user_group } = await client.addUserGroupMembers({ + id: groupId, + member_ids: [dave, eve, frank], + }); + + // Add members as group admins + const { user_group: admins } = await client.addUserGroupMembers({ + id: groupId, + member_ids: [grace], + as_admin: true, + }); + + // Promote an existing member to admin + await client.addUserGroupMembers({ + id: groupId, + member_ids: [dave], + as_admin: true, + }); + + // Demote an admin back to regular member + await client.addUserGroupMembers({ + id: groupId, + member_ids: [dave], + as_admin: false, + }); + // #endregion snippet + + expect(user_group?.id).toBe(groupId); + expect(admins?.id).toBe(groupId); + const { user_group: group } = await client.getUserGroup({ id: groupId }); + const isAdmin = Object.fromEntries( + (group?.members ?? []).map((m) => [m.user_id, m.is_admin]), + ); + expect(isAdmin).toEqual({ + [dave]: false, + [eve]: false, + [frank]: false, + [grace]: true, + }); + }); + + it('removes members from a user group', async () => { + await client.addUserGroupMembers({ + id: groupId, + member_ids: [dave, eve, frank], + }); + + // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Removing Members" tab="Node.js" index=1 + // COPY: groupId="design-team", dave="dave", eve="eve" + const { user_group } = await client.removeUserGroupMembers({ + id: groupId, + member_ids: [dave, eve], + }); + // #endregion snippet + + expect(user_group?.id).toBe(groupId); + const members = await memberIdsOf(groupId); + expect(members).toContain(frank); + expect(members).not.toContain(dave); + expect(members).not.toContain(eve); + }); + + // The snippets that pass team_id fail without multi-tenancy ("team_id is not supported + // when multi-tenancy is not enabled"). Enable it for these tests only, on groups of a + // `uniqueId` team. Cleanup steps run in reverse, so these groups are deleted before + // multi-tenancy is restored. + describe('with multi-tenancy', () => { + const teamId = uniqueId('my-team'); + const teamGroupId = uniqueId('design-team'); + const teamGroupName = uniqueId('design'); + + const createTeamGroup = async (id: string, name: string) => { + registerGroup(id, teamId); + const { user_group } = await client.createUserGroup({ + id, + name, + team_id: teamId, + }); + expect(user_group?.team_id).toBe(teamId); + }; + + beforeAll(async () => { + const { app } = await client.getApp(); + const original = app.multi_tenant_enabled; + cleanup.add(async () => { + await client.updateApp({ multi_tenant_enabled: original }); + await waitForAppSetting(client, 'multi_tenant_enabled', original); + await waitForTeamIdSupport(client, teamId, original); + }); + await client.updateApp({ multi_tenant_enabled: true }); + await waitForAppSetting(client, 'multi_tenant_enabled', true); + await waitForTeamIdSupport(client, teamId, true); + await createTeamGroup(teamGroupId, teamGroupName); + }); + + it('retrieves a user group', async () => { + // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Retrieving a User Group" tab="Node.js" index=1 + // COPY: teamGroupId="design-team", teamId="my-team" + const { user_group } = await client.getUserGroup({ + id: teamGroupId, + team_id: teamId, // only with multi-tenancy + }); + // #endregion snippet + + expect(user_group?.id).toBe(teamGroupId); + expect(user_group?.name).toBe(teamGroupName); + }); + + it('lists user groups', async () => { + const lastId = ''; // no cursor: start from the first group + + // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Listing User Groups" tab="Node.js" index=1 + // COPY: lastId="last-id", teamId="my-team" + const { user_groups } = await client.listUserGroups({ + limit: 20, + id_gt: lastId, // optional: cursor for pagination + created_at_gt: '2024-01-01T00:00:00Z', // optional: filter by creation date + team_id: teamId, // only with multi-tenancy + }); + // #endregion snippet + + expect(user_groups.map((g) => g.id)).toContain(teamGroupId); + expect(user_groups.length).toBeLessThanOrEqual(20); + }); + + it('searches user groups', async () => { + const searchQuery = teamGroupName; + const nameGt = ''; // no cursors: start from the first match + const lastId = ''; + + // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Searching User Groups" tab="Node.js" index=1 + // COPY: searchQuery="design", nameGt="abc", lastId="last-id", teamId="my-team" + const { user_groups } = await client.searchUserGroups({ + query: searchQuery, // prefix search on group name + limit: 10, + name_gt: nameGt, // optional: cursor for pagination by name + id_gt: lastId, // optional: cursor for pagination by ID + team_id: teamId, // only with multi-tenancy + }); + // #endregion snippet + + expect(user_groups.map((g) => g.id)).toContain(teamGroupId); + }); + + it('deletes a user group', async () => { + const groupToDelete = uniqueId('design-team'); + await createTeamGroup(groupToDelete, uniqueId('design')); + + // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Deleting a User Group" tab="Node.js" index=1 + // COPY: groupToDelete="design-team", teamId="my-team" + await client.deleteUserGroup({ + id: groupToDelete, + team_id: teamId, // only with multi-tenancy + }); + // #endregion snippet + + await expect( + client.getUserGroup({ id: groupToDelete, team_id: teamId }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/01-creating_channels.test.ts b/__tests__/docs-snippets/pages/03-channels/01-creating_channels.test.ts new file mode 100644 index 0000000..37cc7de --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/01-creating_channels.test.ts @@ -0,0 +1,80 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { getServerChannel } from '../../helpers/server'; + +describe('_default/03-channels/01-creating_channels.md', () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const myUserId = uniqueId('myuserid'); + const thierryId = uniqueId('thierry'); + const tommasoId = uniqueId('tommaso'); + + beforeAll(async () => { + cleanup.users.push(myUserId, thierryId, tommasoId); + await client.upsertUsers( + [myUserId, thierryId, tommasoId].map((id) => ({ id })), + ); + }); + + afterAll(() => cleanup.run()); + + it('creates a channel using a channel ID', async () => { + const travelId = uniqueId('travel'); + cleanup.channels.push(`messaging:${travelId}`); + + // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Creating a Channel Using a Channel ID" tab="Node.js" index=1 + // COPY: travelId="travel", myUserId="myuserid" + const channel = client.chat.channel('messaging', travelId); + // Here, 'travel' will be the channel ID + await channel.getOrCreate({ + data: { + created_by_id: myUserId, + custom: { name: 'Awesome channel about traveling' }, + }, + }); + // #endregion snippet + + const { channel: created } = await getServerChannel( + client, + `messaging:${travelId}`, + ); + expect(created?.id).toBe(travelId); + expect(created?.created_by?.id).toBe(myUserId); + expect(created?.custom.name).toBe('Awesome channel about traveling'); + }); + + it('creates a distinct channel', async () => { + // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Distinct Channels" tab="Node.js" index=1 + // COPY: myUserId="myuserid", thierryId="thierry", tommasoId="tommaso" + // Omitting the channel ID routes to a distinct channel, keyed on the member list. + const channel = client.chat.channel('messaging'); + await channel.getOrCreate({ + data: { + created_by_id: myUserId, + members: [{ user_id: thierryId }, { user_id: tommasoId }], + }, + }); + // #endregion snippet + + const channelId = channel.id; + expect(channelId).toBeTruthy(); + cleanup.channels.push(channel.cid); + + const { members } = await getServerChannel(client, channel.cid); + expect(members.map((m) => m.user_id).sort()).toEqual( + [thierryId, tommasoId].sort(), + ); + + // Same members (any order): same channel. + const again = client.chat.channel('messaging'); + await again.getOrCreate({ + data: { + created_by_id: myUserId, + members: [{ user_id: tommasoId }, { user_id: thierryId }], + }, + }); + expect(again.id).toBe(channelId); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/02-query_channels.test.ts b/__tests__/docs-snippets/pages/03-channels/02-query_channels.test.ts new file mode 100644 index 0000000..2c46df6 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/02-query_channels.test.ts @@ -0,0 +1,266 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { ChannelResponse, QueryChannelsResponse } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; + +const CHANNEL_COUNT = 12; + +const client = getServerClient(); +const thierryId = uniqueId('thierry'); +const otherId = uniqueId('other'); +const channelIds = Array.from({ length: CHANNEL_COUNT }, () => + uniqueId('query-channel'), +); +const cids = channelIds.map((id) => `messaging:${id}`); + +const cleanup = new Cleanup(client); + +// Setup and two regions run at module level: inside a test, the extra indentation +// makes prettier wrap `{ filter_conditions: { type: "messaging" } }`, and the sync +// check keeps that wrapping, so those regions would no longer match the one-line +// docs fences. Everything is registered on `cleanup` before it is created. +cleanup.users.push(thierryId, otherId); +cleanup.channels.push(...cids); +await client.upsertUsers([thierryId, otherId].map((id) => ({ id }))); +for (const id of channelIds) { + await client.chat.channel('messaging', id).getOrCreate({ + data: { + created_by_id: otherId, + members: [{ user_id: thierryId }, { user_id: otherId }], + }, + }); +} +// Messages in sequence, so `last_message_at` orders the channels. +for (const cid of cids) { + await sendServerMessage(client, cid, { text: 'hi', user_id: otherId }); +} + +const channelCID = cids[0]; +const userID = thierryId; +const userId = thierryId; + +// #region snippet docs="_default/03-channels/02-query_channels.md" heading="Filter Best Practices" tab="Node.js" index=1 +// Most performant: filter by CID +await client.chat.queryChannels({ filter_conditions: { cid: channelCID } }); + +// Recommended for social messaging +await client.chat.queryChannels({ + filter_conditions: { type: 'messaging', members: { $in: [userID] } }, +}); + +// Not recommended: type alone +await client.chat.queryChannels({ filter_conditions: { type: 'messaging' } }); +// #endregion snippet +const filterBestPracticesRan = true; + +// #region snippet docs="_default/03-channels/02-query_channels.md" heading="Bad Pattern: Overly Broad or Complex Filters" tab="Node.js" index=1 +// ❌ BAD: Type-only filter (too broad) +await client.chat.queryChannels({ filter_conditions: { type: 'messaging' } }); + +// ❌ BAD: Deep nesting of logical operators +await client.chat.queryChannels({ + filter_conditions: { + $and: [ + { $or: [{ frozen: true }, { disabled: true }] }, + { $or: [{ hidden: true }, { muted: true }] }, + ], + }, + user_id: userId, +}); +// #endregion snippet +const badPatternsRan = true; + +describe('_default/03-channels/02-query_channels.md', () => { + const idsOf = (response: QueryChannelsResponse) => + response.channels + .map((c) => c.channel) + .filter((c): c is ChannelResponse => c !== undefined) + .map((c) => c.id); + + afterAll(() => cleanup.run()); + + /** Channel ids, most recent message first. */ + const byLastMessage = () => [...channelIds].reverse(); + + it('queries channels', async () => { + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="" tab="Node.js" index=1 + // COPY: thierryId="thierry" + const response = await client.chat.queryChannels({ + filter_conditions: { type: 'messaging', members: { $in: [thierryId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 15, + }); + // #endregion snippet + + expect(idsOf(response)).toEqual(byLastMessage()); + }); + + it('filters messaging channels by member', async () => { + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Messaging and Team Channels" tab="Node.js" index=1 + // COPY: thierryId="thierry" + // In the Node SDK the filter goes under `filter_conditions`. + const response = await client.chat.queryChannels({ + filter_conditions: { members: { $in: [thierryId] }, type: 'messaging' }, + }); + // #endregion snippet + + // Default limit: 10 of the user's 12 channels. + expect(idsOf(response)).toHaveLength(10); + expect(channelIds).toEqual(expect.arrayContaining(idsOf(response))); + }); + + it('paginates', async () => { + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Pagination" tab="Node.js" index=1 + // COPY: thierryId="thierry" + // Get channels 11-30 + const response = await client.chat.queryChannels({ + filter_conditions: { members: { $in: [thierryId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + offset: 10, + }); + // #endregion snippet + + expect(idsOf(response)).toEqual(byLastMessage().slice(10)); + }); + + it('runs the filter best practices queries', async () => { + // The region ran at module level (see the top of the file). It has no + // results to check, so check that the CID filter form finds the channel. + expect(filterBestPracticesRan).toBe(true); + const { channels } = await client.chat.queryChannels({ + filter_conditions: { cid: channelCID }, + }); + expect(channels.map((c) => c.channel?.cid)).toEqual([channelCID]); + }); + + it('sorts by last_message_at', async () => { + const userID = thierryId; + + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Sort Best Practices" tab="Node.js" index=1 + // The Node SDK takes an array of explicit field/direction pairs. + const response = await client.chat.queryChannels({ + filter_conditions: { type: 'messaging', members: { $in: [userID] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + // #endregion snippet + + expect(idsOf(response)).toEqual(byLastMessage().slice(0, 10)); + }); + + it('uses a selective filter with indexed fields', async () => { + const userId = thierryId; + const thirtyDaysAgo = new Date( + Date.now() - 30 * 24 * 60 * 60 * 1000, + ).toISOString(); + + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Good Pattern: Selective Filter with Indexed Fields" tab="Node.js" index=1 + // ✅ GOOD: Selective filter using indexed fields + const response = await client.chat.queryChannels({ + filter_conditions: { + type: 'messaging', + members: { $in: [userId] }, + last_message_at: { $gte: thirtyDaysAgo }, + }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); + // #endregion snippet + + expect(idsOf(response)).toEqual(byLastMessage()); + }); + + it('runs the overly broad and complex filters', () => { + // The region ran at module level (see the top of the file): the API accepted both filters. + expect(badPatternsRan).toBe(true); + }); + + describe('predefined filters', () => { + const filterName = uniqueId('user_messaging_channels'); + + beforeAll(async () => { + await client.chat.createPredefinedFilter({ + name: filterName, + operation: 'QueryChannels', + filter: { type: 'messaging', members: { $in: ['{{user_id}}'] } }, + }); + cleanup.add(() => + client.chat.deletePredefinedFilter({ name: filterName }), + ); + }); + + it('uses a predefined filter in production', async () => { + const userId = thierryId; + + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Using Predefined Filters in Production" tab="Node.js" index=1 + // COPY: filterName="user_messaging_channels" + // Production-ready: use a predefined filter + // `filter_conditions` is ignored when `predefined_filter` is set. + const response = await client.chat.queryChannels({ + predefined_filter: filterName, + filter_values: { user_id: userId }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); + // #endregion snippet + + expect(idsOf(response)).toEqual(byLastMessage()); + }); + + it('uses a predefined filter', async () => { + const user123 = thierryId; + + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Using Predefined Filters" tab="Node.js" index=1 + // COPY: filterName="user_messaging_channels", user123="user123" + // `filter_conditions` is ignored when `predefined_filter` is set. + const response = await client.chat.queryChannels({ + predefined_filter: filterName, + filter_values: { user_id: user123 }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); + // #endregion snippet + + expect(idsOf(response)).toEqual(byLastMessage()); + }); + }); + + it('uses a simple, selective filter', async () => { + const userId = thierryId; + const thirtyDaysAgo = new Date( + Date.now() - 30 * 24 * 60 * 60 * 1000, + ).toISOString(); + + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Query Complexity" tab="Node.js" index=1 + // RECOMMENDED: simple, selective filter with indexed fields + const response = await client.chat.queryChannels({ + filter_conditions: { + type: 'messaging', + members: { $in: [userId] }, + last_message_at: { $gte: thirtyDaysAgo }, + }, + }); + // #endregion snippet + + // Default limit: 10 of the user's 12 channels. + expect(idsOf(response)).toHaveLength(10); + expect(channelIds).toEqual(expect.arrayContaining(idsOf(response))); + }); + + it('paginates with a members filter', async () => { + const userId = thierryId; + + // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Pagination Best Practices" tab="Node.js" index=1 + // Efficient pagination with members filter + const response = await client.chat.queryChannels({ + filter_conditions: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); + // #endregion snippet + + expect(idsOf(response)).toEqual(byLastMessage()); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/03-channel_update.test.ts b/__tests__/docs-snippets/pages/03-channels/03-channel_update.test.ts new file mode 100644 index 0000000..d20212e --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/03-channel_update.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; + +const DOCS = '_default/03-channels/03-channel_update.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const userId = uniqueId('myuserid'); + const partialChannelId = uniqueId('partial'); + const fullChannelId = uniqueId('full'); + + const getCustom = async (channelId: string) => { + const { channels } = await client.chat.queryChannels({ + filter_conditions: { cid: `messaging:${channelId}` }, + }); + expect(channels).toHaveLength(1); + return channels[0].channel?.custom; + }; + + beforeAll(async () => { + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + + // The partial update snippet creates its own channel. + cleanup.channels.push(`messaging:${partialChannelId}`); + + // A channel with custom data that the full update must remove. + cleanup.channels.push(`messaging:${fullChannelId}`); + await client.chat.channel('messaging', fullChannelId).getOrCreate({ + data: { + created_by_id: userId, + custom: { topic: 'Plants and Animals', color: 'red' }, + }, + }); + }); + + afterAll(() => cleanup.run()); + + it('partially updates a channel', async () => { + const type = 'messaging'; + const id = partialChannelId; + + // #region snippet docs="_default/03-channels/03-channel_update.md" heading="Partial Update" tab="Node.js" index=1 + // COPY: userId="myuserid" + // Here's a channel with some custom field data that might be useful + const channel = client.chat.channel(type, id); + await channel.getOrCreate({ + data: { + created_by_id: userId, + custom: { + source: 'user', + source_detail: { user_id: 123 }, + channel_detail: { topic: 'Plants and Animals', rating: 'pg' }, + }, + }, + }); + + // let's change the source of this channel + await channel.updateChannelPartial({ set: { source: 'system' } }); + + // since it's system generated we no longer need source_detail + await channel.updateChannelPartial({ unset: ['source_detail'] }); + + // and finally update one of the nested fields in the channel_detail + await channel.updateChannelPartial({ + set: { 'channel_detail.topic': 'Nature' }, + }); + + // and maybe we decide we no longer need a rating + await channel.updateChannelPartial({ unset: ['channel_detail.rating'] }); + // #endregion snippet + + const custom = await getCustom(partialChannelId); + expect(custom?.source).toBe('system'); + expect(custom).not.toHaveProperty('source_detail'); + expect(custom?.channel_detail).toEqual({ topic: 'Nature' }); + }); + + it('fully updates a channel', async () => { + const channel = client.chat.channel('messaging', fullChannelId); + + // #region snippet docs="_default/03-channels/03-channel_update.md" heading="Full Update" tab="Node.js" index=1 + // `name` and `color` are custom data, so they go under `data.custom`. + const update = await channel.update({ + data: { + custom: { + name: 'myspecialchannel', + color: 'green', + }, + }, + }); + // #endregion snippet + + expect(update.channel?.custom.name).toBe('myspecialchannel'); + + const custom = await getCustom(fullChannelId); + expect(custom?.name).toBe('myspecialchannel'); + expect(custom?.color).toBe('green'); + // Data not included in a full update is deleted. + expect(custom).not.toHaveProperty('topic'); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/04-query_members.test.ts b/__tests__/docs-snippets/pages/03-channels/04-query_members.test.ts new file mode 100644 index 0000000..7cb47b1 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/04-query_members.test.ts @@ -0,0 +1,157 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { MembersResponse } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; + +const DOCS = '_default/03-channels/04-query_members.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const channelId = uniqueId('general'); + const tommasoId = uniqueId('tommaso'); + const tomId = uniqueId('tomas'); + const thierryId = uniqueId('thierry'); + const memberIds = [tommasoId, tomId, thierryId]; + + /** Spies on `client.chat.queryMembers` (which `channel.queryMembers` calls) and returns the responses. */ + const spyOnQueryMembers = () => { + const spy = vi.spyOn(client.chat, 'queryMembers'); + return { + responses: () => + Promise.all( + spy.mock.results.map( + (result) => result.value as Promise, + ), + ), + restore: () => spy.mockRestore(), + }; + }; + + const ids = (response: MembersResponse) => + response.members.map((m) => m.user_id); + + beforeAll(async () => { + cleanup.users.push(...memberIds); + await client.upsertUsers([ + { id: tommasoId, name: 'tommaso' }, + { id: tomId, name: 'tomas' }, + { id: thierryId, name: 'thierry' }, + ]); + + cleanup.channels.push(`messaging:${channelId}`); + // Members one by one, so their `created_at` differ. + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { created_by_id: tommasoId }, + }); + for (const user_id of memberIds) { + await client.chat + .channel('messaging', channelId) + .update({ add_members: [{ user_id }] }); + } + }); + + afterAll(() => cleanup.run()); + + it('queries members by name, autocomplete and all', async () => { + const spy = spyOnQueryMembers(); + + // #region snippet docs="_default/03-channels/04-query_members.md" heading="" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // Query members by user name + await channel.queryMembers({ + payload: { filter_conditions: { name: 'tommaso' } }, + }); + + // Autocomplete members by user name + await channel.queryMembers({ + payload: { filter_conditions: { name: { $autocomplete: 'tom' } } }, + }); + + // Query all members + await channel.queryMembers({ payload: { filter_conditions: {} } }); + // #endregion snippet + + const [byName, autocomplete, all] = await spy.responses(); + spy.restore(); + expect(channel.cid).toBe(`messaging:${channelId}`); + expect(ids(byName)).toEqual([tommasoId]); + expect(ids(autocomplete).sort()).toEqual([tommasoId, tomId].sort()); + expect(ids(all).sort()).toEqual([...memberIds].sort()); + }); + + it('paginates channel members', async () => { + const spy = spyOnQueryMembers(); + + // #region snippet docs="_default/03-channels/04-query_members.md" heading="Paginating Channel Members" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // Returns members ordered by created_at descending + const byCreatedAt = await channel.queryMembers({ + payload: { + filter_conditions: {}, + sort: [{ field: 'created_at', direction: -1 }], + limit: 100, + }, + }); + + // Paginate by created_at in descending order + const lastByCreatedAt = byCreatedAt.members[byCreatedAt.members.length - 1]; + await channel.queryMembers({ + payload: { + filter_conditions: {}, + sort: [{ field: 'created_at', direction: -1 }], + limit: 100, + created_at_before: lastByCreatedAt.created_at, + }, + }); + + // Returns members ordered by user_id ascending + const byUserId = await channel.queryMembers({ + payload: { + filter_conditions: {}, + sort: [{ field: 'user_id', direction: 1 }], + limit: 100, + }, + }); + + // Paginate by user_id in ascending order + const lastByUserId = byUserId.members[byUserId.members.length - 1]; + await channel.queryMembers({ + payload: { + filter_conditions: {}, + sort: [{ field: 'user_id', direction: 1 }], + limit: 100, + user_id_gt: lastByUserId.user_id, + }, + }); + + // Paginate using offset + await channel.queryMembers({ + payload: { + filter_conditions: {}, + sort: [{ field: 'user_id', direction: 1 }], + limit: 20, + offset: 20, + }, + }); + // #endregion snippet + + const responses = await spy.responses(); + spy.restore(); + expect(responses).toHaveLength(5); + const [, afterCreatedAt, , afterUserId, offset] = responses; + + // created_at descending: the last member added comes first. + expect(ids(byCreatedAt)).toEqual([...memberIds].reverse()); + expect(ids(afterCreatedAt)).toEqual([]); + expect(ids(byUserId)).toEqual([...memberIds].sort()); + expect(ids(afterUserId)).toEqual([]); + expect(ids(offset)).toEqual([]); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/05-channel_pagination.test.ts b/__tests__/docs-snippets/pages/03-channels/05-channel_pagination.test.ts new file mode 100644 index 0000000..1ffa3c8 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/05-channel_pagination.test.ts @@ -0,0 +1,113 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; + +const DOCS = '_default/03-channels/05-channel_pagination.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + const ownerId = uniqueId('owner'); + // 22 members, so the `limit: 20` page is cut off and the `limit: 110` one isn't. + const memberIds = [ + ownerId, + ...Array.from({ length: 21 }, (_, i) => uniqueId(`member-${i}`)), + ]; + // 25 messages, oldest first. + const messageIds: string[] = []; + + beforeAll(async () => { + cleanup.users.push(...memberIds); + await client.upsertUsers(memberIds.map((id) => ({ id }))); + + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: ownerId, + members: memberIds.map((user_id) => ({ user_id })), + }, + }); + + for (let i = 0; i < 25; i++) { + const message = await sendServerMessage(client, cid, { + text: `message ${i}`, + user_id: ownerId, + }); + messageIds.push(message.id); + } + }); + + afterAll(() => cleanup.run()); + + it('paginates messages by id', async () => { + const messageId = messageIds[10]; + + // #region snippet docs="_default/03-channels/05-channel_pagination.md" heading="Pagination Parameters" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // Load the latest page of messages + const page = await channel.getOrCreate({ + state: true, + messages: { limit: 20 }, + }); + + // Get the ID of the oldest message on the current page + const lastMessageId = page.messages[0].id; + + // Fetch older messages + const older = await channel.getOrCreate({ + state: true, + messages: { limit: 20, id_lt: lastMessageId }, + }); + + // Fetch messages around a specific message + const around = await channel.getOrCreate({ + state: true, + messages: { limit: 20, id_around: messageId }, + }); + // #endregion snippet + + // The latest 20 messages, oldest first. + expect(page.messages.map((m) => m.id)).toEqual(messageIds.slice(5)); + expect(lastMessageId).toBe(messageIds[5]); + // The 5 messages older than the first page. + expect(older.messages.map((m) => m.id)).toEqual(messageIds.slice(0, 5)); + // A page that contains the message, with messages on both sides of it. + const aroundIds = around.messages.map((m) => m.id); + expect(aroundIds).toContain(messageId); + expect(aroundIds.indexOf(messageId)).toBeGreaterThan(0); + expect(aroundIds.indexOf(messageId)).toBeLessThan(aroundIds.length - 1); + }); + + it('paginates members and watchers with limit and offset', async () => { + // #region snippet docs="_default/03-channels/05-channel_pagination.md" heading="Member and Watcher Pagination" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // Paginate members and watchers + const result = await channel.getOrCreate({ + state: true, + members: { limit: 20, offset: 0 }, + watchers: { limit: 20, offset: 0 }, + }); + + const withState = await channel.getOrCreate({ + state: true, + members: { limit: 110, offset: 0 }, + }); + // #endregion snippet + + expect(result.members).toHaveLength(20); + expect(withState.members.map((m) => m.user_id).sort()).toEqual( + [...memberIds].sort(), + ); + // Nobody watches the channel (no connected users). + expect(result.watchers ?? []).toHaveLength(0); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/06-channel_members.test.ts b/__tests__/docs-snippets/pages/03-channels/06-channel_members.test.ts new file mode 100644 index 0000000..da76649 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/06-channel_members.test.ts @@ -0,0 +1,346 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { + getServerChannel, + getServerMember, + sendServerMessage, +} from '../../helpers/server'; + +const DOCS = '_default/03-channels/06-channel_members.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const thierryId = uniqueId('thierry'); + const joshId = uniqueId('josh'); + const jamesBondId = uniqueId('james-bond'); + const alecId = uniqueId('alec-trevelyan'); + const tommasoId = uniqueId('tommaso'); + const myUserId = uniqueId('myuserid'); + const userId1 = uniqueId('userid1'); + const userId2 = uniqueId('userid2'); + const userId3 = uniqueId('userid3'); + const janeId = uniqueId('jane'); + const allUsers = [ + thierryId, + joshId, + jamesBondId, + alecId, + tommasoId, + myUserId, + userId1, + userId2, + userId3, + janeId, + ]; + + /** A new `messaging` channel created by `myUserId`, registered for cleanup. */ + const newChannel = async (members: string[] = []) => { + const id = uniqueId('general'); + cleanup.channels.push(`messaging:${id}`); + await client.chat.channel('messaging', id).getOrCreate({ + data: { + created_by_id: myUserId, + members: members.map((user_id) => ({ user_id })), + }, + }); + return id; + }; + + const memberIds = async (cid: string) => { + const { members } = await getServerChannel(client, cid); + return members.map((m) => m.user_id); + }; + + beforeAll(async () => { + cleanup.users.push(...allUsers); + await client.upsertUsers(allUsers.map((id) => ({ id }))); + }); + + afterAll(() => cleanup.run()); + + it('adds members, with a channel role and with custom data', async () => { + const channelId = await newChannel(); + const cid = `messaging:${channelId}`; + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Members" tab="Node.js" index=1 + // COPY: channelId="general", thierryId="thierry", joshId="josh", jamesBondId="james_bond", alecId="alec_trevelyan" + const channel = client.chat.channel('messaging', channelId); + + await channel.update({ + add_members: [{ user_id: thierryId }, { user_id: joshId }], + }); + + // Add user to the channel with role set + await channel.update({ + add_members: [ + { user_id: jamesBondId, channel_role: 'channel_moderator' }, + ], + }); + + // Add new channel member with custom data + await channel.update({ + add_members: [{ user_id: alecId, custom: { code_name: '006' } }], + }); + // #endregion snippet + + expect(await memberIds(cid)).toEqual( + expect.arrayContaining([thierryId, joshId, jamesBondId, alecId]), + ); + const bond = await getServerMember(client, cid, jamesBondId); + expect(bond.channel_role).toBe('channel_moderator'); + const alec = await getServerMember(client, cid, alecId); + expect(alec.custom.code_name).toBe('006'); + }); + + it('adds members when creating a channel', async () => { + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + cleanup.channels.push(cid); + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Members" tab="Node.js" index=2 + // COPY: channelId="general", myUserId="myuserid", jamesBondId="james_bond", alecId="alec_trevelyan" + const channel = client.chat.channel('messaging', channelId); + await channel.getOrCreate({ + data: { + created_by_id: myUserId, + members: [ + { user_id: jamesBondId, custom: { code_name: '007' } }, + { user_id: alecId, custom: { code_name: '006' } }, + ], + }, + }); + // #endregion snippet + + const bond = await getServerMember(client, cid, jamesBondId); + expect(bond.custom.code_name).toBe('007'); + const alec = await getServerMember(client, cid, alecId); + expect(alec.custom.code_name).toBe('006'); + }); + + it('removes members', async () => { + const channelId = await newChannel([tommasoId, thierryId]); + const cid = `messaging:${channelId}`; + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Removing Members" tab="Node.js" index=1 + // COPY: channelId="general", tommasoId="tommaso" + const channel = client.chat.channel('messaging', channelId); + await channel.update({ remove_members: [tommasoId] }); + // #endregion snippet + + const ids = await memberIds(cid); + expect(ids).not.toContain(tommasoId); + expect(ids).toContain(thierryId); + }); + + it('adds a member with hide_history', async () => { + const channelId = await newChannel(); + const cid = `messaging:${channelId}`; + await sendServerMessage(client, cid, { + text: 'before thierry joined', + user_id: myUserId, + }); + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Hide History" tab="Node.js" index=1 + // COPY: channelId="general", thierryId="thierry" + const channel = client.chat.channel('messaging', channelId); + await channel.update({ + add_members: [{ user_id: thierryId }], + hide_history: true, + }); + // #endregion snippet + + await sendServerMessage(client, cid, { + text: 'after thierry joined', + user_id: myUserId, + }); + const { messages, members } = await getServerChannel(client, cid, { + user_id: thierryId, + message_limit: 10, + }); + expect(members.map((m) => m.user_id)).toContain(thierryId); + expect(messages.map((m) => m.text)).toEqual(['after thierry joined']); + }); + + it('adds a member with hide_history_before', async () => { + const channelId = await newChannel(); + const cid = `messaging:${channelId}`; + // Sent after the cutoff (7 days ago), so the new member still sees it. + await sendServerMessage(client, cid, { + text: 'sent today', + user_id: myUserId, + }); + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Hide History Before a Specific Date" tab="Node.js" index=1 + // COPY: channelId="general", thierryId="thierry" + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 7); // Last 7 days + + const channel = client.chat.channel('messaging', channelId); + await channel.update({ + add_members: [{ user_id: thierryId }], + hide_history_before: cutoff, + }); + // #endregion snippet + + const { messages, members } = await getServerChannel(client, cid, { + user_id: thierryId, + message_limit: 10, + }); + expect(members.map((m) => m.user_id)).toContain(thierryId); + expect(messages.map((m) => m.text)).toEqual(['sent today']); + }); + + it('adds and removes members with a system message', async () => { + const channelId = await newChannel([myUserId]); + const cid = `messaging:${channelId}`; + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="System Message Parameter" tab="Node.js" index=1 + // COPY: channelId="general", tommasoId="tommaso" + const channel = client.chat.channel('messaging', channelId); + + // Server-side calls must specify the sender user_id on the message + await channel.update({ + add_members: [{ user_id: tommasoId }], + message: { + text: 'Tommaso joined the channel.', + user_id: tommasoId, + }, + }); + + // The same message parameter works when removing members + await channel.update({ + remove_members: [tommasoId], + message: { + text: 'Tommaso left the channel.', + user_id: tommasoId, + }, + }); + // #endregion snippet + + const { messages, members } = await getServerChannel(client, cid, { + message_limit: 10, + }); + expect(members.map((m) => m.user_id)).not.toContain(tommasoId); + expect(messages.map((m) => m.text)).toEqual([ + 'Tommaso joined the channel.', + 'Tommaso left the channel.', + ]); + }); + + it('adds moderators', async () => { + const channelId = await newChannel([thierryId, joshId]); + const cid = `messaging:${channelId}`; + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Add Moderators" tab="Node.js" index=1 + // COPY: channelId="general", thierryId="thierry", joshId="josh" + const channel = client.chat.channel('messaging', channelId); + await channel.update({ add_moderators: [thierryId, joshId] }); + // #endregion snippet + + for (const userId of [thierryId, joshId]) { + const member = await getServerMember(client, cid, userId); + expect(member.channel_role).toBe('channel_moderator'); + } + }); + + it('demotes moderators', async () => { + const channelId = await newChannel([tommasoId]); + const cid = `messaging:${channelId}`; + await client.chat + .channel('messaging', channelId) + .update({ add_moderators: [tommasoId] }); + expect((await getServerMember(client, cid, tommasoId)).channel_role).toBe( + 'channel_moderator', + ); + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Remove Moderators" tab="Node.js" index=1 + // COPY: channelId="general", tommasoId="tommaso" + const channel = client.chat.channel('messaging', channelId); + await channel.update({ demote_moderators: [tommasoId] }); + // #endregion snippet + + const member = await getServerMember(client, cid, tommasoId); + expect(member.channel_role).toBe('channel_member'); + }); + + it('adds member custom data', async () => { + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + cleanup.channels.push(cid); + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Custom Data" tab="Node.js" index=1 + // COPY: channelId="general", myUserId="myuserid", userId1="userid1", userId2="userid2", userId3="userid3" + // Add custom data while creating the channel + const channel = client.chat.channel('messaging', channelId); + await channel.getOrCreate({ + data: { + created_by_id: myUserId, + members: [ + { user_id: userId1, custom: { key1: 'value1' } }, + { user_id: userId2, custom: { key1: 'value1' } }, + { user_id: userId3, custom: { key2: 'value2' } }, + ], + }, + }); + + // Add custom data when adding members + await channel.update({ + add_members: [{ user_id: userId1, custom: { key1: 'value1' } }], + }); + // #endregion snippet + + expect((await getServerMember(client, cid, userId1)).custom).toEqual({ + key1: 'value1', + }); + expect((await getServerMember(client, cid, userId2)).custom).toEqual({ + key1: 'value1', + }); + expect((await getServerMember(client, cid, userId3)).custom).toEqual({ + key2: 'value2', + }); + }); + + it('partially updates member data', async () => { + const channelId = await newChannel([janeId]); + const cid = `messaging:${channelId}`; + + // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Updating Member Data" tab="Node.js" index=1 + // COPY: channelId="general", janeId="jane" + const channel = client.chat.channel('messaging', channelId); + + // Set some fields + await channel.updateMemberPartial({ + user_id: janeId, + set: { + key1: 'new value 1', + key2: 'new value 2', + channel_role: 'channel_moderator', + }, + }); + + // Unset some fields + await channel.updateMemberPartial({ + user_id: janeId, + unset: ['key1', 'key2'], + }); + + // Set and unset in the same call + await channel.updateMemberPartial({ + user_id: janeId, + set: { + key1: 'new value 1', + key2: 'new value 2', + }, + unset: ['key3'], + }); + // #endregion snippet + + const member = await getServerMember(client, cid, janeId); + expect(member.channel_role).toBe('channel_moderator'); + expect(member.custom).toEqual({ key1: 'new value 1', key2: 'new value 2' }); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts new file mode 100644 index 0000000..d245ead --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { + QueryChannelsResponse, + UpdateMemberPartialResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerMember } from '../../../helpers/server'; + +const DOCS = '_default/03-channels/07-channel_management/08-archiving.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const amyId = uniqueId('amy'); + const otherId = uniqueId('other'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + + beforeAll(async () => { + cleanup.users.push(amyId, otherId); + await client.upsertUsers([{ id: amyId }, { id: otherId }]); + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: amyId, + members: [{ user_id: amyId }, { user_id: otherId }], + }, + }); + }); + + afterAll(() => cleanup.run()); + + it('archives, queries archived channels and unarchives', async () => { + const memberSpy = vi.spyOn(client.chat, 'updateMemberPartial'); + const querySpy = vi.spyOn(client.chat, 'queryChannels'); + + // #region snippet docs="_default/03-channels/07-channel_management/08-archiving.md" heading="Archive a Channel" tab="Node.js" index=1 + // COPY: channelId="general", amyId="amy" + // Get a channel + const channel = client.chat.channel('messaging', channelId); + + // Archive the channel for user amy + const userId = amyId; + await channel.updateMemberPartial({ + user_id: userId, + set: { archived: true }, + }); + + // Query for channels that are archived + await client.chat.queryChannels({ + filter_conditions: { archived: true }, + user_id: userId, + }); + + // Unarchive the channel + await channel.updateMemberPartial({ + user_id: userId, + unset: ['archived'], + }); + // #endregion snippet + + const [archived, unarchived] = await Promise.all( + memberSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + const [queried] = await Promise.all( + querySpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + memberSpy.mockRestore(); + querySpy.mockRestore(); + + expect(archived.channel_member?.user_id).toBe(amyId); + expect(archived.channel_member?.archived_at).toBeDefined(); + expect(queried.channels.map((c) => c.channel?.cid)).toEqual([cid]); + expect(unarchived.channel_member?.archived_at).toBeUndefined(); + + const amy = await getServerMember(client, cid, amyId); + expect(amy.archived_at).toBeUndefined(); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts new file mode 100644 index 0000000..1145c2f --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { + QueryChannelsResponse, + UpdateMemberPartialResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerMember } from '../../../helpers/server'; + +const DOCS = '_default/03-channels/07-channel_management/09-pinning.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const amyId = uniqueId('amy'); + const benId = uniqueId('ben'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + const otherChannelId = uniqueId('other'); + const otherCid = `messaging:${otherChannelId}`; + + beforeAll(async () => { + cleanup.users.push(amyId, benId); + await client.upsertUsers([{ id: amyId }, { id: benId }]); + // the unpinned channel is created last, so without the pinned_at sort it + // would not come first + for (const id of [channelId, otherChannelId]) { + cleanup.channels.push(`messaging:${id}`); + await client.chat.channel('messaging', id).getOrCreate({ + data: { + created_by_id: amyId, + members: [{ user_id: amyId }, { user_id: benId }], + }, + }); + } + }); + + afterAll(() => cleanup.run()); + + it('pins, queries pinned channels, sorts pinned first and unpins', async () => { + const memberSpy = vi.spyOn(client.chat, 'updateMemberPartial'); + const querySpy = vi.spyOn(client.chat, 'queryChannels'); + + // #region snippet docs="_default/03-channels/07-channel_management/09-pinning.md" heading="Pin a Channel" tab="Node.js" index=1 + // COPY: channelId="general", amyId="amy", benId="ben" + // Get a channel + const channel = client.chat.channel('messaging', channelId); + + // Pin the channel for user amy + const userId = amyId; + await channel.updateMemberPartial({ + user_id: userId, + set: { pinned: true }, + }); + + // Query for channels that are pinned + await client.chat.queryChannels({ + filter_conditions: { pinned: true }, + user_id: userId, + }); + + // Query for channels for specific members and show pinned first + await client.chat.queryChannels({ + filter_conditions: { members: { $in: [amyId, benId] } }, + sort: [{ field: 'pinned_at', direction: -1 }], + user_id: userId, + }); + + // Unpin the channel + await channel.updateMemberPartial({ + user_id: userId, + unset: ['pinned'], + }); + // #endregion snippet + + const [pinned, unpinned] = await Promise.all( + memberSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + const [pinnedOnly, pinnedFirst] = await Promise.all( + querySpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + memberSpy.mockRestore(); + querySpy.mockRestore(); + + expect(pinned.channel_member?.user_id).toBe(amyId); + expect(pinned.channel_member?.pinned_at).toBeDefined(); + expect(pinnedOnly.channels.map((c) => c.channel?.cid)).toEqual([cid]); + expect(pinnedFirst.channels.map((c) => c.channel?.cid)).toEqual([ + cid, + otherCid, + ]); + expect(unpinned.channel_member?.pinned_at).toBeUndefined(); + + const amy = await getServerMember(client, cid, amyId); + expect(amy.pinned_at).toBeUndefined(); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts new file mode 100644 index 0000000..4fe9f13 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts @@ -0,0 +1,140 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { + MuteChannelResponse, + QueryChannelsResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = '_default/03-channels/07-channel_management/10-muting.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + const otherChannelId = uniqueId('other'); + const otherCid = `messaging:${otherChannelId}`; + + /** `cid`s of the channels `johnId` has muted (from a `queryChannels` as john). */ + const mutedCids = async () => { + const response = await client.chat.queryChannels({ + filter_conditions: { members: { $in: [johnId] }, muted: true }, + user_id: johnId, + }); + return response.channels.map((c) => c.channel?.cid); + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + for (const id of [channelId, otherChannelId]) { + cleanup.channels.push(`messaging:${id}`); + await client.chat.channel('messaging', id).getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + } + }); + + afterAll(() => cleanup.run()); + + it('mutes a channel, with and without expiration', async () => { + const spy = vi.spyOn(client.chat, 'muteChannel'); + + // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Mute a Channel" tab="Node.js" index=1 + // COPY: cid="messaging:general", johnId="john" + // Channel mutes are per-user, so a server-side call always names the user. + // `channel_cids` accepts several channels in one call. + await client.chat.muteChannel({ + channel_cids: [cid], + user_id: johnId, + }); + + // mute a channel for 2 weeks (expiration in milliseconds) + await client.chat.muteChannel({ + channel_cids: [cid], + user_id: johnId, + expiration: 14 * 24 * 60 * 60 * 1000, + }); + + // mute a channel for 10 seconds + await client.chat.muteChannel({ + channel_cids: [cid], + user_id: johnId, + expiration: 10000, + }); + // #endregion snippet + + const [forever, twoWeeks, tenSeconds] = await Promise.all( + spy.mock.results.map((r) => r.value as Promise), + ); + spy.mockRestore(); + + // one channel in `channel_cids`: the mute is returned as `channel_mute` + const muteOf = (response: MuteChannelResponse) => { + expect(response.channel_mute?.channel?.cid).toBe(cid); + return response.channel_mute; + }; + expect(muteOf(forever)?.expires).toBeUndefined(); + const now = Date.now(); + const twoWeeksExpires = muteOf(twoWeeks)?.expires; + expect(twoWeeksExpires).toBeDefined(); + expect(new Date(twoWeeksExpires ?? 0).getTime()).toBeGreaterThan( + now + 13 * 24 * 60 * 60 * 1000, + ); + const tenSecondsExpires = muteOf(tenSeconds)?.expires; + expect(tenSecondsExpires).toBeDefined(); + expect(new Date(tenSecondsExpires ?? 0).getTime()).toBeLessThan( + now + 60 * 1000, + ); + expect(await mutedCids()).toEqual([cid]); + }); + + it('queries muted and not muted channels', async () => { + // mute without expiration, so the previous test's 10s mute can't run out + await client.chat.muteChannel({ channel_cids: [cid], user_id: johnId }); + const userId = johnId; + const spy = vi.spyOn(client.chat, 'queryChannels'); + + // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Query Muted Channels" tab="Node.js" index=1 + // Mutes are per-user, so pass `user_id` to evaluate the `muted` filter. + // retrieve all channels excluding muted ones + await client.chat.queryChannels({ + filter_conditions: { members: { $in: [userId] }, muted: false }, + user_id: userId, + }); + + // retrieve all muted channels + await client.chat.queryChannels({ + filter_conditions: { muted: true }, + user_id: userId, + }); + // #endregion snippet + + const [notMuted, muted] = await Promise.all( + spy.mock.results.map((r) => r.value as Promise), + ); + spy.mockRestore(); + + expect(notMuted.channels.map((c) => c.channel?.cid)).toEqual([otherCid]); + expect(muted.channels.map((c) => c.channel?.cid)).toEqual([cid]); + }); + + it('removes a channel mute', async () => { + await client.chat.muteChannel({ channel_cids: [cid], user_id: johnId }); + expect(await mutedCids()).toEqual([cid]); + + // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Remove a Channel Mute" tab="Node.js" index=1 + // COPY: cid="messaging:general", johnId="john" + await client.chat.unmuteChannel({ + channel_cids: [cid], + user_id: johnId, + }); + // #endregion snippet + + expect(await mutedCids()).toEqual([]); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts new file mode 100644 index 0000000..d5901f9 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../../helpers/server'; + +const DOCS = '_default/03-channels/07-channel_management/11-hiding.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + const otherId = uniqueId('other'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + + const hiddenCids = async () => { + const { channels } = await client.chat.queryChannels({ + filter_conditions: { cid, hidden: true }, + user_id: johnId, + }); + return channels.map((c) => c.channel?.cid); + }; + + beforeAll(async () => { + cleanup.users.push(johnId, otherId); + await client.upsertUsers([{ id: johnId }, { id: otherId }]); + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: otherId, + members: [{ user_id: johnId }, { user_id: otherId }], + }, + }); + await sendServerMessage(client, cid, { text: 'before', user_id: otherId }); + }); + + afterAll(() => cleanup.run()); + + it('hides, hides with clear history and shows a channel', async () => { + const before = await getServerChannel(client, cid, { + user_id: johnId, + message_limit: 10, + }); + expect(before.messages.map((m) => m.text)).toEqual(['before']); + + // After each hide, record whether the channel is hidden for john. + const hiddenAfterHide: Array> = []; + const original = client.chat.hideChannel.bind(client.chat); + const hideSpy = vi + .spyOn(client.chat, 'hideChannel') + .mockImplementation(async (request) => { + const response = await original(request); + hiddenAfterHide.push(await hiddenCids()); + return response; + }); + + // #region snippet docs="_default/03-channels/07-channel_management/11-hiding.md" heading="Hide a Channel" tab="Node.js" index=1 + // COPY: channelId="general", johnId="john" + // Get a channel + const channel = client.chat.channel('messaging', channelId); + + // hides the channel for john until a new message is added there + await channel.hide({ user_id: johnId }); + + // the same, also clearing the history for that user + await channel.hide({ user_id: johnId, clear_history: true }); + + // shows a previously hidden channel + await channel.show({ user_id: johnId }); + // #endregion snippet + + const clearHistory = hideSpy.mock.calls.map( + ([request]) => request.clear_history, + ); + hideSpy.mockRestore(); + + expect(clearHistory).toEqual([undefined, true]); + expect(hiddenAfterHide).toEqual([[cid], [cid]]); + expect(await hiddenCids()).toEqual([]); + + // shown again, and the history before the clearing hide is gone for john + const after = await getServerChannel(client, cid, { + user_id: johnId, + message_limit: 10, + }); + expect(after.messages).toEqual([]); + + const forOther = await getServerChannel(client, cid, { + user_id: otherId, + message_limit: 10, + }); + expect(forOther.messages.map((m) => m.text)).toEqual(['before']); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts new file mode 100644 index 0000000..3131973 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { + UpdateChannelPartialResponse, + UpdateChannelResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; + +const DOCS = '_default/03-channels/07-channel_management/12-disabling.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const ownerId = uniqueId('owner'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + + beforeAll(async () => { + cleanup.users.push(ownerId); + await client.upsertUsers([{ id: ownerId }]); + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { created_by_id: ownerId, members: [{ user_id: ownerId }] }, + }); + }); + + afterAll(() => cleanup.run()); + + it('disables and enables a channel with full and partial updates', async () => { + const updateSpy = vi.spyOn(client.chat, 'updateChannel'); + const partialSpy = vi.spyOn(client.chat, 'updateChannelPartial'); + + // #region snippet docs="_default/03-channels/07-channel_management/12-disabling.md" heading="Disable a Channel" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // disable a channel with full update + await channel.update({ data: { disabled: true } }); + + // disable a channel with partial update + await channel.updateChannelPartial({ set: { disabled: true } }); + + // enable a channel with full update + await channel.update({ data: { disabled: false } }); + + // enable a channel with partial update + await channel.updateChannelPartial({ set: { disabled: false } }); + // #endregion snippet + + const [disabledFull, enabledFull] = await Promise.all( + updateSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + const [disabledPartial, enabledPartial] = await Promise.all( + partialSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(partialSpy).toHaveBeenCalledTimes(2); + updateSpy.mockRestore(); + partialSpy.mockRestore(); + + expect(disabledFull.channel?.disabled).toBe(true); + expect(disabledPartial.channel?.disabled).toBe(true); + expect(enabledFull.channel?.disabled).toBe(false); + expect(enabledPartial.channel?.disabled).toBe(false); + + const { channel: current } = await getServerChannel(client, cid); + expect(current?.disabled).toBe(false); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts new file mode 100644 index 0000000..986d8af --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts @@ -0,0 +1,186 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { + DeleteChannelResponse, + DeleteChannelsResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { waitForTask } from '../../../helpers/wait'; + +const DOCS = '_default/03-channels/07-channel_management/13-deleting.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const ownerId = uniqueId('owner'); + const memberIds = [ + uniqueId('member'), + uniqueId('member'), + uniqueId('member'), + ]; + + const countChannels = async (cids: string[]) => { + const { channels } = await client.chat.queryChannels({ + filter_conditions: { cid: { $in: cids } }, + }); + return channels.length; + }; + + /** A distinct channel of `ownerId` and `memberId`, with one message in it. */ + const createDistinctChannel = async (memberId: string) => { + const distinct = client.chat.channel('messaging'); + await distinct.getOrCreate({ + data: { + created_by_id: ownerId, + members: [{ user_id: ownerId }, { user_id: memberId }], + }, + }); + cleanup.channels.push(distinct.cid); + const message = await sendServerMessage(client, distinct.cid, { + text: 'kept through the delete', + user_id: ownerId, + }); + return { cid: distinct.cid, messageId: message.id }; + }; + + /** Recreates the distinct channel of `ownerId` and `memberId` and returns its messages. */ + const recreateDistinctChannel = async (memberId: string) => { + const recreated = client.chat.channel('messaging'); + const response = await recreated.getOrCreate({ + state: true, + data: { + created_by_id: ownerId, + members: [{ user_id: ownerId }, { user_id: memberId }], + }, + }); + return response.messages.map((m) => m.id); + }; + + beforeAll(async () => { + cleanup.users.push(ownerId, ...memberIds); + await client.upsertUsers([ownerId, ...memberIds].map((id) => ({ id }))); + }); + + afterAll(() => cleanup.run()); + + it('soft deletes and hard deletes a channel', async () => { + const channelId = uniqueId('general'); + const otherChannelId = uniqueId('random'); + const cid = `messaging:${channelId}`; + const otherCid = `messaging:${otherChannelId}`; + cleanup.channels.push(cid, otherCid); + for (const id of [channelId, otherChannelId]) { + await client.chat + .channel('messaging', id) + .getOrCreate({ data: { created_by_id: ownerId } }); + } + + const deleteSpy = vi.spyOn(client.chat, 'deleteChannel'); + + // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting a Channel" tab="Node.js" index=1 + // COPY: channelId="general", otherChannelId="random" + // Get a channel + const channel = client.chat.channel('messaging', channelId); + + await channel.delete(); + + // Pass hard_delete to remove a channel and its data permanently + const otherChannel = client.chat.channel('messaging', otherChannelId); + await otherChannel.delete({ hard_delete: true }); + // #endregion snippet + + const [soft, hard] = await Promise.all( + deleteSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(deleteSpy).toHaveBeenCalledTimes(2); + expect(deleteSpy.mock.calls[1][0].hard_delete).toBe(true); + deleteSpy.mockRestore(); + + expect(soft.channel?.cid).toBe(cid); + expect(soft.channel?.deleted_at).toBeInstanceOf(Date); + expect(hard.channel?.cid).toBe(otherCid); + expect(await countChannels([cid, otherCid])).toBe(0); + }); + + it('preserves message history through a soft delete', async () => { + const memberId = memberIds[0]; + const { cid, messageId } = await createDistinctChannel(memberId); + const channelId = cid.split(':')[1]; + + // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Preserving Message History" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // Preserve history through the soft delete + await channel.delete({ skip_truncate: true }); + // #endregion snippet + + expect(await countChannels([cid])).toBe(0); + expect(await recreateDistinctChannel(memberId)).toContain(messageId); + }); + + it('deletes many channels', async () => { + const ids = [uniqueId('channel-1'), uniqueId('channel-2')]; + const [cid1, cid2] = ids.map((id) => `messaging:${id}`); + cleanup.channels.push(cid1, cid2); + for (const id of ids) { + await client.chat + .channel('messaging', id) + .getOrCreate({ data: { created_by_id: ownerId } }); + } + // The preserve history call only works on distinct channels. + const distinct = [ + await createDistinctChannel(memberIds[1]), + await createDistinctChannel(memberIds[2]), + ]; + const [distinctCid1, distinctCid2] = distinct.map((d) => d.cid); + + // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting Many Channels" tab="Node.js" index=1 + // soft delete + const response = await client.chat.deleteChannels({ cids: [cid1, cid2] }); + + // hard delete + const hardResponse = await client.chat.deleteChannels({ + cids: [cid1, cid2], + hard_delete: true, + }); + + // task_id is optional on the response, so check it before polling + if (hardResponse.task_id) { + const task = await client.getTask({ id: hardResponse.task_id }); + if (task.status === 'completed') { + // success! + } + } + + // preserve history (distinct channels only) + const preserveResponse = await client.chat.deleteChannels({ + cids: [distinctCid1, distinctCid2], + skip_truncate: true, + }); + // #endregion snippet + + const responses: DeleteChannelsResponse[] = [ + response, + hardResponse, + preserveResponse, + ]; + for (const r of responses) { + expect(r.task_id).toBeTruthy(); + if (r.task_id) await waitForTask(client, r.task_id); + } + expect(await countChannels([cid1, cid2])).toBe(0); + expect(await countChannels(distinct.map((d) => d.cid))).toBe(0); + + for (const [i, memberId] of [memberIds[1], memberIds[2]].entries()) { + expect(await recreateDistinctChannel(memberId)).toContain( + distinct[i].messageId, + ); + } + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts new file mode 100644 index 0000000..1416b1c --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { + UpdateChannelPartialResponse, + UpdateChannelResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { updateChannelType } from '../../../helpers/channel-types'; +import { getServerChannel } from '../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; + +const DOCS = '_default/03-channels/07-channel_management/14-freezing.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const userId = uniqueId('thierry'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + + beforeAll(async () => { + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { created_by_id: userId, members: [{ user_id: userId }] }, + }); + }); + + afterAll(() => cleanup.run()); + + it('freezes a channel with a full and a partial update', async () => { + const updateSpy = vi.spyOn(client.chat, 'updateChannel'); + const partialSpy = vi.spyOn(client.chat, 'updateChannelPartial'); + + // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Freeze a Channel" tab="Node.js" index=1 + // COPY: channelId="general", userId="thierry" + const channel = client.chat.channel('messaging', channelId); + + // Full update, with a system message announcing the freeze + await channel.update({ + data: { frozen: true }, + message: { text: 'Thierry has frozen the channel', user_id: userId }, + }); + + // Or a partial update + await channel.updateChannelPartial({ set: { frozen: true } }); + // #endregion snippet + + const [full] = await Promise.all( + updateSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + const [partial] = await Promise.all( + partialSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(partialSpy).toHaveBeenCalledTimes(1); + updateSpy.mockRestore(); + partialSpy.mockRestore(); + + expect(full.channel?.frozen).toBe(true); + expect(full.message?.text).toBe('Thierry has frozen the channel'); + expect(full.message?.user?.id).toBe(userId); + expect(partial.channel?.frozen).toBe(true); + + const { channel: current } = await getServerChannel(client, cid); + expect(current?.frozen).toBe(true); + }); + + it('unfreezes a channel', async () => { + await client.chat + .channel('messaging', channelId) + .updateChannelPartial({ set: { frozen: true } }); + const updateSpy = vi.spyOn(client.chat, 'updateChannel'); + + // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Unfreeze a Channel" tab="Node.js" index=1 + // COPY: channelId="general", userId="thierry" + const channel = client.chat.channel('messaging', channelId); + + await channel.update({ + data: { frozen: false }, + message: { text: 'Thierry has unfrozen the channel', user_id: userId }, + }); + // #endregion snippet + + const [full] = await Promise.all( + updateSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(updateSpy).toHaveBeenCalledTimes(1); + updateSpy.mockRestore(); + + expect(full.channel?.frozen).toBe(false); + expect(full.message?.text).toBe('Thierry has unfrozen the channel'); + + const { channel: current } = await getServerChannel(client, cid); + expect(current?.frozen).toBe(false); + }); + + it('grants use-frozen-channel to admins of the messaging type', async () => { + const original = await client.chat.getChannelType({ name: 'messaging' }); + const originalAdmin = original.grants.admin ?? []; + expect(originalAdmin).not.toContain('use-frozen-channel'); + cleanup.add(async () => { + await updateChannelType(client, 'messaging', { + grants: { admin: originalAdmin }, + }); + // Let the restore reach every API node before the DRIFT check reads it. + await waitForChannelTypePropagation(); + }); + + // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Granting the Frozen Channel Permission" tab="Node.js" index=1 + const channelType = await client.chat.getChannelType({ name: 'messaging' }); + + const adminGrants = [...channelType.grants.admin, 'use-frozen-channel']; + + // automod, automod_behavior and max_message_length are required on update and + // are not preserved when omitted, so read them back from the current type. + await client.chat.updateChannelType({ + name: 'messaging', + automod: channelType.automod, + automod_behavior: channelType.automod_behavior, + max_message_length: channelType.max_message_length, + grants: { admin: adminGrants }, + }); + // #endregion snippet + + await waitForChannelTypePropagation(); + + const updated = await client.chat.getChannelType({ name: 'messaging' }); + expect(updated.grants.admin).toContain('use-frozen-channel'); + expect(updated.grants.channel_member).toEqual( + original.grants.channel_member, + ); + expect(updated.automod).toBe(original.automod); + expect(updated.max_message_length).toBe(original.max_message_length); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts new file mode 100644 index 0000000..1722938 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts @@ -0,0 +1,192 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { TruncateChannelResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../../helpers/server'; + +const DOCS = '_default/03-channels/07-channel_management/15-truncating.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const ownerId = uniqueId('owner'); + const janeId = uniqueId('jane'); + const johnId = uniqueId('john'); + + const createChannel = async (name: string) => { + const channelId = uniqueId(name); + const cid = `messaging:${channelId}`; + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: ownerId, + members: [ownerId, janeId, johnId].map((user_id) => ({ user_id })), + }, + }); + return { channelId, cid }; + }; + + const visibleTexts = async (cid: string, userId: string) => { + const { messages } = await getServerChannel(client, cid, { + user_id: userId, + message_limit: 20, + }); + return messages.map((m) => m.text); + }; + + beforeAll(async () => { + cleanup.users.push(ownerId, janeId, johnId); + await client.upsertUsers([ownerId, janeId, johnId].map((id) => ({ id }))); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => cleanup.run()); + + it('truncates a channel', async () => { + const { channelId, cid } = await createChannel('general'); + const { channelId: otherChannelId, cid: otherCid } = + await createChannel('random'); + await sendServerMessage(client, otherCid, { + text: 'recent', + user_id: ownerId, + }); + const user = { id: ownerId }; + + // Before each truncate, send a message; after it, record what the owner sees. + const visibleAfter: Array> = []; + const original = client.chat.truncateChannel.bind(client.chat); + const truncateSpy = vi + .spyOn(client.chat, 'truncateChannel') + .mockImplementation(async (request) => { + const requestCid = `${request.type}:${request.id}`; + await sendServerMessage(client, requestCid, { + text: `before-${visibleAfter.length + 1}`, + user_id: ownerId, + }); + const response = await original(request); + visibleAfter.push(await visibleTexts(requestCid, ownerId)); + return response; + }); + + // #region snippet docs="_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate a Channel" tab="Node.js" index=1 + // COPY: channelId="general", otherChannelId="random" + // Get a channel + const channel = client.chat.channel('messaging', channelId); + + await channel.truncate(); + + // Or with parameters: + await channel.truncate({ + hard_delete: true, + skip_push: false, + message: { + text: 'Dear Everyone. The channel has been truncated.', + user_id: user.id, + }, + }); + + // Setting user_id server side: + await channel.truncate({ user_id: user.id }); + + const otherChannel = client.chat.channel('messaging', otherChannelId); + await otherChannel.truncate({ + truncated_at: new Date(Date.now() - 60 * 60 * 1000), + }); + // #endregion snippet + + const responses = await Promise.all( + truncateSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(truncateSpy).toHaveBeenCalledTimes(4); + truncateSpy.mockRestore(); + + expect(responses.map((r) => r.channel?.cid)).toEqual([ + cid, + cid, + cid, + otherCid, + ]); + for (const r of responses) expect(r.channel?.truncated_at).toBeTruthy(); + // Each truncation of `general` hides everything sent before it. + expect(visibleAfter[0]).toEqual([]); + expect(responses[1].message?.text).toBe( + 'Dear Everyone. The channel has been truncated.', + ); + expect(visibleAfter[1]).toEqual([ + 'Dear Everyone. The channel has been truncated.', + ]); + expect(visibleAfter[2]).toEqual([]); + // `truncated_at` an hour ago keeps the messages sent since then. + const truncatedAt = responses[3].channel?.truncated_at; + expect(truncatedAt).toBeInstanceOf(Date); + if (truncatedAt) { + expect(Date.now() - truncatedAt.getTime()).toBeGreaterThan( + 59 * 60 * 1000, + ); + } + expect(visibleAfter[3]).toEqual(['recent', 'before-4']); + }); + + it('truncates for specific members', async () => { + const { channelId, cid } = await createChannel('general'); + await sendServerMessage(client, cid, { text: 'history', user_id: ownerId }); + + const visibleAfter: Array>> = []; + const original = client.chat.truncateChannel.bind(client.chat); + const truncateSpy = vi + .spyOn(client.chat, 'truncateChannel') + .mockImplementation(async (request) => { + const response = await original(request); + visibleAfter.push({ + owner: await visibleTexts(cid, ownerId), + jane: await visibleTexts(cid, janeId), + john: await visibleTexts(cid, johnId), + }); + return response; + }); + + // #region snippet docs="_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate for Specific Members" tab="Node.js" index=1 + // COPY: channelId="general", janeId="jane", johnId="john" + const channel = client.chat.channel('messaging', channelId); + + // Hide the history for two members; everyone else keeps their full view + await channel.truncate({ + member_ids: [janeId, johnId], + }); + + // Hide only the messages created before a given time + await channel.truncate({ + member_ids: [janeId], + truncated_at: new Date('2026-01-01T00:00:00Z'), + }); + // #endregion snippet + + expect(truncateSpy).toHaveBeenCalledTimes(2); + truncateSpy.mockRestore(); + // The first call hides the history from jane and john, not from the owner. + expect(visibleAfter[0]).toEqual({ owner: ['history'], jane: [], john: [] }); + // The second call replaces jane's cutoff with an earlier one: she sees it again. + expect(visibleAfter[1]).toEqual({ + owner: ['history'], + jane: ['history'], + john: [], + }); + // No messages were deleted. + expect(await visibleTexts(cid, ownerId)).toEqual(['history']); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts new file mode 100644 index 0000000..06f5b79 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts @@ -0,0 +1,161 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerMember } from '../../../helpers/server'; + +const DOCS = '_default/03-channels/07-channel_management/16-channel_invites.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const ownerId = uniqueId('owner'); + const nickId = uniqueId('nick'); + const robId = uniqueId('rob'); + + // one channel per snippet, plus one per invite status for rob + const inviteChannelId = uniqueId('general'); + const acceptChannelId = uniqueId('awesome-chat'); + const rejectChannelId = uniqueId('general'); + const robAcceptedChannelId = uniqueId('rob-accepted'); + const robPendingChannelId = uniqueId('rob-pending'); + + const createChannel = async (id: string, invites: string[]) => { + cleanup.channels.push(`messaging:${id}`); + await client.chat.channel('messaging', id).getOrCreate({ + data: { + created_by_id: ownerId, + members: [{ user_id: ownerId }], + invites: invites.map((user_id) => ({ user_id })), + }, + }); + }; + + beforeAll(async () => { + cleanup.users.push(ownerId, nickId, robId); + await client.upsertUsers([{ id: ownerId }, { id: nickId }, { id: robId }]); + await createChannel(inviteChannelId, []); + await createChannel(acceptChannelId, [nickId]); + await createChannel(rejectChannelId, [robId]); + await createChannel(robAcceptedChannelId, [robId]); + await createChannel(robPendingChannelId, [robId]); + await client.chat + .channel('messaging', robAcceptedChannelId) + .update({ accept_invite: true, user_id: robId }); + }); + + afterAll(() => cleanup.run()); + + it('invites a user', async () => { + // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Invite Users" tab="Node.js" index=1 + // COPY: inviteChannelId="general", nickId="nick" + // Get a channel + const channel = client.chat.channel('messaging', inviteChannelId); + + await channel.update({ invites: [{ user_id: nickId }] }); + // #endregion snippet + + const member = await getServerMember( + client, + `messaging:${inviteChannelId}`, + nickId, + ); + expect(member.invited).toBe(true); + expect(member.invite_accepted_at).toBeUndefined(); + expect(member.invite_rejected_at).toBeUndefined(); + }); + + it('accepts an invite with a system message', async () => { + // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Accept an Invite" tab="Node.js" index=1 + // COPY: acceptChannelId="awesome-chat", nickId="nick" + // initialize the channel + const channel = client.chat.channel('messaging', acceptChannelId); + + // accept the invite on behalf of nick, posting a system message + await channel.update({ + accept_invite: true, + user_id: nickId, + message: { text: 'Nick joined this channel!', user_id: nickId }, + }); + // #endregion snippet + + const member = await getServerMember( + client, + `messaging:${acceptChannelId}`, + nickId, + ); + expect(member.invite_accepted_at).toBeDefined(); + + const { messages } = await channel.getOrCreate({ + state: true, + messages: { limit: 10 }, + }); + expect( + messages.some( + (m) => m.text === 'Nick joined this channel!' && m.user.id === nickId, + ), + ).toBe(true); + }); + + it('rejects an invite', async () => { + // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Reject an Invite" tab="Node.js" index=1 + // COPY: rejectChannelId="general", robId="rob" + const channel = client.chat.channel('messaging', rejectChannelId); + + await channel.update({ reject_invite: true, user_id: robId }); + // #endregion snippet + + const member = await getServerMember( + client, + `messaging:${rejectChannelId}`, + robId, + ); + expect(member.invite_rejected_at).toBeDefined(); + }); + + it('queries accepted invites', async () => { + // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Accepted Invites" tab="Node.js" index=1 + // COPY: robId="rob" + // Query accepted invites for user rob + const invites = await client.chat.queryChannels({ + filter_conditions: { invite: 'accepted' }, + user_id: robId, + }); + // #endregion snippet + + expect(invites.channels.map((c) => c.channel?.id)).toEqual([ + robAcceptedChannelId, + ]); + }); + + it('queries rejected invites', async () => { + // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Rejected Invites" tab="Node.js" index=1 + // COPY: robId="rob" + // Query rejected invites for user rob + const invites = await client.chat.queryChannels({ + filter_conditions: { invite: 'rejected' }, + user_id: robId, + }); + // #endregion snippet + + expect(invites.channels.map((c) => c.channel?.id)).toEqual([ + rejectChannelId, + ]); + }); + + it('queries pending invites', async () => { + // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Pending Invites" tab="Node.js" index=1 + // COPY: robId="rob" + // Query pending invites for user rob + const invites = await client.chat.queryChannels({ + filter_conditions: { invite: 'pending' }, + user_id: robId, + }); + // #endregion snippet + + expect(invites.channels.map((c) => c.channel?.id)).toEqual([ + robPendingChannelId, + ]); + }); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/17-batch-updates.test.ts b/__tests__/docs-snippets/pages/03-channels/07-channel_management/17-batch-updates.test.ts new file mode 100644 index 0000000..bbc719e --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/07-channel_management/17-batch-updates.test.ts @@ -0,0 +1,207 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { GetTaskResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel, getServerMember } from '../../../helpers/server'; +import { + waitForBatchTask, + waitForChannelTypePropagation, +} from '../../../helpers/wait'; + +const DOCS = '_default/03-channels/07-channel_management/17-batch-updates.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const userId = uniqueId('user'); + const creatorId = uniqueId('creator'); + + // The docs target every `messaging` / `team` channel of the app: the tests use their + // own channel types instead, so only the test's channels are touched. + const messagingType = uniqueId('batch-messaging'); + const teamType = uniqueId('batch-team'); + const messagingCids = [ + `${messagingType}:${uniqueId('a')}`, + `${messagingType}:${uniqueId('b')}`, + ]; + const teamCid = `${teamType}:${uniqueId('c')}`; + // Channels for the CID filter (any type works, they are matched by CID). + const [cid1, cid2, cid3, cid4] = ['d', 'e', 'f', 'g'].map( + (name) => `messaging:${uniqueId(name)}`, + ); + + const createChannel = (cid: string) => { + const [type, id] = cid.split(':'); + cleanup.channels.push(cid); + return client.chat.channel(type, id).getOrCreate({ + data: { + created_by_id: creatorId, + members: [{ user_id: creatorId }], + custom: { keep: 'me' }, + }, + }); + }; + + beforeAll(async () => { + cleanup.users.push(userId, creatorId); + await client.upsertUsers([{ id: userId }, { id: creatorId }]); + + for (const name of [messagingType, teamType]) { + await client.chat.createChannelType({ + name, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(name); + } + await waitForChannelTypePropagation(); + + for (const cid of [...messagingCids, teamCid, cid1, cid2, cid3, cid4]) { + await createChannel(cid); + } + }); + + afterAll(() => cleanup.run()); + + it('targets channels by type and by CID', async () => { + // #region snippet docs="_default/03-channels/07-channel_management/17-batch-updates.md" heading="Filter examples" tab="Node.js" index=1 + // COPY: messagingType="messaging", cid1="messaging:3b11838a-7734-4ece-8547-4b8524257671", cid2="messaging:a266bee6-dc3c-4188-a37d-e554d4bfac34", cid3="messaging:40fef12a-0b7c-4bcf-bd97-3ddf604efed5", cid4="messaging:2a58963e-d769-4ce3-9309-bff93c14db57" + // 1) Filter by type + const filters = { + types: { $in: [messagingType] }, + }; + + // 2) Filter by specific CIDs + const filtersByCIDS = { + cids: { + $in: [cid1, cid2, cid3, cid4], + }, + }; + // #endregion snippet + + const byType = await client.chat.channelBatchUpdate({ + operation: 'updateData', + filter: filters, + custom_set: { by_type: true }, + }); + expect(byType.task_id).toBeDefined(); + if (byType.task_id) { + const task = await waitForBatchTask(client, byType.task_id); + expect(task.result?.success_channels_count).toBe(messagingCids.length); + } + + const byCids = await client.chat.channelBatchUpdate({ + operation: 'updateData', + synchronous: true, + filter: filtersByCIDS, + custom_set: { by_cid: true }, + }); + if (byCids.task_id) { + await waitForBatchTask(client, byCids.task_id); + } else { + expect(byCids.success_channels_count).toBe(4); + } + + for (const cid of messagingCids) { + const { channel } = await getServerChannel(client, cid); + expect(channel?.custom).toMatchObject({ keep: 'me', by_type: true }); + } + const { channel: other } = await getServerChannel(client, teamCid); + expect(other?.custom.by_type).toBeUndefined(); + for (const cid of [cid1, cid2, cid3, cid4]) { + const { channel } = await getServerChannel(client, cid); + expect(channel?.custom).toMatchObject({ keep: 'me', by_cid: true }); + } + }, 240000); + + it('adds members and updates channel data', async () => { + // #region snippet docs="_default/03-channels/07-channel_management/17-batch-updates.md" heading="Config overrides" tab="Node.js" index=1 + // COPY: messagingType="messaging", teamType="team", userId="user-123" + // Name the operation explicitly. + + // Add members + const addResp = await client.chat.channelBatchUpdate({ + operation: 'addMembers', + filter: { types: { $in: [messagingType] } }, + members: [{ user_id: userId }], + }); + + // Update channel data + const dataResp = await client.chat.channelBatchUpdate({ + operation: 'updateData', + filter: { types: { $in: [messagingType, teamType] } }, + data: { + frozen: true, + custom: { color: 'blue' }, + }, + }); + // #endregion snippet + + expect(addResp.task_id).toBeDefined(); + expect(dataResp.task_id).toBeDefined(); + if (addResp.task_id) { + const task = await waitForBatchTask(client, addResp.task_id); + expect(task.result?.success_channels_count).toBe(messagingCids.length); + } + if (dataResp.task_id) { + const task = await waitForBatchTask(client, dataResp.task_id); + expect(task.result?.success_channels_count).toBe( + messagingCids.length + 1, + ); + } + + for (const cid of messagingCids) { + const member = await getServerMember(client, cid, userId); + expect(member.user_id).toBe(userId); + } + await expect(getServerMember(client, teamCid, userId)).rejects.toThrow( + 'got 0', + ); + for (const cid of [...messagingCids, teamCid]) { + const { channel } = await getServerChannel(client, cid); + expect(channel?.frozen).toBe(true); + // `custom` replaces the whole custom object. + expect(channel?.custom).toEqual({ color: 'blue' }); + } + }, 240000); + + it('checks the status of a batch update', async () => { + const response = await client.chat.channelBatchUpdate({ + operation: 'updateData', + filter: { cids: { $in: [cid1] } }, + custom_set: { status_check: true }, + }); + expect(response.task_id).toBeDefined(); + const getTaskSpy = vi.spyOn(client, 'getTask'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // #region snippet docs="_default/03-channels/07-channel_management/17-batch-updates.md" heading="Status" tab="Node.js" index=1 + // task_id is optional on the batch update response, so check it before polling + if (response.task_id) { + const taskResponse = await client.getTask({ id: response.task_id }); + console.log(taskResponse.result?.status); + } + // #endregion snippet + + const [taskResponse] = await Promise.all( + getTaskSpy.mock.results.map((r) => r.value as Promise), + ); + expect(getTaskSpy).toHaveBeenCalledTimes(1); + expect(getTaskSpy.mock.calls[0][0]).toEqual({ id: response.task_id }); + expect(logSpy).toHaveBeenCalledTimes(1); + getTaskSpy.mockRestore(); + logSpy.mockRestore(); + expect(taskResponse.task_id).toBe(response.task_id); + + if (response.task_id) { + const task = await waitForBatchTask(client, response.task_id); + expect(task.result?.success_channels_count).toBe(1); + expect(task.result?.operation).toBe('updateData'); + } + const { channel } = await getServerChannel(client, cid1); + expect(channel?.custom.status_check).toBe(true); + }, 240000); +}); diff --git a/__tests__/docs-snippets/pages/03-channels/08-get_channel.test.ts b/__tests__/docs-snippets/pages/03-channels/08-get_channel.test.ts new file mode 100644 index 0000000..584b802 --- /dev/null +++ b/__tests__/docs-snippets/pages/03-channels/08-get_channel.test.ts @@ -0,0 +1,89 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { StreamError } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; + +const DOCS = '_default/03-channels/08-get_channel.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const userId = uniqueId('owner'); + const channelId = uniqueId('general'); + const missingId = uniqueId('does-not-exist'); + + beforeAll(async () => { + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + + cleanup.channels.push(`messaging:${channelId}`); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { created_by_id: userId, members: [{ user_id: userId }] }, + }); + await sendServerMessage(client, `messaging:${channelId}`, { + text: 'hello', + user_id: userId, + }); + }); + + afterAll(() => cleanup.run()); + + it('gets a channel on the default path, with state, and a missing one', async () => { + // The snippet's last call rejects with a 404. Record that error instead of throwing, so + // the region runs without a try/catch and its results can be checked after it. + const original = client.chat.getChannel.bind(client.chat); + let error: unknown; + const getChannel = vi + .spyOn(client.chat, 'getChannel') + .mockImplementation(async (request) => { + try { + return await original(request); + } catch (e) { + if (request.id !== missingId) throw e; + error = e; + return undefined as never; + } + }); + + // #region snippet docs="_default/03-channels/08-get_channel.md" heading="Using an SDK" tab="Node.js" index=1 + // COPY: channelId="general", missingId="does-not-exist" + // Get a channel handle + const channel = client.chat.channel('messaging', channelId); + + // Cheap default path (channel, config, members) + const response = await channel.get(); + + // Full channel state (messages, read state, watchers) + const withState = await channel.get({ + state: true, + messages_limit: 25, + watchers_limit: 10, + }); + + // A missing channel returns a 404 + await client.chat.getChannel({ type: 'messaging', id: missingId }); + // #endregion snippet + + expect(getChannel).toHaveBeenCalledTimes(3); + getChannel.mockRestore(); + + expect(response.channel?.cid).toBe(`messaging:${channelId}`); + expect(response.members.map((m) => m.user_id)).toEqual([userId]); + expect(response.messages ?? []).toHaveLength(0); + + expect(withState.channel?.cid).toBe(`messaging:${channelId}`); + expect(withState.messages.map((m) => m.text)).toEqual(['hello']); + + expect(error).toBeInstanceOf(StreamError); + expect((error as StreamError).metadata.responseCode).toBe(404); + + // Get Channel doesn't create the missing channel. + const { channels } = await client.chat.queryChannels({ + filter_conditions: { cid: `messaging:${missingId}` }, + }); + expect(channels).toHaveLength(0); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/01-send_message.test.ts b/__tests__/docs-snippets/pages/04-messages/01-send_message.test.ts new file mode 100644 index 0000000..16852da --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/01-send_message.test.ts @@ -0,0 +1,351 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; +import { retry } from '../../helpers/wait'; + +const DOCS = '_default/04-messages/01-send_message.md'; + +const isNotFound = (error: unknown) => + error instanceof Error && /not.?found|404/i.test(error.message); + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const john = uniqueId('john'); + const josh = uniqueId('josh'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + const designTeamId = uniqueId('design-team'); + + beforeAll(async () => { + cleanup.users.push(john, josh); + await client.upsertUsers([{ id: john }, { id: josh }]); + + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: john, + members: [{ user_id: john }, { user_id: josh }], + }, + }); + + cleanup.add(async () => { + try { + await client.deleteUserGroup({ id: designTeamId }); + } catch (error) { + if (!isNotFound(error)) throw error; + } + }); + await client.createUserGroup({ + id: designTeamId, + name: uniqueId('design'), + member_ids: [josh], + }); + }); + + afterAll(() => cleanup.run()); + + it('sends a message', async () => { + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 + // COPY: channelId="general", john="john" + const channel = client.chat.channel('messaging', channelId); + + const { message } = await channel.sendMessage({ + message: { + text: 'Hello, world!', + user_id: john, + }, + }); + // #endregion snippet + + expect(message.text).toBe('Hello, world!'); + expect(message.user?.id).toBe(john); + expect(message.cid).toBe(cid); + }); + + it('sends messages with mentions', async () => { + const sendMessage = vi.spyOn(client.chat, 'sendMessage'); + + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Mentions" tab="Node.js" index=1 + // COPY: channelId="general", john="john", designTeamId="design-team-id", josh="josh" + const channel = client.chat.channel('messaging', channelId); + + // Send a message mentioning all online users + await channel.sendMessage({ + message: { text: 'Hey everyone!', user_id: john, mentioned_here: true }, + }); + + // Send a message mentioning all channel members + await channel.sendMessage({ + message: { + text: 'Important announcement!', + user_id: john, + mentioned_channel: true, + }, + }); + + // Send a message mentioning specific roles + await channel.sendMessage({ + message: { + text: 'Attention moderators!', + user_id: john, + mentioned_roles: ['channel_moderator', 'admin'], + }, + }); + + // Send a message mentioning a user group + await channel.sendMessage({ + message: { + text: 'Design team, please review!', + user_id: john, + mentioned_group_ids: [designTeamId], + }, + }); + + // Send a message mentioning specific users + await channel.sendMessage({ + message: { + text: '@Josh can you take a look?', + user_id: john, + mentioned_users: [josh], + }, + }); + // #endregion snippet + + const messages = await Promise.all( + sendMessage.mock.results.map( + async (r) => + (await (r.value as ReturnType)) + .message, + ), + ); + sendMessage.mockRestore(); + + expect(messages).toHaveLength(5); + const [here, all, roles, group, users] = messages; + expect(here.mentioned_here).toBe(true); + expect(all.mentioned_channel).toBe(true); + expect(roles.mentioned_roles).toEqual(['channel_moderator', 'admin']); + expect(group.mentioned_group_ids).toEqual([designTeamId]); + expect(users.mentioned_users.map((u) => u.id)).toEqual([josh]); + }); + + it('sends a message with attachments', async () => { + const sendMessage = vi.spyOn(client.chat, 'sendMessage'); + + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending Messages with Attachments" tab="Node.js" index=1 + // COPY: channelId="general", john="john", josh="josh" + const channel = client.chat.channel('messaging', channelId); + + // Custom fields go under `custom`, on the message and on each attachment. + // `custom` is a required field on an attachment, so pass `{}` when empty. + await channel.sendMessage({ + message: { + text: '@Josh Check out this image!', + user_id: john, + attachments: [ + { + type: 'image', + asset_url: 'https://bit.ly/2K74TaG', + thumb_url: 'https://bit.ly/2Uumxti', + custom: { myCustomField: 123 }, + }, + ], + mentioned_users: [josh], + custom: { priority: 'high' }, + }, + skip_push: true, + }); + // #endregion snippet + + const { message } = await (sendMessage.mock.results[0].value as ReturnType< + typeof client.chat.sendMessage + >); + sendMessage.mockRestore(); + + expect(message.custom.priority).toBe('high'); + expect(message.mentioned_users.map((u) => u.id)).toEqual([josh]); + expect(message.attachments).toHaveLength(1); + expect(message.attachments[0].type).toBe('image'); + expect(message.attachments[0].asset_url).toBe('https://bit.ly/2K74TaG'); + expect(message.attachments[0].custom.myCustomField).toBe(123); + }); + + it('enriches URLs', async () => { + // #region snippet docs="_default/04-messages/01-send_message.md" heading="URL Enrichment" tab="Node.js" index=1 + // COPY: channelId="general", john="john" + const channel = client.chat.channel('messaging', channelId); + + const response = await channel.sendMessage({ + message: { + text: 'Check this out https://imgur.com/r/bears/4zmGbMN', + user_id: john, + }, + }); + // #endregion snippet + + expect(response.message.text).toContain('https://imgur.com'); + // Server-side, the scrape runs after the response: the attachment shows up on the + // stored message a moment later. + const attachment = await retry( + async () => { + const { message } = await client.chat.getMessage({ + id: response.message.id, + }); + const [first] = message.attachments; + if (!first) throw new Error('no URL attachment yet'); + return first; + }, + { timeout: 30000 }, + ); + expect(attachment.og_scrape_url).toBe('https://imgur.com/r/bears/4zmGbMN'); + }); + + it('retrieves a message, and a soft-deleted one with its original content', async () => { + const messageID = ( + await sendServerMessage(client, cid, { + text: 'to be retrieved', + user_id: john, + }) + ).id; + await client.chat.deleteMessage({ id: messageID }); + + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Retrieving a Message" tab="Node.js" index=1 + const { message } = await client.chat.getMessage({ id: messageID }); + + // For soft-deleted messages, retrieve the original content (server-side only) + const deleted = await client.chat.getMessage({ + id: messageID, + show_deleted_message: true, + }); + // #endregion snippet + + expect(message.id).toBe(messageID); + expect(message.type).toBe('deleted'); + expect(message.text).not.toBe('to be retrieved'); + expect(deleted.message.text).toBe('to be retrieved'); + }); + + it('updates a message', async () => { + const original = await sendServerMessage(client, cid, { + text: 'original text', + user_id: john, + custom: { color: 'red' }, + }); + const messageId = original.id; + + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Updating a Message" tab="Node.js" index=1 + // COPY: john="john" + // A full update replaces the message, so send every field you want to keep. + // Use the partial update below to change one field in place. + const updated = await client.chat.updateMessage({ + id: messageId, + message: { + text: 'Updated message text', + user_id: john, + }, + }); + // #endregion snippet + + expect(updated.message.id).toBe(messageId); + expect(updated.message.text).toBe('Updated message text'); + const { message } = await client.chat.getMessage({ id: messageId }); + expect(message.text).toBe('Updated message text'); + expect(message.custom.color).toBeUndefined(); + }); + + it('partially updates a message', async () => { + const originalMessage = await sendServerMessage(client, cid, { + text: 'original text', + user_id: john, + custom: { color: 'red', details: { status: 'pending', owner: john } }, + }); + + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Partial Update" tab="Node.js" index=1 + // COPY: john="john" + // Update text + await client.chat.updateMessagePartial({ + id: originalMessage.id, + set: { text: 'Updated text' }, + user_id: john, + }); + + // Remove a custom field + await client.chat.updateMessagePartial({ + id: originalMessage.id, + unset: ['color'], + user_id: john, + }); + + // Update nested properties + await client.chat.updateMessagePartial({ + id: originalMessage.id, + set: { 'details.status': 'complete' }, + user_id: john, + }); + // #endregion snippet + + const { message } = await client.chat.getMessage({ + id: originalMessage.id, + }); + expect(message.text).toBe('Updated text'); + expect(message.custom.color).toBeUndefined(); + expect(message.custom.details).toEqual({ status: 'complete', owner: john }); + }); + + it('deletes messages', async () => { + const messageID = ( + await sendServerMessage(client, cid, { text: 'to delete', user_id: john }) + ).id; + const anotherMessageID = ( + await sendServerMessage(client, cid, { + text: 'to delete as john', + user_id: josh, + }) + ).id; + + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Deleting a Message" tab="Node.js" index=1 + // COPY: john="john" + // Soft delete + await client.chat.deleteMessage({ id: messageID }); + + // Hard delete + await client.chat.deleteMessage({ id: messageID, hard: true }); + + // Attribute the deletion to a specific user + await client.chat.deleteMessage({ id: anotherMessageID, deleted_by: john }); + // #endregion snippet + + await expect(client.chat.getMessage({ id: messageID })).rejects.toThrow( + /doesn't exist|not found/i, + ); + const { message } = await client.chat.getMessage({ id: anotherMessageID }); + expect(message.type).toBe('deleted'); + expect(message.deleted_at).toBeDefined(); + }); + + it('undeletes a message', async () => { + const userID = john; + const messageID = ( + await sendServerMessage(client, cid, { + text: 'to restore', + user_id: john, + }) + ).id; + await client.chat.deleteMessage({ id: messageID }); + + // #region snippet docs="_default/04-messages/01-send_message.md" heading="Undeleting a Message" tab="Node.js" index=1 + await client.chat.undeleteMessage({ + id: messageID, + undeleted_by: userID, + }); + // #endregion snippet + + const { message } = await client.chat.getMessage({ id: messageID }); + expect(message.type).toBe('regular'); + expect(message.text).toBe('to restore'); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/02-file_uploads.standalone.test.ts b/__tests__/docs-snippets/pages/04-messages/02-file_uploads.standalone.test.ts new file mode 100644 index 0000000..71b1044 --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/02-file_uploads.standalone.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { getServerUser } from '../../helpers/server'; + +// The "Uploading Standalone Files" fence of 02-file_uploads.md. It is in its own file +// because it imports the same names as the "Uploading Files to a Channel" fence, and +// imports can only run at module level (one binding per module). + +// A 4x4 JPEG, written to a temp dir: the docs read `./avatar.jpg`, COPY-mapped to it. +const JPEG_BASE64 = + '/9j/4AAQSkZJRgABAQAAkACQAAD/4QCARXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAASgAwAEAAAAAQAAAAQAAAAA/8AAEQgABAAEAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8A/fSJPL34JO5i3J6Z7D2qXNNHeloA/9k='; +const fixtureDir = mkdtempSync(join(tmpdir(), 'docs-file-uploads-')); +const avatarPath = join(fixtureDir, 'avatar.jpg'); +writeFileSync(avatarPath, Buffer.from(JPEG_BASE64, 'base64')); + +const client = getServerClient(); +const cleanup = new Cleanup(client); +const userId = uniqueId('user'); + +// Everything is registered on `cleanup` before it is created. +cleanup.users.push(userId); +cleanup.add(() => rm(fixtureDir, { recursive: true, force: true })); +await client.upsertUsers([{ id: userId }]); + +// #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Uploading Standalone Files" tab="Node.js" index=1 +// COPY: avatarPath="./avatar.jpg" +import { readFile } from 'node:fs/promises'; +import { File } from 'node:buffer'; + +const bytes = await readFile(avatarPath); + +const { file: imageUrl } = await client.uploadImage({ + file: new File([bytes], 'avatar.jpg', { type: 'image/jpeg' }), + user: { id: userId }, +}); + +// Example: use the image URL as a user avatar +await client.updateUsersPartial({ + users: [{ id: userId, set: { image: imageUrl } }], +}); +// #endregion snippet + +if (imageUrl) { + cleanup.add(() => client.deleteImage({ url: imageUrl })); +} + +describe('_default/04-messages/02-file_uploads.md (standalone files)', () => { + afterAll(() => cleanup.run()); + + it('uploads a standalone image and uses it as a user avatar', async () => { + expect(imageUrl).toMatch(/^https:\/\//); + const user = await getServerUser(client, userId); + // The stored URL is re-signed on read, so compare without the query string. + expect(user.image?.split('?')[0]).toBe(imageUrl?.split('?')[0]); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/02-file_uploads.test.ts b/__tests__/docs-snippets/pages/04-messages/02-file_uploads.test.ts new file mode 100644 index 0000000..ddb66d2 --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/02-file_uploads.test.ts @@ -0,0 +1,225 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { StreamError } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; + +// A 4x4 JPEG and a minimal PDF, written to a temp dir: the docs read `./image1.jpg`, +// `./image2.jpg` and `./report.pdf`, COPY-mapped to these paths. +const JPEG_BASE64 = + '/9j/4AAQSkZJRgABAQAAkACQAAD/4QCARXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAASgAwAEAAAAAQAAAAQAAAAA/8AAEQgABAAEAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8A/fSJPL34JO5i3J6Z7D2qXNNHeloA/9k='; +const fixtureDir = mkdtempSync(join(tmpdir(), 'docs-file-uploads-')); +const image1Path = join(fixtureDir, 'image1.jpg'); +const image2Path = join(fixtureDir, 'image2.jpg'); +const reportPath = join(fixtureDir, 'report.pdf'); +writeFileSync(image1Path, Buffer.from(JPEG_BASE64, 'base64')); +writeFileSync(image2Path, Buffer.from(JPEG_BASE64, 'base64')); +writeFileSync(reportPath, '%PDF-1.4\n%%EOF\n'); + +const client = getServerClient(); + +/** Swallows the 404 of deleting an upload that is already gone. */ +const ignoreMissingFile = (error: unknown) => { + if (error instanceof StreamError && error.metadata?.responseCode === 404) { + return; + } + throw error; +}; +const cleanup = new Cleanup(client); +const johnId = uniqueId('john'); +const channelId = uniqueId('general'); +const cid = `messaging:${channelId}`; + +// The first region imports from node modules, so it runs at module level (imports +// can't go inside a test). Everything is registered on `cleanup` before it is created. +cleanup.users.push(johnId); +cleanup.channels.push(cid); +cleanup.add(() => rm(fixtureDir, { recursive: true, force: true })); +await client.upsertUsers([{ id: johnId }]); +await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: johnId } }); + +// #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Uploading Files to a Channel" tab="Node.js" index=1 +// COPY: image1Path="./image1.jpg", image2Path="./image2.jpg", reportPath="./report.pdf", channelId="general", johnId="john" +import { readFile } from 'node:fs/promises'; +import { File } from 'node:buffer'; + +const channel = client.chat.channel('messaging', channelId); + +// Upload multiple images in parallel +const results = await Promise.all( + [image1Path, image2Path].map(async (path) => { + const bytes = await readFile(path); + const upload = new File([bytes], path, { type: 'image/jpeg' }); + return channel.uploadChannelImage({ file: upload, user: { id: johnId } }); + }), +); + +await channel.sendMessage({ + message: { + text: 'Check out these images', + user_id: johnId, + attachments: results.map((response) => ({ + type: 'image', + asset_url: response.file, + thumb_url: response.file, + custom: {}, + })), + }, +}); + +const report = await readFile(reportPath); +const reportUpload = new File([report], 'report.pdf', { + type: 'application/pdf', +}); +const fileRes = await channel.uploadChannelFile({ + file: reportUpload, + user: { id: johnId }, +}); + +await channel.sendMessage({ + message: { + text: 'Here is the report', + user_id: johnId, + attachments: [ + { + type: 'file', + asset_url: fileRes.file, + title: 'report.pdf', + custom: {}, + }, + ], + }, +}); +// #endregion snippet + +describe('_default/04-messages/02-file_uploads.md', () => { + afterAll(() => cleanup.run()); + + it('uploads files to a channel and attaches them to messages', async () => { + expect(results).toHaveLength(2); + for (const response of results) { + expect(response.file).toMatch(/^https:\/\//); + } + expect(fileRes.file).toMatch(/^https:\/\//); + + const { messages } = await client.chat + .channel('messaging', channelId) + .getOrCreate({ state: true, messages: { limit: 10 } }); + const images = messages.find((m) => m.text === 'Check out these images'); + expect(images?.attachments.map((a) => a.type)).toEqual(['image', 'image']); + expect(images?.attachments.every((a) => !!a.asset_url)).toBe(true); + const reportMessage = messages.find((m) => m.text === 'Here is the report'); + expect(reportMessage?.attachments[0]).toMatchObject({ + type: 'file', + title: 'report.pdf', + }); + expect(reportMessage?.attachments[0]?.asset_url).toBeTruthy(); + }); + + it('deletes files', async () => { + const uploadChannel = client.chat.channel('messaging', channelId); + const jpeg = Buffer.from(JPEG_BASE64, 'base64'); + const { file: fileURL } = await uploadChannel.uploadChannelFile({ + file: new File(['hello'], 'hello.txt', { type: 'text/plain' }), + user: { id: johnId }, + }); + const { file: imageURL } = await uploadChannel.uploadChannelImage({ + file: new File([jpeg], 'image.jpg', { type: 'image/jpeg' }), + user: { id: johnId }, + }); + const { file: standaloneFileURL } = await client.uploadFile({ + file: new File(['hello'], 'hello.txt', { type: 'text/plain' }), + user: { id: johnId }, + }); + const { file: standaloneImageURL } = await client.uploadImage({ + file: new File([jpeg], 'image.jpg', { type: 'image/jpeg' }), + user: { id: johnId }, + }); + // In case the region fails before deleting them. + cleanup.add(() => + client.deleteFile({ url: standaloneFileURL }).catch(ignoreMissingFile), + ); + cleanup.add(() => + client.deleteImage({ url: standaloneImageURL }).catch(ignoreMissingFile), + ); + for (const url of [ + fileURL, + imageURL, + standaloneFileURL, + standaloneImageURL, + ]) { + expect(url).toMatch(/^https:\/\//); + } + + // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Deleting Files" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // Delete from the channel + await channel.deleteChannelFile({ url: fileURL }); + await channel.deleteChannelImage({ url: imageURL }); + + // Delete a standalone upload + await client.deleteFile({ url: standaloneFileURL }); + await client.deleteImage({ url: standaloneImageURL }); + // #endregion snippet + + // Deleting them again fails: they are gone. + const missing = { code: 16, metadata: { responseCode: 404 } }; + await expect( + channel.deleteChannelFile({ url: fileURL }), + ).rejects.toMatchObject(missing); + await expect( + channel.deleteChannelImage({ url: imageURL }), + ).rejects.toMatchObject(missing); + await expect( + client.deleteFile({ url: standaloneFileURL }), + ).rejects.toMatchObject(missing); + await expect( + client.deleteImage({ url: standaloneImageURL }), + ).rejects.toMatchObject(missing); + }); + + it('sends an attachment hosted on your own CDN', async () => { + const localPath = './image.jpg'; + const cdnUrl = 'https://example.com/cdn/image.jpg'; + const customCDN = { upload: (_path: string) => Promise.resolve(cdnUrl) }; + + // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="Node.js" index=1 + // COPY: channelId="general", johnId="john" + // An attachment URL can point anywhere. Upload to your own CDN and build the + // attachment yourself. + const assetUrl = await customCDN.upload(localPath); + + const channel = client.chat.channel('messaging', channelId); + await channel.sendMessage({ + message: { + text: 'Message with file attachment', + user_id: johnId, + attachments: [ + { type: 'file', asset_url: assetUrl, title: 'image.jpg', custom: {} }, + ], + }, + }); + // #endregion snippet + + const { messages } = await channel.getOrCreate({ + state: true, + messages: { limit: 10 }, + }); + const sent = messages.find( + (m) => m.text === 'Message with file attachment', + ); + expect(sent?.attachments[0]).toMatchObject({ + type: 'file', + asset_url: cdnUrl, + title: 'image.jpg', + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/03-threads.test.ts b/__tests__/docs-snippets/pages/04-messages/03-threads.test.ts new file mode 100644 index 0000000..2c1feb1 --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/03-threads.test.ts @@ -0,0 +1,344 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { ThreadStateResponse } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; + +const DOCS = '_default/04-messages/03-threads.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const john = uniqueId('john'); + const alice = uniqueId('alice'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + + // Thread parent sent by alice; john replies to it in the first test. + let parentMessageId = ''; + + beforeAll(async () => { + cleanup.users.push(john, alice); + await client.upsertUsers([{ id: john }, { id: alice }]); + + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: alice, + members: [{ user_id: john }, { user_id: alice }], + }, + }); + + const parent = await sendServerMessage(client, cid, { + text: 'Thread parent', + user_id: alice, + }); + parentMessageId = parent.id; + }); + + afterAll(() => cleanup.run()); + + it('starts a thread', async () => { + // #region snippet docs="_default/04-messages/03-threads.md" heading="Starting a Thread" tab="Node.js" index=1 + // COPY: channelId="general", john="john" + const channel = client.chat.channel('messaging', channelId); + + const { message: reply } = await channel.sendMessage({ + message: { + text: 'This is a reply in a thread', + parent_id: parentMessageId, + show_in_channel: false, + user_id: john, + }, + }); + // #endregion snippet + + expect(reply.parent_id).toBe(parentMessageId); + expect(reply.type).toBe('reply'); + expect(reply.show_in_channel).toBeFalsy(); + }); + + it('paginates thread replies', async () => { + const { messages: existing } = await client.chat.getReplies({ + parent_id: parentMessageId, + limit: 20, + }); + const extra = []; + for (const text of ['second reply', 'third reply']) { + extra.push( + await sendServerMessage(client, cid, { + text, + parent_id: parentMessageId, + user_id: alice, + }), + ); + } + const allIds = [...existing.map((m) => m.id), ...extra.map((m) => m.id)]; + expect(allIds).toHaveLength(3); + // Page back from the second reply: it and the first reply are returned. + const olderThanId = allIds[1]; + + // #region snippet docs="_default/04-messages/03-threads.md" heading="Paginating Thread Replies" tab="Node.js" index=1 + // COPY: olderThanId="42" + // Get the first 20 replies + const replies = await client.chat.getReplies({ + parent_id: parentMessageId, + limit: 20, + }); + + // Get older replies (before message with ID "42") + const olderReplies = await client.chat.getReplies({ + parent_id: parentMessageId, + limit: 20, + id_lte: olderThanId, + }); + + // Get oldest replies first + const oldestFirst = await client.chat.getReplies({ + parent_id: parentMessageId, + limit: 20, + sort: [{ field: 'created_at', direction: 1 }], + }); + // #endregion snippet + + expect(replies.messages.map((m) => m.id)).toEqual(allIds); + expect(olderReplies.messages.map((m) => m.id)).toEqual(allIds.slice(0, 2)); + expect(oldestFirst.messages.map((m) => m.id)).toEqual(allIds); + }); + + it('sends an inline reply', async () => { + const original = await sendServerMessage(client, cid, { + text: 'The original message text', + user_id: alice, + }); + const originalMessageId = original.id; + const sendMessage = vi.spyOn(client.chat, 'sendMessage'); + + // #region snippet docs="_default/04-messages/03-threads.md" heading="Inline Replies" tab="Node.js" index=1 + // COPY: channelId="general", john="john" + const channel = client.chat.channel('messaging', channelId); + + await channel.sendMessage({ + message: { + text: 'I agree with this point', + quoted_message_id: originalMessageId, + user_id: john, + }, + }); + // #endregion snippet + + const { message } = await (sendMessage.mock.results[0].value as ReturnType< + typeof client.chat.sendMessage + >); + sendMessage.mockRestore(); + + expect(message.quoted_message_id).toBe(originalMessageId); + const { message: stored } = await client.chat.getMessage({ + id: message.id, + }); + expect(stored.quoted_message?.id).toBe(originalMessageId); + expect(stored.quoted_message?.text).toBe('The original message text'); + }); + + it('queries threads', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // #region snippet docs="_default/04-messages/03-threads.md" heading="Querying Threads" tab="Node.js" index=1 + // COPY: john="john" + // Threads are per user, so a server-side query names the user it queries for. + const { threads } = await client.chat.queryThreads({ user_id: john }); + + for (const thread of threads) { + console.log(thread.parent_message?.text); + console.log(thread.latest_replies); + console.log(thread.thread_participants); + console.log(thread.read); + } + // #endregion snippet + + expect(logSpy).toHaveBeenCalledTimes(4); + logSpy.mockRestore(); + + expect(threads.map((t) => t.parent_message_id)).toEqual([parentMessageId]); + const [thread] = threads; + expect(thread.parent_message?.text).toBe('Thread parent'); + expect(thread.latest_replies.length).toBeGreaterThan(0); + expect(thread.thread_participants?.map((p) => p.user_id)).toContain(john); + }); + + it('filters, sorts and paginates threads', async () => { + // A second thread created by john, and a page size of 1, so the snippet's + // `next` page is actually fetched. + const second = await sendServerMessage(client, cid, { + text: 'Second thread parent', + user_id: alice, + }); + await sendServerMessage(client, cid, { + text: 'Reply to the second thread', + parent_id: second.id, + user_id: john, + }); + const pageSize = 1; + // A thread's `created_by_user_id` is the user of its first reply, not of the parent. + const threadCreator = john; + // `page2` only exists inside the snippet's `if`: read it from the snippet's log. + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/04-messages/03-threads.md" heading="Supported Sort Fields" tab="Node.js" index=1 + // COPY: threadCreator="user-1", john="john", pageSize=10 + const filter = { + created_by_user_id: { $eq: threadCreator }, + updated_at: { $gte: '2024-01-01T00:00:00Z' }, + }; + const sort = [{ field: 'created_at', direction: -1 }]; + + // Get threads created by a specific user, sorted by creation date + const page1 = await client.chat.queryThreads({ + user_id: john, + filter, + sort, + limit: pageSize, + }); + + // Get next page + if (page1.next) { + const page2 = await client.chat.queryThreads({ + user_id: john, + filter, + sort, + limit: pageSize, + next: page1.next, + }); + console.log(page2.threads); + } + // #endregion snippet + + // Newest thread first. + expect(page1.threads.map((t) => t.parent_message_id)).toEqual([second.id]); + expect(page1.next).toBeTruthy(); + expect(log).toHaveBeenCalledTimes(1); + const [page2Threads] = log.mock.calls[0] as [ThreadStateResponse[]]; + log.mockRestore(); + expect(page2Threads.map((t) => t.parent_message_id)).toEqual([ + parentMessageId, + ]); + }); + + it('gets a thread by id', async () => { + // #region snippet docs="_default/04-messages/03-threads.md" heading="Getting a Thread by ID" tab="Node.js" index=1 + const { thread } = await client.chat.getThread({ + message_id: parentMessageId, + reply_limit: 10, + participant_limit: 25, + }); + // #endregion snippet + + expect(thread.parent_message_id).toBe(parentMessageId); + expect(thread.channel_cid).toBe(cid); + expect(thread.reply_count).toBe(3); + expect(thread.latest_replies).toHaveLength(3); + }); + + it('updates thread title and custom data', async () => { + const updateThreadPartial = vi.spyOn(client.chat, 'updateThreadPartial'); + + // #region snippet docs="_default/04-messages/03-threads.md" heading="Updating Thread Title and Custom Data" tab="Node.js" index=1 + // Set properties. The thread is addressed by its parent message ID. + const { thread } = await client.chat.updateThreadPartial({ + message_id: parentMessageId, + set: { + title: 'Project Discussion', + priority: 'high', + }, + }); + + // Remove properties + await client.chat.updateThreadPartial({ + message_id: parentMessageId, + unset: ['priority'], + }); + // #endregion snippet + + const [, unsetResult] = await Promise.all( + updateThreadPartial.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + updateThreadPartial.mockRestore(); + + expect(thread.title).toBe('Project Discussion'); + expect(thread.custom.priority).toBe('high'); + expect(unsetResult.thread?.custom.priority).toBeUndefined(); + + const { thread: stored } = await client.chat.getThread({ + message_id: parentMessageId, + }); + expect(stored.title).toBe('Project Discussion'); + expect(stored.custom.priority).toBeUndefined(); + }); + + const johnsThreadUnread = async () => { + const { threads } = await client.chat.unreadCounts({ user_id: john }); + return threads.find((t) => t.parent_message_id === parentMessageId) + ?.unread_count; + }; + + it('marks a thread as read and unread', async () => { + const probes: Array = []; + const originalMarkRead = client.chat.markRead.bind(client.chat); + const readSpy = vi + .spyOn(client.chat, 'markRead') + .mockImplementation(async (request) => { + const response = await originalMarkRead(request); + probes.push(await johnsThreadUnread()); + return response; + }); + + // #region snippet docs="_default/04-messages/03-threads.md" heading="Marking Threads as Read or Unread" tab="Node.js" index=1 + // COPY: channelId="general", john="john" + const channel = client.chat.channel('messaging', channelId); + + // Mark thread as read on behalf of a user + await channel.markRead({ thread_id: parentMessageId, user_id: john }); + + // Mark thread as unread on behalf of a user + await channel.markUnread({ thread_id: parentMessageId, user_id: john }); + // #endregion snippet + + readSpy.mockRestore(); + + // After markRead, john has nothing unread in the thread (it is absent or 0). + expect(probes).toHaveLength(1); + expect(probes[0] ?? 0).toBe(0); + expect(await johnsThreadUnread()).toBeGreaterThan(0); + }); + + it('gets unread counts per thread', async () => { + const channel = client.chat.channel('messaging', channelId); + await channel.markUnread({ thread_id: parentMessageId, user_id: john }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // #region snippet docs="_default/04-messages/03-threads.md" heading="Unread Count Per Thread" tab="Node.js" index=1 + // COPY: john="john" + const response = await client.chat.unreadCounts({ user_id: john }); + + console.log(response.total_unread_threads_count); + + for (const thread of response.threads) { + console.log(thread.parent_message_id); + console.log(thread.unread_count); + console.log(thread.last_read); + } + // #endregion snippet + + logSpy.mockRestore(); + + expect(response.total_unread_threads_count).toBeGreaterThan(0); + const thread = response.threads.find( + (t) => t.parent_message_id === parentMessageId, + ); + expect(thread?.unread_count).toBeGreaterThan(0); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/04-send_reaction.test.ts b/__tests__/docs-snippets/pages/04-messages/04-send_reaction.test.ts new file mode 100644 index 0000000..d07585c --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/04-send_reaction.test.ts @@ -0,0 +1,221 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { SendReactionResponse } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; + +const DOCS = '_default/04-messages/04-send_reaction.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const john = uniqueId('john'); + const alice = uniqueId('alice'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + + const newMessage = async (text: string) => + sendServerMessage(client, cid, { text, user_id: alice }); + + const reactionsOf = async (id: string) => + (await client.chat.getReactions({ id, limit: 100 })).reactions; + + beforeAll(async () => { + cleanup.users.push(john, alice); + await client.upsertUsers([{ id: john }, { id: alice }]); + + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: alice, + members: [{ user_id: john }, { user_id: alice }], + }, + }); + }); + + afterAll(() => cleanup.run()); + + it('sends reactions', async () => { + const messageID = (await newMessage('React to me')).id; + // A "like" reaction from john that enforce_unique should remove. + await client.chat.sendReaction({ + id: messageID, + reaction: { type: 'like', user_id: john }, + }); + const spy = vi.spyOn(client.chat, 'sendReaction'); + + // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Sending a Reaction" tab="Node.js" index=1 + // COPY: john="john" + // Add a reaction. Server-side calls must name the reacting user. + const { reaction } = await client.chat.sendReaction({ + id: messageID, + reaction: { type: 'love', user_id: john }, + }); + + // Add a reaction with custom data + await client.chat.sendReaction({ + id: messageID, + reaction: { + type: 'love', + user_id: john, + custom: { customField: 'value' }, + }, + }); + + // Replace all existing reactions from this user with the new one + await client.chat.sendReaction({ + id: messageID, + reaction: { type: 'love', user_id: john }, + enforce_unique: true, + }); + // #endregion snippet + + const results = await Promise.all( + spy.mock.results.map((r) => r.value as Promise), + ); + spy.mockRestore(); + expect(results).toHaveLength(3); + expect(results[1].reaction.custom).toMatchObject({ customField: 'value' }); + expect(reaction.type).toBe('love'); + expect(reaction.user_id).toBe(john); + expect(reaction.message_id).toBe(messageID); + const reactions = await reactionsOf(messageID); + expect(reactions.map((r) => r.type)).toEqual(['love']); + expect(reactions[0].user_id).toBe(john); + }); + + it('removes a reaction', async () => { + const messageID = (await newMessage('Remove my reaction')).id; + await client.chat.sendReaction({ + id: messageID, + reaction: { type: 'love', user_id: john }, + }); + expect(await reactionsOf(messageID)).toHaveLength(1); + + // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Removing a Reaction" tab="Node.js" index=1 + // COPY: john="john" + await client.chat.deleteReaction({ + id: messageID, + type: 'love', + user_id: john, + }); + // #endregion snippet + + expect(await reactionsOf(messageID)).toHaveLength(0); + }); + + it('paginates reactions', async () => { + const messageID = (await newMessage('Many reactions')).id; + // 13 reactions: one user can have one reaction of each type. + for (let i = 0; i < 13; i++) { + await client.chat.sendReaction({ + id: messageID, + reaction: { type: `type${i}`, user_id: john }, + }); + } + + // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Paginating Reactions" tab="Node.js" index=1 + // Get the first 10 reactions + const response = await client.chat.getReactions({ + id: messageID, + limit: 10, + }); + + // Get reactions 11-13 + const nextResponse = await client.chat.getReactions({ + id: messageID, + limit: 3, + offset: 10, + }); + // #endregion snippet + + expect(response.reactions).toHaveLength(10); + expect(nextResponse.reactions).toHaveLength(3); + const all = [...response.reactions, ...nextResponse.reactions].map( + (r) => r.type, + ); + expect(new Set(all).size).toBe(13); + }); + + it('queries reactions', async () => { + const message = await newMessage('Query my reactions'); + const userId = john; + for (const [type, user_id] of [ + ['like', john], + ['love', john], + ['like', alice], + ]) { + await client.chat.sendReaction({ + id: message.id, + reaction: { type, user_id }, + }); + } + + // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Querying Reactions" tab="Node.js" index=1 + // Query reactions by type + await client.chat.queryReactions({ + id: message.id, + filter: { type: 'like' }, + }); + + // Query reactions by user + await client.chat.queryReactions({ + id: message.id, + filter: { user_id: userId }, + }); + + // Paginate results + const firstPage = await client.chat.queryReactions({ id: message.id }); + const secondPage = await client.chat.queryReactions({ + id: message.id, + limit: 5, + next: firstPage.next, + }); + // #endregion snippet + + const byType = await client.chat.queryReactions({ + id: message.id, + filter: { type: 'like' }, + }); + expect(byType.reactions.map((r) => r.user_id).sort()).toEqual( + [alice, john].sort(), + ); + const byUser = await client.chat.queryReactions({ + id: message.id, + filter: { user_id: userId }, + }); + expect(byUser.reactions.map((r) => r.type).sort()).toEqual([ + 'like', + 'love', + ]); + expect(firstPage.reactions).toHaveLength(3); + expect(Array.isArray(secondPage.reactions)).toBe(true); + }); + + it('sends cumulative reactions', async () => { + const messageID = (await newMessage('Clap for me')).id; + + // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Cumulative Reactions" tab="Node.js" index=1 + // COPY: john="john" + // User claps 5 times + await client.chat.sendReaction({ + id: messageID, + reaction: { type: 'clap', score: 5, user_id: john }, + }); + + // Same user claps 20 more times (total becomes 25) + await client.chat.sendReaction({ + id: messageID, + reaction: { type: 'clap', score: 25, user_id: john }, + }); + // #endregion snippet + + const reactions = await reactionsOf(messageID); + expect(reactions).toHaveLength(1); + expect(reactions[0].score).toBe(25); + const { message } = await client.chat.getMessage({ id: messageID }); + expect(message.reaction_groups?.clap?.sum_scores).toBe(25); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/05-pinned_messages.test.ts b/__tests__/docs-snippets/pages/04-messages/05-pinned_messages.test.ts new file mode 100644 index 0000000..8272b24 --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/05-pinned_messages.test.ts @@ -0,0 +1,181 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; +import type { UpdateMessagePartialResponse } from '../../../../index'; + +const DOCS = '_default/04-messages/05-pinned_messages.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const john = uniqueId('john'); + + const newChannel = async () => { + const id = uniqueId('general'); + cleanup.channels.push(`messaging:${id}`); + await client.chat.channel('messaging', id).getOrCreate({ + data: { created_by_id: john, members: [{ user_id: john }] }, + }); + return id; + }; + + beforeAll(async () => { + cleanup.users.push(john); + await client.upsertUsers([{ id: john }]); + }); + + afterAll(() => cleanup.run()); + + it('pins and unpins messages', async () => { + const channelId = await newChannel(); + const userId = john; + const updateMessagePartial = vi.spyOn(client.chat, 'updateMessagePartial'); + const before = Date.now(); + + // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Pinning and Unpinning Messages" tab="Node.js" index=1 + // COPY: channelId="general", userId="john" + const channel = client.chat.channel('messaging', channelId); + + // Create a pinned message + const { message } = await channel.sendMessage({ + message: { + text: 'Important announcement', + user_id: userId, + pinned: true, + pin_expires: new Date('2077-01-01T00:00:00Z'), + }, + }); + + // Pin an existing message for 120 seconds + await client.chat.updateMessagePartial({ + id: message.id, + set: { + pinned: true, + pin_expires: new Date(Date.now() + 120 * 1000).toISOString(), + }, + user_id: userId, + }); + + // Pin indefinitely + await client.chat.updateMessagePartial({ + id: message.id, + set: { pinned: true, pin_expires: null }, + user_id: userId, + }); + + // Unpin a message + await client.chat.updateMessagePartial({ + id: message.id, + set: { pinned: false }, + user_id: userId, + }); + // #endregion snippet + + const [timed, indefinite, unpinned] = await Promise.all( + updateMessagePartial.mock.results.map( + (r) => r.value as Promise, + ), + ); + updateMessagePartial.mockRestore(); + + expect(message.pinned).toBe(true); + expect(message.pinned_by?.id).toBe(john); + expect(message.pin_expires?.toISOString()).toBe('2077-01-01T00:00:00.000Z'); + + expect(timed.message?.pinned).toBe(true); + const timedExpiry = timed.message?.pin_expires?.getTime() ?? 0; + expect(timedExpiry).toBeGreaterThanOrEqual(before + 110 * 1000); + expect(timedExpiry).toBeLessThan(Date.now() + 130 * 1000); + + expect(indefinite.message?.pinned).toBe(true); + expect(indefinite.message?.pin_expires ?? null).toBeNull(); + + expect(unpinned.message?.pinned).toBe(false); + const { message: stored } = await client.chat.getMessage({ + id: message.id, + }); + expect(stored.pinned).toBe(false); + }); + + it('retrieves pinned messages', async () => { + const channelId = await newChannel(); + await sendServerMessage(client, `messaging:${channelId}`, { + text: 'Not pinned', + user_id: john, + }); + const pinned = await sendServerMessage(client, `messaging:${channelId}`, { + text: 'Pinned', + user_id: john, + pinned: true, + }); + + // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Retrieving Pinned Messages" tab="Node.js" index=1 + const channel = client.chat.channel('messaging', channelId); + const channelState = await channel.getOrCreate({ state: true }); + const pinnedMessages = channelState.pinned_messages; + // #endregion snippet + + expect(pinnedMessages.map((m) => m.id)).toEqual([pinned.id]); + }); + + it('paginates pinned messages', async () => { + const channelId = await newChannel(); + // Three pinned messages and a page size of 2, so every page call has + // something to return. + const pinnedIds: string[] = []; + for (const text of ['first', 'second', 'third']) { + const m = await sendServerMessage(client, `messaging:${channelId}`, { + text, + user_id: john, + pinned: true, + }); + pinnedIds.push(m.id); + } + const pageSize = 2; + const aroundSize = 4; + const [first, second, third] = pinnedIds; + + // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Paginating Pinned Messages" tab="Node.js" index=1 + // COPY: channelId="general", pageSize=10, aroundSize=4 + const channel = client.chat.channel('messaging', channelId); + + // First page, newest first + const page1 = await channel.getPinnedMessages({ + limit: pageSize, + sort: [{ field: 'pinned_at', direction: -1 }], + }); + + // Next page + const lastMsg = page1.messages[page1.messages.length - 1]; + const page2 = await channel.getPinnedMessages({ + limit: pageSize, + id_lt: lastMsg.id, + sort: [{ field: 'pinned_at', direction: -1 }], + }); + + // Oldest first + const ascPage = await channel.getPinnedMessages({ limit: pageSize }); + const ascLastMsg = ascPage.messages[ascPage.messages.length - 1]; + const ascPage2 = await channel.getPinnedMessages({ + limit: pageSize, + id_gt: ascLastMsg.id, + }); + + // Center a page on a known message instead of paging from an edge + const around = await channel.getPinnedMessages({ + limit: aroundSize, + id_around: lastMsg.id, + }); + // #endregion snippet + + const ids = (r: typeof page1) => r.messages.map((m) => m.id); + expect(ids(page1)).toEqual([third, second]); + expect(ids(page2)).toEqual([first]); + expect(ids(ascPage)).toEqual([first, second]); + expect(ids(ascPage2)).toEqual([third]); + expect([...ids(around)].sort()).toEqual([...pinnedIds].sort()); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/06-search.test.ts b/__tests__/docs-snippets/pages/04-messages/06-search.test.ts new file mode 100644 index 0000000..34c7a24 --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/06-search.test.ts @@ -0,0 +1,178 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; +import { retry } from '../../helpers/wait'; +import type { SearchPayload, SearchResponse } from '../../../../index'; + +const DOCS = '_default/04-messages/06-search.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const john = uniqueId('john'); + const channelId = uniqueId('my-channel'); + const cid = `messaging:${channelId}`; + // Its own channel, so the first test's messages don't match the pagination search. + const pageChannelId = uniqueId('my-channel'); + const pageCid = `messaging:${pageChannelId}`; + + /** Search is indexed asynchronously: retry until `payload` finds `count` messages. */ + const waitForSearch = (payload: SearchPayload, count: number) => + retry(async () => { + const { results } = await client.chat.search({ payload }); + if (results.length < count) { + throw new Error(`search found ${results.length} of ${count} messages`); + } + }); + + const ids = (r: SearchResponse) => r.results.map((hit) => hit.message?.id); + + beforeAll(async () => { + cleanup.users.push(john); + await client.upsertUsers([{ id: john }]); + for (const id of [channelId, pageChannelId]) { + cleanup.channels.push(`messaging:${id}`); + await client.chat.channel('messaging', id).getOrCreate({ + data: { created_by_id: john, members: [{ user_id: john }] }, + }); + } + }); + + afterAll(() => cleanup.run()); + + it('searches messages', async () => { + const exact = await sendServerMessage(client, cid, { + text: 'supercalifragilisticexpialidocious', + user_id: john, + }); + const withAttachment = await sendServerMessage(client, cid, { + text: 'superb picture', + user_id: john, + attachments: [ + { + type: 'image', + image_url: 'https://example.com/picture.jpg', + custom: {}, + }, + ], + }); + // Matches the autocomplete but has no attachment, so the message filter drops it. + await sendServerMessage(client, cid, { + text: 'superb text only', + user_id: john, + }); + const userId = john; + await waitForSearch( + { + filter_conditions: { members: { $in: [userId] } }, + message_filter_conditions: { text: { $autocomplete: 'super' } }, + }, + 3, + ); + + // #region snippet docs="_default/04-messages/06-search.md" heading="Searching Messages" tab="Node.js" index=1 + // COPY: userId="john" + // Search for messages containing text + const results = await client.chat.search({ + payload: { + filter_conditions: { members: { $in: [userId] } }, + query: 'supercalifragilisticexpialidocious', + limit: 10, + }, + }); + + // Search with message filters + const filtered = await client.chat.search({ + payload: { + filter_conditions: { members: { $in: [userId] } }, + message_filter_conditions: { + text: { $autocomplete: 'super' }, + attachments: { $exists: true }, + }, + limit: 10, + }, + }); + // #endregion snippet + + expect(ids(results)).toEqual([exact.id]); + expect(ids(filtered)).toEqual([withAttachment.id]); + }); + + it('paginates search results', async () => { + const channelCid = pageCid; + const pageSize = 1; + // Three matches and a page size of 1, so there is a next and a previous page. + const sent: string[] = []; + for (const n of [1, 2, 3]) { + const m = await sendServerMessage(client, pageCid, { + text: `supercali page ${n}`, + user_id: john, + custom: { my_custom_field: n }, + }); + sent.push(m.id); + } + await waitForSearch( + { + filter_conditions: { cid: channelCid }, + message_filter_conditions: { + text: { $autocomplete: 'supercali' }, + id: { $in: sent }, + }, + }, + 3, + ); + + // #region snippet docs="_default/04-messages/06-search.md" heading="Pagination" tab="Node.js" index=1 + // COPY: channelCid="messaging:my-channel", pageSize=10 + const channelFilters = { cid: channelCid }; + const messageFilters = { text: { $autocomplete: 'supercali' } }; + + // First page with custom sorting + const page1 = await client.chat.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + sort: [ + { field: 'relevance', direction: -1 }, + { field: 'updated_at', direction: 1 }, + { field: 'my_custom_field', direction: -1 }, + ], + limit: pageSize, + }, + }); + + // Next page using cursor + const page2 = await client.chat.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + limit: pageSize, + next: page1.next, + }, + }); + + // Previous page + const page1Again = await client.chat.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + limit: pageSize, + next: page2.previous, + }, + }); + // #endregion snippet + + expect(page1.results).toHaveLength(1); + expect(page1.next).toBeTruthy(); + expect(page2.results).toHaveLength(1); + expect(page2.previous).toBeTruthy(); + expect(ids(page2)).not.toEqual(ids(page1)); + expect(ids(page1Again)).toEqual(ids(page1)); + for (const id of [...ids(page1), ...ids(page2)]) { + expect(sent).toContain(id); + } + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/07-silent_messages.test.ts b/__tests__/docs-snippets/pages/04-messages/07-silent_messages.test.ts new file mode 100644 index 0000000..ca141b1 --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/07-silent_messages.test.ts @@ -0,0 +1,104 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../helpers/server'; +import { retry } from '../../helpers/wait'; + +const DOCS = '_default/04-messages/07-silent_messages.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const systemUserID = uniqueId('system'); + const jane = uniqueId('jane'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + + const unreadFor = async (userId: string) => { + const channel = await getServerChannel(client, cid, { user_id: userId }); + return channel.read?.find((r) => r.user.id === userId)?.unread_messages; + }; + + beforeAll(async () => { + cleanup.users.push(systemUserID, jane); + await client.upsertUsers([{ id: systemUserID }, { id: jane }]); + + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: systemUserID, + members: [{ user_id: systemUserID }, { user_id: jane }], + }, + }); + }); + + afterAll(() => cleanup.run()); + + it('sends a silent message', async () => { + const tripData = { trip_id: 'trip-1', distance_km: 12 }; + const before = await unreadFor(jane); + expect(before).toBe(0); + + // #region snippet docs="_default/04-messages/07-silent_messages.md" heading="Silent Messages" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + await channel.sendMessage({ + message: { + text: 'You completed your trip', + user_id: systemUserID, + silent: true, + attachments: [{ type: 'trip', custom: tripData }], + }, + }); + // #endregion snippet + + const { messages } = await getServerChannel(client, cid, { + user_id: jane, + state: true, + message_limit: 10, + }); + const sent = messages.find((m) => m.text === 'You completed your trip'); + expect(sent?.silent).toBe(true); + expect(sent?.user?.id).toBe(systemUserID); + expect(sent?.attachments[0]?.type).toBe('trip'); + expect(sent?.attachments[0]?.custom).toMatchObject(tripData); + expect(await unreadFor(jane)).toBe(0); + + // Control: a regular message does increment jane's unread count. + await sendServerMessage(client, cid, { + text: 'not silent', + user_id: systemUserID, + }); + await retry(async () => { + expect(await unreadFor(jane)).toBe(1); + }); + }); + + it('sends a system message', async () => { + // #region snippet docs="_default/04-messages/07-silent_messages.md" heading="System Messages" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + await channel.sendMessage({ + message: { + text: 'You completed your trip', + user_id: systemUserID, + type: 'system', + }, + }); + // #endregion snippet + + const { messages } = await getServerChannel(client, cid, { + user_id: jane, + state: true, + message_limit: 10, + }); + const system = messages.filter((m) => m.type === 'system'); + expect(system).toHaveLength(1); + expect(system[0].text).toBe('You completed your trip'); + expect(system[0].user?.id).toBe(systemUserID); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/08-unread_reminders.test.ts b/__tests__/docs-snippets/pages/04-messages/08-unread_reminders.test.ts new file mode 100644 index 0000000..11554ae --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/08-unread_reminders.test.ts @@ -0,0 +1,74 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { waitForAppSetting } from '../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; + +const DOCS = '_default/04-messages/08-unread_reminders.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // The snippet changes the channel type it names: use a uniqueId type instead of the + // built-in `messaging` type. + const channelType = uniqueId('messaging'); + + beforeAll(async () => { + await client.chat.createChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(channelType); + await waitForChannelTypePropagation(); + + const { app } = await client.getApp(); + const originalInterval = app.reminders_interval; + cleanup.add(async () => { + await client.updateApp({ reminders_interval: originalInterval }); + await waitForAppSetting(client, 'reminders_interval', originalInterval); + }); + }); + + afterAll(() => cleanup.run()); + + it('enables reminders and sets the interval', async () => { + const before = await client.chat.getChannelType({ name: channelType }); + expect(before.reminders).toBe(false); + + // #region snippet docs="_default/04-messages/08-unread_reminders.md" heading="Enabling Unread Reminders" tab="Node.js" index=1 + // COPY: channelType="messaging" + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: channelType }); + + // Enable reminders for the messaging channel type + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + reminders: true, + }); + + // Change reminders interval to 1 hour (default is 5 minutes) + await client.updateApp({ reminders_interval: 3600 }); + // #endregion snippet + + const updated = await retry(async () => { + const response = await client.chat.getChannelType({ name: channelType }); + if (!response.reminders) throw new Error('reminders not enabled yet'); + return response; + }); + expect(updated.reminders).toBe(true); + expect(updated.automod).toBe(current.automod); + expect(updated.max_message_length).toBe(current.max_message_length); + + await waitForAppSetting(client, 'reminders_interval', 3600); + const { app } = await client.getApp(); + expect(app.reminders_interval).toBe(3600); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/09-message_reminders.test.ts b/__tests__/docs-snippets/pages/04-messages/09-message_reminders.test.ts new file mode 100644 index 0000000..0085018 --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/09-message_reminders.test.ts @@ -0,0 +1,404 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../helpers/server'; +import { waitForChannelTypePropagation } from '../../helpers/wait'; + +const DOCS = '_default/04-messages/09-message_reminders.md'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // The "Enabling Reminders" snippet changes the channel type it names: run it on a + // uniqueId type instead of the built-in `messaging` type. + const channelType = uniqueId('messaging'); + const channelId = uniqueId('reminders-type-channel'); + + // The other snippets use `messaging` channels with reminders enabled through + // `config_overrides`. Each snippet gets its own user so its results are exact. + const owner = uniqueId('owner'); + const creator = uniqueId('creator'); + const updater = uniqueId('updater'); + const deleter = uniqueId('deleter'); + const querier = uniqueId('querier'); + const filterer = uniqueId('filterer'); + const pager = uniqueId('pager'); + const users = [owner, creator, updater, deleter, querier, filterer, pager]; + const reminderChannelId = uniqueId('reminders'); + const otherChannelId = uniqueId('reminders-other'); + const reminderCid = `messaging:${reminderChannelId}`; + + // remind_at must be at least one minute in the future, so the reminder that the + // "overdue" filter should find is created first and becomes due during the run. + let overdueAt: Date; + let overdueMessageId: string; + + const sendMessage = async (cid: string, userId: string) => + ( + await sendServerMessage(client, cid, { + text: 'remind me', + user_id: userId, + }) + ).id; + + beforeAll(async () => { + await client.upsertUsers(users.map((id) => ({ id }))); + cleanup.users.push(...users); + + for (const id of [reminderChannelId, otherChannelId]) { + const channel = client.chat.channel('messaging', id); + await channel.getOrCreate({ + data: { + created_by_id: owner, + members: users.map((user_id) => ({ user_id })), + }, + }); + cleanup.channels.push(channel.cid); + await channel.updateChannelPartial({ + set: { config_overrides: { user_message_reminders: true } }, + }); + } + + overdueMessageId = await sendMessage(reminderCid, owner); + overdueAt = new Date(Date.now() + 62000); + await client.chat.createReminder({ + message_id: overdueMessageId, + user_id: filterer, + remind_at: overdueAt, + }); + + await client.chat.createChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(channelType); + await waitForChannelTypePropagation(); + + const channel = client.chat.channel(channelType, channelId); + await channel.getOrCreate({ data: { created_by_id: owner } }); + cleanup.channels.push(channel.cid); + }); + + afterAll(() => cleanup.run()); + + it('enables reminders for a channel and for a channel type', async () => { + const before = await client.chat.getChannelType({ name: channelType }); + expect(before.user_message_reminders).toBe(false); + + // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Enabling Reminders" tab="Node.js" index=1 + // COPY: channelType="messaging" + // Enabling it for a channel + const channel = client.chat.channel(channelType, channelId); + await channel.updateChannelPartial({ + set: { + config_overrides: { + user_message_reminders: true, + }, + }, + }); + + // Enabling it for a channel type. + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: channelType }); + const update = await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + user_message_reminders: true, + }); + // #endregion snippet + await waitForChannelTypePropagation(); + + expect(update.user_message_reminders).toBe(true); + expect(update.automod).toBe(current.automod); + expect(update.max_message_length).toBe(current.max_message_length); + + const updated = await client.chat.getChannelType({ name: channelType }); + expect(updated.user_message_reminders).toBe(true); + const { channel: channelResponse } = await getServerChannel( + client, + channel.cid, + ); + expect(channelResponse?.config?.user_message_reminders).toBe(true); + + // Reminders now work on that channel. + const messageId = await sendMessage(channel.cid, owner); + const { reminder } = await client.chat.createReminder({ + message_id: messageId, + user_id: owner, + }); + expect(reminder.channel_cid).toBe(channel.cid); + }); + + it('creates reminders', async () => { + const messageId = await sendMessage(reminderCid, owner); + const anotherMessageId = await sendMessage(reminderCid, owner); + const userId = creator; + const startedAt = Date.now(); + + // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Creating a Message Reminder" tab="Node.js" index=1 + // COPY: messageId="message-id", anotherMessageId="another-message-id", userId="user-id" + // Create a reminder with a specific due date + await client.chat.createReminder({ + message_id: messageId, + user_id: userId, + remind_at: new Date(Date.now() + 3600000), + }); + + // Create a "Save for later" reminder without a specific time + await client.chat.createReminder({ + message_id: anotherMessageId, + user_id: userId, + }); + // #endregion snippet + + const { reminders } = await client.chat.queryReminders({ + user_id: userId, + }); + const byMessage = new Map(reminders.map((r) => [r.message_id, r])); + expect([...byMessage.keys()].sort()).toEqual( + [messageId, anotherMessageId].sort(), + ); + const timed = byMessage.get(messageId)?.remind_at; + expect(timed).toBeInstanceOf(Date); + expect(timed?.getTime()).toBeGreaterThanOrEqual(startedAt + 3600000 - 1000); + expect(byMessage.get(anotherMessageId)?.remind_at ?? null).toBeNull(); + }); + + it('updates reminders', async () => { + const messageId = await sendMessage(reminderCid, owner); + const userId = updater; + await client.chat.createReminder({ + message_id: messageId, + user_id: userId, + remind_at: new Date(Date.now() + 3600000), + }); + const startedAt = Date.now(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Updating a Message Reminder" tab="Node.js" index=1 + // COPY: messageId="message-id", userId="user-id" + // Update a reminder with a new due date + const updated = await client.chat.updateReminder({ + message_id: messageId, + user_id: userId, + remind_at: new Date(Date.now() + 7200000), + }); + console.log(updated.reminder.remind_at); + + // Omit `remind_at` to convert a timed reminder into "Save for later". + await client.chat.updateReminder({ + message_id: messageId, + user_id: userId, + }); + // #endregion snippet + + const newDueDate = updated.reminder.remind_at; + expect(newDueDate).toBeInstanceOf(Date); + expect(newDueDate?.getTime()).toBeGreaterThanOrEqual( + startedAt + 7200000 - 1000, + ); + expect(log.mock.calls[0]).toEqual([newDueDate]); + } finally { + log.mockRestore(); + } + + const { reminders } = await client.chat.queryReminders({ + user_id: userId, + }); + expect(reminders.map((r) => r.message_id)).toEqual([messageId]); + expect(reminders[0].remind_at ?? null).toBeNull(); + }); + + it('deletes a reminder', async () => { + const messageId = await sendMessage(reminderCid, owner); + const userId = deleter; + await client.chat.createReminder({ + message_id: messageId, + user_id: userId, + }); + + // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Deleting a Message Reminder" tab="Node.js" index=1 + // COPY: messageId="message-id", userId="user-id" + // Delete the reminder for the message + await client.chat.deleteReminder({ + message_id: messageId, + user_id: userId, + }); + // #endregion snippet + + const { reminders } = await client.chat.queryReminders({ + user_id: userId, + }); + expect(reminders).toEqual([]); + await expect( + client.chat.deleteReminder({ message_id: messageId, user_id: userId }), + ).rejects.toMatchObject({ code: 16, metadata: { responseCode: 404 } }); + }); + + it('queries reminders', async () => { + const userId = querier; + const channelCid = reminderCid; + const inChannel = await sendMessage(reminderCid, owner); + const inOtherChannel = await sendMessage( + `messaging:${otherChannelId}`, + owner, + ); + for (const message_id of [inChannel, inOtherChannel]) { + await client.chat.createReminder({ message_id, user_id: userId }); + } + + // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Querying Message Reminders" tab="Node.js" index=1 + // COPY: userId="user-id", channelCid="messaging:general" + // Query reminders for a user + const reminders = await client.chat.queryReminders({ user_id: userId }); + + // Query reminders with filters + const filtered = await client.chat.queryReminders({ + user_id: userId, + filter: { channel_cid: channelCid }, + }); + // #endregion snippet + + expect(reminders.reminders.map((r) => r.message_id).sort()).toEqual( + [inChannel, inOtherChannel].sort(), + ); + expect(filtered.reminders.map((r) => r.message_id)).toEqual([inChannel]); + expect(filtered.reminders[0].channel_cid).toBe(channelCid); + }); + + it('filters overdue, upcoming and saved reminders', async () => { + const userId = filterer; + // Two upcoming reminders (to check the sort) and two saved for later ones. + const upcomingIds: string[] = []; + for (const offset of [7200000, 3600000]) { + const message_id = await sendMessage(reminderCid, owner); + await client.chat.createReminder({ + message_id, + user_id: userId, + remind_at: new Date(Date.now() + offset), + }); + upcomingIds.push(message_id); + } + const savedIds: string[] = []; + for (let i = 0; i < 2; i++) { + const message_id = await sendMessage(reminderCid, owner); + await client.chat.createReminder({ message_id, user_id: userId }); + savedIds.push(message_id); + await sleep(10); + } + // Wait until the reminder created in beforeAll is overdue. + const waitMs = overdueAt.getTime() + 2000 - Date.now(); + if (waitMs > 0) await sleep(waitMs); + + // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Filtering Reminders" tab="Node.js" index=1 + // COPY: userId="user-id" + // Filter overdue reminders, most recently expired first + const overdue = await client.chat.queryReminders({ + user_id: userId, + filter: { remind_at: { $lt: new Date() } }, + sort: [{ field: 'remind_at', direction: -1 }], + }); + + // Filter upcoming reminders, nearest to expire first + const upcoming = await client.chat.queryReminders({ + user_id: userId, + filter: { remind_at: { $gt: new Date() } }, + sort: [{ field: 'remind_at', direction: 1 }], + }); + + // Filter reminders with no due date (saved for later) + const saved = await client.chat.queryReminders({ + user_id: userId, + filter: { remind_at: null }, + sort: [{ field: 'created_at', direction: -1 }], + }); + // #endregion snippet + + expect(overdue.reminders.map((r) => r.message_id)).toEqual([ + overdueMessageId, + ]); + // Created furthest-first, so only the sort puts the nearest first. + expect(upcoming.reminders.map((r) => r.message_id)).toEqual([ + upcomingIds[1], + upcomingIds[0], + ]); + // Created oldest-first, so only the sort puts the newest first. + expect(saved.reminders.map((r) => r.message_id)).toEqual([ + savedIds[1], + savedIds[0], + ]); + }, 120000); + + it('paginates reminders', async () => { + const userId = pager; + const pageSize = 1; + // Timed reminders: with the default sort (remind_at), the `next` cursor skips + // reminders without a due date. + const messageIds: string[] = []; + for (const offset of [3600000, 7200000]) { + const message_id = await sendMessage(reminderCid, owner); + await client.chat.createReminder({ + message_id, + user_id: userId, + remind_at: new Date(Date.now() + offset), + }); + messageIds.push(message_id); + } + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const query = vi.spyOn(client.chat, 'queryReminders'); + + try { + // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Pagination" tab="Node.js" index=1 + // COPY: userId="user-id", pageSize=10 + const page1 = await client.chat.queryReminders({ + user_id: userId, + limit: pageSize, + }); + + // Load the next page + if (page1.next) { + const page2 = await client.chat.queryReminders({ + user_id: userId, + limit: pageSize, + next: page1.next, + }); + console.log(page2.reminders); + } + + // Load the previous page + if (page1.prev) { + const previous = await client.chat.queryReminders({ + user_id: userId, + limit: pageSize, + prev: page1.prev, + }); + console.log(previous.reminders); + } + // #endregion snippet + + expect(page1.reminders.map((r) => r.message_id)).toEqual([messageIds[0]]); + expect(page1.next).toBeTruthy(); + // The first page has no previous page. + expect(page1.prev).toBeUndefined(); + expect(query).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenCalledTimes(1); + const [page2Reminders] = log.mock.calls[0] as [ + Array<{ message_id: string }>, + ]; + expect(page2Reminders.map((r) => r.message_id)).toEqual([messageIds[1]]); + } finally { + log.mockRestore(); + query.mockRestore(); + } + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/10-message_receipts.test.ts b/__tests__/docs-snippets/pages/04-messages/10-message_receipts.test.ts new file mode 100644 index 0000000..273182d --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/10-message_receipts.test.ts @@ -0,0 +1,177 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { getServerUser } from '../../helpers/server'; +import { waitForChannelTypePropagation } from '../../helpers/wait'; + +const DOCS = '_default/04-messages/10-message_receipts.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + // The fences create a channel type and then read and update it right away. A new + // channel type takes ~30s to reach every API node, so wait after the create call + // (outside the region) before the region reads it back. + const waitAfterCreate = () => { + const createChannelType = client.chat.createChannelType.bind(client.chat); + return vi + .spyOn(client.chat, 'createChannelType') + .mockImplementation(async (request) => { + const response = await createChannelType(request); + await waitForChannelTypePropagation(); + return response; + }); + }; + + it('enables delivery events on a channel type', async () => { + const targetChannelType = uniqueId('delivery-type'); + cleanup.channelTypes.push(targetChannelType); + const create = waitAfterCreate(); + const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); + + // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="Channel Type Configuration" tab="Node.js" index=1 + // COPY: targetChannelType="targetChannelType" + // When creating a channel type + await client.chat.createChannelType({ + name: targetChannelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + delivery_events: true, + }); + + // When updating an existing channel type. + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ + name: targetChannelType, + }); + await client.chat.updateChannelType({ + name: targetChannelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + delivery_events: true, + }); + // #endregion snippet + await waitForChannelTypePropagation(); + + const [created] = await Promise.all( + create.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(created.delivery_events).toBe(true); + expect(current.delivery_events).toBe(true); + expect(current.automod).toBe('disabled'); + const [updated] = await Promise.all( + updateSpy.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(updated.delivery_events).toBe(true); + const after = await client.chat.getChannelType({ name: targetChannelType }); + expect(after.delivery_events).toBe(true); + expect(after.max_message_length).toBe(5000); + }); + + it('turns off delivery receipts in the user privacy settings', async () => { + const userId = uniqueId('user'); + cleanup.users.push(userId); + + // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="Node.js" index=1 + // COPY: userId="user-id" + await client.upsertUsers([ + { + id: userId, + privacy_settings: { + delivery_receipts: { + enabled: false, // Do not report delivery status + }, + }, + }, + ]); + // #endregion snippet + + const user = await getServerUser(client, userId); + expect(user.privacy_settings?.delivery_receipts?.enabled).toBe(false); + }); + + it('enables read events on a channel type', async () => { + const targetChannelType = uniqueId('read-type'); + cleanup.channelTypes.push(targetChannelType); + const create = waitAfterCreate(); + const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); + + // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="Channel Type Configuration" tab="Node.js" index=2 + // COPY: targetChannelType="targetChannelType" + // When creating a channel type + await client.chat.createChannelType({ + name: targetChannelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + read_events: true, + }); + + // When updating an existing channel type. + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ + name: targetChannelType, + }); + await client.chat.updateChannelType({ + name: targetChannelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + read_events: true, + }); + // #endregion snippet + await waitForChannelTypePropagation(); + + const [created] = await Promise.all( + create.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(created.read_events).toBe(true); + expect(current.read_events).toBe(true); + const [updated] = await Promise.all( + updateSpy.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(updated.read_events).toBe(true); + const after = await client.chat.getChannelType({ name: targetChannelType }); + expect(after.read_events).toBe(true); + expect(after.max_message_length).toBe(5000); + }); + + it('turns off read receipts in the user privacy settings', async () => { + const userId = uniqueId('user'); + cleanup.users.push(userId); + + // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="Node.js" index=2 + // COPY: userId="user-id" + await client.upsertUsers([ + { + id: userId, + privacy_settings: { + read_receipts: { + enabled: false, // Do not report read status + }, + }, + }, + ]); + // #endregion snippet + + const user = await getServerUser(client, userId); + expect(user.privacy_settings?.read_receipts?.enabled).toBe(false); + }); +}); diff --git a/__tests__/docs-snippets/pages/04-messages/11-ai-message-streaming.test.ts b/__tests__/docs-snippets/pages/04-messages/11-ai-message-streaming.test.ts new file mode 100644 index 0000000..c1c254e --- /dev/null +++ b/__tests__/docs-snippets/pages/04-messages/11-ai-message-streaming.test.ts @@ -0,0 +1,97 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { UpdateMessagePartialResponse } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; + +const DOCS = '_default/04-messages/11-ai-message-streaming.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const botId = uniqueId('ai-bot'); + const userId = uniqueId('user'); + const channelId = uniqueId('ai-chat'); + const cid = `agent:${channelId}`; + + const sendPlaceholder = async () => { + const { message } = await client.chat + .channel('agent', channelId) + .sendMessage({ + message: { text: '', user_id: botId, custom: { ai_generated: true } }, + }); + return message.id; + }; + + beforeAll(async () => { + cleanup.users.push(botId, userId); + await client.upsertUsers([{ id: botId }, { id: userId }]); + + cleanup.channels.push(cid); + await client.chat.channel('agent', channelId).getOrCreate({ + data: { + created_by_id: userId, + members: [{ user_id: userId }, { user_id: botId }], + }, + }); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('commits the final text with a persisted partial update', async () => { + const msgId = await sendPlaceholder(); + const finalText = 'The full assistant reply.'; + + // #region snippet docs="_default/04-messages/11-ai-message-streaming.md" heading="Persisted partial update" tab="Node.js" index=1 + await client.chat.updateMessagePartial({ + id: msgId, + set: { text: finalText, generating: false }, + user_id: botId, + }); + // #endregion snippet + + const { message } = await client.chat.getMessage({ id: msgId }); + expect(message.text).toBe(finalText); + expect(message.custom).toMatchObject({ + ai_generated: true, + generating: false, + }); + }); + + it('streams an interim chunk with an ephemeral partial update', async () => { + const msgId = await sendPlaceholder(); + const buffer = 'The assistant is typ'; + const spy = vi.spyOn(client.chat, 'ephemeralMessageUpdate'); + + // #region snippet docs="_default/04-messages/11-ai-message-streaming.md" heading="Ephemeral partial update" tab="Node.js" index=1 + await client.chat.ephemeralMessageUpdate({ + id: msgId, + set: { text: buffer, generating: true }, + user_id: botId, + }); + // #endregion snippet + + const [response] = await Promise.all( + spy.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(response.message?.text).toBe(buffer); + expect(response.message?.custom).toMatchObject({ generating: true }); + + // Not written to the database: a re-read still has the empty placeholder. + const { message } = await client.chat.getMessage({ id: msgId }); + expect(message.text).toBe(''); + expect(message.custom).not.toHaveProperty('generating'); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/02-events.test.ts b/__tests__/docs-snippets/pages/05-features/02-events.test.ts new file mode 100644 index 0000000..08e4c7f --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/02-events.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { EventResponse, Response } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; + +const DOCS = '_default/05-features/02-events.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + const targetUserID = uniqueId('target-user'); + const channelId = uniqueId('general'); + const cid = `messaging:${channelId}`; + + beforeAll(async () => { + cleanup.users.push(johnId, targetUserID); + cleanup.channels.push(cid); + await client.upsertUsers([{ id: johnId }, { id: targetUserID }]); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + }); + + afterAll(() => cleanup.run()); + + it('sends a custom event to a channel', async () => { + const spy = vi.spyOn(client.chat, 'sendEvent'); + + // #region snippet docs="_default/05-features/02-events.md" heading="To a channel" tab="Node.js" index=1 + // COPY: channelId="general", johnId="john" + const channel = client.chat.channel('messaging', channelId); + + // server-side the event must name the user it is sent on behalf of + await channel.sendEvent({ + event: { + type: 'friendship_request', + user_id: johnId, + custom: { text: 'Hey there, long time no see!' }, + }, + }); + // #endregion snippet + + expect(spy).toHaveBeenCalledTimes(1); + const response = await (spy.mock.results[0] + ?.value as Promise); + spy.mockRestore(); + expect(channel.cid).toBe(cid); + expect(response.event.type).toBe('friendship_request'); + expect(response.event.cid).toBe(cid); + expect(response.event.user?.id).toBe(johnId); + expect(response.event.custom.text).toBe('Hey there, long time no see!'); + }); + + it('sends a custom event to a user', async () => { + const spy = vi.spyOn(client.chat, 'sendUserCustomEvent'); + + // #region snippet docs="_default/05-features/02-events.md" heading="To a user" tab="Node.js" index=1 + await client.chat.sendUserCustomEvent({ + user_id: targetUserID, + event: { + type: 'friendship_request', + custom: { text: 'Tommaso wants to be your friend' }, + }, + }); + // #endregion snippet + + expect(spy).toHaveBeenCalledTimes(1); + const response = await (spy.mock.results[0]?.value as Promise); + spy.mockRestore(); + expect(typeof response.duration).toBe('string'); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/03-unread.test.ts b/__tests__/docs-snippets/pages/05-features/03-unread.test.ts new file mode 100644 index 0000000..a917d77 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/03-unread.test.ts @@ -0,0 +1,261 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../helpers/server'; +import { retry } from '../../helpers/wait'; + +const DOCS = '_default/05-features/03-unread.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const sender = uniqueId('sender'); + // unread counts (single and batch) + const userID = uniqueId('reader'); + const userID2 = uniqueId('reader-two'); + // mark read / mark unread + const fooId = uniqueId('foo'); + // mark all read + const markAllId = uniqueId('myid'); + // unread messages per channel + const perChannelId = uniqueId('myid'); + + const countsChannelId = uniqueId('counts'); + const generalId = uniqueId('general'); + const allReadIds = [uniqueId('all-read'), uniqueId('all-read')]; + const testChannelId = uniqueId('test'); + const perChannelIds = [uniqueId('per-channel'), uniqueId('per-channel')]; + + const createChannel = async (id: string, members: string[]) => { + cleanup.channels.push(`messaging:${id}`); + await client.chat.channel('messaging', id).getOrCreate({ + data: { + created_by_id: sender, + members: [sender, ...members].map((user_id) => ({ user_id })), + }, + }); + }; + + const send = (id: string, text: string) => + sendServerMessage(client, `messaging:${id}`, { text, user_id: sender }); + + const unreadFor = async (id: string, userId: string) => { + const channel = await getServerChannel(client, `messaging:${id}`, { + user_id: userId, + }); + return channel.read?.find((r) => r.user.id === userId)?.unread_messages; + }; + + const totalUnread = async (userId: string) => + (await client.chat.unreadCounts({ user_id: userId })).total_unread_count; + + /** Waits until `userId`'s total unread count is `expected`. */ + const waitForTotalUnread = (userId: string, expected: number) => + retry(async () => { + expect(await totalUnread(userId)).toBe(expected); + }); + + beforeAll(async () => { + const users = [sender, userID, userID2, fooId, markAllId, perChannelId]; + cleanup.users.push(...users); + await client.upsertUsers(users.map((id) => ({ id }))); + + await createChannel(countsChannelId, [userID, userID2]); + await createChannel(generalId, [fooId]); + for (const id of allReadIds) await createChannel(id, [markAllId]); + await createChannel(testChannelId, [userID, userID2]); + for (const id of perChannelIds) await createChannel(id, [perChannelId]); + }); + + afterEach(() => vi.restoreAllMocks()); + + afterAll(() => cleanup.run()); + + it('fetches unread counts server-side', async () => { + await send(countsChannelId, 'one'); + await send(countsChannelId, 'two'); + await waitForTotalUnread(userID, 2); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/05-features/03-unread.md" heading="Unread Counts - Server side" tab="Node.js" index=1 + const response = await client.chat.unreadCounts({ user_id: userID }); + console.log(response.total_unread_count); // total unread count for user + console.log(response.channels); // distribution of unread counts across channels + console.log(response.channel_type); // distribution of unread counts across channel types + console.log(response.total_unread_threads_count); // total unread threads + console.log(response.threads); // list of unread counts per thread + // #endregion snippet + + expect(log).toHaveBeenCalledTimes(5); + expect(response.total_unread_count).toBe(2); + expect(response.channels).toEqual([ + expect.objectContaining({ + channel_id: `messaging:${countsChannelId}`, + unread_count: 2, + }), + ]); + expect(response.channel_type).toEqual([ + expect.objectContaining({ + channel_type: 'messaging', + channel_count: 1, + unread_count: 2, + }), + ]); + expect(response.total_unread_threads_count).toBe(0); + expect(response.threads).toEqual([]); + }); + + it('fetches unread counts for several users at once', async () => { + await waitForTotalUnread(userID2, 2); + const userID1 = userID; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/05-features/03-unread.md" heading="Batch Fetch Unread" tab="Node.js" index=1 + const response = await client.chat.unreadCountsBatch({ + user_ids: [userID1, userID2], + }); + console.log(response.counts_by_user[userID1].total_unread_count); // total unread count for userID1 + console.log(response.counts_by_user[userID1].channels); // distribution of unread counts across channels for userID1 + console.log(response.counts_by_user[userID1].channel_type); // distribution of unread counts across channel types for userID1 + console.log(response.counts_by_user[userID1].total_unread_threads_count); // total unread threads count for userID1 + console.log(response.counts_by_user[userID1].threads); // list of unread counts per thread for userID1 + // #endregion snippet + + expect(log).toHaveBeenCalledTimes(5); + expect(Object.keys(response.counts_by_user).sort()).toEqual( + [userID1, userID2].sort(), + ); + for (const id of [userID1, userID2]) { + const counts = response.counts_by_user[id]; + expect(counts.total_unread_count).toBe(2); + expect(counts.channels.map((c) => c.channel_id)).toEqual([ + `messaging:${countsChannelId}`, + ]); + } + }); + + it('marks a channel as read and a message as unread for a user', async () => { + const first = await send(generalId, 'first'); + await send(generalId, 'second'); + await retry(async () => { + expect(await unreadFor(generalId, fooId)).toBe(2); + }); + + { + // #region snippet docs="_default/05-features/03-unread.md" heading="Mark Read" tab="Node.js" index=1 + // COPY: generalId="general", fooId="foo" + const channel = client.chat.channel('messaging', generalId); + + // mark all messages on a channel as read (server side) + await channel.markRead({ user_id: fooId }); + // #endregion snippet + } + + await retry(async () => { + expect(await unreadFor(generalId, fooId)).toBe(0); + }); + + const messageId = first.id; + const userId = fooId; + + // #region snippet docs="_default/05-features/03-unread.md" heading="Mark Read" tab="Node.js" index=2 + // COPY: generalId="general", messageId="", userId="" + const channel = client.chat.channel('messaging', generalId); + + await channel.markUnread({ + message_id: messageId, + user_id: userId, + }); + // #endregion snippet + + expect(channel.cid).toBe(`messaging:${generalId}`); + // `first` and every message after it are unread again + await retry(async () => { + expect(await unreadFor(generalId, fooId)).toBe(2); + }); + }); + + it('marks all channels as read for a user', async () => { + for (const id of allReadIds) await send(id, 'unread'); + await waitForTotalUnread(markAllId, 2); + + // #region snippet docs="_default/05-features/03-unread.md" heading="Mark All As Read" tab="Node.js" index=1 + // COPY: markAllId="myid" + // mark all channels as read for one user + await client.chat.markChannelsRead({ user_id: markAllId }); + // #endregion snippet + + await waitForTotalUnread(markAllId, 0); + for (const id of allReadIds) { + expect(await unreadFor(id, markAllId)).toBe(0); + } + }); + + it('reads how far each member has read', async () => { + await send(testChannelId, 'hello'); + await client.chat + .channel('messaging', testChannelId) + .markRead({ user_id: userID2 }); + await retry(async () => { + expect(await unreadFor(testChannelId, userID)).toBe(1); + expect(await unreadFor(testChannelId, userID2)).toBe(0); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/05-features/03-unread.md" heading="Read State - Showing how far other users have read" tab="Node.js" index=1 + // COPY: testChannelId="test" + const channel = client.chat.channel('messaging', testChannelId); + const { read } = await channel.get({ state: true }); + + // one entry per member, with how far that member has read + for (const state of read ?? []) { + console.log(state.user.id, state.last_read, state.unread_messages); + } + // #endregion snippet + + const unreadByUser = Object.fromEntries( + (read ?? []).map((r) => [r.user.id, r.unread_messages]), + ); + expect(unreadByUser).toEqual({ [sender]: 0, [userID]: 1, [userID2]: 0 }); + expect(log).toHaveBeenCalledTimes(3); + expect(log).toHaveBeenCalledWith(userID, expect.any(Date), 1); + }); + + it('reads the unread messages per channel', async () => { + await send(perChannelIds[0], 'one'); + await send(perChannelIds[1], 'two'); + await send(perChannelIds[1], 'three'); + await waitForTotalUnread(perChannelId, 3); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/05-features/03-unread.md" heading="Unread Messages Per Channel" tab="Node.js" index=1 + // COPY: perChannelId="myid" + // Read the per-channel distribution from the user's unread counts. + const { channels } = await client.chat.unreadCounts({ + user_id: perChannelId, + }); + + for (const entry of channels) { + console.log(entry.channel_id, entry.unread_count); + } + // #endregion snippet + + expect(channels).toHaveLength(2); + expect(log.mock.calls).toEqual( + expect.arrayContaining([ + [`messaging:${perChannelIds[0]}`, 1], + [`messaging:${perChannelIds[1]}`, 2], + ]), + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/06-campaign_api.test.ts b/__tests__/docs-snippets/pages/05-features/06-campaign_api.test.ts new file mode 100644 index 0000000..e73a332 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/06-campaign_api.test.ts @@ -0,0 +1,854 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { StreamError } from '../../../../index'; +import type { + CampaignResponse, + CreateSegmentResponse, + QuerySegmentTargetsResponse, +} from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { deletePollsCreatedBy } from '../../helpers/server'; +import { retry } from '../../helpers/wait'; + +const DOCS = '_default/05-features/06-campaign_api.md'; + +const isNotFound = (error: unknown) => + error instanceof StreamError && error.code === 16; + +const errorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // Short ids: campaign channels are named "{{receiver.id}}-{{sender.id}}" (max 64 chars). + const campaignSender = uniqueId('s'); + const member1 = uniqueId('m'); + const member2 = uniqueId('m'); + const receiver1 = uniqueId('r'); + const receiver2 = uniqueId('r'); + const updatedSender = uniqueId('s'); + const usaReceiver = uniqueId('r'); + const allUserIds = [ + campaignSender, + member1, + member2, + receiver1, + receiver2, + updatedSender, + usaReceiver, + ]; + /** Custom `country` of `usaReceiver`, in place of the docs' "USA" (other users of the app may have it). */ + const usaCountry = uniqueId('usa'); + /** + * The docs segment targets every user created after 2020: only target the users of this + * file. A few seconds earlier than now, in case the server clock is behind. + */ + const createdAfter = new Date(Date.now() - 5000).toISOString(); + + /** A segment with `receiver1` as its only target, and a draft campaign that uses it. */ + const sharedSegmentId = uniqueId('seg'); + const sharedSegmentName = uniqueId('seg-name'); + const listedCampaignId = uniqueId('camp'); + const listedCampaignName = uniqueId('camp-name'); + + const campaigns: string[] = [listedCampaignId]; + const segments: string[] = [sharedSegmentId]; + let pollId = ''; + + /** Stops (when needed) and deletes a campaign; a running one can't be deleted. */ + const removeCampaign = async (id: string) => { + try { + await retry( + async () => { + try { + await client.chat.deleteCampaign({ id }); + } catch (error) { + if (/in progress/.test(errorMessage(error))) { + await client.chat.stopCampaign({ id }).catch(() => undefined); + } + throw error; + } + }, + { + timeout: 120000, + interval: 3000, + retryIf: (error) => /in progress/.test(errorMessage(error)), + }, + ); + } catch (error) { + if (!isNotFound(error)) throw error; + } + }; + + const removeSegment = async (id: string) => { + try { + await client.chat.deleteSegment({ id }); + } catch (error) { + if (!isNotFound(error)) throw error; + } + }; + + /** Campaigns create channels with ids from the channel template: find them by member. */ + const registerCampaignChannels = async () => { + const { channels } = await client.chat.queryChannels({ + filter_conditions: { members: { $in: allUserIds } }, + limit: 100, + state: false, + }); + for (const { channel } of channels) { + if (channel && !cleanup.channels.includes(channel.cid)) { + cleanup.channels.push(channel.cid); + } + } + }; + + /** + * A draft campaign for the "" placeholder: a campaign can only be + * started once, so the docs' "start now" and "schedule" alternatives need one each. + */ + const createDraftCampaign = async () => { + const id = uniqueId('camp'); + campaigns.push(id); + await client.chat.createCampaign({ + id, + name: uniqueId('camp-name'), + user_ids: [receiver2], + sender_id: campaignSender, + message_template: { text: 'Hi' }, + }); + return id; + }; + + /** A campaign started "now" is queued as `scheduled` without a future `scheduled_for`. */ + const expectStartedNow = (campaign: CampaignResponse | undefined) => { + expect(['scheduled', 'in_progress', 'completed']).toContain( + campaign?.status, + ); + expect(campaign?.scheduled_for?.getTime() ?? 0).toBeLessThan( + Date.now() + 60000, + ); + }; + + /** Captures the segments a region creates without keeping the response. */ + const spyOnCreateSegment = () => vi.spyOn(client.chat, 'createSegment'); + const createdSegments = async ( + spy: ReturnType, + ) => { + const responses = await Promise.all( + spy.mock.results.map((r) => r.value as Promise), + ); + return responses.flatMap((r) => (r.segment ? [r.segment] : [])); + }; + + beforeAll(async () => { + cleanup.users.push(...allUserIds); + cleanup.add(() => deletePollsCreatedBy(client, campaignSender)); + // Campaigns first (a segment can't be deleted while a campaign uses it), then the + // channels the campaigns created, then the segments. + cleanup.add(async () => { + for (const id of campaigns) await removeCampaign(id); + await registerCampaignChannels(); + for (const id of segments) await removeSegment(id); + }); + + await client.upsertUsers([ + ...allUserIds + .filter((id) => id !== usaReceiver) + .map((id) => ({ id, name: id })), + { id: usaReceiver, name: usaReceiver, custom: { country: usaCountry } }, + ]); + + const { poll } = await client.createPoll({ + name: 'Favourite feature?', + user_id: campaignSender, + options: [{ text: 'Chat' }, { text: 'Video' }], + }); + pollId = poll?.id ?? ''; + + await client.chat.createSegment({ + type: 'user', + id: sharedSegmentId, + name: sharedSegmentName, + }); + await client.chat.addSegmentTargets({ + id: sharedSegmentId, + target_ids: [receiver1], + }); + await client.chat.createCampaign({ + id: listedCampaignId, + name: listedCampaignName, + segment_ids: [sharedSegmentId], + sender_id: campaignSender, + message_template: { text: 'Hi' }, + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + afterAll(() => cleanup.run()); + + it('sends a campaign', async () => { + const testSegmentId = uniqueId('seg'); + const testCampaignId = uniqueId('camp'); + const campaignName = uniqueId('camp-name'); + segments.push(testSegmentId); + campaigns.push(testCampaignId); + const anotherCampaignId = await createDraftCampaign(); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Sending a Campaign" tab="Node.js" index=1 + // COPY: anotherCampaignId="", testSegmentId="", createdAfter="2020-01-01T00:00:00Z", testCampaignId="", campaignSender="", campaignName="Campaign name", pollId="poll-id", member1="user-id-1", member2="user-id-2" + const segmentId = testSegmentId; // Optional, generated server side when omitted + // Create a dynamic user segment based on the filter provided. + // e.g., following segment will include all users created after 2020-01-01 + await client.chat.createSegment({ + type: 'user', // Mandatory, "user" or "channel" + id: segmentId, + name: 'New App Users Segment (optional)', + filter: { + created_at: { + $gte: createdAfter, + }, + }, + }); + + const campaignId = testCampaignId; // Optional, generated server side when omitted + await client.chat.createCampaign({ + id: campaignId, + // Users targeted by following segment will receive the message + segment_ids: [segmentId], + // Alternatively, instead of segment_ids, you can also provide user_ids to send the message to specific users + // user_ids: ['', ''], + sender_id: campaignSender, // Mandatory + // Optional, specifies whether to 'exclude' or 'include' the sender from the channel. Omit for the default. + sender_mode: 'exclude', + // Optional, controls the visibility of the new channels for the sender ('hidden' or 'archived'). Omit for the default. + sender_visibility: 'hidden', + name: campaignName, // Optional + description: 'Campaign Description', // Optional + message_template: { + text: 'Hello {{receiver.name}}!', // Mandatory, message text template + attachments: [], // Optional, message attachments + poll_id: pollId, // Optional, send a poll with message + custom: { promotional: true }, // Optional, custom fields will be added to message object received by the receiver. + }, + show_channels: true, // Optional, show hidden channels for receiver + create_channels: true, // Optional, create channel between sender and receiver if not already present + // channel_template is required if create_channels is true + channel_template: { + // Mandatory, channel type + type: 'messaging', + // Optional, template for channel id for channel creation + // if not provided, channel id will be generated on server side + id: '{{receiver.id}}-{{sender.id}}', + // Optional, custom fields will be added to channel object + custom: { promotional: true }, + // Optional, if provided (and multi tenancy is enabled), you can limit accessibility to the channel only to a team + team: 'kansas-city-chiefs', + // Optional, if provided following members will be added to each of the newly created channel + // if not provided, only sender and receiver will be added to the channel + // You can use this to add e.g., moderator or admin to each newly created channel + members: [member1, member2], + // Alternatively, you can use members_template to specify channel roles and custom data for members. + // members and members_template cannot be used together. + // members_template: [ + // { + // user_id: 'user-id-1', + // channel_role: 'channel_moderator', // Optional + // custom: { key1: 'value1' }, // Optional + // }, + // { + // user_id: 'user-id-2', + // channel_role: 'channel_moderator', // Optional + // }, + // ] + }, + }); + + // Start sending messages to targeted users + await client.chat.startCampaign({ id: campaignId }); + + // Alternatively you can also schedule the campaign to start at a later time and stop at a specific time. + await client.chat.startCampaign({ + id: anotherCampaignId, + scheduled_for: new Date(Date.now() + 48 * 60 * 60 * 1000), + stop_at: new Date(Date.now() + 72 * 60 * 60 * 1000), + }); + // #endregion snippet + + const { segment } = await client.chat.getSegment({ id: testSegmentId }); + expect(segment?.filter).toEqual({ created_at: { $gte: createdAfter } }); + + const { campaign } = await client.chat.getCampaign({ id: testCampaignId }); + expect(campaign?.name).toBe(campaignName); + expect(campaign?.segment_ids).toEqual([testSegmentId]); + expect(campaign?.sender_id).toBe(campaignSender); + expect(campaign?.sender_mode).toBe('exclude'); + expect(campaign?.sender_visibility).toBe('hidden'); + expect(campaign?.create_channels).toBe(true); + expect(campaign?.message_template?.poll_id).toBe(pollId); + expect(campaign?.channel_template?.members).toEqual([member1, member2]); + expectStartedNow(campaign); + + const { campaign: scheduled } = await client.chat.getCampaign({ + id: anotherCampaignId, + }); + expect(scheduled?.status).toBe('scheduled'); + expect(scheduled?.scheduled_for).toBeInstanceOf(Date); + expect(scheduled?.stop_at).toBeInstanceOf(Date); + }); + + it('updates, starts, stops, deletes and queries a campaign', async () => { + const testCampaignId = uniqueId('camp'); + const updatedName = uniqueId('camp-name'); + campaigns.push(testCampaignId); + await client.chat.createCampaign({ + id: testCampaignId, + name: uniqueId('camp-name'), + user_ids: [receiver2], + sender_id: campaignSender, + message_template: { text: 'Hello {{receiver.name}}!' }, + }); + const updateCampaign = vi.spyOn(client.chat, 'updateCampaign'); + const startCampaign = vi.spyOn(client.chat, 'startCampaign'); + const listedSegmentId = sharedSegmentId; + const encodedNext = ''; + const anotherCampaignId = await createDraftCampaign(); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Sending a Campaign" tab="Node.js" index=2 + // COPY: anotherCampaignId="", testCampaignId="", sharedSegmentId="a869fc0f-2e7e-4fe0-8651-775c892c1718", updatedSender="Updated-user-id-of-sender", updatedName="Updated name (optional)", listedSegmentId="", encodedNext="" + const campaignId = testCampaignId; + + // Check the status of the campaign + await client.chat.getCampaign({ id: campaignId }); + + // Update the campaign data. sender_id and message_template are always required, + // even when you are only changing one of the other fields. + await client.chat.updateCampaign({ + id: campaignId, + segment_ids: [sharedSegmentId], + sender_id: updatedSender, // mandatory + sender_mode: 'include', // optional + sender_visibility: 'archived', // optional + name: updatedName, + message_template: { + text: 'Updated Hello {{receiver.name}}!', // mandatory + }, + }); + + // You can start a campaign immediately, which will start sending messages to the users in the segment(s) immediately. + await client.chat.startCampaign({ id: anotherCampaignId }); + + // Or you can schedule a campaign to start at a later time. + await client.chat.startCampaign({ + id: campaignId, + // start campaign in 48 hours; optional, campaign will start running after this time + scheduled_for: new Date(Date.now() + 48 * 60 * 60 * 1000), + // automatically stop the campaign after 72 hours; optional + stop_at: new Date(Date.now() + 72 * 60 * 60 * 1000), + }); + + // Stop the campaign + await client.chat.stopCampaign({ id: campaignId }); + + // Delete the campaign + await client.chat.deleteCampaign({ id: campaignId }); + + // Query campaigns + const result = await client.chat.queryCampaigns({ + filter: { segments: { $in: [listedSegmentId] } }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 30, + next: encodedNext, + }); + // result.campaigns is an array of campaigns + // result.next is a cursor for the next page of campaigns + // #endregion snippet + + const [updated] = await Promise.all( + updateCampaign.mock.results.map( + (r) => r.value as Promise, + ), + ); + // Typed as `CampaignResponse`, but the API wraps it in `campaign` (see the report). + expect(updated).toMatchObject({ + campaign: { + name: updatedName, + sender_id: updatedSender, + sender_mode: 'include', + segment_ids: [sharedSegmentId], + }, + }); + expect(startCampaign).toHaveBeenCalledTimes(2); + const { campaign: started } = await client.chat.getCampaign({ + id: anotherCampaignId, + }); + expectStartedNow(started); + + await expect( + client.chat.getCampaign({ id: testCampaignId }), + ).rejects.toMatchObject({ code: 16 }); + const ids = result.campaigns.map((c) => c.id); + expect(ids).toContain(listedCampaignId); + expect(ids).not.toContain(testCampaignId); + }); + + it('queries campaigns with pagination', async () => { + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Querying Campaigns" tab="Node.js" index=1 + // Query campaigns with which are scheduled or in progress + const filter = { status: { $in: ['scheduled', 'in_progress'] } }; + const sort = [{ field: 'created_at', direction: -1 }]; + + // query first page + const { campaigns, next } = await client.chat.queryCampaigns({ + filter, + sort, + limit: 10, + }); + + // query next page + const res = await client.chat.queryCampaigns({ + filter, + sort, + limit: 10, + next, + }); + // #endregion snippet + + expect(campaigns.length).toBeLessThanOrEqual(10); + for (const c of [...campaigns, ...res.campaigns]) { + expect(['scheduled', 'in_progress']).toContain(c.status); + } + const firstPageIds = campaigns.map((c) => c.id); + if (next) { + for (const c of res.campaigns) expect(firstPageIds).not.toContain(c.id); + } else { + // No next page: the second query is the first one again. + expect(res.campaigns.map((c) => c.id)).toEqual(firstPageIds); + } + }); + + it('filters campaigns', async () => { + const otherCampaignId = uniqueId('camp'); + const otherName = uniqueId('camp-name'); + const otherSegmentId = uniqueId('seg'); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Querying Campaigns" tab="Node.js" index=2 + // COPY: listedCampaignId="campaign_id_1", otherCampaignId="campaign_id_2", sharedSegmentId="segment_id_1", otherSegmentId="segment_id_2", listedCampaignName="campaign_name_1", otherName="campaign_name_2", campaignSender="sender_1", updatedSender="sender_2" + const filterStatus = { status: { $in: ['scheduled', 'in_progress'] } }; + const filterID = { id: { $in: [listedCampaignId, otherCampaignId] } }; + const filterBySegments = { + segments: { $in: [sharedSegmentId, otherSegmentId] }, + }; + const filterByName = { name: { $in: [listedCampaignName, otherName] } }; + const filterBySender = { + sender_id: { $in: [campaignSender, updatedSender] }, + }; + const filterByCreated = { created_at: { $gte: '2021-01-01T00:00:00Z' } }; + const filterByUpdated = { updated_at: { $gte: '2021-01-01T00:00:00Z' } }; + // #endregion snippet + + const query = async (filter: Record) => + (await client.chat.queryCampaigns({ filter, limit: 100 })).campaigns.map( + (c) => c.id, + ); + // The draft campaign of this file matches every filter except the status one. + expect(await query(filterStatus)).not.toContain(listedCampaignId); + for (const filter of [ + filterID, + filterBySegments, + filterByName, + filterBySender, + ]) { + expect(await query(filter)).toContain(listedCampaignId); + } + for (const filter of [filterByCreated, filterByUpdated]) { + const { campaigns } = await client.chat.queryCampaigns({ + filter, + limit: 10, + }); + expect(campaigns.length).toBeGreaterThan(0); + } + }); + + it('paginates campaign users', async () => { + const testCampaignId = uniqueId('camp'); + campaigns.push(testCampaignId); + await client.chat.createCampaign({ + id: testCampaignId, + name: uniqueId('camp-name'), + user_ids: [receiver1, receiver2], + sender_id: campaignSender, + message_template: { text: 'Hi' }, + }); + // Two users, one per page, so the second page has something to fetch. + const pageSize = 1; + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Paginating Campaign Users" tab="Node.js" index=1 + // COPY: testCampaignId="", pageSize=1000 + // Let's say you have a campaign with 2000 users + const campaignId = testCampaignId; + + // Fetch campaign with the first 1000 users. + // limit, next and prev are flat parameters on the request, not nested under `users`. + const res1 = await client.chat.getCampaign({ + id: campaignId, + limit: pageSize, + }); + // The user objects live on the campaign, the page cursors on the response + const firstPageUsers = res1.campaign?.users; + + // Fetch campaign with the next 1000 users + const res2 = await client.chat.getCampaign({ + id: campaignId, + limit: pageSize, // 1000 is max allowed limit + next: res1.users?.next, + // or use prev to get the previous page; only one of next and prev is allowed + // prev: res1.users?.prev, + }); + const secondPageUsers = res2.campaign?.users; + // #endregion snippet + + expect(res1.users?.next).toBeTruthy(); + const pages = [firstPageUsers, secondPageUsers].map((users) => + (users ?? []).map((u) => u.id), + ); + expect(pages[0]).toHaveLength(1); + expect(pages[1]).toHaveLength(1); + expect([...pages[0], ...pages[1]].sort()).toEqual( + [receiver1, receiver2].sort(), + ); + }); + + it('creates a user segment', async () => { + const testSegmentId = uniqueId('seg'); + const segmentName = uniqueId('seg-name'); + segments.push(testSegmentId); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=1 + // COPY: testSegmentId="", segmentName="segment_name" + const { segment } = await client.chat.createSegment({ + type: 'user', // mandatory, "user" or "channel" + id: testSegmentId, // optional, generated server side when omitted + // everything below is optional + filter: { + team: 'commonteam', + }, + name: segmentName, + description: 'segment_description', + }); + // #endregion snippet + + expect(segment?.id).toBe(testSegmentId); + expect(segment?.type).toBe('user'); + expect(segment?.name).toBe(segmentName); + expect(segment?.description).toBe('segment_description'); + expect(segment?.filter).toEqual({ team: 'commonteam' }); + }); + + it('manages a user segment and its targets', async () => { + const testSegmentId = uniqueId('seg'); + segments.push(testSegmentId); + const userIds = [receiver1, receiver2]; + const userId = receiver1; + const querySegmentTargets = vi.spyOn(client.chat, 'querySegmentTargets'); + const updateSegment = vi.spyOn(client.chat, 'updateSegment'); + const segmentTargetExists = vi.spyOn(client.chat, 'segmentTargetExists'); + const firstTarget = ''; + const encodedNext = ''; + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=2 + // COPY: testSegmentId="", firstTarget="", encodedNext="" + const segmentId = testSegmentId; // optional on create, generated server side when omitted + + await client.chat.createSegment({ + type: 'user', // mandatory + id: segmentId, + }); + + // Fetch the segment + await client.chat.getSegment({ id: segmentId }); + + // Update the segment. Only name, description and filter can be updated. + await client.chat.updateSegment({ id: segmentId, name: 'new name' }); + + // Add targets. A max of 10,000 users can be added in 1 API call. + await client.chat.addSegmentTargets({ id: segmentId, target_ids: userIds }); + + // Check if a target exists in the segment. Rejects with a 404 when it does not. + await client.chat.segmentTargetExists({ id: segmentId, target_id: userId }); + + // Query targets in the segment + await client.chat.querySegmentTargets({ + id: segmentId, + filter: { target_id: { $gte: firstTarget } }, + sort: [{ field: 'target_id', direction: -1 }], + limit: 10000, + next: encodedNext, // or prev + }); + + // Remove targets. No error if they don't exist. + await client.chat.deleteSegmentTargets({ + id: segmentId, + target_ids: userIds, + }); + + // Delete the segment + await client.chat.deleteSegment({ id: segmentId }); + // #endregion snippet + + const [updated] = await Promise.all( + updateSegment.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(updated.segment?.name).toBe('new name'); + expect(segmentTargetExists).toHaveBeenCalledTimes(1); + const [targets] = await Promise.all( + querySegmentTargets.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(targets.targets.map((t) => t.target_id)).toEqual( + [...userIds].sort().reverse(), + ); + await expect( + client.chat.getSegment({ id: testSegmentId }), + ).rejects.toMatchObject({ code: 16 }); + }); + + it('creates a segment of all users', async () => { + const everyoneName = uniqueId('everyone'); + const createSegment = spyOnCreateSegment(); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=3 + // COPY: everyoneName="everyone" + await client.chat.createSegment({ + type: 'user', + name: everyoneName, + all_users: true, + }); + // #endregion snippet + + const [segment] = await createdSegments(createSegment); + segments.push(segment.id); + expect(segment.name).toBe(everyoneName); + expect(segment.all_users).toBe(true); + }); + + it('manages a channel segment and its targets', async () => { + const channelSegmentId = uniqueId('seg'); + segments.push(channelSegmentId); + const channelCids: string[] = []; + for (const name of ['ch-a', 'ch-b']) { + const channel = client.chat.channel('messaging', uniqueId(name)); + await channel.getOrCreate({ data: { created_by_id: campaignSender } }); + cleanup.channels.push(channel.cid); + channelCids.push(channel.cid); + } + const channelCid = channelCids[0]; + const getSegment = vi.spyOn(client.chat, 'getSegment'); + const segmentTargetExists = vi.spyOn(client.chat, 'segmentTargetExists'); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Channel Segments" tab="Node.js" index=1 + // COPY: channelSegmentId="segmentId" + // note: channel template is not required for channel segment + const segmentId = channelSegmentId; + + await client.chat.createSegment({ + type: 'channel', + id: segmentId, + name: 'segment_name', + description: 'segment_description', + filter: { + team: 'commonteam', + }, + }); + + await client.chat.getSegment({ id: segmentId }); + await client.chat.addSegmentTargets({ + id: segmentId, + target_ids: channelCids, + }); + await client.chat.segmentTargetExists({ + id: segmentId, + target_id: channelCid, + }); + await client.chat.deleteSegmentTargets({ + id: segmentId, + target_ids: channelCids, + }); + await client.chat.deleteSegment({ id: segmentId }); + // #endregion snippet + + const [fetched] = await Promise.all( + getSegment.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(fetched.segment?.type).toBe('channel'); + expect(fetched.segment?.filter).toEqual({ team: 'commonteam' }); + expect(segmentTargetExists).toHaveBeenCalledTimes(1); + await expect( + client.chat.getSegment({ id: channelSegmentId }), + ).rejects.toMatchObject({ code: 16 }); + }); + + it("creates a segment of all the sender's channels", async () => { + const segmentName = uniqueId('all-chats'); + const createSegment = spyOnCreateSegment(); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Channel Segments" tab="Node.js" index=2 + // COPY: segmentName="All my existing chats" + await client.chat.createSegment({ + type: 'channel', + name: segmentName, + all_sender_channels: true, + }); + // #endregion snippet + + const [segment] = await createdSegments(createSegment); + segments.push(segment.id); + expect(segment.type).toBe('channel'); + expect(segment.name).toBe(segmentName); + expect(segment.all_sender_channels).toBe(true); + }); + + it('gets a segment', async () => { + const segmentId = sharedSegmentId; + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Getting Segment" tab="Node.js" index=1 + // The segment type is not needed, the ID identifies the segment on its own + const response = await client.chat.getSegment({ id: segmentId }); + // #endregion snippet + + expect(response.segment?.id).toBe(sharedSegmentId); + expect(response.segment?.name).toBe(sharedSegmentName); + expect(response.segment?.size).toBe(1); + }); + + describe('example: messaging users in the USA', () => { + const usaSegmentId = uniqueId('seg'); + const usaCampaignId = uniqueId('camp'); + const usaCampaignName = uniqueId('camp-name'); + segments.push(usaSegmentId); + campaigns.push(usaCampaignId); + + it('creates a segment for users in the USA', async () => { + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="**Create a segment for user’s in the USA**" tab="Node.js" index=1 + // COPY: usaSegmentId="", usaCountry="USA" + const segmentId = usaSegmentId; // optional, generated server side when omitted + await client.chat.createSegment({ + type: 'user', + id: segmentId, + name: 'People in the USA', + filter: { + country: usaCountry, + }, + }); + // #endregion snippet + + const { segment } = await client.chat.getSegment({ id: usaSegmentId }); + expect(segment?.filter).toEqual({ country: usaCountry }); + }); + + it('messages the segment', async () => { + const segmentId = usaSegmentId; + const startCampaign = vi.spyOn(client.chat, 'startCampaign'); + const anotherCampaignId = await createDraftCampaign(); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="**Message the above segment**" tab="Node.js" index=1 + // COPY: anotherCampaignId="", usaCampaignId="", campaignSender="user-id-of-sender", usaCampaignName="Campaign name (optional)" + const campaignId = usaCampaignId; // optional, generated server side when omitted + await client.chat.createCampaign({ + id: campaignId, + segment_ids: [segmentId], + sender_id: campaignSender, // mandatory + name: usaCampaignName, + description: 'Optional description', + message_template: { + text: "Hi {{ receiver.name }} I'm {{ sender.name }}!", + }, + }); + await client.chat.startCampaign({ id: campaignId }); + + // Alternatively you can schedule the campaign to start at a later time. + // Also you can stop the campaign at a specific time. E.g., + await client.chat.startCampaign({ + id: anotherCampaignId, + scheduled_for: new Date(Date.now() + 48 * 60 * 60 * 1000), + stop_at: new Date(Date.now() + 72 * 60 * 60 * 1000), + }); + // #endregion snippet + + expect(startCampaign).toHaveBeenCalledTimes(2); + const { campaign } = await client.chat.getCampaign({ id: usaCampaignId }); + expect(campaign?.segment_ids).toEqual([usaSegmentId]); + expectStartedNow(campaign); + const { campaign: scheduled } = await client.chat.getCampaign({ + id: anotherCampaignId, + }); + expect(scheduled?.status).toBe('scheduled'); + }); + + it('checks the status of the campaign', async () => { + const campaignId = usaCampaignId; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="**Check the status of the campaign**" tab="Node.js" index=1 + const res = await client.chat.getCampaign({ id: campaignId }); + console.log(res.campaign?.status); // "draft" | "scheduled" | "stopped" | "completed" | "in_progress" + // #endregion snippet + + expect(log.mock.calls[0]).toEqual([res.campaign?.status]); + expectStartedNow(res.campaign); + }); + + it('reads the campaign stats', async () => { + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Campaign Stats" tab="Node.js" index=1 + // COPY: usaCampaignId="" + const res = await client.chat.getCampaign({ id: usaCampaignId }); + + const stats = res.campaign?.stats; + // stats.stats_started_at + // stats.stats_completed_at + // stats.stats_messages_sent + // stats.stats_channels_created + // stats.progress + // stats.stats_users_sent number of users the campaign message was sent to + // stats.stats_users_read number of users who read the campaign message + // #endregion snippet + + expect(typeof stats?.progress).toBe('number'); + expect(typeof stats?.stats_messages_sent).toBe('number'); + expect(typeof stats?.stats_users_sent).toBe('number'); + expect(stats?.stats_started_at).toBeInstanceOf(Date); + }); + }); + + it('queries segments by name', async () => { + const encodedNext = ''; + + // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Updating a large Segment" tab="Node.js" index=1 + // COPY: sharedSegmentName="", encodedNext="" + const response = await client.chat.querySegments({ + filter: { name: sharedSegmentName }, // mandatory, pass {} to match every segment + sort: [{ field: 'created_at', direction: -1 }], + limit: 30, + next: encodedNext, + }); + // #endregion snippet + + expect(response.segments.map((s) => s.id)).toEqual([sharedSegmentId]); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/07-polls_api.test.ts b/__tests__/docs-snippets/pages/05-features/07-polls_api.test.ts new file mode 100644 index 0000000..a94e036 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/07-polls_api.test.ts @@ -0,0 +1,435 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { CreatePollRequest } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { deletePollsCreatedBy } from '../../helpers/server'; + +const DOCS = '_default/05-features/07-polls_api.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const johnId = uniqueId('john'); + const channelId = uniqueId('general'); + + beforeAll(async () => { + cleanup.users.push(johnId); + cleanup.channels.push(`messaging:${channelId}`); + // Polls survive their creator's deletion: delete them before the user. + cleanup.add(() => deletePollsCreatedBy(client, johnId)); + await client.upsertUsers([{ id: johnId }]); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: johnId } }); + }); + + afterAll(() => cleanup.run()); + + /** A poll created by john with two options. */ + const createPoll = async (extra: Partial = {}) => { + const { poll } = await client.createPoll({ + name: 'Test poll', + options: [{ text: 'Option 1' }, { text: 'Option 2' }], + user_id: johnId, + ...extra, + }); + const [option1, option2] = poll.options; + return { poll, option1Id: option1.id, option2Id: option2.id }; + }; + + /** Sends `pollId` in a message to the shared channel (votes need a message). */ + const sendPoll = async (pollId: string) => { + const { message } = await client.chat + .channel('messaging', channelId) + .sendMessage({ + message: { text: 'Poll', poll_id: pollId, user_id: johnId }, + }); + return message.id; + }; + + it('Polls at a Quick Glance', async () => { + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Polls at a Quick Glance" tab="Node.js" index=1 + // COPY: johnId="john", channelId="general" + // Create a poll and send it in a message + const { poll } = await client.createPoll({ + name: 'Where should we host our next event?', + options: [{ text: 'Amsterdam' }, { text: 'Boulder' }], + user_id: johnId, + }); + + const channel = client.chat.channel('messaging', channelId); + const { message } = await channel.sendMessage({ + message: { text: 'Vote now!', poll_id: poll.id, user_id: johnId }, + }); + + // Vote on a poll + await client.chat.castPollVote({ + message_id: message.id, + poll_id: poll.id, + vote: { option_id: poll.options[0].id }, + user_id: johnId, + }); + + // Retrieve poll results + const { poll: results } = await client.getPoll({ poll_id: poll.id }); + console.log(results.vote_counts_by_option); // { 'option-id': 5 } + // #endregion snippet + + expect(message.poll_id).toBe(poll.id); + expect(results.vote_counts_by_option).toEqual({ [poll.options[0].id]: 1 }); + }); + + it('Creating a poll and sending it as part of a message', async () => { + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Creating a poll and sending it as part of a message" tab="Node.js" index=1 + // COPY: johnId="john", channelId="general" + const { poll } = await client.createPoll({ + name: 'Where should we host our next company event?', + options: [ + { text: 'Amsterdam, The Netherlands' }, + { text: 'Boulder, CO' }, + ], + user_id: johnId, + }); + + const channel = client.chat.channel('messaging', channelId); + + const { message } = await channel.sendMessage({ + message: { + text: 'We want to know your opinion!', + poll_id: poll.id, + user_id: johnId, + }, + }); + + // message.poll contains all relevant poll data + // #endregion snippet + + expect(message.poll?.id).toBe(poll.id); + expect(message.poll?.name).toBe( + 'Where should we host our next company event?', + ); + expect(message.poll?.options.map((o) => o.text)).toEqual([ + 'Amsterdam, The Netherlands', + 'Boulder, CO', + ]); + }); + + it('Poll options', async () => { + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Poll options" tab="Node.js" index=1 + // COPY: johnId="john" + // custom properties live under `custom`, on the poll and on each option + const { poll } = await client.createPoll({ + name: 'Where should we host our next company event?', + options: [ + { text: 'Amsterdam, The Netherlands', custom: { foo: 'bar' } }, + { text: 'Boulder, CO', custom: { foo: 'baz' } }, + ], + custom: { foo: 'bar' }, + user_id: johnId, + }); + // #endregion snippet + + expect(poll.custom).toMatchObject({ foo: 'bar' }); + expect(poll.options.map((o) => o.custom)).toEqual([ + { foo: 'bar' }, + { foo: 'baz' }, + ]); + }); + + it('Send vote on option', async () => { + const created = await createPoll(); + const pollId = created.poll.id; + const messageId = await sendPoll(pollId); + const someOptionId = created.option1Id; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Send vote on option" tab="Node.js" index=1 + // COPY: johnId="john", someOptionId="some-option-id-328904342" + const { vote } = await client.chat.castPollVote({ + message_id: messageId, + poll_id: pollId, + vote: { option_id: someOptionId }, + user_id: johnId, + }); + // #endregion snippet + + expect(vote?.option_id).toBe(someOptionId); + expect(vote?.user_id).toBe(johnId); + }); + + it('Send an answer (if answers are configured to be allowed)', async () => { + const created = await createPoll({ allow_answers: true }); + const pollId = created.poll.id; + const messageId = await sendPoll(pollId); + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Send an answer (if answers are configured to be allowed)" tab="Node.js" index=1 + // COPY: johnId="john" + const { vote } = await client.chat.castPollVote({ + message_id: messageId, + poll_id: pollId, + vote: { answer_text: 'This is my answer text' }, + user_id: johnId, + }); + // #endregion snippet + + expect(vote?.is_answer).toBe(true); + expect(vote?.answer_text).toBe('This is my answer text'); + const { poll } = await client.getPoll({ poll_id: pollId }); + expect(poll.answers_count).toBe(1); + }); + + it('Removing a vote', async () => { + const created = await createPoll(); + const pollId = created.poll.id; + const messageId = await sendPoll(pollId); + const cast = await client.chat.castPollVote({ + message_id: messageId, + poll_id: pollId, + vote: { option_id: created.option1Id }, + user_id: johnId, + }); + const voteId = cast.vote?.id ?? ''; + expect(voteId).not.toBe(''); + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Removing a vote" tab="Node.js" index=1 + // COPY: johnId="john" + const { vote } = await client.chat.deletePollVote({ + message_id: messageId, + poll_id: pollId, + vote_id: voteId, + user_id: johnId, + }); + // #endregion snippet + + expect(vote?.id).toBe(voteId); + const { poll } = await client.getPoll({ poll_id: pollId }); + expect(poll.vote_count).toBe(0); + }); + + it('Closing a poll', async () => { + const pollId = (await createPoll()).poll.id; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Closing a poll" tab="Node.js" index=1 + // COPY: johnId="john" + // close a poll by setting `is_closed` + const { poll } = await client.updatePollPartial({ + poll_id: pollId, + set: { is_closed: true }, + user_id: johnId, + }); + // #endregion snippet + + expect(poll.is_closed).toBe(true); + }); + + it('Retrieving a poll', async () => { + const created = await createPoll(); + const pollId = created.poll.id; + const messageId = await sendPoll(pollId); + await client.chat.castPollVote({ + message_id: messageId, + poll_id: pollId, + vote: { option_id: created.option2Id }, + user_id: johnId, + }); + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Retrieving a poll" tab="Node.js" index=1 + // COPY: johnId="john" + // user_id is optional, provide it when you want the response to include + // that user's own votes + const { poll } = await client.getPoll({ poll_id: pollId, user_id: johnId }); + // #endregion snippet + + expect(poll.id).toBe(pollId); + expect(poll.own_votes.map((v) => v.option_id)).toEqual([created.option2Id]); + }); + + it('Full update', async () => { + const created = await createPoll({ + description: 'reset by a full update', + }); + const pollId = created.poll.id; + const { option1Id, option2Id } = created; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Full update" tab="Node.js" index=1 + // COPY: johnId="john" + // `name` is required on a full update, and every property you leave out is + // reset to its default, so send the complete poll. + const { poll } = await client.updatePoll({ + id: pollId, + name: 'Where should we not go to?', + options: [ + { + id: option1Id, + text: 'Amsterdam, The Netherlands', + custom: { foo: 'bar' }, + }, + { id: option2Id, text: 'Boulder, CO', custom: { foo: 'baz' } }, + ], + user_id: johnId, + }); + // #endregion snippet + + expect(poll.name).toBe('Where should we not go to?'); + expect(poll.description).toBe(''); + expect(poll.options).toMatchObject([ + { + id: option1Id, + text: 'Amsterdam, The Netherlands', + custom: { foo: 'bar' }, + }, + { id: option2Id, text: 'Boulder, CO', custom: { foo: 'baz' } }, + ]); + }); + + it('Partial update', async () => { + const created = await createPoll({ + custom: { custom_property: 'value', kept: 'yes' }, + }); + const pollId = created.poll.id; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Partial update" tab="Node.js" index=1 + // COPY: johnId="john" + const { poll } = await client.updatePollPartial({ + poll_id: pollId, + set: { name: 'Where should we not go to?' }, + unset: ['custom_property'], + user_id: johnId, + }); + // #endregion snippet + + expect(poll.name).toBe('Where should we not go to?'); + expect(poll.custom).toEqual({ kept: 'yes' }); + }); + + it('Deleting a poll', async () => { + const pollId = (await createPoll()).poll.id; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Deleting a poll" tab="Node.js" index=1 + // COPY: johnId="john" + await client.deletePoll({ poll_id: pollId, user_id: johnId }); + // #endregion snippet + + await expect(client.getPoll({ poll_id: pollId })).rejects.toMatchObject({ + code: 16, + metadata: { responseCode: 404 }, + }); + }); + + it('Add poll option', async () => { + const pollId = (await createPoll()).poll.id; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="**Add poll option**" tab="Node.js" index=1 + // COPY: johnId="john" + const { poll_option } = await client.createPollOption({ + poll_id: pollId, + text: 'Another option', + user_id: johnId, + }); + // #endregion snippet + + expect(poll_option.text).toBe('Another option'); + const { poll } = await client.getPoll({ poll_id: pollId }); + expect(poll.options.map((o) => o.id)).toContain(poll_option.id); + }); + + it('Update poll option', async () => { + const created = await createPoll(); + const pollId = created.poll.id; + const optionId = created.option1Id; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Update poll option" tab="Node.js" index=1 + // COPY: johnId="john" + const { poll_option } = await client.updatePollOption({ + poll_id: pollId, + id: optionId, + text: 'Updated option', + custom: { my_custom_property: 'my_custom_value' }, + user_id: johnId, + }); + // #endregion snippet + + expect(poll_option).toMatchObject({ + id: optionId, + text: 'Updated option', + custom: { my_custom_property: 'my_custom_value' }, + }); + }); + + it('Delete poll option', async () => { + const created = await createPoll(); + const pollId = created.poll.id; + const optionId = created.option1Id; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Delete poll option" tab="Node.js" index=1 + // COPY: johnId="john" + await client.deletePollOption({ + poll_id: pollId, + option_id: optionId, + user_id: johnId, + }); + // #endregion snippet + + const { poll } = await client.getPoll({ poll_id: pollId }); + expect(poll.options.map((o) => o.id)).toEqual([created.option2Id]); + }); + + it('Querying votes', async () => { + const created = await createPoll({ + options: [ + { text: 'Option 1' }, + { text: 'Option 2' }, + { text: 'Option 3' }, + ], + }); + const pollId = created.poll.id; + const { option1Id, option2Id } = created; + const option3Id = created.poll.options[2].id; + const messageId = await sendPoll(pollId); + for (const option_id of [option1Id, option2Id, option3Id]) { + await client.chat.castPollVote({ + message_id: messageId, + poll_id: pollId, + vote: { option_id }, + user_id: johnId, + }); + } + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Querying votes" tab="Node.js" index=1 + // COPY: johnId="john" + // retrieve all votes on either option1Id or option2Id + const { votes } = await client.queryPollVotes({ + poll_id: pollId, + filter: { option_id: { $in: [option1Id, option2Id] } }, + user_id: johnId, + }); + // #endregion snippet + + expect(votes.map((v) => v.option_id).sort()).toEqual( + [option1Id, option2Id].sort(), + ); + }); + + it('Querying polls', async () => { + const open = (await createPoll()).poll.id; + const closed1 = (await createPoll({ is_closed: true })).poll.id; + const closed2 = (await createPoll({ is_closed: true })).poll.id; + + // #region snippet docs="_default/05-features/07-polls_api.md" heading="Querying polls" tab="Node.js" index=1 + // COPY: johnId="john" + // retrieve all polls that are closed for voting sorted by created_at + const { polls } = await client.queryPolls({ + filter: { is_closed: true }, + sort: [{ field: 'created_at', direction: -1 }], + user_id: johnId, + }); + // #endregion snippet + + // The filter matches every closed poll of the app: check the test's own ones. + expect(polls.every((p) => p.is_closed)).toBe(true); + const ids = polls.map((p) => p.id); + expect(ids).not.toContain(open); + expect(ids.indexOf(closed2)).toBeGreaterThanOrEqual(0); + expect(ids.indexOf(closed2)).toBeLessThan(ids.indexOf(closed1)); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/08-location_sharing.test.ts b/__tests__/docs-snippets/pages/05-features/08-location_sharing.test.ts new file mode 100644 index 0000000..4c86570 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/08-location_sharing.test.ts @@ -0,0 +1,320 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, + type MockInstance, +} from 'vitest'; +import type { StreamClient } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { getServerChannel } from '../../helpers/server'; +import { waitForChannelTypePropagation } from '../../helpers/wait'; + +const DOCS = '_default/05-features/08-location_sharing.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const johnId = uniqueId('john'); + // Shared locations enabled through config_overrides. + const channelId = uniqueId('general'); + + /** Sends a live location message as john and returns its id. */ + const sendLiveLocation = async (latitude: number, longitude: number) => { + const { message } = await client.chat + .channel('messaging', channelId) + .sendMessage({ + message: { + text: 'Live location', + user_id: johnId, + shared_location: { + created_by_device_id: 'device-id', + latitude, + longitude, + end_at: new Date(Date.now() + 60 * 60 * 1000), + }, + }, + }); + return message.id; + }; + + /** Sends a static location message as john to channel `type:id`. */ + const sendLocation = async (type: string, id: string) => { + const { message } = await client.chat.channel(type, id).sendMessage({ + message: { + text: 'Static location', + user_id: johnId, + shared_location: { + created_by_device_id: 'device-id', + latitude: 10, + longitude: 10, + }, + }, + }); + return message; + }; + + /** + * The shared location of message `messageId`, read from john's view of the + * channel (`getMessage` and `queryChannels` without `user_id` don't return + * `shared_location`). + */ + const storedLocation = async (messageId: string) => { + const { messages } = await getServerChannel( + client, + `messaging:${channelId}`, + { user_id: johnId, message_limit: 100 }, + ); + const message = messages.find((m) => m.id === messageId); + if (!message) throw new Error(`message ${messageId} not in channel state`); + return message.shared_location; + }; + + /** Message ids of john's active live locations. */ + const activeLiveLocationIds = async () => { + const { active_live_locations } = await client.getUserLiveLocations({ + user_id: johnId, + }); + return active_live_locations.map((l) => l.message_id); + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + const channel = client.chat.channel('messaging', channelId); + await channel.getOrCreate({ data: { created_by_id: johnId } }); + cleanup.channels.push(`messaging:${channelId}`); + await channel.updateChannelPartial({ + set: { config_overrides: { shared_locations: true } }, + }); + }); + + afterAll(() => cleanup.run()); + afterEach(() => vi.restoreAllMocks()); + + it('Enabling location sharing', async () => { + // The channel type part of the fence runs on its own channel type. + const channelType = uniqueId('location-type'); + await client.chat.createChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(channelType); + await waitForChannelTypePropagation(); + const otherChannelId = uniqueId('general'); + await client.chat + .channel(channelType, otherChannelId) + .getOrCreate({ data: { created_by_id: johnId } }); + cleanup.channels.push(`${channelType}:${otherChannelId}`); + // A second channel of the type, without an override: only the channel type + // part of the fence enables it. + const typeChannelId = uniqueId('general'); + await client.chat + .channel(channelType, typeChannelId) + .getOrCreate({ data: { created_by_id: johnId } }); + cleanup.channels.push(`${channelType}:${typeChannelId}`); + const before = await client.chat.getChannelType({ name: channelType }); + expect(before.shared_locations).toBe(false); + // Code 17 "location sharing is not enabled for this channel". + const notEnabled = { code: 17, metadata: { responseCode: 403 } }; + await expect( + sendLocation(channelType, otherChannelId), + ).rejects.toMatchObject(notEnabled); + await expect( + sendLocation(channelType, typeChannelId), + ).rejects.toMatchObject(notEnabled); + + // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Enabling location sharing" tab="Node.js" index=1 + // COPY: channelType="messaging", otherChannelId="general" + const channel = client.chat.channel(channelType, otherChannelId); + + // Enabling it for a channel + await channel.updateChannelPartial({ + set: { + config_overrides: { + shared_locations: true, + }, + }, + }); + + // Enabling it for a channel type. + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + shared_locations: true, + }); + // #endregion snippet + await waitForChannelTypePropagation(); + + const { channel: updated } = await channel.getOrCreate({}); + expect(updated?.config?.shared_locations).toBe(true); + const after = await client.chat.getChannelType({ name: channelType }); + expect(after.shared_locations).toBe(true); + // Both channels now accept location messages. + const viaOverride = await sendLocation(channelType, otherChannelId); + expect(viaOverride.shared_location?.message_id).toBe(viaOverride.id); + const viaType = await sendLocation(channelType, typeChannelId); + expect(viaType.shared_location?.message_id).toBe(viaType.id); + }); + + /** The message sent by the only `client.chat.sendMessage` call of `spy`. */ + const sentMessage = async ( + spy: MockInstance, + ) => { + expect(spy).toHaveBeenCalledTimes(1); + const { message } = await (spy.mock.results[0].value as ReturnType< + StreamClient['chat']['sendMessage'] + >); + return message; + }; + + it('Sending static location', async () => { + const spy = vi.spyOn(client.chat, 'sendMessage'); + // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Sending static location" tab="Node.js" index=1 + // COPY: channelId="general", johnId="john" + const channel = client.chat.channel('messaging', channelId); + + // Send a message with a static location. + await channel.sendMessage({ + message: { + text: 'Message with static location', + user_id: johnId, + shared_location: { + created_by_device_id: 'device-id', + latitude: 10, + longitude: 10, + // no `end_at` for a static location + }, + }, + }); + // #endregion snippet + + const message = await sentMessage(spy); + expect(message.shared_location).toMatchObject({ + latitude: 10, + longitude: 10, + created_by_device_id: 'device-id', + message_id: message.id, + }); + expect(message.shared_location?.end_at).toBeUndefined(); + // The location is stored on the message, and a static one is not live. + expect(await storedLocation(message.id)).toMatchObject({ + latitude: 10, + longitude: 10, + }); + expect(await activeLiveLocationIds()).not.toContain(message.id); + }); + + it('Starting live location sharing', async () => { + const spy = vi.spyOn(client.chat, 'sendMessage'); + // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Starting live location sharing" tab="Node.js" index=1 + // COPY: channelId="general", johnId="john" + const channel = client.chat.channel('messaging', channelId); + + // Send a message with a live location. + // Live location differs from the static location by the termination timestamp end_at. + await channel.sendMessage({ + message: { + text: 'Message with live location', + user_id: johnId, + shared_location: { + created_by_device_id: 'device-id', + latitude: 10, + longitude: 10, + end_at: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now + }, + }, + }); + // #endregion snippet + + const message = await sentMessage(spy); + expect(message.shared_location).toMatchObject({ + latitude: 10, + longitude: 10, + message_id: message.id, + }); + expect(message.shared_location?.end_at?.getTime()).toBeGreaterThan( + Date.now(), + ); + expect(await activeLiveLocationIds()).toContain(message.id); + }); + + it('Stopping live location sharing', async () => { + const messageID = await sendLiveLocation(10, 10); + expect(await activeLiveLocationIds()).toContain(messageID); + const spy = vi.spyOn(client, 'updateLiveLocation'); + + // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Stopping live location sharing" tab="Node.js" index=1 + // COPY: messageID="message-id", johnId="john" + // Stop live location sharing by setting end_at to now + await client.updateLiveLocation({ + message_id: messageID, + end_at: new Date(), + latitude: 10, + longitude: 10, + user_id: johnId, + }); + // #endregion snippet + + expect(spy).toHaveBeenCalledTimes(1); + const stopped = await (spy.mock.results[0].value as ReturnType< + StreamClient['updateLiveLocation'] + >); + expect(stopped.message_id).toBe(messageID); + expect(stopped.end_at?.getTime()).toBeLessThanOrEqual(Date.now()); + expect((await storedLocation(messageID))?.end_at?.getTime()).toBe( + stopped.end_at?.getTime(), + ); + expect(await activeLiveLocationIds()).not.toContain(messageID); + }); + + it('Updating live location', async () => { + const messageID = await sendLiveLocation(10, 10); + + // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Updating live location" tab="Node.js" index=1 + // COPY: messageID="message-id", johnId="john" + // Get the user's active live locations + const { active_live_locations } = await client.getUserLiveLocations({ + user_id: johnId, + }); + + // Update the user's live location (e.g., when the device location changes) + await client.updateLiveLocation({ + message_id: messageID, + latitude: 1, + longitude: 2, + user_id: johnId, + }); + // #endregion snippet + + const before = active_live_locations.find( + (l) => l.message_id === messageID, + ); + expect(before).toMatchObject({ latitude: 10, longitude: 10 }); + // The message and the active live location now have the new position. + expect(await storedLocation(messageID)).toMatchObject({ + latitude: 1, + longitude: 2, + }); + const { active_live_locations: after } = await client.getUserLiveLocations({ + user_id: johnId, + }); + expect(after.find((l) => l.message_id === messageID)).toMatchObject({ + latitude: 1, + longitude: 2, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/09-translation.test.ts b/__tests__/docs-snippets/pages/05-features/09-translation.test.ts new file mode 100644 index 0000000..9670536 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/09-translation.test.ts @@ -0,0 +1,115 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { getServerUser, waitForAppSetting } from '../../helpers/server'; + +const DOCS = '_default/05-features/09-translation.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const johnId = uniqueId('john'); + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId, language: 'en' }]); + }); + + afterAll(() => cleanup.run()); + + it('Message Translation Endpoint', async () => { + const channelId = uniqueId('general'); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: johnId } }); + cleanup.channels.push(`messaging:${channelId}`); + const messageID = uniqueId('message'); + + // #region snippet docs="_default/05-features/09-translation.md" heading="Message Translation Endpoint" tab="Node.js" index=1 + // COPY: channelId="general", johnId="john" + const channel = client.chat.channel('messaging', channelId); + + await channel.sendMessage({ + message: { + id: messageID, + text: 'Hello, I would like to have more information about your product.', + user_id: johnId, + }, + }); + + // returns the message.text translated into French + const response = await client.chat.translateMessage({ + id: messageID, + language: 'fr', + }); + + // the translation will be added to the i18n object + console.log(response.message.i18n?.fr_text); + // "Bonjour, J'aimerais avoir plus d'informations sur votre produit.", + // #endregion snippet + + expect(response.message.id).toBe(messageID); + expect(response.message.i18n?.fr_text).toMatch(/\S/); + expect(response.message.i18n?.language).toBe('en'); + const { message } = await client.chat.getMessage({ id: messageID }); + expect(message.i18n?.fr_text).toBe(response.message.i18n?.fr_text); + }); + + it('Enabling automatic translation', async () => { + const channelId = uniqueId('melting-pot'); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: johnId } }); + cleanup.channels.push(`messaging:${channelId}`); + const { app } = await client.getApp(); + const original = app.auto_translation_enabled; + expect(original).toBe(false); + cleanup.add(async () => { + await client.updateApp({ auto_translation_enabled: original }); + await waitForAppSetting(client, 'auto_translation_enabled', original); + }); + + // #region snippet docs="_default/05-features/09-translation.md" heading="Enabling automatic translation" tab="Node.js" index=1 + // COPY: channelId="melting-pot" + const channel = client.chat.channel('messaging', channelId); + + // enable auto-translation only for this channel + await channel.update({ data: { auto_translation_enabled: true } }); + + // ensure all messages are translated in english for this channel + await channel.update({ + data: { + auto_translation_enabled: true, + auto_translation_language: 'en', + }, + }); + + // auto translate messages for all channels + await client.updateApp({ auto_translation_enabled: true }); + // #endregion snippet + + const { channel: updated } = await channel.getOrCreate({}); + expect(updated?.auto_translation_enabled).toBe(true); + expect(updated?.auto_translation_language).toBe('en'); + await waitForAppSetting(client, 'auto_translation_enabled', true); + }); + + it('Set user language', async () => { + const userId = uniqueId('user'); + cleanup.users.push(userId); + + // #region snippet docs="_default/05-features/09-translation.md" heading="Set user language" tab="Node.js" index=1 + // COPY: userId="userId" + // sets the user language + await client.updateUsers({ + users: { + userId: { id: userId, language: 'en' }, + }, + }); + // #endregion snippet + + const user = await getServerUser(client, userId); + expect(user.language).toBe('en'); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts b/__tests__/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts new file mode 100644 index 0000000..04ce156 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts @@ -0,0 +1,58 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { UpdateChannelResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; + +const DOCS = '_default/05-features/10-advanced/11-slow_mode_and_throttling.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const userId = uniqueId('slowmode-user'); + const channelId = uniqueId('general'); + + beforeAll(async () => { + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + + cleanup.channels.push(`messaging:${channelId}`); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: userId } }); + }); + + afterAll(() => cleanup.run()); + + it('enables, changes and disables slow mode', async () => { + const updateSpy = vi.spyOn(client.chat, 'updateChannel'); + + // #region snippet docs="_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // enable slow mode and set cooldown to 1s + await channel.update({ cooldown: 1 }); + + // increase cooldown to 30s + await channel.update({ cooldown: 30 }); + + // disable slow mode + await channel.update({ cooldown: 0 }); + // #endregion snippet + + const responses = await Promise.all( + updateSpy.mock.results.map( + (r) => r.value as Promise, + ), + ); + updateSpy.mockRestore(); + + expect(responses.map((r) => r.channel?.cooldown ?? 0)).toEqual([1, 30, 0]); + + const current = await getServerChannel(client, `messaging:${channelId}`); + expect(current.channel?.cooldown ?? 0).toBe(0); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts b/__tests__/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts new file mode 100644 index 0000000..5ad1460 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts @@ -0,0 +1,191 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createUserToken, getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +describe('_default/05-features/10-advanced/12-drafts.md', () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const ownerId = uniqueId('owner'); + + /** + * Draft creation has no server-side endpoint (the docs say so), so setup creates drafts + * as the user through the client-side REST endpoint, signed with a user token. + */ + const createDraft = async ( + userId: string, + channelId: string, + message: { text: string; parent_id?: string }, + ) => { + const url = `https://chat.stream-io-api.com/channels/messaging/${channelId}/draft?api_key=${process.env.STREAM_API_KEY as string}`; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: createUserToken(userId), + 'stream-auth-type': 'jwt', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message }), + }); + if (!res.ok) { + throw new Error(`createDraft failed: ${res.status} ${await res.text()}`); + } + }; + + /** A `messaging` channel with `memberIds` as members. */ + const createChannel = async (memberIds: string[]) => { + const channelId = uniqueId('general'); + cleanup.channels.push(`messaging:${channelId}`); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: ownerId, + members: memberIds.map((user_id) => ({ user_id })), + }, + }); + return channelId; + }; + + /** A user with its own channel, a parent message, and a channel and a thread draft. */ + const setupUserWithDrafts = async () => { + const userId = uniqueId('user'); + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + const channelId = await createChannel([userId]); + const { message } = await client.chat + .channel('messaging', channelId) + .sendMessage({ message: { text: 'parent', user_id: userId } }); + const parentMessageId = message.id; + await createDraft(userId, channelId, { text: 'channel draft' }); + await createDraft(userId, channelId, { + text: 'thread draft', + parent_id: parentMessageId, + }); + return { userId, channelId, parentMessageId }; + }; + + beforeAll(async () => { + cleanup.users.push(ownerId); + await client.upsertUsers([{ id: ownerId }]); + }); + + afterAll(() => cleanup.run()); + + it('deletes a channel and a thread draft', async () => { + const { userId, channelId, parentMessageId } = await setupUserWithDrafts(); + const before = await client.chat.queryDrafts({ user_id: userId }); + expect(before.drafts).toHaveLength(2); + + // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Deleting a draft message" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // Channel draft + await channel.deleteDraft({ user_id: userId }); + + // Thread draft + await channel.deleteDraft({ user_id: userId, parent_id: parentMessageId }); + // #endregion snippet + + const { drafts } = await client.chat.queryDrafts({ user_id: userId }); + expect(drafts).toEqual([]); + }); + + it('loads a channel and a thread draft', async () => { + const { userId, channelId, parentMessageId } = await setupUserWithDrafts(); + + // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Loading a draft message" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // Channel draft + const draft = await channel.getDraft({ user_id: userId }); + + // Thread draft + const threadDraft = await channel.getDraft({ + user_id: userId, + parent_id: parentMessageId, + }); + // #endregion snippet + + expect(draft.draft.message.text).toBe('channel draft'); + expect(draft.draft.channel_cid).toBe(`messaging:${channelId}`); + expect(draft.draft.parent_id).toBeUndefined(); + expect(threadDraft.draft.message.text).toBe('thread draft'); + expect(threadDraft.draft.parent_id).toBe(parentMessageId); + }); + + it('queries drafts', async () => { + const userId = uniqueId('user'); + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + const channel1 = await createChannel([userId]); + const channel2 = await createChannel([userId]); + const channel3 = await createChannel([userId]); + await createDraft(userId, channel1, { text: 'draft 1' }); + await createDraft(userId, channel2, { text: 'draft 2' }); + await createDraft(userId, channel3, { text: 'draft 3' }); + const cid1 = `messaging:${channel1}`; + const cid2 = `messaging:${channel2}`; + + // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Querying draft messages" tab="Node.js" index=1 + // COPY: cid1="messaging:channel-1", cid2="messaging:channel-2" + // Query all user drafts + const response = await client.chat.queryDrafts({ + user_id: userId, + }); + + // Query drafts for certain channels and sort + const filteredResponse = await client.chat.queryDrafts({ + user_id: userId, + filter: { + channel_cid: { $in: [cid1, cid2] }, + }, + sort: [{ field: 'created_at', direction: -1 }], + }); + // #endregion snippet + + expect(response.drafts.map((d) => d.channel_cid).sort()).toEqual( + [cid1, cid2, `messaging:${channel3}`].sort(), + ); + // Newest first: draft 2 was created after draft 1. + expect(filteredResponse.drafts.map((d) => d.channel_cid)).toEqual([ + cid2, + cid1, + ]); + }); + + it('paginates drafts', async () => { + const userId = uniqueId('user'); + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + const channel1 = await createChannel([userId]); + const channel2 = await createChannel([userId]); + await createDraft(userId, channel1, { text: 'draft 1' }); + await createDraft(userId, channel2, { text: 'draft 2' }); + // Two drafts, one per page. + const pageSize = 1; + + // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Pagination" tab="Node.js" index=1 + // COPY: pageSize=5 + // Query drafts with a limit + const firstPage = await client.chat.queryDrafts({ + user_id: userId, + limit: pageSize, + }); + + // Query the next page + const secondPage = await client.chat.queryDrafts({ + user_id: userId, + limit: pageSize, + next: firstPage.next, + }); + // #endregion snippet + + expect(firstPage.drafts).toHaveLength(1); + expect(firstPage.next).toBeTruthy(); + expect(secondPage.drafts).toHaveLength(1); + expect( + [...firstPage.drafts, ...secondPage.drafts].map((d) => d.channel_cid), + ).toEqual([`messaging:${channel2}`, `messaging:${channel1}`]); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts b/__tests__/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts new file mode 100644 index 0000000..5bc1286 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts @@ -0,0 +1,71 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; + +const DOCS = '_default/05-features/10-advanced/13-private_messaging.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const adminId = uniqueId('admin'); + const janeId = uniqueId('jane'); + const johnId = uniqueId('john'); + const rideId = uniqueId('ride'); + const cid = `messaging:${rideId}`; + + beforeAll(async () => { + cleanup.users.push(adminId, janeId, johnId); + await client.upsertUsers([ + { id: adminId, role: 'admin' }, + { id: janeId }, + { id: johnId }, + ]); + + cleanup.channels.push(cid); + await client.chat.channel('messaging', rideId).getOrCreate({ + data: { + created_by_id: adminId, + members: [{ user_id: janeId }, { user_id: johnId }], + }, + }); + }); + + afterAll(() => cleanup.run()); + + it('sends a message with restricted visibility', async () => { + // #region snippet docs="_default/05-features/10-advanced/13-private_messaging.md" heading="Sending a message with restricted visibility" tab="Node.js" index=1 + // COPY: rideId="ride-08467339", adminId="admin", janeId="jane" + // Get a channel + const channel = client.chat.channel('messaging', rideId); + + // Send a message only visible to Jane + const message = await channel.sendMessage({ + message: { + text: 'Hi Jane, your driver John will be at your location in 1 minute', + type: 'system', + user_id: adminId, + restricted_visibility: [janeId], + }, + }); + // #endregion snippet + + expect(message.message.type).toBe('system'); + expect(message.message.restricted_visibility).toEqual([janeId]); + const messageId = message.message.id; + + // Jane (in the list) sees it, John (a member, not in the list) doesn't. + const asJane = await getServerChannel(client, cid, { + user_id: janeId, + message_limit: 10, + }); + expect(asJane.messages.map((m) => m.id)).toContain(messageId); + const asJohn = await getServerChannel(client, cid, { + user_id: johnId, + message_limit: 10, + }); + expect(asJohn.messages.map((m) => m.id)).not.toContain(messageId); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/14-user_average_response_time.test.ts b/__tests__/docs-snippets/pages/05-features/10-advanced/14-user_average_response_time.test.ts new file mode 100644 index 0000000..296ebbc --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/10-advanced/14-user_average_response_time.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { + getServerUser, + sendServerMessage, + waitForAppSetting, +} from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; + +const DOCS = + '_default/05-features/10-advanced/14-user_average_response_time.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const buyerId = uniqueId('buyer'); + const sellerId = uniqueId('seller'); + const channelId = uniqueId('marketplace'); + const cid = `messaging:${channelId}`; + + beforeAll(async () => { + cleanup.users.push(buyerId, sellerId); + await client.upsertUsers([{ id: buyerId }, { id: sellerId }]); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: buyerId, + members: [{ user_id: buyerId }, { user_id: sellerId }], + }, + }); + cleanup.channels.push(cid); + }); + + afterAll(() => cleanup.run()); + + it('Configuration', async () => { + const { app } = await client.getApp(); + const original = app.user_response_time_enabled; + cleanup.add(async () => { + await client.updateApp({ user_response_time_enabled: original }); + await waitForAppSetting(client, 'user_response_time_enabled', original); + }); + + // #region snippet docs="_default/05-features/10-advanced/14-user_average_response_time.md" heading="Configuration" tab="Node.js" index=1 + // Enable user response time tracking + await client.updateApp({ + user_response_time_enabled: true, + }); + // #endregion snippet + + await waitForAppSetting(client, 'user_response_time_enabled', true); + + // "Once enabled, the avg_response_time field will be included in user responses" + await sendServerMessage(client, cid, { + text: 'Is this still available?', + user_id: buyerId, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await sendServerMessage(client, cid, { + text: 'Yes, it is.', + user_id: sellerId, + }); + const seller = await retry( + async () => { + const user = await getServerUser(client, sellerId); + if (user.avg_response_time === undefined) { + throw new Error('avg_response_time not set yet'); + } + return user; + }, + { timeout: 30000 }, + ); + expect(seller.avg_response_time).toBeGreaterThan(0); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts b/__tests__/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts new file mode 100644 index 0000000..6c68d6f --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts @@ -0,0 +1,307 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { StreamClient } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; + +const DOCS = '_default/05-features/10-advanced/15-pending_messages.md'; + +const PENDING_HOOK_URL = 'https://example.com/pending-messages'; + +/** Sends a pending message as `userId` to `messaging:` and returns its id. */ +const sendPending = async ( + client: StreamClient, + channelId: string, + userId: string, + text: string, +) => { + const response = await client.chat + .channel('messaging', channelId) + .sendMessage({ message: { text, user_id: userId }, pending: true }); + return response.message.id; +}; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const senderId = uniqueId('sender'); + const otherId = uniqueId('other'); + // The docs' "user-id" is the sender. + const userId = senderId; + const members = [{ user_id: senderId }, { user_id: otherId }]; + + // The first snippet changes the channel type it names: use a uniqueId type instead + // of the built-in `messaging` type. + const channelType = uniqueId('messaging'); + const typeChannelId = uniqueId('pending-by-type'); + const channelId = uniqueId('pending'); + + beforeAll(async () => { + cleanup.users.push(senderId, otherId); + await client.upsertUsers([{ id: senderId }, { id: otherId }]); + + await client.chat.createChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(channelType); + await waitForChannelTypePropagation(); + + await client.chat + .channel(channelType, typeChannelId) + .getOrCreate({ data: { created_by_id: senderId, members } }); + cleanup.channels.push(`${channelType}:${typeChannelId}`); + + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: senderId, members } }); + cleanup.channels.push(`messaging:${channelId}`); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('Sending Pending Messages (channel config)', async () => { + const before = await client.chat.getChannelType({ name: channelType }); + expect(before.mark_messages_pending).toBe(false); + + // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Sending Pending Messages" tab="Node.js" index=1 + // COPY: channelType="messaging" + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: channelType }); + + const response = await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + mark_messages_pending: true, + }); + // #endregion snippet + + expect(response.mark_messages_pending).toBe(true); + await waitForChannelTypePropagation(); + + // A message sent without `pending` is now pending: only the sender sees it. + const cid = `${channelType}:${typeChannelId}`; + const { message } = await client.chat + .channel(channelType, typeChannelId) + .sendMessage({ + message: { text: 'pending by default', user_id: senderId }, + }); + + const senderView = await getServerChannel(client, cid, { + user_id: senderId, + message_limit: 10, + }); + expect(senderView.pending_messages?.map((p) => p.message?.id)).toEqual([ + message.id, + ]); + const otherView = await getServerChannel(client, cid, { + user_id: otherId, + message_limit: 10, + }); + expect(otherView.messages.map((m) => m.id)).not.toContain(message.id); + }); + + it('Sending Pending Messages (per message)', async () => { + // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Sending Pending Messages" tab="Node.js" index=2 + // COPY: channelId="channel-id", userId="user-id" + const channel = client.chat.channel('messaging', channelId); + + const message = await channel.sendMessage({ + message: { + text: 'this is my pending message', + user_id: userId, + }, + pending: true, + pending_message_metadata: { + my: 'metadata', + }, + }); + // #endregion snippet + + expect(message.pending_message_metadata).toEqual({ my: 'metadata' }); + + const cid = `messaging:${channelId}`; + const senderView = await getServerChannel(client, cid, { + user_id: userId, + message_limit: 10, + }); + expect( + senderView.pending_messages?.find( + (p) => p.message?.id === message.message.id, + )?.metadata, + ).toEqual({ my: 'metadata' }); + const otherView = await getServerChannel(client, cid, { + user_id: otherId, + message_limit: 10, + }); + expect(otherView.messages.map((m) => m.id)).not.toContain( + message.message.id, + ); + }); + it('Querying pending messages', async () => { + const pendingId = await sendPending(client, channelId, senderId, 'one'); + const pendingId1 = pendingId; + const pendingId2 = await sendPending(client, channelId, senderId, 'two'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Querying pending messages" tab="Node.js" index=1 + // COPY: pendingId="pending_message_id", pendingId1="pending_message_id_1", pendingId2="pending_message_id_2", channelId="channel-id" + // To retrieve single message + const response = await client.chat.getMessage({ id: pendingId }); + console.log(response.message, response.pending_message_metadata); + + // To retrieve multiple messages + const channel = client.chat.channel('messaging', channelId); + const messagesResponse = await channel.getManyMessages({ + ids: [pendingId1, pendingId2], + }); + console.log(messagesResponse.messages); + // #endregion snippet + + expect(response.message.id).toBe(pendingId); + expect(messagesResponse.messages.map((m) => m.id).sort()).toEqual( + [pendingId1, pendingId2].sort(), + ); + expect(log).toHaveBeenCalledTimes(2); + }); + + it('Query channels', async () => { + const pendingId = await sendPending(client, channelId, senderId, 'qc'); + const filters = { cid: `messaging:${channelId}` }; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Query channels" tab="Node.js" index=1 + // COPY: channelId="channel_id", userId="user-id" + // Quering multiple channels + const response = await client.chat.queryChannels({ + filter_conditions: filters, + user_id: userId, + }); + console.log(response.channels[0].pending_messages); + + // Querying single channel + const channel = client.chat.channel('messaging', channelId); + const channelResponse = await channel.get({ state: true, user_id: userId }); + console.log(channelResponse.pending_messages); + // #endregion snippet + + const ids = (calls: unknown) => + (calls as Array<{ message?: { id: string } }> | undefined)?.map( + (p) => p.message?.id, + ); + expect(ids(log.mock.calls[0][0])).toContain(pendingId); + expect(ids(log.mock.calls[1][0])).toContain(pendingId); + expect( + channelResponse.pending_messages?.map((p) => p.message?.id), + ).toContain(pendingId); + }); + + it('Committing pending messages', async () => { + const messageId = await sendPending(client, channelId, senderId, 'commit'); + const cid = `messaging:${channelId}`; + const otherBefore = await getServerChannel(client, cid, { + user_id: otherId, + message_limit: 25, + }); + expect(otherBefore.messages.map((m) => m.id)).not.toContain(messageId); + + // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Committing pending messages" tab="Node.js" index=1 + // COPY: messageId="message-1" + await client.chat.commitMessage({ id: messageId }); + // #endregion snippet + + const otherAfter = await getServerChannel(client, cid, { + user_id: otherId, + message_limit: 25, + }); + expect(otherAfter.messages.map((m) => m.id)).toContain(messageId); + // "Only the first commit to a pending message will succeed" + await expect(client.chat.commitMessage({ id: messageId })).rejects.toThrow( + /is no longer pending/, + ); + }); + + it('Using Server-Side SDKs (pending message hook)', async () => { + const originalHooks = (await client.getApp()).app.event_hooks; + cleanup.add(async () => { + await client.updateApp({ event_hooks: originalHooks }); + await retry(async () => { + const { app } = await client.getApp(); + if (app.event_hooks.some((h) => h.webhook_url === PENDING_HOOK_URL)) { + throw new Error('pending message hook still there'); + } + }); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Using Server-Side SDKs" tab="Node.js" index=1 + // Note: Any previously existing hooks not included in event_hooks array will be deleted. + // Get current settings first to preserve your existing configuration. + + // STEP 1: Get current app settings to preserve existing hooks + const { app } = await client.getApp(); + console.log('Current event hooks:', app.event_hooks); + + // STEP 2: Add pending message hook while preserving existing hooks + const existingHooks = app.event_hooks || []; + + // STEP 3: Update with complete array including existing hooks + await client.updateApp({ + event_hooks: [ + ...existingHooks, + { + enabled: true, + hook_type: 'pending_message', + webhook_url: 'https://example.com/pending-messages', + timeout_ms: 10000, // how long messages should stay pending before being deleted + callback: { + mode: 'CALLBACK_MODE_REST', + }, + }, + ], + }); + // #endregion snippet + + expect(log).toHaveBeenCalledWith('Current event hooks:', app.event_hooks); + const hooks = await retry(async () => { + const { app: updated } = await client.getApp(); + if ( + !updated.event_hooks.some((h) => h.webhook_url === PENDING_HOOK_URL) + ) { + throw new Error('pending message hook not there yet'); + } + return updated.event_hooks; + }); + expect(hooks.map((h) => h.id)).toEqual( + expect.arrayContaining(originalHooks.map((h) => h.id)), + ); + expect(hooks.find((h) => h.webhook_url === PENDING_HOOK_URL)).toMatchObject( + { + enabled: true, + hook_type: 'pending_message', + timeout_ms: 10000, + callback: { mode: 'CALLBACK_MODE_REST' }, + }, + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/16-dynamic_partitioning.test.ts b/__tests__/docs-snippets/pages/05-features/10-advanced/16-dynamic_partitioning.test.ts new file mode 100644 index 0000000..1c68bfd --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/10-advanced/16-dynamic_partitioning.test.ts @@ -0,0 +1,211 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + CreateChannelTypeResponse, + GetChannelTypeResponse, + UpdateChannelTypeResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; + +const DOCS = '_default/05-features/10-advanced/16-dynamic_partitioning.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // The docs create a channel type named "example": use a uniqueId type instead. + const channelType = uniqueId('example'); + const channelId = uniqueId('test'); + const adminId = uniqueId('admin'); + + /** Reads the channel type until `check` passes (channel type reads are eventually consistent). */ + const waitForChannelType = ( + check: (response: GetChannelTypeResponse) => boolean, + ) => + retry( + async () => { + const response = await client.chat.getChannelType({ + name: channelType, + }); + if (!check(response)) { + throw new Error( + `channel type not updated yet: partition_size=${response.partition_size} partition_ttl=${response.partition_ttl}`, + ); + } + return response; + }, + { timeout: 60000, interval: 2000 }, + ); + + beforeAll(async () => { + cleanup.users.push(adminId); + await client.upsertUsers([{ id: adminId }]); + }); + + afterEach(() => vi.restoreAllMocks()); + + afterAll(() => cleanup.run()); + + it('creates a channel type with dynamic partitioning and a channel of it', async () => { + const createChannelType = client.chat.createChannelType.bind(client.chat); + const createSpy = vi + .spyOn(client.chat, 'createChannelType') + .mockImplementation(async (request) => { + const response = await createChannelType(request); + cleanup.channelTypes.push(request.name); + await waitForChannelTypePropagation(); + return response; + }); + + // #region snippet docs="_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Enabling Dynamic Partitioning" tab="Node.js" index=1 + // COPY: channelType="example", channelId="test", adminId="admin" + await client.chat.createChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + partition_size: 100, + }); + + const channel = client.chat.channel(channelType, channelId); + await channel.getOrCreate({ + data: { + created_by_id: adminId, + }, + }); + // #endregion snippet + + cleanup.channels.push(`${channelType}:${channelId}`); + + const created = await (createSpy.mock.results[0] + .value as Promise); + expect(created.partition_size).toBe(100); + + const type = await waitForChannelType((r) => r.partition_size === 100); + expect(type.partition_size).toBe(100); + + const stored = await getServerChannel( + client, + `${channelType}:${channelId}`, + ); + expect(stored.channel?.created_by?.id).toBe(adminId); + }); + + it('updates the partition size', async () => { + const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); + + // #region snippet docs="_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Updating dynamic partitioning" tab="Node.js" index=1 + // COPY: channelType="example" + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + partition_size: 200, + }); + // #endregion snippet + + const updated = await (updateSpy.mock.results[0] + .value as Promise); + expect(updated.partition_size).toBe(200); + expect(updated.automod).toBe(current.automod); + expect(updated.max_message_length).toBe(current.max_message_length); + + await waitForChannelTypePropagation(); + const type = await waitForChannelType((r) => r.partition_size === 200); + expect(type.partition_size).toBe(200); + }); + + it('disables dynamic partitioning and clears partition_ttl', async () => { + // Set a TTL first, so the test can check that disabling clears it. + const before = await client.chat.getChannelType({ name: channelType }); + await client.chat.updateChannelType({ + name: channelType, + automod: before.automod, + automod_behavior: before.automod_behavior, + max_message_length: before.max_message_length, + partition_ttl: '6h', + }); + await waitForChannelTypePropagation(); + await waitForChannelType((r) => r.partition_ttl === '6h'); + + const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); + + // #region snippet docs="_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Disabling dynamic partitioning" tab="Node.js" index=1 + // COPY: channelType="example" + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + partition_size: 0, + }); + // #endregion snippet + + const updated = await (updateSpy.mock.results[0] + .value as Promise); + expect(updated.partition_size ?? 0).toBe(0); + expect(updated.partition_ttl ?? '').toBe(''); + + await waitForChannelTypePropagation(); + const type = await waitForChannelType( + (r) => (r.partition_size ?? 0) === 0 && (r.partition_ttl ?? '') === '', + ); + expect(type.partition_size ?? 0).toBe(0); + }, 150000); + + it('sets the partition TTL', async () => { + // partition_ttl requires partition_size: re-enable partitioning after the previous test. + const before = await client.chat.getChannelType({ name: channelType }); + await client.chat.updateChannelType({ + name: channelType, + automod: before.automod, + automod_behavior: before.automod_behavior, + max_message_length: before.max_message_length, + partition_size: 100, + }); + await waitForChannelTypePropagation(); + await waitForChannelType((r) => r.partition_size === 100); + + const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); + + // #region snippet docs="_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Partition TTL" tab="Node.js" index=1 + // COPY: channelType="example" + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + partition_ttl: '24h', + }); + // #endregion snippet + + const updated = await (updateSpy.mock.results[0] + .value as Promise); + expect(updated.partition_ttl).toBe('24h'); + expect(updated.partition_size).toBe(100); + + await waitForChannelTypePropagation(); + const type = await waitForChannelType((r) => r.partition_ttl === '24h'); + expect(type.partition_ttl).toBe('24h'); + }, 150000); +}); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/17-audit_logs.test.ts b/__tests__/docs-snippets/pages/05-features/10-advanced/17-audit_logs.test.ts new file mode 100644 index 0000000..784f9c0 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/10-advanced/17-audit_logs.test.ts @@ -0,0 +1,150 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { retryOnRateLimit } from '../../../helpers/wait'; + +const DOCS = '_default/05-features/10-advanced/17-audit_logs.md'; + +const ORIGINAL_TEXT = 'Hello bob! give me a call.'; +const WITH_NUMBER = + 'Hello bob! give me a call. Here is my number: +31 6 12345678'; +const REMOVED = 'Hello bob! give me a call. Here is my number: [removed]'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const aliceId = uniqueId('alice'); + const bobId = uniqueId('bob'); + const adminId = uniqueId('admin'); + const messageId = uniqueId('message-1'); + const channelId = uniqueId('history'); + const channel = client.chat.channel('messaging', channelId); + + beforeAll(async () => { + cleanup.users.push(aliceId, bobId, adminId); + await client.upsertUsers([ + { id: aliceId }, + { id: bobId }, + { id: adminId, role: 'admin' }, + ]); + await channel.getOrCreate({ + data: { + created_by_id: aliceId, + members: [{ user_id: aliceId }, { user_id: bobId }], + }, + }); + cleanup.channels.push(`messaging:${channelId}`); + + // queryMessageHistory is limited to 6 requests per minute: retry the + // regions' calls on 429 instead of failing on back-to-back runs. + const queryMessageHistory = client.chat.queryMessageHistory.bind( + client.chat, + ); + vi.spyOn(client.chat, 'queryMessageHistory').mockImplementation((request) => + retryOnRateLimit(() => queryMessageHistory(request)), + ); + }); + + afterAll(async () => { + vi.restoreAllMocks(); + await cleanup.run(); + }); + + it('Example', async () => { + // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=1 + // COPY: messageId="message-1", aliceId="alice" + const message = await channel.sendMessage({ + message: { + id: messageId, + text: 'Hello bob! give me a call.', + user_id: aliceId, + }, + }); + // #endregion snippet + + expect(message.message.id).toBe(messageId); + const afterSend = await client.chat.queryMessageHistory({ + filter: { message_id: messageId }, + }); + expect(afterSend.message_history).toEqual([]); + + // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=2 + // COPY: messageId="message-1", aliceId="alice" + await client.chat.updateMessage({ + id: messageId, + message: { + text: 'Hello bob! give me a call. Here is my number: +31 6 12345678', + user_id: aliceId, + }, + }); + // #endregion snippet + + // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=3 + // COPY: messageId="message-1", adminId="admin" + await client.chat.updateMessage({ + id: messageId, + message: { + text: 'Hello bob! give me a call. Here is my number: [removed]', + user_id: adminId, + }, + }); + // #endregion snippet + + const current = await client.chat.getMessage({ id: messageId }); + expect(current.message.text).toBe(REMOVED); + + // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=4 + // COPY: messageId="message-1" + const history = await client.chat.queryMessageHistory({ + filter: { message_id: messageId }, + }); + // #endregion snippet + + // Latest first; each entry holds the text before an update. Its + // `message_updated_by_id` is the user who wrote that text (alice wrote both + // versions), not the user of the update that replaced it, as the docs' JSON + // response shows (admin for the "+31 ..." entry). + expect( + history.message_history.map((entry) => ({ + message_id: entry.message_id, + text: entry.text, + message_updated_by_id: entry.message_updated_by_id, + })), + ).toEqual([ + { + message_id: messageId, + text: WITH_NUMBER, + message_updated_by_id: aliceId, + }, + { + message_id: messageId, + text: ORIGINAL_TEXT, + message_updated_by_id: aliceId, + }, + ]); + }); + + it('Querying message history', async () => { + // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Querying message history" tab="Node.js" index=1 + // COPY: messageId="message-1" + // sorted by `message_updated_at` ascending: + const history = await client.chat.queryMessageHistory({ + filter: { message_id: messageId }, + sort: [{ field: 'message_updated_at', direction: 1 }], + }); + // #endregion snippet + + expect( + history.message_history.map((entry) => [ + entry.text, + entry.is_deleted, + entry.message_updated_by_id, + ]), + ).toEqual([ + [ORIGINAL_TEXT, false, aliceId], + [WITH_NUMBER, false, aliceId], + ]); + }); +}); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/18-data_retention_policy.test.ts b/__tests__/docs-snippets/pages/05-features/10-advanced/18-data_retention_policy.test.ts new file mode 100644 index 0000000..6e19637 --- /dev/null +++ b/__tests__/docs-snippets/pages/05-features/10-advanced/18-data_retention_policy.test.ts @@ -0,0 +1,89 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { RetentionPolicy } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { + RETENTION_POLICY_NAMES as POLICY_NAMES, + restoreRetentionPolicies, +} from '../../../helpers/retention'; +import type { RetentionPolicyName as PolicyName } from '../../../helpers/retention'; + +describe('_default/05-features/10-advanced/18-data_retention_policy.md', () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const getPolicy = async (name: PolicyName) => + (await client.chat.getRetentionPolicy()).policies.find( + (p) => p.policy === name, + ); + + beforeAll(async () => { + // Retention policies are app-level: restore whatever the app had before. + const original: RetentionPolicy[] = (await client.chat.getRetentionPolicy()) + .policies; + cleanup.add(() => restoreRetentionPolicies(client, original)); + }); + + afterAll(() => cleanup.run()); + + it('sets retention policies', async () => { + // #region snippet docs="_default/05-features/10-advanced/18-data_retention_policy.md" heading="Setting a Retention Policy" tab="Node.js" index=1 + // Delete messages older than 6 months (4,380 hours) + const oldMsgResponse = await client.chat.setRetentionPolicy({ + policy: 'old-messages', + max_age_hours: 4380, + }); + + // Delete channels inactive for more than 6 months + const inactiveChanResponse = await client.chat.setRetentionPolicy({ + policy: 'inactive-channels', + max_age_hours: 4380, + }); + // #endregion snippet + + expect(oldMsgResponse.policy.policy).toBe('old-messages'); + expect(oldMsgResponse.policy.config.max_age_hours).toBe(4380); + expect(inactiveChanResponse.policy.policy).toBe('inactive-channels'); + expect(inactiveChanResponse.policy.config.max_age_hours).toBe(4380); + }); + + it('retrieves retention policies', async () => { + // #region snippet docs="_default/05-features/10-advanced/18-data_retention_policy.md" heading="Retrieving Retention Policies" tab="Node.js" index=1 + const response = await client.chat.getRetentionPolicy(); + console.log(response.policies); + // #endregion snippet + + expect(response.policies.map((p) => p.policy)).toEqual( + expect.arrayContaining(POLICY_NAMES), + ); + }); + + it('deletes a retention policy', async () => { + // #region snippet docs="_default/05-features/10-advanced/18-data_retention_policy.md" heading="Deleting a Retention Policy" tab="Node.js" index=1 + await client.chat.deleteRetentionPolicy({ + policy: 'old-messages', + }); + // #endregion snippet + + expect(await getPolicy('old-messages')).toBeUndefined(); + expect(await getPolicy('inactive-channels')).toBeDefined(); + }); + + it('queries cleanup run history', async () => { + // #region snippet docs="_default/05-features/10-advanced/18-data_retention_policy.md" heading="Viewing Cleanup Run History" tab="Node.js" index=1 + const response = await client.chat.getRetentionPolicyRuns({ + filter_conditions: { + policy: { $eq: 'old-messages' }, + }, + sort: [{ field: 'date', direction: -1 }], + limit: 10, + }); + + console.log(response.runs); + // #endregion snippet + + // A fresh policy has a 24h grace period, so there may be no runs yet. + expect(Array.isArray(response.runs)).toBe(true); + for (const run of response.runs) expect(run.policy).toBe('old-messages'); + }); +}); diff --git a/__tests__/docs-snippets/pages/06-app_and_channel_settings/01-channel_types.test.ts b/__tests__/docs-snippets/pages/06-app_and_channel_settings/01-channel_types.test.ts new file mode 100644 index 0000000..656b772 --- /dev/null +++ b/__tests__/docs-snippets/pages/06-app_and_channel_settings/01-channel_types.test.ts @@ -0,0 +1,322 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + GetChannelTypeResponse, + UpdateChannelTypeResponse, +} from '../../../../index'; +import { updateChannelType } from '../../helpers/channel-types'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; + +const DOCS = '_default/06-app_and_channel_settings/01-channel_types.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // The docs change the built-in "agent" type and create "my-channel-type" and + // "public": use uniqueId types instead, so nothing built-in needs restoring. + const agentType = uniqueId('agent'); + const myChannelType = uniqueId('my-channel-type'); + const publicType = uniqueId('public'); + + /** Reads a channel type until `check` passes (channel type reads are eventually consistent). */ + const waitForChannelType = ( + name: string, + check: (response: GetChannelTypeResponse) => boolean, + ) => + retry( + async () => { + const response = await client.chat.getChannelType({ name }); + if (!check(response)) { + throw new Error(`channel type ${name} not updated yet`); + } + return response; + }, + { timeout: 60000, interval: 2000 }, + ); + + const commandNames = (response: GetChannelTypeResponse) => + response.commands.map((command) => command.name).sort(); + + /** Makes `createChannelType` register the type for cleanup and wait for propagation. */ + const waitAfterCreate = () => { + const createChannelType = client.chat.createChannelType.bind(client.chat); + return vi + .spyOn(client.chat, 'createChannelType') + .mockImplementation(async (request) => { + const response = await createChannelType(request); + cleanup.channelTypes.push(request.name); + await waitForChannelTypePropagation(); + return response; + }); + }; + + beforeAll(async () => { + await client.chat.createChannelType({ + name: agentType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(agentType); + await waitForChannelTypePropagation(); + // A new type has no guest grants: give it the built-in agent type's ones (read only). + const agent = await client.chat.getChannelType({ name: 'agent' }); + await updateChannelType(client, agentType, { + grants: { guest: agent.grants.guest ?? [] }, + }); + await waitForChannelTypePropagation(); + await waitForChannelType( + agentType, + (r) => (r.grants.guest ?? []).length > 0, + ); + }, 180000); + + afterEach(() => vi.restoreAllMocks()); + + afterAll(() => cleanup.run()); + + it('removes all guest grants of a channel type', async () => { + const original = await client.chat.getChannelType({ name: agentType }); + expect(original.grants.guest?.length ?? 0).toBeGreaterThan(0); + + const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); + + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Restricting guest access" tab="Node.js" index=1 + // COPY: agentType="agent" + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: agentType }); + + await client.chat.updateChannelType({ + name: agentType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + grants: { + guest: [], // guests can no longer create agent channels or act as owners + }, + }); + // #endregion snippet + + const updated = await (updateSpy.mock.results[0] + .value as Promise); + expect(updated.grants.guest ?? []).toEqual([]); + expect(updated.automod).toBe(original.automod); + expect(updated.max_message_length).toBe(original.max_message_length); + // Other roles keep their grants. + expect(updated.grants.user).toEqual(original.grants.user); + + await waitForChannelTypePropagation(); + const type = await waitForChannelType( + agentType, + (r) => (r.grants.guest ?? []).length === 0, + ); + expect(type.grants.guest ?? []).toEqual([]); + }); + + it('creates and updates a channel type', async () => { + const createSpy = waitAfterCreate(); + const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); + + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Updating or creating a channel type" tab="Node.js" index=1 + // COPY: myChannelType="my-channel-type" + // Create a new channel type + await client.chat.createChannelType({ + name: myChannelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + typing_events: true, + read_events: true, + reactions: true, + replies: true, + }); + + // Update an existing channel type + await client.chat.updateChannelType({ + name: myChannelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 1000, + reactions: false, + }); + // #endregion snippet + + expect(createSpy).toHaveBeenCalledTimes(1); + const updated = await (updateSpy.mock.results[0] + .value as Promise); + expect(updated.max_message_length).toBe(1000); + expect(updated.reactions).toBe(false); + // Fields the update doesn't send stay as created. + expect(updated.typing_events).toBe(true); + expect(updated.replies).toBe(true); + + await waitForChannelTypePropagation(); + const type = await waitForChannelType( + myChannelType, + (r) => r.max_message_length === 1000, + ); + expect(type.reactions).toBe(false); + }, 150000); + + it('creates the "public" channel type', async () => { + const createSpy = waitAfterCreate(); + + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Creating a Channel Type" tab="Node.js" index=1 + // COPY: publicType="public" + await client.chat.createChannelType({ + name: publicType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + mutes: false, + reactions: false, + }); + // #endregion snippet + + expect(createSpy).toHaveBeenCalledTimes(1); + const type = await client.chat.getChannelType({ name: publicType }); + expect(type.mutes).toBe(false); + expect(type.reactions).toBe(false); + expect(type.max_message_length).toBe(5000); + }); + + it('lists the channel types', async () => { + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="List Channel Types" tab="Node.js" index=1 + const { channel_types } = await client.chat.listChannelTypes(); + // #endregion snippet + + expect(Object.keys(channel_types)).toEqual( + expect.arrayContaining(['messaging', publicType]), + ); + expect(channel_types[publicType].reactions).toBe(false); + }); + + it('gets a channel type', async () => { + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Get a Channel Type" tab="Node.js" index=1 + // COPY: publicType="public" + const channelType = await client.chat.getChannelType({ name: publicType }); + // #endregion snippet + + expect(channelType.name).toBe(publicType); + expect(channelType.mutes).toBe(false); + }); + + it('edits commands and replies', async () => { + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=1 + // COPY: publicType="public" + const current = await client.chat.getChannelType({ name: publicType }); + + const update = await client.chat.updateChannelType({ + name: publicType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + replies: false, + commands: ['all'], + }); + // #endregion snippet + + expect(update.replies).toBe(false); + // "all" expands to every built-in command. + expect(update.commands).toEqual( + expect.arrayContaining(['ban', 'giphy', 'mute', 'unban', 'unmute']), + ); + expect(update.max_message_length).toBe(current.max_message_length); + + await waitForChannelTypePropagation(); + const type = await waitForChannelType(publicType, (r) => !r.replies); + expect(commandNames(type)).toEqual( + expect.arrayContaining(['ban', 'giphy', 'mute', 'unban', 'unmute']), + ); + }); + + it('updates feature flags', async () => { + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=2 + // COPY: publicType="public" + const current = await client.chat.getChannelType({ name: publicType }); + + const update = await client.chat.updateChannelType({ + name: publicType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + typing_events: false, + read_events: true, + connect_events: true, + search: false, + reactions: true, + replies: false, + mutes: true, + }); + // #endregion snippet + + expect(update).toMatchObject({ + typing_events: false, + read_events: true, + connect_events: true, + search: false, + reactions: true, + replies: false, + mutes: true, + }); + + await waitForChannelTypePropagation(); + const type = await waitForChannelType( + publicType, + (r) => !r.typing_events && r.mutes, + ); + expect(type.search).toBe(false); + }); + + it('updates settings with new values', async () => { + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=3 + // COPY: publicType="public" + const current = await client.chat.getChannelType({ name: publicType }); + + const update = await client.chat.updateChannelType({ + name: publicType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: 140, // the value this example is changing + commands: ['giphy', 'ban'], + }); + // #endregion snippet + + expect(update.max_message_length).toBe(140); + expect([...update.commands].sort()).toEqual(['ban', 'giphy']); + + await waitForChannelTypePropagation(); + const type = await waitForChannelType( + publicType, + (r) => r.max_message_length === 140, + ); + expect(commandNames(type)).toEqual(['ban', 'giphy']); + }); + + it('removes a channel type', async () => { + // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Remove a Channel Type" tab="Node.js" index=1 + // COPY: publicType="public" + const destroy = await client.chat.deleteChannelType({ name: publicType }); + // #endregion snippet + + expect(destroy.duration).toBeDefined(); + // Already deleted: Cleanup would fail on "is not a defined channel type". + cleanup.channelTypes.splice(cleanup.channelTypes.indexOf(publicType), 1); + await waitForChannelTypePropagation(); + await expect( + client.chat.getChannelType({ name: publicType }), + ).rejects.toThrow(/does not exist|not found/i); + }); +}); diff --git a/__tests__/docs-snippets/pages/06-app_and_channel_settings/02-app_settings.test.ts b/__tests__/docs-snippets/pages/06-app_and_channel_settings/02-app_settings.test.ts new file mode 100644 index 0000000..ae7e8ff --- /dev/null +++ b/__tests__/docs-snippets/pages/06-app_and_channel_settings/02-app_settings.test.ts @@ -0,0 +1,243 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { StreamError } from '../../../../index'; +import type { StreamChannel, UpdateAppRequest } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { waitForAppSetting } from '../../helpers/server'; +import { retry } from '../../helpers/wait'; + +const DOCS = '_default/06-app_and_channel_settings/02-app_settings.md'; + +// A 4x4 JPEG, uploaded once as `.jpeg` (allowed) and once as `.jpg` (not in the docs list). +const JPEG_BASE64 = + '/9j/4AAQSkZJRgABAQAAkACQAAD/4QCARXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAASgAwAEAAAAAQAAAAQAAAAA/8AAEQgABAAEAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8A/fSJPL34JO5i3J6Z7D2qXNNHeloA/9k='; + +// Short CSV content is sniffed as text/plain by the API, so use a few rows. +const CSV = 'name,age,city\nalice,30,Amsterdam\nbob,25,Boulder\n'.repeat(20); + +/** A dev token: unsigned, only accepted while `disable_auth_checks` is on. */ +const devToken = (userId: string) => { + const part = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${part({ alg: 'HS256', typ: 'JWT' })}.${part({ user_id: userId })}.devtoken`; +}; + +/** Client-side query channels with a dev token: the HTTP status and the error message. */ +const queryChannelsWithDevToken = async (userId: string) => { + const response = await fetch( + `https://chat.stream-io-api.com/channels?api_key=${process.env.STREAM_API_KEY ?? ''}`, + { + method: 'POST', + headers: { + Authorization: devToken(userId), + 'stream-auth-type': 'jwt', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + filter_conditions: { members: { $in: [userId] } }, + }), + }, + ); + const body = (await response.json()) as { message?: string }; + return { status: response.status, message: body.message ?? '' }; +}; + +/** The upload's Stream error message, or `undefined` if it was accepted. */ +const uploadError = async (upload: () => Promise) => { + try { + await upload(); + return undefined; + } catch (error) { + if (error instanceof StreamError && error.code === 4) return error.message; + throw error; + } +}; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const userId = uniqueId('uploader'); + const channelId = uniqueId('uploads'); + let channel: StreamChannel; + + beforeAll(async () => { + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + channel = client.chat.channel('messaging', channelId); + cleanup.channels.push(`messaging:${channelId}`); + await channel.getOrCreate({ data: { created_by_id: userId } }); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Authentication Behaviour', async () => { + const { app } = await client.getApp(); + const original = app.disable_auth_checks; + expect(original).toBe(false); + cleanup.add(async () => { + await client.updateApp({ disable_auth_checks: original }); + await waitForAppSetting(client, 'disable_auth_checks', original); + }); + + // After each update: wait until the setting has settled, then record whether a dev + // token is accepted ("allows dev token usage"). + const devTokenStatus: number[] = []; + const updateApp = client.updateApp.bind(client); + vi.spyOn(client, 'updateApp').mockImplementation( + async (request?: UpdateAppRequest) => { + const response = await updateApp(request); + const disabled = request?.disable_auth_checks ?? false; + await waitForAppSetting(client, 'disable_auth_checks', disabled); + // With auth checks on, API nodes reject the dev token with either "development + // tokens are not allowed for this application" or "Token signature is invalid" + // (seen for over a minute on some nodes): both are 401 rejections. + const result = await retry(async () => { + const query = await queryChannelsWithDevToken(userId); + const settled = disabled + ? query.status === 201 + : query.status === 401 && + (query.message.includes( + 'development tokens are not allowed for this application', + ) || + query.message.includes('Token signature is invalid')); + if (!settled) { + throw new Error( + `dev token not settled: ${query.status} ${query.message}`, + ); + } + return query; + }); + devTokenStatus.push(result.status); + return response; + }, + ); + + // #region snippet docs="_default/06-app_and_channel_settings/02-app_settings.md" heading="Authentication Behaviour" tab="Node.js" index=1 + // disable auth checks, allows dev token usage + await client.updateApp({ + disable_auth_checks: true, + }); + + // re-enable auth checks + await client.updateApp({ + disable_auth_checks: false, + }); + // #endregion snippet + + expect(devTokenStatus).toEqual([201, 401]); + expect((await client.getApp()).app.disable_auth_checks).toBe(false); + }); + + it('File Uploads', async () => { + const { app } = await client.getApp(); + const original = app.file_upload_config; + expect(original.allowed_file_extensions ?? []).toEqual([]); + cleanup.add(async () => { + await client.updateApp({ file_upload_config: original }); + await retry(async () => { + const { app: restored } = await client.getApp(); + expect(restored.file_upload_config).toEqual(original); + }); + }); + + // #region snippet docs="_default/06-app_and_channel_settings/02-app_settings.md" heading="File Uploads" tab="Node.js" index=1 + // Only accept .CSV files + await client.updateApp({ + file_upload_config: { + size_limit: 0, + allowed_file_extensions: ['.csv'], + allowed_mime_types: ['text/csv'], + }, + }); + // #endregion snippet + + const { app: updated } = await client.getApp(); + expect(updated.file_upload_config).toMatchObject({ + size_limit: 0, + allowed_file_extensions: ['.csv'], + allowed_mime_types: ['text/csv'], + }); + + // A .txt upload is rejected once the setting has reached the API nodes. + const txtError = await retry(async () => { + const message = await uploadError(() => + channel.uploadChannelFile({ + file: new File(['hello'], 'hello.txt', { type: 'text/plain' }), + user: { id: userId }, + }), + ); + if (message === undefined) throw new Error('.txt upload still accepted'); + return message; + }); + expect(txtError).toContain('File extension .txt is not supported'); + + const { file: csvUrl } = await channel.uploadChannelFile({ + file: new File([CSV], 'report.csv', { type: 'text/csv' }), + user: { id: userId }, + }); + expect(csvUrl).toEqual(expect.any(String)); + await channel.deleteChannelFile({ url: csvUrl }); + }); + + it('Image Uploads', async () => { + const { app } = await client.getApp(); + const original = app.image_upload_config; + expect(original.allowed_file_extensions ?? []).toEqual([]); + cleanup.add(async () => { + await client.updateApp({ image_upload_config: original }); + await retry(async () => { + const { app: restored } = await client.getApp(); + expect(restored.image_upload_config).toEqual(original); + }); + }); + + // #region snippet docs="_default/06-app_and_channel_settings/02-app_settings.md" heading="Image Uploads" tab="Node.js" index=1 + // Only accept gif, jpeg, or png files. + await client.updateApp({ + image_upload_config: { + size_limit: 0, + allowed_file_extensions: ['.gif', '.jpeg', '.png'], + allowed_mime_types: ['image/gif', 'image/jpeg', 'image/png'], + }, + }); + // #endregion snippet + + const { app: updated } = await client.getApp(); + expect(updated.image_upload_config).toMatchObject({ + size_limit: 0, + allowed_file_extensions: ['.gif', '.jpeg', '.png'], + allowed_mime_types: ['image/gif', 'image/jpeg', 'image/png'], + }); + + const jpeg = Buffer.from(JPEG_BASE64, 'base64'); + // A .webp upload is rejected once the setting has reached the API nodes. + const webpError = await retry(async () => { + const message = await uploadError(() => + channel.uploadChannelImage({ + file: new File([jpeg], 'image.webp', { type: 'image/webp' }), + user: { id: userId }, + }), + ); + if (message === undefined) throw new Error('.webp upload still accepted'); + return message; + }); + expect(webpError).toContain('File extension .webp is not supported'); + + const { file: jpegUrl } = await channel.uploadChannelImage({ + file: new File([jpeg], 'image.jpeg', { type: 'image/jpeg' }), + user: { id: userId }, + }); + expect(jpegUrl).toEqual(expect.any(String)); + if (jpegUrl) await channel.deleteChannelImage({ url: jpegUrl }); + }); +}); diff --git a/__tests__/docs-snippets/pages/06-app_and_channel_settings/03-chat_permission_policies.test.ts b/__tests__/docs-snippets/pages/06-app_and_channel_settings/03-chat_permission_policies.test.ts new file mode 100644 index 0000000..c790603 --- /dev/null +++ b/__tests__/docs-snippets/pages/06-app_and_channel_settings/03-chat_permission_policies.test.ts @@ -0,0 +1,413 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + UpdateChannelResponse, + UpdateChannelTypeResponse, +} from '../../../../index'; +import { updateChannelType } from '../../helpers/channel-types'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { + getServerMember, + getServerUser, + waitForAppSetting, +} from '../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; + +const DOCS = + '_default/06-app_and_channel_settings/03-chat_permission_policies.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // The docs change the built-in "messaging" type: use a uniqueId type instead. + const channelType = uniqueId('messaging'); + let defaultGrants: Record = {}; + + const roleNames = async () => + (await client.listRoles()).roles.map((role) => role.name); + + /** A new `messaging` channel with no members, registered for cleanup. */ + const createChannel = async (creatorId: string) => { + const id = uniqueId('channel'); + cleanup.channels.push(`messaging:${id}`); + const channel = client.chat.channel('messaging', id); + await channel.getOrCreate({ data: { created_by_id: creatorId } }); + return channel; + }; + + /** A new user, registered for cleanup. */ + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + beforeAll(async () => { + await client.chat.createChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(channelType); + await waitForChannelTypePropagation(); + defaultGrants = (await client.chat.getChannelType({ name: channelType })) + .grants; + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + describe('Role Management', () => { + it('changes the role of a user', async () => { + const specialAgent = uniqueId('special-agent'); + await client.createRole({ name: specialAgent }); + const jamesBond = await createUser('james-bond'); + // The role can only be deleted once no user has it: reset the user's role first. + cleanup.add(async () => { + await client.updateUsersPartial({ + users: [{ id: jamesBond, set: { role: 'user' } }], + }); + await retry(() => client.deleteRole({ name: specialAgent }), { + timeout: 30000, + interval: 2000, + }); + }); + // A new role takes a moment to reach every API node ("role ... provided but not + // defined"): retry the snippet's call until it is known. + const updateUsersPartial = client.updateUsersPartial.bind(client); + vi.spyOn(client, 'updateUsersPartial').mockImplementation((request) => + retry(() => updateUsersPartial(request), { + timeout: 30000, + interval: 1000, + retryIf: (error) => + error instanceof Error && + error.message.includes('provided but not defined'), + }), + ); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=1 + // COPY: jamesBond="james_bond", specialAgent="special_agent" + await client.updateUsersPartial({ + users: [ + { + id: jamesBond, + set: { role: specialAgent }, + }, + ], + }); + // #endregion snippet + + expect((await getServerUser(client, jamesBond)).role).toBe(specialAgent); + }); + + it('assigns channel_member to a new member', async () => { + const jamesBond = await createUser('james-bond'); + const channel = await createChannel(jamesBond); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=2 + // COPY: jamesBond="james_bond" + const result = await channel.update({ + add_members: [{ user_id: jamesBond }], + }); + console.log(result.members[0].channel_role); // "channel_member" + // #endregion snippet + + expect(result.members.map((member) => member.user_id)).toEqual([ + jamesBond, + ]); + expect(log.mock.calls).toEqual([['channel_member']]); + }); + + it('adds a member with a role and assigns a new one', async () => { + const jamesBond = await createUser('james-bond'); + const channel = await createChannel(jamesBond); + const updateChannel = vi.spyOn(client.chat, 'updateChannel'); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=3 + // COPY: jamesBond="james_bond" + // Add user to the channel with role set + await channel.update({ + add_members: [ + { user_id: jamesBond, channel_role: 'channel_moderator' }, + ], + }); + + // Assign new channel member role + await channel.update({ + assign_roles: [{ user_id: jamesBond, channel_role: 'channel_member' }], + }); + // #endregion snippet + + const [added, assigned] = await Promise.all( + updateChannel.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(added.members[0]).toMatchObject({ + user_id: jamesBond, + channel_role: 'channel_moderator', + }); + expect(assigned.members[0]).toMatchObject({ + user_id: jamesBond, + channel_role: 'channel_member', + }); + expect( + (await getServerMember(client, channel.cid, jamesBond)).channel_role, + ).toBe('channel_member'); + }); + }); + + describe('Custom Roles', () => { + it('creates a role', async () => { + const specialAgent = uniqueId('special-agent'); + cleanup.add(() => client.deleteRole({ name: specialAgent })); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Custom Roles" tab="Node.js" index=1 + // COPY: specialAgent="special_agent" + await client.createRole({ name: specialAgent }); + // #endregion snippet + + expect(await roleNames()).toContain(specialAgent); + }); + + it('deletes a role', async () => { + const agent006 = uniqueId('agent-006'); + await client.createRole({ name: agent006 }); + expect(await roleNames()).toContain(agent006); + + // The new role takes a moment to reach every API node (`role "..." does not + // exist`): retry the snippet's call until it is known. + const deleteRole = client.deleteRole.bind(client); + vi.spyOn(client, 'deleteRole').mockImplementation((request) => + retry(() => deleteRole(request), { + timeout: 30000, + interval: 1000, + retryIf: (error) => + error instanceof Error && error.message.includes('does not exist'), + }), + ); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Custom Roles" tab="Node.js" index=2 + // COPY: agent006="agent_006" + await client.deleteRole({ name: agent006 }); + // #endregion snippet + + expect(await roleNames()).not.toContain(agent006); + }); + }); + + describe('Granting permissions', () => { + it('lists permissions', async () => { + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=1 + const { permissions } = await client.listPermissions(); // List of Permission objects + // #endregion snippet + + expect(permissions.length).toBeGreaterThan(0); + const permission = permissions.find( + (p) => p.id === 'create-message-owner', + ); + expect(permission).toMatchObject({ + action: 'CreateMessage', + owner: true, + }); + expect(typeof permission?.name).toBe('string'); + expect(typeof permission?.description).toBe('string'); + expect(typeof permission?.same_team).toBe('boolean'); + }); + + it('updates channel_member grants of a channel type', async () => { + const memberGrants = [ + 'read-channel', + 'create-message', + 'update-message-owner', + 'delete-message-owner', + ]; + expect([...(defaultGrants.channel_member ?? [])].sort()).not.toEqual( + [...memberGrants].sort(), + ); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=2 + // COPY: channelType="messaging" + // observe current grants of the channel type + const { grants } = await client.chat.getChannelType({ + name: channelType, + }); + + // update "channel_member" role grants in "messaging" scope + await client.chat.updateChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + grants: { + channel_member: [ + 'read-channel', // allow access to the channel + 'create-message', // create messages in the channel + 'update-message-owner', // update own user messages + 'delete-message-owner', // delete own user messages + ], + }, + }); + // #endregion snippet + await waitForChannelTypePropagation(); + + expect(grants).toEqual(defaultGrants); + const updated = await client.chat.getChannelType({ name: channelType }); + expect([...updated.grants.channel_member].sort()).toEqual( + [...memberGrants].sort(), + ); + // Only the role in the request changed. + expect(updated.grants.channel_moderator).toEqual( + defaultGrants.channel_moderator, + ); + }, 90000); + + it('removes all grants of a role', async () => { + // Give guest and anonymous something to remove (both are empty by default). + await updateChannelType(client, channelType, { + grants: { guest: ['read-channel'], anonymous: ['read-channel'] }, + }); + await waitForChannelTypePropagation(); + const before = await client.chat.getChannelType({ name: channelType }); + expect(before.grants.guest).toEqual(['read-channel']); + expect(before.grants.anonymous).toEqual(['read-channel']); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=3 + // COPY: channelType="messaging" + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + grants: { + guest: [], // removes all grants of "guest" role + anonymous: [], // removes all grants of "anonymous" role + }, + }); + // #endregion snippet + await waitForChannelTypePropagation(); + + const after = await client.chat.getChannelType({ name: channelType }); + expect(after.grants.guest ?? []).toEqual([]); + expect(after.grants.anonymous ?? []).toEqual([]); + expect(after.grants.channel_moderator).toEqual( + defaultGrants.channel_moderator, + ); + }, 150000); + + it('resets the grants of a scope to the defaults', async () => { + // The earlier tests changed the channel_member grants. + const before = await client.chat.getChannelType({ name: channelType }); + expect(before.grants.channel_member).not.toEqual( + defaultGrants.channel_member, + ); + const updateChannelType = vi.spyOn(client.chat, 'updateChannelType'); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=4 + // COPY: channelType="messaging" + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + grants: null, // resets the whole scope to default settings + }); + // #endregion snippet + await waitForChannelTypePropagation(); + + // The reset grants are the API's defaults (not the same as a new type's initial + // grants): compare with the update response. + const reset = await (updateChannelType.mock.results[0] + .value as Promise); + const after = await client.chat.getChannelType({ name: channelType }); + expect(after.grants).toEqual(reset.grants); + expect(after.grants.channel_member).toContain('read-channel'); + expect(after.grants.channel_member.length).toBeGreaterThan(4); + }, 90000); + + it('updates grants of the .app scope', async () => { + const roles = ['anonymous', 'guest', 'user', 'admin']; + const { app } = await client.getApp(); + const original = app.grants; + cleanup.add(async () => { + await client.updateApp({ + grants: Object.fromEntries( + roles.map((role) => [role, original[role] ?? []]), + ), + }); + await waitForAppSetting(client, 'grants', original); + }); + expect(original.user).toContain('flag-user'); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=5 + // update grants of multiple roles in ".app" scope + await client.updateApp({ + grants: { + anonymous: [], + guest: [], + user: ['search-user', 'mute-user'], + admin: ['search-user', 'mute-user', 'ban-user'], + }, + }); + // #endregion snippet + + const { app: updated } = await client.getApp(); + expect(updated.grants.anonymous ?? []).toEqual([]); + expect(updated.grants.guest ?? []).toEqual([]); + expect([...updated.grants.user].sort()).toEqual([ + 'mute-user', + 'search-user', + ]); + expect([...updated.grants.admin].sort()).toEqual([ + 'ban-user', + 'mute-user', + 'search-user', + ]); + // Only the roles in the request changed. + expect(updated.grants.moderator).toEqual(original.moderator); + }); + }); + + it('Channel-level permissions', async () => { + const creatorId = await createUser('creator'); + const channel = await createChannel(creatorId); + const before = await channel.getOrCreate({}); + expect(before.channel?.config?.grants).toBeUndefined(); + + // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Channel-level permissions" tab="Node.js" index=1 + await channel.updateChannelPartial({ + set: { + config_overrides: { + grants: { + user: ['!add-links', 'create-reaction'], + }, + }, + }, + }); + // #endregion snippet + + const after = await channel.getOrCreate({}); + expect(after.channel?.config?.grants).toEqual({ + user: ['!add-links', 'create-reaction'], + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/06-app_and_channel_settings/06-channel-level_settings.test.ts b/__tests__/docs-snippets/pages/06-app_and_channel_settings/06-channel-level_settings.test.ts new file mode 100644 index 0000000..ab1bbb8 --- /dev/null +++ b/__tests__/docs-snippets/pages/06-app_and_channel_settings/06-channel-level_settings.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; + +const DOCS = + '_default/06-app_and_channel_settings/06-channel-level_settings.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const userId = uniqueId('user'); + const blocklistName = uniqueId('medical-blocklist'); + const channelId = uniqueId('general'); + const channel = client.chat.channel('messaging', channelId); + + /** The channel's merged config (channel type settings plus overrides). */ + const channelConfig = async () => { + const { channel: response } = await channel.getOrCreate({}); + if (!response?.config) throw new Error('channel has no config'); + return response.config; + }; + + const sendText = (text: string, parentId?: string) => + channel.sendMessage({ + message: { text, user_id: userId, parent_id: parentId }, + }); + + beforeAll(async () => { + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + await client.createBlockList({ name: blocklistName, words: ['aspirin'] }); + cleanup.add(() => client.deleteBlockList({ name: blocklistName })); + await channel.getOrCreate({ + data: { created_by_id: userId, members: [{ user_id: userId }] }, + }); + cleanup.channels.push(channel.cid); + }); + + afterAll(() => cleanup.run()); + + it('Use a different blocklist', async () => { + const before = await channelConfig(); + expect(before.blocklist).toBeUndefined(); + expect(before.blocklist_behavior).toBe('flag'); + + // #region snippet docs="_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Use a different blocklist" tab="Node.js" index=1 + // COPY: blocklistName="medical_blocklist" + await channel.updateChannelPartial({ + set: { + config_overrides: { + blocklist: blocklistName, + blocklist_behavior: 'block', + }, + }, + }); + // #endregion snippet + + // Only the override is checked: this app (moderation enabled) doesn't block + // messages with the channel-level blocklist's words (see the TODO). + const after = await channelConfig(); + expect(after.blocklist).toBe(blocklistName); + expect(after.blocklist_behavior).toBe('block'); + }); + + it('Disables replies', async () => { + expect((await channelConfig()).replies).toBe(true); + const { message: parent } = await sendText('parent'); + await sendText('reply before', parent.id); + + // #region snippet docs="_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Disables replies" tab="Node.js" index=1 + await channel.updateChannelPartial({ + set: { config_overrides: { replies: false } }, + }); + // #endregion snippet + + const after = await channelConfig(); + expect(after.replies).toBe(false); + // `set` replaces the whole `config_overrides` object: the blocklist override is gone. + expect(after.blocklist).toBeUndefined(); + await expect(sendText('reply after', parent.id)).rejects.toMatchObject({ + code: 19, + metadata: { responseCode: 400 }, + }); + }); + + it('Remove overrides and go back to default settings', async () => { + const before = await channelConfig(); + expect(before.replies).toBe(false); + + // #region snippet docs="_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Remove overrides and go back to default settings" tab="Node.js" index=1 + await channel.updateChannelPartial({ set: { config_overrides: {} } }); + // #endregion snippet + + const after = await channelConfig(); + expect(after.replies).toBe(true); + expect(after.blocklist).toBeUndefined(); + expect(after.blocklist_behavior).toBe('flag'); + }); +}); diff --git a/__tests__/docs-snippets/pages/07-push/07-legacy_push_system.test.ts b/__tests__/docs-snippets/pages/07-push/07-legacy_push_system.test.ts new file mode 100644 index 0000000..2e95e7b --- /dev/null +++ b/__tests__/docs-snippets/pages/07-push/07-legacy_push_system.test.ts @@ -0,0 +1,115 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FirebaseConfigFields } from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { + firebaseCredentials, + hasFirebaseCredentials, +} from '../../helpers/push'; + +// Both fences create a push provider and set the app-level (non-multi-bundle) +// `firebase_config`: the API rejects both with code 4 "firebase credentials are +// invalid" unless the credentials are a real Firebase service account. +describe.skipIf(!hasFirebaseCredentials)( + '_default/07-push/07-legacy_push_system.md', + () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + let originalFirebase: FirebaseConfigFields; + + beforeAll(async () => { + originalFirebase = (await client.getApp()).app.push_notifications + .firebase; + // Restore the legacy app-level Firebase config the fences overwrite. + cleanup.add(() => + client.updateApp({ + firebase_config: { + apn_template: originalFirebase.apn_template ?? '', + notification_template: originalFirebase.notification_template ?? '', + disabled: !originalFirebase.enabled, + }, + }), + ); + }); + + afterAll(() => cleanup.run()); + + const registerProvider = (name: string) => + cleanup.add(() => client.deletePushProvider({ type: 'firebase', name })); + + it('Updating Firebase APN template', async () => { + const providerName = uniqueId('apn-provider'); + registerProvider(providerName); + + // #region snippet docs="_default/07-push/07-legacy_push_system.md" heading="Updating Firebase APN template" tab="Node.js" index=1 + // COPY: providerName="my-name", firebaseCredentials="your service worker config" + // for multi-bundle support + await client.upsertPushProvider({ + push_provider: { + name: providerName, + type: 'firebase', + firebase_credentials: firebaseCredentials, + firebase_apn_template: 'your-template', + }, + }); + + // for non-multi-bundle support + await client.updateApp({ + firebase_config: { + credentials_json: firebaseCredentials, + apn_template: 'your template', + }, + }); + // #endregion snippet + + const { push_providers } = await client.listPushProviders(); + const provider = push_providers.find((p) => p.name === providerName); + expect(provider).toMatchObject({ + type: 'firebase', + firebase_apn_template: 'your-template', + }); + const { app } = await client.getApp(); + expect(app.push_notifications.firebase.apn_template).toBe( + 'your template', + ); + }); + + it('Updating Firebase Notification template', async () => { + const providerName = uniqueId('notification-provider'); + registerProvider(providerName); + + // #region snippet docs="_default/07-push/07-legacy_push_system.md" heading="Updating Firebase Notification template" tab="Node.js" index=1 + // COPY: providerName="my-name", firebaseCredentials="your service worker config" + // for multi-bundle support + await client.upsertPushProvider({ + push_provider: { + name: providerName, + type: 'firebase', + firebase_credentials: firebaseCredentials, + firebase_notification_template: 'your-template', + }, + }); + + // for non-multi-bundle support + await client.updateApp({ + firebase_config: { + credentials_json: firebaseCredentials, + notification_template: 'your template', + }, + }); + // #endregion snippet + + const { push_providers } = await client.listPushProviders(); + const provider = push_providers.find((p) => p.name === providerName); + expect(provider).toMatchObject({ + type: 'firebase', + firebase_notification_template: 'your-template', + }); + const { app } = await client.getApp(); + expect(app.push_notifications.firebase.notification_template).toBe( + 'your template', + ); + }); + }, +); diff --git a/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts b/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts new file mode 100644 index 0000000..9ff54ea --- /dev/null +++ b/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts @@ -0,0 +1,115 @@ +import crypto from 'crypto'; +import zlib from 'zlib'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { waitForAppSetting } from '../../../helpers/server'; + +const DOCS = + '_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Configuration', async () => { + const { app } = await client.getApp(); + const original = app.before_message_send_hook_url; + cleanup.add(async () => { + await client.updateApp({ before_message_send_hook_url: original ?? '' }); + await waitForAppSetting(client, 'before_message_send_hook_url', original); + }); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Configuration" tab="Node.js" index=1 + await client.updateApp({ + before_message_send_hook_url: + 'https://example.com/webhooks/stream/before-message-send', + }); + // #endregion snippet + + await waitForAppSetting( + client, + 'before_message_send_hook_url', + 'https://example.com/webhooks/stream/before-message-send', + ); + }); + + it('Per-attempt HTTP timeout', async () => { + const { app } = await client.getApp(); + const original = app.before_message_send_hook_attempt_timeout_ms; + expect(original).not.toBe(2000); + cleanup.add(async () => { + await client.updateApp({ + before_message_send_hook_attempt_timeout_ms: original ?? 0, + }); + await waitForAppSetting( + client, + 'before_message_send_hook_attempt_timeout_ms', + original, + ); + }); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Per-attempt HTTP timeout" tab="Node.js" index=1 + await client.updateApp({ + before_message_send_hook_attempt_timeout_ms: 2000, + }); + // #endregion snippet + + await waitForAppSetting( + client, + 'before_message_send_hook_attempt_timeout_ms', + 2000, + ); + + // "The API rejects negative values and values greater than 5000 with an error." + await expect( + client.updateApp({ before_message_send_hook_attempt_timeout_ms: 5001 }), + ).rejects.toMatchObject({ code: 4 }); + }); + + it('Payload Compression', () => { + // A gzipped Before Message Send payload, signed with the API secret over the + // uncompressed bytes, as Stream sends it with compression enabled. + const payload = JSON.stringify({ + message: { id: '', text: 'hello', type: 'regular' }, + user: { id: 'john', role: 'user' }, + channel: { cid: 'messaging:general', id: 'general', type: 'messaging' }, + }); + const signature = crypto + .createHmac('sha256', process.env.STREAM_SECRET as string) + .update(payload) + .digest('hex'); + const req = { + rawBody: zlib.gzipSync(payload), + headers: { 'x-signature': signature }, + }; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Payload Compression" tab="Node.js" index=1 + try { + const event = client.verifyAndParseWebhook( + req.rawBody, + req.headers['x-signature'], + ); + console.log(event); + } catch (err) { + // err is an InvalidWebhookError; see the webhooks overview for failure-mode messages + console.error(err); + } + // #endregion snippet + + expect(error).not.toHaveBeenCalled(); + expect(log.mock.calls[0]?.[0]).toEqual(JSON.parse(payload)); + + // A wrong signature throws an InvalidWebhookError. + expect(() => + client.verifyAndParseWebhook(req.rawBody, 'wrong-signature'), + ).toThrow('signature mismatch'); + }); +}); diff --git a/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts b/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts new file mode 100644 index 0000000..22a6244 --- /dev/null +++ b/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts @@ -0,0 +1,260 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + CreateCommandResponse, + DeleteCommandResponse, + GetCommandResponse, + ListCommandsResponse, + UpdateCommandResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { updateChannelType } from '../../../helpers/channel-types'; +import { waitForAppSetting } from '../../../helpers/server'; +import { + retry, + retryWhileChannelTypePropagates, + waitForChannelTypePropagation, +} from '../../../helpers/wait'; + +const DOCS = + '_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md'; + +const handlerUrl = + 'https://example.com/webhooks/stream/custom-commands?type={type}'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const userId = uniqueId('user'); + const ticketCommand = uniqueId('ticket'); + const supportChannelType = uniqueId('support'); + const supportChannelId = uniqueId('channel'); + const ticketText = `/${ticketCommand} suspicious transaction with id 1234`; + + const deleteCommandIfExists = () => + client.chat.deleteCommand({ name: ticketCommand }).catch((error) => { + if ((error as { code?: number }).code !== 16) throw error; + }); + + beforeAll(async () => { + await client.upsertUsers([{ id: userId }]); + cleanup.users.push(userId); + // Runs if a test fails before "Remove a Command" deletes it. + cleanup.add(deleteCommandIfExists); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Creating a Command', async () => { + const createCommand = vi.spyOn(client.chat, 'createCommand'); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Creating a Command" tab="Node.js" index=1 + // COPY: ticketCommand="ticket" + await client.chat.createCommand({ + name: ticketCommand, + description: 'Create a support ticket', + args: '[description]', + set: 'support_commands_set', + }); + // #endregion snippet + + const response = await (createCommand.mock.results[0] + .value as Promise); + expect(response.command).toMatchObject({ + name: ticketCommand, + description: 'Create a support ticket', + args: '[description]', + set: 'support_commands_set', + }); + }); + + it('List Commands', async () => { + const listCommands = vi.spyOn(client.chat, 'listCommands'); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="List Commands" tab="Node.js" index=1 + await client.chat.listCommands(); + // #endregion snippet + + const { commands } = await (listCommands.mock.results[0] + .value as Promise); + expect(commands.map((command) => command.name)).toContain(ticketCommand); + }); + + it('Get a Command', async () => { + const getCommand = vi.spyOn(client.chat, 'getCommand'); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Get a Command" tab="Node.js" index=1 + // COPY: ticketCommand="ticket" + await client.chat.getCommand({ name: ticketCommand }); + // #endregion snippet + + const command = await (getCommand.mock.results[0] + .value as Promise); + expect(command).toMatchObject({ + name: ticketCommand, + description: 'Create a support ticket', + }); + }); + + it('Edit a Command', async () => { + const updateCommand = vi.spyOn(client.chat, 'updateCommand'); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Edit a Command" tab="Node.js" index=1 + // COPY: ticketCommand="ticket" + await client.chat.updateCommand({ + name: ticketCommand, + description: 'Create customer support tickets', + }); + // #endregion snippet + + const response = await (updateCommand.mock.results[0] + .value as Promise); + expect(response.command?.description).toBe( + 'Create customer support tickets', + ); + // The page says fields that aren't sent stay unchanged, but the API resets + // `args` and `set` (the SDK leaves them out of the body): see the TODO's + // Open docs bugs. + const command = await client.chat.getCommand({ name: ticketCommand }); + expect(command).toMatchObject({ + description: 'Create customer support tickets', + args: '', + set: '', + }); + }); + + it('Remove a Command', async () => { + const deleteCommand = vi.spyOn(client.chat, 'deleteCommand'); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Remove a Command" tab="Node.js" index=1 + // COPY: ticketCommand="ticket" + await client.chat.deleteCommand({ name: ticketCommand }); + // #endregion snippet + + const response = await (deleteCommand.mock.results[0] + .value as Promise); + expect(response.name).toBe(ticketCommand); + await expect( + client.chat.getCommand({ name: ticketCommand }), + ).rejects.toMatchObject({ code: 16, metadata: { responseCode: 404 } }); + }); + + it('Configure a Channel Type', async () => { + // The previous test removed the command: the channel type needs it again. + await client.chat.createCommand({ + name: ticketCommand, + description: 'Create a support ticket', + }); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Configure a Channel Type" tab="Node.js" index=1 + // COPY: supportChannelType="support-channel-type", ticketCommand="ticket" + await client.chat.createChannelType({ + name: supportChannelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + commands: [ticketCommand], + }); + // #endregion snippet + + cleanup.channelTypes.push(supportChannelType); + // "You cannot delete a custom command if there are any active channel + // configurations referencing it explicitly": take it off the type first. + cleanup.add(async () => { + await updateChannelType(client, supportChannelType, { commands: [] }); + await retry(deleteCommandIfExists, { + timeout: 60000, + interval: 2000, + retryIf: (error) => + error instanceof Error && error.message.includes('in use'), + }); + }); + await expect( + client.chat.deleteCommand({ name: ticketCommand }), + ).rejects.toMatchObject({ code: 4 }); + await waitForChannelTypePropagation(); + + const type = await client.chat.getChannelType({ name: supportChannelType }); + expect(type.commands.map((command) => command.name)).toEqual([ + ticketCommand, + ]); + }, 120000); + + it('Configure a Custom Action URL', async () => { + const { app } = await client.getApp(); + const original = app.custom_action_handler_url; + expect(original ?? '').toBe(''); + cleanup.add(async () => { + await client.updateApp({ custom_action_handler_url: original ?? '' }); + await waitForAppSetting(client, 'custom_action_handler_url', original); + }); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Configure a Custom Action URL" tab="Node.js" index=1 + await client.updateApp({ + custom_action_handler_url: + 'https://example.com/webhooks/stream/custom-commands?type={type}', + }); + // #endregion snippet + + await waitForAppSetting(client, 'custom_action_handler_url', handlerUrl); + }); + + // Runs last: the page's first fence needs the command, the channel type and the URL. + it('Sending a custom command message', async () => { + await retryWhileChannelTypePropagates(() => + client.chat + .channel(supportChannelType, supportChannelId) + .getOrCreate({ data: { created_by_id: userId } }), + ); + cleanup.channels.push(`${supportChannelType}:${supportChannelId}`); + // The handler URL is a placeholder (example.com), so Stream's POST to it fails and + // the send is rejected with code 45: that proves the command message was routed to + // the custom action handler. Record that error so the region runs as written. + const original = client.chat.sendMessage.bind(client.chat); + const errors: unknown[] = []; + const sendMessage = vi + .spyOn(client.chat, 'sendMessage') + .mockImplementation(async (request) => { + try { + return await original(request); + } catch (error) { + errors.push(error); + return undefined as never; + } + }); + + // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="" tab="Node.js" index=1 + // COPY: supportChannelType="messaging", supportChannelId="channel-id", ticketText="/ticket suspicious transaction with id 1234" + const channel = client.chat.channel(supportChannelType, supportChannelId); + await channel.sendMessage({ + message: { + text: ticketText, + user_id: userId, + }, + }); + // #endregion snippet + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls[0][0]).toMatchObject({ + type: supportChannelType, + id: supportChannelId, + message: { text: ticketText, user_id: userId }, + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: 45 }); + expect(String(errors[0])).toContain( + 'custom command endpoint returned an error', + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/10-migrating/03-exporting_channels.test.ts b/__tests__/docs-snippets/pages/10-migrating/03-exporting_channels.test.ts new file mode 100644 index 0000000..d71a1b7 --- /dev/null +++ b/__tests__/docs-snippets/pages/10-migrating/03-exporting_channels.test.ts @@ -0,0 +1,174 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { sendServerMessage } from '../../helpers/server'; +import { waitForTask } from '../../helpers/wait'; + +const DOCS = '_default/10-migrating/03-exporting_channels.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const ownerId = uniqueId('owner'); + const channelId = uniqueId('white-room'); + const channelId2 = uniqueId('white-room2'); + const user1 = { id: uniqueId('user-1') }; + const user2 = { id: uniqueId('user-2') }; + + beforeAll(async () => { + cleanup.users.push(ownerId, user1.id, user2.id); + await client.upsertUsers([{ id: ownerId }, user1, user2]); + + for (const id of [channelId, channelId2]) { + cleanup.channels.push(`livestream:${id}`); + await client.chat.channel('livestream', id).getOrCreate({ + data: { + created_by_id: ownerId, + members: [{ user_id: user1.id }, { user_id: user2.id }], + }, + }); + await sendServerMessage(client, `livestream:${id}`, { + text: `hello from ${user1.id}`, + user_id: user1.id, + }); + await sendServerMessage(client, `livestream:${id}`, { + text: `hello from ${user2.id}`, + user_id: user2.id, + }); + } + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('exports one and several channels, then checks the export status', async () => { + // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Exporting Channels" tab="Node.js" index=1 + // COPY: channelId="white-room", channelId2="white-room2" + // Export a single channel + const response = await client.chat.exportChannels({ + channels: [ + { + type: 'livestream', + id: channelId, + messages_since: new Date('2020-11-10T09:30:00.000Z'), + messages_until: new Date('2020-11-10T11:30:00.000Z'), + }, + ], + include_truncated_messages: true, + include_soft_deleted_channels: true, + }); + + const taskId = response.task_id; + + // Export multiple channels + const multiResponse = await client.chat.exportChannels({ + channels: [ + { + type: 'livestream', + id: channelId, + messages_since: new Date('2020-11-10T09:30:00.000Z'), + messages_until: new Date('2020-11-10T11:30:00.000Z'), + }, + { + type: 'livestream', + id: channelId2, + }, + ], + include_truncated_messages: true, + include_soft_deleted_channels: true, + }); + const multiTaskId = multiResponse.task_id; + // #endregion snippet + + expect(typeof taskId).toBe('string'); + expect(typeof multiTaskId).toBe('string'); + expect(multiTaskId).not.toBe(taskId); + + const multiTask = await waitForTask(client, multiTaskId); + expect(typeof multiTask.result?.url).toBe('string'); + + await waitForTask(client, taskId); + { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Checking Export Status" tab="Node.js" index=1 + const response = await client.getTask({ id: taskId }); + + console.log(response.status); // Task status + console.log(response.result); // Result object (when completed) + console.log(response.result?.url); // Download URL (when completed) + console.log(response.error); // Error description (if failed) + // #endregion snippet + + expect(response.task_id).toBe(taskId); + expect(response.status).toBe('completed'); + expect(typeof response.result?.url).toBe('string'); + expect(log.mock.calls).toEqual([ + ['completed'], + [response.result], + [response.result?.url], + [undefined], + ]); + } + }); + + it('exports a channel in the v2 format', async () => { + // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Export Format (v2)" tab="Node.js" index=1 + // COPY: channelId="white-room" + const response = await client.chat.exportChannels({ + channels: [{ type: 'livestream', id: channelId }], + version: 'v2', + }); + // #endregion snippet + + const task = await waitForTask(client, response.task_id); + const url: unknown = task.result?.url; + expect(typeof url).toBe('string'); + + // v2 is line-separated JSON: one entity per line, including the channel's messages. + const body = await (await fetch(String(url))).text(); + const lines = body.trim().split('\n'); + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) + expect(() => JSON.parse(line) as unknown).not.toThrow(); + expect(body).toContain(channelId); + expect(body).toContain(`hello from ${user1.id}`); + }); + + it('exports users, then checks the export status', async () => { + // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Exporting Users" tab="Node.js" index=1 + const response = await client.exportUsers({ + user_ids: [user1.id, user2.id], + }); + const taskId = response.task_id; + // #endregion snippet + + expect(typeof taskId).toBe('string'); + await waitForTask(client, taskId); + { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Checking Export Status" tab="Node.js" index=2 + const response = await client.getTask({ id: taskId }); + + if (response.status === 'completed') { + console.log(response.result?.url); + } + // #endregion snippet + + expect(response.status).toBe('completed'); + expect(typeof response.result?.url).toBe('string'); + expect(log.mock.calls).toEqual([[response.result?.url]]); + } + }); +}); diff --git a/__tests__/docs-snippets/pages/12-best_practices/02-moderation.test.ts b/__tests__/docs-snippets/pages/12-best_practices/02-moderation.test.ts new file mode 100644 index 0000000..bbd2e77 --- /dev/null +++ b/__tests__/docs-snippets/pages/12-best_practices/02-moderation.test.ts @@ -0,0 +1,538 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + FlagItemResponse, + UpdateAppRequest, + UpdateChannelResponse, +} from '../../../../index'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { + getServerChannel, + getServerMember, + getServerUser, + sendServerMessage, + waitForAppSetting, +} from '../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; + +const DOCS = '_default/12-best_practices/02-moderation.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // The docs change the built-in "messaging" type: use uniqueId types instead. + const grantsType = uniqueId('messaging'); + const commandsType = uniqueId('messaging'); + + const authorId = uniqueId('author'); + const channelCid = `messaging:${uniqueId('channel')}`; + + /** A new user, registered for cleanup. */ + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + /** A new `messaging` channel created by `creatorId`, registered for cleanup. */ + const createChannel = async (creatorId: string) => { + const id = uniqueId('channel'); + cleanup.channels.push(`messaging:${id}`); + await client.chat + .channel('messaging', id) + .getOrCreate({ data: { created_by_id: creatorId } }); + return id; + }; + + /** A message by the author in the shared channel. */ + const createMessage = async () => + ( + await sendServerMessage(client, channelCid, { + text: 'buy cheap stuff now', + user_id: authorId, + }) + ).id; + + beforeAll(async () => { + cleanup.users.push(authorId); + await client.upsertUsers([{ id: authorId }]); + const [type, id] = channelCid.split(':'); + cleanup.channels.push(channelCid); + await client.chat + .channel(type, id) + .getOrCreate({ data: { created_by_id: authorId } }); + + for (const name of [grantsType, commandsType]) { + await client.chat.createChannelType({ + name, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(name); + } + await waitForChannelTypePropagation(); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('removes link and attachment permissions from channel_member', async () => { + const original = (await client.chat.getChannelType({ name: grantsType })) + .grants.channel_member; + // The snippet only proves something if the role had these permissions. + expect(original).toEqual( + expect.arrayContaining([ + 'add-links', + 'create-attachment', + 'upload-attachment', + ]), + ); + const channelType = grantsType; + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Disabling the permission to post links or add attachments" tab="Node.js" index=1 + // COPY: channelType="messaging" + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: channelType }); + + // Remove link and attachment permissions for channel_member role + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + grants: { + channel_member: [ + 'read-channel', + 'create-message', + 'update-message-owner', + 'delete-message-owner', + // "add-links" - removed to disable links + // "create-attachment" - removed to disable attachments + // "upload-attachment" - removed to disable uploads + ], + }, + }); + // #endregion snippet + + await waitForChannelTypePropagation(); + const { grants } = await client.chat.getChannelType({ name: grantsType }); + expect([...grants.channel_member].sort()).toEqual( + [ + 'read-channel', + 'create-message', + 'update-message-owner', + 'delete-message-owner', + ].sort(), + ); + }); + + it('restricts image and file uploads', async () => { + const { app } = await client.getApp(); + const originalImage = app.image_upload_config; + const originalFile = app.file_upload_config; + cleanup.add(async () => { + await client.updateApp({ + image_upload_config: originalImage, + file_upload_config: originalFile, + }); + await waitForAppSetting(client, 'image_upload_config', originalImage); + await waitForAppSetting(client, 'file_upload_config', originalFile); + }); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Image & Video file types" tab="Node.js" index=1 + // Restrict images to common formats only + await client.updateApp({ + image_upload_config: { + allowed_file_extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'], + allowed_mime_types: [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ], + size_limit: 5 * 1024 * 1024, // 5MB + }, + // Restrict file uploads to documents only + file_upload_config: { + allowed_file_extensions: ['.pdf', '.doc', '.docx'], + allowed_mime_types: ['application/pdf', 'application/msword'], + size_limit: 10 * 1024 * 1024, // 10MB + }, + }); + // #endregion snippet + + await retry(async () => { + const { app: updated } = await client.getApp(); + expect(updated.image_upload_config).toMatchObject({ + allowed_file_extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'], + allowed_mime_types: [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ], + size_limit: 5 * 1024 * 1024, + }); + expect(updated.file_upload_config).toMatchObject({ + allowed_file_extensions: ['.pdf', '.doc', '.docx'], + allowed_mime_types: ['application/pdf', 'application/msword'], + size_limit: 10 * 1024 * 1024, + }); + }); + }); + + describe('Giving moderators more permissions', () => { + it('adds a channel moderator and promotes a member', async () => { + const jamesBond = await createUser('james-bond'); + const channelId = await createChannel(authorId); + const updateChannel = vi.spyOn(client.chat, 'updateChannel'); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="Node.js" index=1 + // COPY: channelId="channel-id", jamesBond="james_bond" + const channel = client.chat.channel('messaging', channelId); + + // Add a member with moderator role + await channel.update({ + add_members: [ + { user_id: jamesBond, channel_role: 'channel_moderator' }, + ], + }); + + // Or promote someone who is already a member + await channel.update({ + assign_roles: [ + { user_id: jamesBond, channel_role: 'channel_moderator' }, + ], + }); + // #endregion snippet + + const [added, assigned] = await Promise.all( + updateChannel.mock.results.map( + (r) => r.value as Promise, + ), + ); + for (const response of [added, assigned]) { + expect(response.members).toEqual([ + expect.objectContaining({ + user_id: jamesBond, + channel_role: 'channel_moderator', + }), + ]); + } + expect( + (await getServerMember(client, channel.cid, jamesBond)).channel_role, + ).toBe('channel_moderator'); + }); + + it('makes a user an admin across all channels', async () => { + const jamesBond = await createUser('james-bond'); + expect((await getServerUser(client, jamesBond)).role).toBe('user'); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="Node.js" index=2 + // COPY: jamesBond="james_bond" + await client.updateUsersPartial({ + users: [{ id: jamesBond, set: { role: 'admin' } }], + }); + // #endregion snippet + + expect((await getServerUser(client, jamesBond)).role).toBe('admin'); + }); + }); + + it('enables, changes and disables slow mode', async () => { + const channelId = await createChannel(authorId); + const updateChannel = vi.spyOn(client.chat, 'updateChannel'); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Slow mode" tab="Node.js" index=1 + // COPY: channelId="general" + const channel = client.chat.channel('messaging', channelId); + + // slow mode is the channel cooldown, in seconds + // enable slow mode and set cooldown to 1s + await channel.update({ cooldown: 1 }); + + // increase cooldown to 30s + await channel.update({ cooldown: 30 }); + + // disable slow mode + await channel.update({ cooldown: 0 }); + // #endregion snippet + + const responses = await Promise.all( + updateChannel.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(responses.map((r) => r.channel?.cooldown ?? 0)).toEqual([1, 30, 0]); + const current = await getServerChannel(client, channel.cid); + expect(current.channel?.cooldown ?? 0).toBe(0); + }); + + it('enforces unique usernames on app and team level', async () => { + const { app } = await client.getApp(); + const original = app.enforce_unique_usernames; + cleanup.add(async () => { + await client.updateApp({ + enforce_unique_usernames: + original as UpdateAppRequest['enforce_unique_usernames'], + }); + await waitForAppSetting(client, 'enforce_unique_usernames', original); + }); + const updateApp = vi.spyOn(client, 'updateApp'); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Enforce unique usernames" tab="Node.js" index=1 + // Enable uniqueness constraints on App level + await client.updateApp({ + enforce_unique_usernames: 'app', + }); + + // Enable uniqueness constraints on Team level + await client.updateApp({ + enforce_unique_usernames: 'team', + }); + // #endregion snippet + + expect( + updateApp.mock.calls.map( + ([request]) => request?.enforce_unique_usernames, + ), + ).toEqual(['app', 'team']); + await waitForAppSetting(client, 'enforce_unique_usernames', 'team', { + agreeing: 5, + }); + }); + + it('enables the ban and unban commands', async () => { + const channelType = commandsType; + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Slash commands for banning" tab="Node.js" index=1 + // COPY: channelType="messaging" + // automod, automod_behavior and max_message_length are required here, so read + // the current values and carry them through. + const current = await client.chat.getChannelType({ name: channelType }); + + // Enable ban/unban commands for a channel type + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + commands: ['giphy', 'ban', 'unban', 'mute', 'unmute', 'flag'], + }); + // #endregion snippet + + await waitForChannelTypePropagation(); + const { commands } = await client.chat.getChannelType({ + name: commandsType, + }); + expect(commands.map((command) => command.name)).toEqual( + expect.arrayContaining(['giphy', 'ban', 'unban', 'mute', 'unmute']), + ); + }); + + /** The review queue item of `messageId`, once its flag is there. */ + const waitForFlag = (messageId: string) => + retry(async () => { + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_id: messageId }, + }); + expect(items).toHaveLength(1); + expect(items[0].flags).toHaveLength(1); + return items[0].flags[0]; + }); + + it('flags a message', async () => { + const reportingUserId = await createUser('reporting-user'); + const messageId = await createMessage(); + const anotherMessageId = await createMessage(); + const flag = vi.spyOn(client.moderation, 'flag'); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Flag" tab="Node.js" index=1 + // COPY: reportingUserId="reporting-user-id" + // Flag a message. Server-side the flag must name the reporting user. + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: messageId, + user_id: reportingUserId, + }); + + // Flag with a reason and custom data + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: anotherMessageId, + user_id: reportingUserId, + reason: 'spammy_user', + custom: { + user_comment: 'This user is spamming.', + }, + }); + // #endregion snippet + + const responses = await Promise.all( + flag.mock.results.map((r) => r.value as Promise), + ); + expect(responses.every((r) => typeof r.item_id === 'string')).toBe(true); + const plain = await waitForFlag(messageId); + expect(plain.user?.id).toBe(reportingUserId); + expect(plain.reason).toBeUndefined(); + expect(await waitForFlag(anotherMessageId)).toMatchObject({ + reason: 'spammy_user', + custom: { user_comment: 'This user is spamming.' }, + }); + }); + + it('flags a message with reasons and custom data', async () => { + const reportingUserId = await createUser('reporting-user'); + const messageID = await createMessage(); + const anotherMessageID = await createMessage(); + const thirdMessageID = await createMessage(); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Reasons & custom data" tab="Node.js" index=1 + // COPY: reportingUserId="reporting-user-id" + // flag with a reason + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: messageID, + user_id: reportingUserId, + reason: 'spammy_user', + }); + + // flag with a reason and additional custom data + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: anotherMessageID, + user_id: reportingUserId, + reason: 'spammy_user', + custom: { + user_comment: 'This user is spamming the homepage.', + page: 'homepage', + }, + }); + + // flag with only custom data + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: thirdMessageID, + user_id: reportingUserId, + custom: { + page: 'homepage', + }, + }); + // #endregion snippet + + expect(await waitForFlag(messageID)).toMatchObject({ + reason: 'spammy_user', + }); + expect(await waitForFlag(anotherMessageID)).toMatchObject({ + reason: 'spammy_user', + custom: { + user_comment: 'This user is spamming the homepage.', + page: 'homepage', + }, + }); + const customOnly = await waitForFlag(thirdMessageID); + expect(customOnly.reason).toBeUndefined(); + expect(customOnly.custom).toEqual({ page: 'homepage' }); + }); + + it('queries flagged messages', async () => { + const reportingUserId = await createUser('reporting-user'); + const messageId = await createMessage(); + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: messageId, + user_id: reportingUserId, + }); + await waitForFlag(messageId); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Query Flagged Messages" tab="Node.js" index=1 + const response = await client.moderation.queryReviewQueue({ + filter: { entity_type: 'stream:chat:v1:message' }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 25, + }); + + for (const item of response.items) { + console.log(item.message?.id); + console.log(item.message?.text); + console.log(item.message?.type); + console.log(item.message?.created_at); + } + + console.log(response.next); // <-- next cursor for pagination + // #endregion snippet + + expect(response.items.length).toBeGreaterThan(0); + expect(response.items.length).toBeLessThanOrEqual(25); + expect( + response.items.every((i) => i.entity_type === 'stream:chat:v1:message'), + ).toBe(true); + // The message flagged above is the newest item. + expect(response.items[0].entity_id).toBe(messageId); + expect(log.mock.calls.slice(0, 2)).toEqual([ + [messageId], + ['buy cheap stuff now'], + ]); + expect(log.mock.calls.at(-1)).toEqual([response.next]); + }); + + it('blocks, lists and unblocks a user', async () => { + const user1Id = await createUser('user1'); + const user2Id = await createUser('user2'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=1 + // COPY: user1Id="user1", user2Id="user2" + const blockingUser = user1Id; + const blockedUser = user2Id; + + // Blocks a user on behalf of `blockingUser`. + await client.blockUsers({ + blocked_user_id: blockedUser, + user_id: blockingUser, + }); + + const { blocks } = await client.getBlockedUsers({ user_id: blockingUser }); + // #endregion snippet + + expect(blocks.map((b) => b.blocked_user_id)).toEqual([user2Id]); + + { + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=3 + const { blocks } = await client.getBlockedUsers({ + user_id: blockingUser, + }); + + for (const block of blocks) { + console.log(block.blocked_user_id); + } + // #endregion snippet + } + + expect(log.mock.calls).toEqual([[user2Id]]); + + // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=2 + await client.unblockUsers({ + blocked_user_id: blockedUser, + user_id: blockingUser, + }); + // #endregion snippet + + const after = await client.getBlockedUsers({ user_id: user1Id }); + expect(after.blocks).toEqual([]); + }); +}); diff --git a/__tests__/docs-snippets/pages/12-best_practices/05-marketplace_best_practices.test.ts b/__tests__/docs-snippets/pages/12-best_practices/05-marketplace_best_practices.test.ts new file mode 100644 index 0000000..fbf67f7 --- /dev/null +++ b/__tests__/docs-snippets/pages/12-best_practices/05-marketplace_best_practices.test.ts @@ -0,0 +1,253 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../helpers/clients'; +import { Cleanup } from '../../helpers/cleanup'; +import { uniqueId } from '../../helpers/ids'; +import { + getServerChannel, + getServerUser, + sendServerMessage, + waitForAppSetting, +} from '../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; + +const DOCS = '_default/12-best_practices/05-marketplace_best_practices.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // The docs change the built-in "messaging" type: use uniqueId types instead. + const featuresType = uniqueId('messaging'); + // Created with the recommended features off, so the "Recommended Settings" fence + // has something to turn on. + const settingsType = uniqueId('messaging'); + + const buyerId = uniqueId('buyer'); + const sellerId = uniqueId('seller'); + + beforeAll(async () => { + cleanup.users.push(buyerId, sellerId); + await client.upsertUsers([{ id: buyerId }, { id: sellerId }]); + + const { app } = await client.getApp(); + const originalInterval = app.reminders_interval; + const originalResponseTime = app.user_response_time_enabled; + cleanup.add(async () => { + await client.updateApp({ + reminders_interval: originalInterval, + user_response_time_enabled: originalResponseTime, + }); + await waitForAppSetting(client, 'reminders_interval', originalInterval); + await waitForAppSetting( + client, + 'user_response_time_enabled', + originalResponseTime, + ); + }); + + await client.chat.createChannelType({ + name: featuresType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(featuresType); + await client.chat.createChannelType({ + name: settingsType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + typing_events: false, + read_events: false, + reactions: false, + replies: false, + uploads: false, + }); + cleanup.channelTypes.push(settingsType); + await waitForChannelTypePropagation(); + }); + + afterAll(() => cleanup.run()); + + it('enables reminders and sets the interval', async () => { + const before = await client.chat.getChannelType({ name: featuresType }); + expect(before.reminders).toBe(false); + const { app } = await client.getApp(); + expect(app.reminders_interval).not.toBe(3600); + const channelType = featuresType; + + // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="Unread Message Reminders" tab="Node.js" index=1 + // COPY: channelType="messaging" + // Enable reminders for the messaging channel type. automod, automod_behavior and + // max_message_length are required on this endpoint, so carry the current values + // through to avoid overwriting them. + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + reminders: true, + }); + + // Set reminder interval (default is 5 minutes, max 24 hours) + await client.updateApp({ reminders_interval: 3600 }); // 1 hour + // #endregion snippet + + await waitForChannelTypePropagation(); + const updated = await client.chat.getChannelType({ name: featuresType }); + expect(updated.reminders).toBe(true); + expect(updated.max_message_length).toBe(current.max_message_length); + await waitForAppSetting(client, 'reminders_interval', 3600); + }); + + it('enables user average response time', async () => { + const channelId = uniqueId('listing'); + const cid = `messaging:${channelId}`; + cleanup.channels.push(cid); + await client.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: buyerId, + members: [{ user_id: buyerId }, { user_id: sellerId }], + }, + }); + + // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="User Average Response Time" tab="Node.js" index=1 + await client.updateApp({ + user_response_time_enabled: true, + }); + // #endregion snippet + + await waitForAppSetting(client, 'user_response_time_enabled', true); + + // "Once enabled, the avg_response_time field will be included in user responses" + await sendServerMessage(client, cid, { + text: 'Is the camera still available?', + user_id: buyerId, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await sendServerMessage(client, cid, { + text: 'Yes, it is.', + user_id: sellerId, + }); + const seller = await retry( + async () => { + const user = await getServerUser(client, sellerId); + if (user.avg_response_time === undefined) { + throw new Error('avg_response_time not set yet'); + } + return user; + }, + { timeout: 30000 }, + ); + expect(seller.avg_response_time).toBeGreaterThan(0); + }); + + it('enables pending messages', async () => { + const before = await client.chat.getChannelType({ name: featuresType }); + expect(before.mark_messages_pending).toBe(false); + const channelType = featuresType; + + // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="Pending Messages" tab="Node.js" index=1 + // COPY: channelType="messaging" + // automod, automod_behavior and max_message_length are required on this endpoint, + // so carry the current values through to avoid overwriting them. + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + mark_messages_pending: true, + }); + // #endregion snippet + + await waitForChannelTypePropagation(); + const updated = await client.chat.getChannelType({ name: featuresType }); + expect(updated.mark_messages_pending).toBe(true); + // Settings set by the earlier fence are kept. + expect(updated.reminders).toBe(true); + }); + + it('applies the recommended settings', async () => { + const before = await client.chat.getChannelType({ name: settingsType }); + expect(before).toMatchObject({ + typing_events: false, + read_events: false, + reactions: false, + replies: false, + uploads: false, + reminders: false, + }); + const channelType = settingsType; + + // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="Recommended Settings" tab="Node.js" index=1 + // COPY: channelType="messaging" + // automod, automod_behavior and max_message_length are required on this endpoint, + // so carry the current values through to avoid overwriting them. + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + typing_events: true, // Show when users are typing + read_events: true, // Track read status for messages + reactions: true, // Allow reactions for quick responses + replies: true, // Enable threaded replies for complex discussions + uploads: true, // Allow image uploads for product photos + reminders: true, // Enable unread reminders + }); + // #endregion snippet + + await waitForChannelTypePropagation(); + const updated = await client.chat.getChannelType({ name: settingsType }); + expect(updated).toMatchObject({ + typing_events: true, + read_events: true, + reactions: true, + replies: true, + uploads: true, + reminders: true, + }); + }); + + it('stores listing information on a distinct channel', async () => { + // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="Custom Data" tab="Node.js" index=1 + // Store listing information on the channel. + // Omitting the channel ID creates a distinct channel, keyed on the member list. + const channel = client.chat.channel('messaging'); + await channel.getOrCreate({ + data: { + created_by_id: buyerId, + members: [{ user_id: buyerId }, { user_id: sellerId }], + custom: { + listing_id: 'listing-123', + listing_title: 'Vintage Camera', + listing_price: 299.99, + listing_image: 'https://example.com/camera.jpg', + }, + }, + }); + // #endregion snippet + + cleanup.channels.push(channel.cid); + expect(channel.id).toBeTruthy(); + const { channel: created, members } = await getServerChannel( + client, + channel.cid, + ); + expect(created?.custom).toMatchObject({ + listing_id: 'listing-123', + listing_title: 'Vintage Camera', + listing_price: 299.99, + listing_image: 'https://example.com/camera.jpg', + }); + expect(members.map((m) => m.user_id).sort()).toEqual( + [buyerId, sellerId].sort(), + ); + }); +}); diff --git a/__tests__/docs-snippets/setup.ts b/__tests__/docs-snippets/setup.ts index d05893f..9b331ef 100644 --- a/__tests__/docs-snippets/setup.ts +++ b/__tests__/docs-snippets/setup.ts @@ -1,6 +1,5 @@ -import { config } from 'dotenv'; -import path from 'node:path'; import { afterAll, beforeAll } from 'vitest'; +import './env'; import { getServerClient } from './helpers/clients'; import { mintedIds, runId } from './helpers/ids'; import { @@ -12,15 +11,6 @@ import { diffAppSnapshots, takeAppSnapshot } from './helpers/snapshot'; import type { AppSnapshot } from './helpers/snapshot'; import { retry } from './helpers/wait'; -// The node SDK's test app, shared with the integration tests (repo root .env). -config({ path: path.join(__dirname, '../../.env'), quiet: true }); - -if (!process.env.STREAM_API_KEY || !process.env.STREAM_SECRET) { - throw new Error( - 'Missing STREAM_API_KEY / STREAM_SECRET. Copy .env-example to .env in the repo root and fill it in.', - ); -} - // These hooks are registered before the test file's own hooks, so `afterAll` below // runs after the file's cleanup (vitest runs after-hooks in reverse order). diff --git a/__tests__/docs-snippets/sync/parse.ts b/__tests__/docs-snippets/sync/parse.ts index 24c9020..af2c160 100644 --- a/__tests__/docs-snippets/sync/parse.ts +++ b/__tests__/docs-snippets/sync/parse.ts @@ -244,6 +244,9 @@ const COMPARE_OPTIONS: Options = { semi: true, printWidth: 80, trailingComma: 'all', + // Prettier keeps an object expanded once it is broken over lines, so a one-line docs call + // wrapped at test indentation would never match again: collapse what fits on one line. + objectWrap: 'collapse', }; /** diff --git a/__tests__/docs-snippets/vitest.config.mts b/__tests__/docs-snippets/vitest.config.mts index 8e8d3e7..5dad673 100644 --- a/__tests__/docs-snippets/vitest.config.mts +++ b/__tests__/docs-snippets/vitest.config.mts @@ -19,6 +19,8 @@ export default defineConfig({ root: import.meta.dirname, include: ["./pages/**/*.test.ts"], setupFiles: ["./setup.ts"], + // Removes leftovers of test files that failed while loading (no hooks ran). + globalSetup: ["./global-setup.ts"], // Shows console output (leak / drift warnings) for passing tests too. reporters: ["verbose"], // Generous because some app-level changes are eventually consistent: a new channel diff --git a/__tests__/docs-snippets/vitest.sweep.config.mts b/__tests__/docs-snippets/vitest.sweep.config.mts index 705146b..8d7c2dc 100644 --- a/__tests__/docs-snippets/vitest.sweep.config.mts +++ b/__tests__/docs-snippets/vitest.sweep.config.mts @@ -5,8 +5,9 @@ export default defineConfig({ test: { root: import.meta.dirname, include: ["./maintenance/sweep.ts"], - setupFiles: ["./setup.ts"], - // Shows console output (leak / drift warnings) for passing tests too. + // Only loads the env: the per-file leak / drift checks would flag the sweep's own removals. + setupFiles: ["./env.ts"], + // Shows console output for passing tests too. reporters: ["verbose"], testTimeout: 600000, hookTimeout: 180000, diff --git a/__tests__/integration/stream-error.test.ts b/__tests__/integration/stream-error.test.ts new file mode 100644 index 0000000..74cc828 --- /dev/null +++ b/__tests__/integration/stream-error.test.ts @@ -0,0 +1,20 @@ +import { randomUUID } from 'crypto'; +import { describe, expect, it } from 'vitest'; +import { StreamError } from '../../index'; +import { createTestClient } from './create-test-client'; + +describe('StreamError', () => { + const client = createTestClient(); + + it('is exported from the package entry and thrown for failed requests', async () => { + const error: unknown = await client.chat + .getChannel({ type: 'messaging', id: `stream-node-test-${randomUUID()}` }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(StreamError); + expect(error).toBeInstanceOf(Error); + const streamError = error as StreamError; + expect(streamError.code).toBeDefined(); + expect(streamError.metadata.responseCode).toBe(404); + }); +}); diff --git a/index.ts b/index.ts index 71c9a50..54ba101 100644 --- a/index.ts +++ b/index.ts @@ -10,3 +10,4 @@ export { InvalidWebhookError, InvalidWebhookErrorMessages, } from './src/utils/webhook'; +export { StreamError } from './src/types'; diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 1beb5f3..0a0a8d7 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -21341,7 +21341,7 @@ export interface UpdateAppRequest { datadog_info?: DataDogInfo; file_upload_config?: FileUploadConfig; firebase_config?: FirebaseConfig; - grants?: Record>; + grants?: Record> | null; huawei_config?: HuaweiConfig; image_upload_config?: FileUploadConfig; moderation_dashboard_preferences?: ModerationDashboardPreferences; @@ -21673,7 +21673,7 @@ export interface UpdateChannelTypeRequest { */ automod_thresholds?: Thresholds; chat_preferences?: ChatPreferences; - grants?: Record>; + grants?: Record> | null; } export interface UpdateChannelTypeResponse { From c258282941429884362f5e352452e110317d354c Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 13 Oct 2026 10:55:37 -0500 Subject: [PATCH 3/9] chore: make the test suite faster --- __tests__/docs-snippets/helpers/clients.ts | 6 +- __tests__/docs-snippets/helpers/leaks.ts | 6 ++ __tests__/docs-snippets/helpers/wait.ts | 106 +++++++++++++++++++-- __tests__/docs-snippets/learnings.md | 2 +- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/__tests__/docs-snippets/helpers/clients.ts b/__tests__/docs-snippets/helpers/clients.ts index 70ad977..a9eeb44 100644 --- a/__tests__/docs-snippets/helpers/clients.ts +++ b/__tests__/docs-snippets/helpers/clients.ts @@ -1,4 +1,5 @@ import { StreamClient } from '../../../index'; +import { trackChannelTypeChanges } from './wait'; const apiKey = () => process.env.STREAM_API_KEY as string; const apiSecret = () => process.env.STREAM_SECRET as string; @@ -6,9 +7,12 @@ const apiSecret = () => process.env.STREAM_SECRET as string; /** * Server-side client (API key + secret), the `client` of the docs snippets. Also used for * setup, cleanup and assertions. The default 3s timeout is too short for some endpoints. + * Its channel type changes are recorded for `waitForChannelTypePropagation()`. */ export const getServerClient = () => - new StreamClient(apiKey(), apiSecret(), { timeout: 30000 }); + trackChannelTypeChanges( + new StreamClient(apiKey(), apiSecret(), { timeout: 30000 }), + ); /** User token for `userId`, signed with the API secret. */ export const createUserToken = (userId: string) => diff --git a/__tests__/docs-snippets/helpers/leaks.ts b/__tests__/docs-snippets/helpers/leaks.ts index 2bb807b..c20f42a 100644 --- a/__tests__/docs-snippets/helpers/leaks.ts +++ b/__tests__/docs-snippets/helpers/leaks.ts @@ -106,6 +106,12 @@ const scanners: Record = { id: channel.cid, createdAt: channel.created_at, remove: () => hardDeleteChannels(client, [channel.cid]), + // One delete task per 100 channels instead of one per channel. + removeMany: async (cids) => { + for (const batch of chunk(cids, 100)) { + await retryOnRateLimit(() => hardDeleteChannels(client, batch)); + } + }, }); for (const ids of chunk(channelIds, 100)) { const { channels } = await client.chat.queryChannels({ diff --git a/__tests__/docs-snippets/helpers/wait.ts b/__tests__/docs-snippets/helpers/wait.ts index e064606..d111273 100644 --- a/__tests__/docs-snippets/helpers/wait.ts +++ b/__tests__/docs-snippets/helpers/wait.ts @@ -1,4 +1,5 @@ import type { StreamClient } from '../../../index'; +import { DOCS_TEST_PREFIX } from './ids'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -59,18 +60,107 @@ export const isChannelTypeMissing = (error: unknown) => { ); }; -/** How long channel type changes take to reach every API node. */ +/** + * How long a change to a built-in or pre-existing channel type gets before it is read + * back (e.g. by the DRIFT check, which compares those types). Reads of a channel type can + * return the old value for a while after an update, longer than the API takes to apply it. + */ export const CHANNEL_TYPE_PROPAGATION_MS = 30000; /** - * Channel type changes are eventually consistent: after `createChannelType` or - * `updateChannelType`, API nodes can disagree for up to ~30s. A `channel.create()` can - * succeed and the next `sendMessage` still fail with "channel type does not exist" (or - * still see the old settings). Call this right after creating/updating a channel type, - * before using it. Polling doesn't help because different nodes answer differently. + * How long an update to a docs-test channel type gets before it is used. Measured on the + * test app (2026-10-13): every API node enforced an update within ~3s. + */ +export const CHANNEL_TYPE_UPDATE_MS = 10000; + +/** + * A new channel type is waited for at least `CHANNEL_TYPE_UPDATE_MS` after creation and + * until this many lookups in a row accept it. Lookups alone aren't enough: they reuse one + * keep-alive connection, so they can all reach a node that already knows the type. + */ +const CHANNEL_TYPE_AGREEING_LOOKUPS = 10; + +/** Channel type changes since the last `waitForChannelTypePropagation()`. */ +const pendingChannelTypes = { + created: new Map(), + lastUpdate: undefined as { at: number; existing: boolean } | undefined, +}; + +/** + * Records `createChannelType` / `updateChannelType` calls of `client` (incl. calls from + * the snippets) for `waitForChannelTypePropagation()`. Called by `getServerClient()`. */ -export const waitForChannelTypePropagation = () => - sleep(CHANNEL_TYPE_PROPAGATION_MS); +export const trackChannelTypeChanges = (client: StreamClient) => { + const chat = client.chat; + const create = chat.createChannelType.bind(chat); + chat.createChannelType = async (request) => { + const response = await create(request); + pendingChannelTypes.created.set(request.name, { client, at: Date.now() }); + return response; + }; + const update = chat.updateChannelType.bind(chat); + chat.updateChannelType = async (request) => { + const response = await update(request); + // A type created since the last wait counts as new; anything else (built-in types, + // types that existed before) is compared by the DRIFT check. + pendingChannelTypes.lastUpdate = { + at: Date.now(), + existing: + !pendingChannelTypes.created.has(request.name) && + !request.name.startsWith(DOCS_TEST_PREFIX), + }; + return response; + }; + return client; +}; + +/** Waits until `CHANNEL_TYPE_AGREEING_LOOKUPS` lookups in a row know channel type `name`. */ +const waitForChannelTypeKnown = async (client: StreamClient, name: string) => { + let streak = 0; + await retry( + async () => { + while (streak < CHANNEL_TYPE_AGREEING_LOOKUPS) { + try { + await client.chat.queryChannels({ + filter_conditions: { type: name }, + limit: 1, + state: false, + }); + streak++; + } catch (error) { + streak = 0; + throw error; + } + } + }, + { timeout: 60000, interval: 500, retryIf: isChannelTypeMissing }, + ); +}; + +/** + * Channel type changes are eventually consistent: right after `createChannelType` or + * `updateChannelType`, API nodes can disagree (a `channel.create()` can succeed and the + * next `sendMessage` still fail with "channel type does not exist", or still see the old + * settings). Call this right after creating/updating a channel type, before using it. + * It waits for what changed since the last call: new types `CHANNEL_TYPE_UPDATE_MS` + * after creation and until lookups agree on them, updates of docs-test types `CHANNEL_TYPE_UPDATE_MS` and updates of other types + * `CHANNEL_TYPE_PROPAGATION_MS` after the last update. + */ +export const waitForChannelTypePropagation = async () => { + for (const [name, { client, at }] of pendingChannelTypes.created) { + await waitForChannelTypeKnown(client, name); + await sleep(at + CHANNEL_TYPE_UPDATE_MS - Date.now()); + } + pendingChannelTypes.created.clear(); + const { lastUpdate } = pendingChannelTypes; + pendingChannelTypes.lastUpdate = undefined; + if (lastUpdate) { + const wait = lastUpdate.existing + ? CHANNEL_TYPE_PROPAGATION_MS + : CHANNEL_TYPE_UPDATE_MS; + await sleep(lastUpdate.at + wait - Date.now()); + } +}; /** * Retries `fn` while it fails with "channel type does not exist". Only a fallback on diff --git a/__tests__/docs-snippets/learnings.md b/__tests__/docs-snippets/learnings.md index 0303741..83485b8 100644 --- a/__tests__/docs-snippets/learnings.md +++ b/__tests__/docs-snippets/learnings.md @@ -164,7 +164,7 @@ Entries are added only after the user approves them (agents propose them in thei - Channels the API creates from templates (campaign channels named `{{receiver.id}}-{{sender.id}}`) aren't found by the leak check: find them by member in cleanup and push them to `cleanup.channels`. Keep the `uniqueId` names short so the combined id stays under 64 characters. - `queryPolls` filters (`is_closed: true`) match every poll in the app. When the fence can't be scoped by COPY, create two matching polls and assert their relative order under the docs sort instead of the full list. - `event_hooks` changes: save `getApp().app.event_hooks` before the region and `cleanup.add` an `updateApp({ event_hooks: original })` followed by `waitForAppSetting(client, 'event_hooks', original)`; passing the returned hooks back keeps their ids (no DRIFT). -- Tests that change the same channel type in setup and again in the snippet need two `waitForChannelTypePropagation()` waits (~60s): give them an explicit 150s timeout (the default is 90s). +- `waitForChannelTypePropagation()` waits only for what changed since its last call (`getServerClient()` records channel type creates and updates): a new type ≥10s after creation plus 10 agreeing lookups, an update of a docs-test type 10s, an update of a built-in or pre-existing type 30s (the DRIFT check reads those back). Measured 2026-10-13: updates are enforced everywhere within ~3s, but `getChannelType` / `listChannelTypes` reads can return the old value for over a minute, so read a type back with a retry (`waitForChannelType`-style) or assert the update response. A test with two waits on a built-in type needs an explicit timeout above the 90s default. - App-level resources that the leak and drift checks don't track (e.g. retention policies): save the original list in `beforeAll`, and `cleanup.add` a restore that puts the originals back and deletes anything the file added. Check that the app is back to its original state after the run. - A role can't be deleted while a user has it: in cleanup, set that user's role back to `'user'` before `deleteRole` (`Cleanup` deletes users only after the custom steps). - Unset optional app settings (`before_message_send_hook_url`, `..._attempt_timeout_ms`) are absent from `getApp()`; restoring with `''` / `0` makes them absent again, so `waitForAppSetting(client, key, undefined)` settles with no DRIFT. From 8ded33a88b9a13e09aa099465cdfac2500d54ade Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 13 Oct 2026 11:40:45 -0500 Subject: [PATCH 4/9] chore: update docs todo --- .../docs-snippets-agent-prompt.md | 14 ++++-- __tests__/docs-snippets/docs-snippets-todo.md | 48 ++++++++++++------- __tests__/docs-snippets/learnings.md | 2 +- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/__tests__/docs-snippets/docs-snippets-agent-prompt.md b/__tests__/docs-snippets/docs-snippets-agent-prompt.md index 7217aac..03c52d1 100644 --- a/__tests__/docs-snippets/docs-snippets-agent-prompt.md +++ b/__tests__/docs-snippets/docs-snippets-agent-prompt.md @@ -15,8 +15,8 @@ You are verifying the server-side Node.js code snippets of one Stream Chat docs - Only touch: - the test files for this page under `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/pages/` - the docs page itself - - this page's status row (and its entries in the Blockers detail / Setup notes / Open docs bugs / Possible SDK issues sections). Fixed docs bugs go in the report only; record a docs bug in **Open docs bugs** only if you leave it unfixed in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md` -- Do not edit the helpers, the configs, other pages, or anything in `src/` (`src/gen` is generated from the OpenAPI spec, so hand edits would be lost). If a helper is missing something, write a local helper in your test file and propose the shared helper in your report. If the SDK is wrong (types or behavior), report it under "Possible SDK issues". + - this page's status row (and its entries in the Blockers detail / Setup notes / API behavior issues / API spec / node SDK issues / Open docs bugs sections). Fixed docs bugs go in the report only; record a docs bug in **Open docs bugs** only if you leave it unfixed in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md` +- Do not edit the helpers, the configs, other pages, or anything in `src/` (`src/gen` is generated from the OpenAPI spec, so hand edits would be lost). If a helper is missing something, write a local helper in your test file and propose the shared helper in your report. If the OpenAPI spec or the generated SDK is wrong, report it under "API spec / node SDK issues". - Change existing docs snippets as little as possible, and only when they are actually wrong. - Never put the API key, secret or push credentials in any file. They are loaded from the repo root `.env` (`STREAM_API_KEY`, `STREAM_SECRET`, optional `DOCS_PUSH_FIREBASE_CREDENTIALS`). - In getstream.io, never run `npm run ci` or `npm run lint` for the whole repo. They rewrite thousands of files, including image assets. Use only the per-file commands listed below. @@ -89,7 +89,11 @@ If a docs snippet is wrong (wrong method name or signature, a type error against 2. Write it to the docs page with `DOCS_SYNC_WRITE= yarn test:docs:sync`, then check `git diff` in getstream.io: only the intended fence should change. 3. Record it for the report as before/after with the reason and the evidence (error message or type error). -If the snippet is correct but the node SDK types reject it or the SDK misbehaves, don't change the snippet or `src/`: put it in `it.skip('BLOCKED: SDK issue: ...')` and report it under "Possible SDK issues". +Tell three kinds of problem apart, and record each in its own list in the todo: + +- **Docs bug**: the snippet (or the page) is wrong. Fix the snippet as above; prose and non-Node.js fences are out of scope, record those under **Open docs bugs**. +- **API spec / node SDK issue**: the snippet is correct and the API accepts it, but the OpenAPI spec or the generated SDK is wrong (missing or wrongly typed fields, wrong response types, a required field typed optional). Don't change the snippet or `src/`; record it under **API spec / node SDK issues** (say whether it looks like the spec or the generator) and report it. If the types reject a correct snippet, stop and report it: `it.skip` doesn't help, skipped bodies are still typechecked. +- **API behavior issue**: the API itself seems faulty, or behaves differently from what the docs say (fields missing from responses, data silently dropped, settings not enforced, inconsistent answers between API nodes). Assert what the API actually does and record it under **API behavior issues** with the probe evidence. It is not a blocker: it doesn't make the page `[~]` on its own. If you're not sure whether the snippet or your setup is wrong, don't edit the docs. Mark it as an open question in the report instead. @@ -109,7 +113,7 @@ yarn test:docs:sync # your page: every Node.js f Don't use `@ts-ignore`/`@ts-expect-error` or `eslint-disable` to silence a docs snippet. The one lint-only docs change allowed: a fence that assigns a value it never reads, which the test can't read either (block-scoped), gets a `console.log` of the field the page talks about (see `learnings.md`); report it as a docs correction. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (report it). -Finally, update the page's row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`: `[x]` when done, `[~]` when there are open questions. +Finally, update the page's row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`: `[x]` when done, `[~]` only when some snippets couldn't run (a blocker); API behavior issues and open docs bugs are recorded in their lists and don't make a page `[~]`. ## Final report (your last message) @@ -117,5 +121,5 @@ Finally, update the page's row in `/Users/zitaszupera/Stream/stream-node/__tests 2. **Docs snippet corrections**: for each one, a before/after diff, the reason, and the evidence. These need human review. 3. **Skipped / untestable snippets**: for each one, the snippet and why (including 🔑 push snippets skipped without credentials, and whether you ran them with credentials). 4. **Client-side code in Node.js snippets**: each fence or part of a fence that needs a connected user, with the error or the reason. -5. **Possible SDK issues**: type or runtime problems that look like node SDK bugs rather than docs bugs (not fixed). +5. **API spec / node SDK issues** and **API behavior issues**: two separate lists (see above), each item with its evidence (not fixed). 6. **Proposed learnings** for `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`: a numbered list of short, reusable lessons that would have saved you time. Skip page-specific trivia. The user will approve or reject each one. diff --git a/__tests__/docs-snippets/docs-snippets-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md index 38c75fb..afaaf8f 100644 --- a/__tests__/docs-snippets/docs-snippets-todo.md +++ b/__tests__/docs-snippets/docs-snippets-todo.md @@ -13,7 +13,9 @@ Pages of the chat Node sidebar (`getstream.io/content/docs/_sidebars/[chat][node - 🔑 **Push credentials**: needs `DOCS_PUSH_FIREBASE_CREDENTIALS`; the snippets run in `describe.skipIf(!hasFirebaseCredentials)`. - **Excluded**: not validated, leave as is. -Status: `[ ]` todo, `[x]` done, `[~]` done with open questions. +Status: `[ ]` todo, `[x]` done, `[~]` done but some snippets couldn't run (a blocker, e.g. ⛔ or 🔑). API behavior issues and open docs bugs are not blockers: they are tracked in their lists below and don't make a page `[~]`. + +Issues are kept in three lists below: [API behavior issues](#api-behavior-issues) (the API itself may be faulty), [API spec / node SDK issues](#api-spec--node-sdk-issues) (the OpenAPI spec or the generated node SDK is wrong) and [Open docs bugs](#open-docs-bugs) (the docs are wrong and not fixed yet). App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `firebase:firebase` (not a docs-test one, leave it), permissions v2, multi-tenancy off, `disable_auth_checks` off, auto-translation off, campaigns on (segments, user groups and polls endpoints answer), no webhook / SQS / SNS / custom action handler URLs set (`webhook_url`, `custom_action_handler_url`, `sqs_url`, `sns_topic_arn` empty), one entry in `event_hooks`, no retention policies. Channel types: `livestream`, `messaging`, `team`, `agent`, `commerce`, `gaming`, plus `streamnodetest*` types left by the integration tests. Delivery receipts: `delivery_events: true` is accepted on create/update channel type without support enablement (2026-10-12). Location sharing: enabled (2026-10-12); channels still need `shared_locations` (`config_overrides` or channel type), otherwise `sendMessage` with `shared_location` fails with code 17 "location sharing is not enabled for this channel". Translation: `translateMessage` works with auto-translation off; `updateApp({ auto_translation_enabled: true })` is accepted (restored after, 2026-10-12). Restricted visibility messages: enabled (`restricted_visibility` on `sendMessage` works, 2026-10-12). User response time: `updateApp({ user_response_time_enabled: true })` is accepted and `avg_response_time` appears on the replying user within seconds (restored after, 2026-10-12). Dynamic partitioning: `partition_size` / `partition_ttl` accepted on create/update channel type without support enablement (2026-10-12). Message history (audit logs): enabled; `queryMessageHistory` is rate limited to 6 requests per minute (2026-10-12). Not probed yet: feature availability per page (each agent probes what its page needs and records it here). @@ -63,7 +65,7 @@ App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `fir | [x] | `chat/_default/04-messages/06-search.md` | 2 | 0 | yes | | | [x] | `chat/_default/04-messages/07-silent_messages.md` | 2 | 0 | yes | | | [x] | `chat/_default/04-messages/08-unread_reminders.md` | 1 | 0 | yes | | -| [~] | `chat/_default/04-messages/09-message_reminders.md` | 7 | 0 | yes | | +| [x] | `chat/_default/04-messages/09-message_reminders.md` | 7 | 0 | yes | | | [x] | `chat/_default/04-messages/10-message_receipts.md` | 4 | 0 | yes | | | [x] | `chat/_default/04-messages/11-ai-message-streaming.md` | 2 | 0 | yes | | @@ -83,7 +85,7 @@ App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `fir | [x] | `chat/_default/05-features/10-advanced/14-user_average_response_time.md` | 1 | 0 | yes | | | [x] | `chat/_default/05-features/10-advanced/15-pending_messages.md` | 6 | 0 | yes | | | [x] | `chat/_default/05-features/10-advanced/16-dynamic_partitioning.md` | 4 | 0 | yes | | -| [~] | `chat/_default/05-features/10-advanced/17-audit_logs.md` | 5 | 0 | yes | | +| [x] | `chat/_default/05-features/10-advanced/17-audit_logs.md` | 5 | 0 | yes | | | [x] | `chat/_default/05-features/10-advanced/18-data_retention_policy.md` | 4 | 0 | yes | | ## chat/\_default/06-app_and_channel_settings @@ -93,7 +95,7 @@ App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `fir | [x] | `chat/_default/06-app_and_channel_settings/01-channel_types.md` | 9 | 0 | yes | | | [x] | `chat/_default/06-app_and_channel_settings/02-app_settings.md` | 3 | 0 | yes | | | [x] | `chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md` | 11 | 0 | yes | | -| [~] | `chat/_default/06-app_and_channel_settings/06-channel-level_settings.md` | 3 | 0 | yes | | +| [x] | `chat/_default/06-app_and_channel_settings/06-channel-level_settings.md` | 3 | 0 | yes | | ## chat/\_default/07-push @@ -106,7 +108,7 @@ App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `fir | Status | Page | Node.js | Unlabelled | Test | Blockers | | ------ | ---------------------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | | [x] | `chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md` | 3 | 0 | yes | | -| [~] | `chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md` | 8 | 0 | yes | | +| [x] | `chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md` | 8 | 0 | yes | | ## chat/\_default/10-migrating @@ -160,29 +162,41 @@ None. - **04-messages/09-message_reminders**: Reminders need `user_message_reminders` on the channel (`config_overrides`) or channel type; the "Enabling Reminders" snippet runs on a `uniqueId` type (COPY-mapped to `messaging`). `remind_at` must be at least one minute in the future (create and update, code 4), so the "overdue" filter test uses a reminder created at the start of `beforeAll` with `remind_at` ~62s ahead and waits for it to pass. A user can have only one reminder per message. - **05-features/06-campaign_api**: Campaigns and segments use `uniqueId` ids and names; the docs segment filters ("created after 2020", `country: "USA"`) are COPY-mapped to the test start time and a `uniqueId` country so only the file's users are targeted. A campaign can be started only once (code 4 "cannot be started" after it ran), and one started "now" reads `scheduled` for a moment before `in_progress` / `completed`; `scheduled_for` must be in the future. A running campaign can't be deleted ("is in progress") and a completed one can't be stopped: cleanup retries stop + delete. Campaign channels get template ids (`{{receiver.id}}-{{sender.id}}`, keep user ids short for the 64-char limit), so cleanup finds them by member. `createSegment` rejects `filter: {}` (code 4 "filter must contain at least 1 item"). `team` in `channel_template` works with multi-tenancy off. - **08-webhooks/01-webhooks_overview/03-custom_commands_webhook**: Commands are app-wide: the docs' `"ticket"` is COPY-mapped to a `uniqueId` name (hyphens are accepted). A command in use by a channel type can't be deleted (code 4 "command in use by channel type"), so cleanup first sets the type's `commands` to `[]`, then deletes the command. The intro fence (sending `/ticket ...`) runs last on a `uniqueId` type with the command (COPY-mapped to `"messaging"`), after the handler URL is set to the example.com placeholder: Stream calls it, it fails, and `sendMessage` rejects with code 45, which the test records through a spy and asserts. -- **All pages that create/update channel types**: Wait 30s (`waitForChannelTypePropagation()`) after `createChannelType` / `updateChannelType` before using the type. +- **All pages that create/update channel types**: Call `waitForChannelTypePropagation()` after `createChannelType` / `updateChannelType` before using the type (it waits ≥10s for a new type, 10s for an update of a docs-test type, 30s for an update of a built-in type). Read a type back with a retry: reads can return old values for over a minute (see API behavior issues). - **05-features/07-polls_api**: Polls work on the built-in `messaging` type with no config change. All polls are created as one `uniqueId` user (john) and removed with `deletePollsCreatedBy` before the user. Votes need the poll sent in a message first (`sendMessage` with `poll_id`). The "Querying polls" filter (`is_closed: true`) matches every closed poll of the app, so the test asserts only on the order of its own two closed polls. - **05-features/10-advanced/17-audit_logs**: Message history is enabled on the test app. `queryMessageHistory` allows 6 requests per minute (code 9, 429): the test spies on it to retry on 429 so back-to-back runs pass. - **05-features/10-advanced/15-pending_messages**: Pending messages are enabled on the test app (2026-10-12). The `mark_messages_pending` snippet runs on a `uniqueId` type (COPY-mapped to `messaging`). Server-side, `pending_messages` is only returned for a user: `queryChannels` needs `user_id`, `channel.get` needs `state: true` and `user_id`. The pending message hook snippet adds a hook with an `https://example.com` URL to `event_hooks`; the test restores the original `event_hooks` array. -## Open docs bugs +## API behavior issues -Docs bugs found but not fixed (open questions, or fixes that need a decision). Remove an entry once it is fixed. +The API itself may be faulty, or behaves differently from what the docs say. Not blockers: the tests assert the observed behavior and the pages count as done unless something else is open. Remove an entry once someone has decided whether the API or the docs change (and fix the docs or the test accordingly). -- **04-messages/09-message_reminders** (open question, API behavior): with the default sort (`remind_at`), `queryReminders` pagination skips reminders without a due date: 2 timed + 2 "saved for later" reminders, `limit: 1`, gives page 1 and page 2 (timed) and then no `next`. With `sort: [{ field: "created_at", direction: 1 }]` all 4 pages come back. The "Pagination" test uses timed reminders only. Check whether this is an API bug or should be documented. -- **05-features/10-advanced/17-audit_logs** (open question, `JSON` response fences, not Node.js): each history entry holds the text before an update, and its `message_updated_by_id` is the user who wrote that text, not the user of the update that replaced it (probed: v1 alice, v2 alice, v3 admin, v4 alice gives entries v1/alice, v2/alice, v3/admin). So after the page's example both entries have `message_updated_by_id: "alice"`, while the docs' JSON shows `admin` for the "+31 6 12345678" entry; the prose ("the user who made the change") reads the same way as the JSON. Both JSON fences are also invalid JSON (`"message_updated_at" "..."` lacks a colon, the second fence lacks a comma after `"is_deleted": false`). The test asserts the observed behavior. Decide whether to fix the JSON/prose or whether the API should record the editor. -- **06-app_and_channel_settings/06-channel-level_settings** (open question, API behavior): The blocklist override is asserted only by reading `channel.config` back. Open question: on this app (`moderation_enabled: true`) a message with the blocklist's word is not blocked, neither server-side nor sent as the user with a user token, and `config_overrides.blocklist` accepts a nonexistent name without error (2026-10-12). Probably blocklists are enforced through moderation configs here, not channel config. `set: { config_overrides: {...} }` replaces all overrides (the later replies fence drops the blocklist override). With `replies: false`, a reply fails with code 19 (400). -- **08-webhooks/01-webhooks_overview/03-custom_commands_webhook** (open question, prose, not Node.js code): "Edit a Command" says fields not sent to the update API stay unchanged, but `updateCommand({ name, description })` resets `args` and `set` to `""` (probed 2026-10-12; the SDK leaves undefined fields out of the body, so it is the API). "Performance considerations" says a 4xx/5xx or slow endpoint lets the message through as usual, but a custom command whose handler returns an error rejects `sendMessage` with code 45 "custom command endpoint returned an error" (and with no handler URL set, code 44 "missing custom command endpoint url"). The paragraph looks copied from the Before Message Send page. Decide whether to fix the prose or the API. +- **04-messages/09-message_reminders**: with the default sort (`remind_at`), `queryReminders` pagination skips reminders without a due date: 2 timed + 2 "saved for later" reminders, `limit: 1`, give page 1 and page 2 (timed) and then no `next`. With `sort: [{ field: "created_at", direction: 1 }]` all 4 come back. The "Pagination" test uses timed reminders only. API bug, or a limitation to document? +- **05-features/08-location_sharing**: server-side `getMessage` (with or without `user_id`), `queryChannels` without `user_id` and `getOrCreate({ state: true })` leave out `shared_location`, while `sendMessage`, `queryChannels` with `user_id` and `getUserLiveLocations` return it (probed 2026-10-13; the SDK doesn't drop the field). The docs don't rely on it; the test reads locations from the channel state as a member and from `getUserLiveLocations`. +- **05-features/10-advanced/17-audit_logs**: each history entry holds the text before an update, and its `message_updated_by_id` is the user who wrote that text, not the user of the update that replaced it (probed: v1 alice, v2 alice, v3 admin, v4 alice give entries v1/alice, v2/alice, v3/admin). The docs (JSON response and prose, "the user who made the change") say it's the editor. Should the API record the editor, or the docs change? (The invalid JSON is listed under Open docs bugs.) +- **06-app_and_channel_settings/02-app_settings**: with `disable_auth_checks: false`, a dev token is rejected with 401, but the message differs per API node: "development tokens are not allowed for this application" or "Token signature is invalid", the latter for over a minute on some nodes after re-enabling (2026-10-13). The test accepts both. +- **06-app_and_channel_settings/06-channel-level_settings**: the channel-level blocklist override is set and reads back (`config.blocklist`, `blocklist_behavior: "block"`), but a message with a blocklisted word is not blocked, neither sent server-side nor as the user (60s of retries), and a nonexistent blocklist name is accepted without error (2026-10-12). Possibly because the app has `moderation_enabled: true` and blocklists are enforced through moderation configs; the test only asserts the setting. +- **08-webhooks/01-webhooks_overview/03-custom_commands_webhook**: (1) "Edit a Command" says fields not sent stay unchanged, but `updateCommand({ name, description })` resets `args` and `set` to `""` (the SDK leaves undefined fields out of the body, so it is the API). (2) "Performance considerations" says a failing or slow endpoint lets the message through as usual, but when the custom command handler returns an error `sendMessage` is rejected with code 45 "custom command endpoint returned an error" (code 44 "missing custom command endpoint url" without a handler URL). The paragraph looks copied from the Before Message Send page. Fix the prose or the API? +- **12-best_practices/02-moderation**: `client.moderation.flag` by the same user on the same entity again raises `flags_count`, but the review item's `flags` keep only the first flag, so the later `reason` and `custom` data are silently dropped (probed 2026-10-12). The docs flagged one message several times with different reasons; they now flag separate messages. Intended (one flag per user) or should the later flag update or error? +- **All pages that update channel types** (not page-specific, the tests work around it): after `updateChannelType`, the update is enforced on every API node within ~3s, but `getChannelType` / `listChannelTypes` reads flip between the new and the old value for over a minute (probed 2026-10-13: correct for ~15s, then the old value for ~40s, then mixed past 90s). Tests read types back with a retry or assert the update response. -## Possible SDK issues +## API spec / node SDK issues -Runtime or type problems that look like node SDK bugs rather than docs bugs. Not fixed by the agents (no `src/` changes). Remove an entry once it is fixed. +The OpenAPI spec or the generated node SDK is wrong; the API itself works. Not fixed by the agents (no `src/` changes, except the local patch noted). Remove an entry once it is fixed. -- **04-messages/08-unread_reminders**: `CreateChannelTypeRequest` (generated, `src/gen/models`) has no `reminders` field, while `UpdateChannelTypeRequest` has it, so a channel type can't be created with reminders on in one call from typed code. Check whether the OpenAPI spec (`CreateChannelType`) lacks it or the API accepts it. +- **06-app_and_channel_settings/03-chat_permission_policies** (node SDK generator): `UpdateChannelTypeRequest.grants` and `UpdateAppRequest.grants` are typed `Record` without `| null`, while the spec marks them `nullable: true` and the docs reset a scope with `grants: null` (TS2322). The API accepts `null` and resets the grants to the defaults. The generator drops `nullable` everywhere (`src/gen/models/index.ts` has no `| null` at all, e.g. `pin_expires?: Date`): a generator change in `../chat` (`tools/openapi`). **Patched locally** in `src/gen/models/index.ts` (`grants?: Record> | null` on both requests) so the docs code typechecks; a regeneration reverts it until the generator emits `| null`. +- **05-features/06-campaign_api** (API spec): `updateCampaign` and `stopCampaign` are typed as returning `CampaignResponse`, but the API returns `{ campaign: CampaignResponse, ... }` (like `StartCampaignResponse`), so `response.campaign` is a TS2339 error and `response.name` etc. are undefined at runtime. The response schemas of `PUT /campaigns/{id}` and `POST /campaigns/{id}/stop`. +- **04-messages/08-unread_reminders** (API spec, to check): `CreateChannelTypeRequest` has no `reminders` field, while `UpdateChannelTypeRequest` has it, so a channel type can't be created with reminders on in one call from typed code. Check whether the spec (`CreateChannelType`) lacks it or the API doesn't accept it. +- **05-features/10-advanced/12-drafts** (API spec): `getDraft` / `deleteDraft` / `queryDrafts` type `user_id` as optional, but a server-side client must pass it (code 4 "either user or user_id must be provided when using server side auth"). +- **12-best_practices/02-moderation** (API spec): `getApp().app.enforce_unique_usernames` is typed `string`, while `UpdateAppRequest.enforce_unique_usernames` is `'no' | 'app' | 'team'`, so restoring a saved value needs a cast. + +## Open docs bugs -- **05-features/06-campaign_api**: `updateCampaign` and `stopCampaign` are typed as returning `CampaignResponse`, but the API returns `{ campaign: CampaignResponse, ... }` (like `StartCampaignResponse`), so `response.campaign` is a TS2339 error and `response.name` etc. are undefined at runtime. Likely the OpenAPI spec's response schema for `PUT /campaigns/{id}` and `POST /campaigns/{id}/stop`. +The docs are wrong and the fix wasn't made (out of scope, e.g. prose or non-Node.js fences, or it waits for a decision above). Not blockers. Remove an entry once it is fixed. -- **06-app_and_channel_settings/03-chat_permission_policies**: `UpdateChannelTypeRequest.grants` (and `UpdateAppRequest.grants`) are typed `Record` without `| null`, while the OpenAPI spec marks them `nullable: true` and the docs reset a scope with `grants: null` (TS2322). The API accepts `null` and resets the grants to the defaults. The generator drops `nullable` everywhere: `src/gen/models/index.ts` has no `| null` at all (e.g. `pin_expires?: Date`), so this is a generator change in `../chat` (`tools/openapi`), not a spec fix. **Patched locally** in `src/gen/models/index.ts` (`grants?: Record> | null` on both requests) so the docs code typechecks; a regeneration reverts it until the generator emits `| null`. +- **05-features/10-advanced/17-audit_logs** (`JSON` response fences, not Node.js): both fences are invalid JSON (`"message_updated_at" "..."` lacks a colon; the second lacks a comma after `"is_deleted": false`). The attribution in them depends on the API behavior issue above. +- **05-features/10-advanced/12-drafts** (prose): a stray `"` at the end of "Draft creation is a client-side operation; there is no server-side alternative." +- **08-webhooks/01-webhooks_overview/03-custom_commands_webhook** (prose): "Edit a Command" and "Performance considerations" depend on the API behavior issue above. ## Notes diff --git a/__tests__/docs-snippets/learnings.md b/__tests__/docs-snippets/learnings.md index 83485b8..3a8e8f4 100644 --- a/__tests__/docs-snippets/learnings.md +++ b/__tests__/docs-snippets/learnings.md @@ -53,7 +53,7 @@ Entries are added only after the user approves them (agents propose them in thei - A fence that builds a request object in a separate `const` and passes it later fails TS2322 when a field is a string-literal union (`callback.mode: "CALLBACK_MODE_REST"` widens to `string`). The plain-JS fix is to move the object inline into the call. - `updateChannelType` returns `commands` as names (`string[]`), while `getChannelType` / `listChannelTypes` return `Command` objects: compare by `.name`. `commands: ["all"]` expands to every built-in command (giphy, ban, unban, mute, unmute). - A `vi.spyOn(client, 'updateApp').mockImplementation(...)` must take `request?: UpdateAppRequest` (optional), or it fails TS2345. -- A correct fence that the SDK types reject can't be parked with `it.skip`: skipped bodies are still typechecked, so `yarn types:docs` fails for everyone. Stop and report it as a Possible SDK issue. The generator drops `nullable` everywhere (no `| null` in `src/gen/models`); `grants` on `UpdateChannelTypeRequest` / `UpdateAppRequest` is patched locally to allow `null`. +- A correct fence that the SDK types reject can't be parked with `it.skip`: skipped bodies are still typechecked, so `yarn types:docs` fails for everyone. Stop and report it under API spec / node SDK issues. The generator drops `nullable` everywhere (no `| null` in `src/gen/models`); `grants` on `UpdateChannelTypeRequest` / `UpdateAppRequest` is patched locally to allow `null`. - `verifyAndParseWebhook` fences: build `req = { rawBody, headers: { 'x-signature': sig } }` before the region, where `sig` is the HMAC-SHA256 hex of the uncompressed JSON keyed with `STREAM_SECRET` and `rawBody` is the JSON or `zlib.gzipSync(json)` (the signature always covers the decompressed bytes). - Optional response fields (`GetTaskResponse.result`, `MessageResponse.i18n`) aren't narrowed by a status check: a fence reading `response.result.url` fails TS18048, and the docs fix is `?.`. - `getApp().app.enforce_unique_usernames` is typed `string` but `updateApp` expects `'no' | 'app' | 'team'`: cast the saved value when restoring it. `"team"` is accepted with multi-tenancy off. From 313a1c65438abc0ba7499f263ad8c2aeb3d9f1f2 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 13 Oct 2026 12:49:29 -0500 Subject: [PATCH 5/9] chore: revert change in generator; typetest fails now --- __tests__/docs-snippets/docs-snippets-todo.md | 4 ++-- __tests__/docs-snippets/learnings.md | 2 +- src/gen/models/index.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/__tests__/docs-snippets/docs-snippets-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md index afaaf8f..bfa76c8 100644 --- a/__tests__/docs-snippets/docs-snippets-todo.md +++ b/__tests__/docs-snippets/docs-snippets-todo.md @@ -182,9 +182,9 @@ The API itself may be faulty, or behaves differently from what the docs say. Not ## API spec / node SDK issues -The OpenAPI spec or the generated node SDK is wrong; the API itself works. Not fixed by the agents (no `src/` changes, except the local patch noted). Remove an entry once it is fixed. +The OpenAPI spec or the generated node SDK is wrong; the API itself works. Not fixed by the agents (no `src/` changes). Remove an entry once it is fixed. -- **06-app_and_channel_settings/03-chat_permission_policies** (node SDK generator): `UpdateChannelTypeRequest.grants` and `UpdateAppRequest.grants` are typed `Record` without `| null`, while the spec marks them `nullable: true` and the docs reset a scope with `grants: null` (TS2322). The API accepts `null` and resets the grants to the defaults. The generator drops `nullable` everywhere (`src/gen/models/index.ts` has no `| null` at all, e.g. `pin_expires?: Date`): a generator change in `../chat` (`tools/openapi`). **Patched locally** in `src/gen/models/index.ts` (`grants?: Record> | null` on both requests) so the docs code typechecks; a regeneration reverts it until the generator emits `| null`. +- **06-app_and_channel_settings/03-chat_permission_policies** (node SDK generator): `UpdateChannelTypeRequest.grants` and `UpdateAppRequest.grants` are typed `Record` without `| null`, while the spec marks them `nullable: true` and the docs reset a scope with `grants: null` (TS2322). The API accepts `null` and resets the grants to the defaults. The generator drops `nullable` everywhere (`src/gen/models/index.ts` has no `| null` at all, e.g. `pin_expires?: Date`): a generator change in `../chat` (`tools/openapi`). Not patched (the local `src/gen` patch was reverted on 2026-10-13), so `yarn types:docs` reports TS2322 on the "Granting permissions" Node.js #4 region until the generator emits `| null`. - **05-features/06-campaign_api** (API spec): `updateCampaign` and `stopCampaign` are typed as returning `CampaignResponse`, but the API returns `{ campaign: CampaignResponse, ... }` (like `StartCampaignResponse`), so `response.campaign` is a TS2339 error and `response.name` etc. are undefined at runtime. The response schemas of `PUT /campaigns/{id}` and `POST /campaigns/{id}/stop`. - **04-messages/08-unread_reminders** (API spec, to check): `CreateChannelTypeRequest` has no `reminders` field, while `UpdateChannelTypeRequest` has it, so a channel type can't be created with reminders on in one call from typed code. Check whether the spec (`CreateChannelType`) lacks it or the API doesn't accept it. - **05-features/10-advanced/12-drafts** (API spec): `getDraft` / `deleteDraft` / `queryDrafts` type `user_id` as optional, but a server-side client must pass it (code 4 "either user or user_id must be provided when using server side auth"). diff --git a/__tests__/docs-snippets/learnings.md b/__tests__/docs-snippets/learnings.md index 3a8e8f4..6c2a0f5 100644 --- a/__tests__/docs-snippets/learnings.md +++ b/__tests__/docs-snippets/learnings.md @@ -53,7 +53,7 @@ Entries are added only after the user approves them (agents propose them in thei - A fence that builds a request object in a separate `const` and passes it later fails TS2322 when a field is a string-literal union (`callback.mode: "CALLBACK_MODE_REST"` widens to `string`). The plain-JS fix is to move the object inline into the call. - `updateChannelType` returns `commands` as names (`string[]`), while `getChannelType` / `listChannelTypes` return `Command` objects: compare by `.name`. `commands: ["all"]` expands to every built-in command (giphy, ban, unban, mute, unmute). - A `vi.spyOn(client, 'updateApp').mockImplementation(...)` must take `request?: UpdateAppRequest` (optional), or it fails TS2345. -- A correct fence that the SDK types reject can't be parked with `it.skip`: skipped bodies are still typechecked, so `yarn types:docs` fails for everyone. Stop and report it under API spec / node SDK issues. The generator drops `nullable` everywhere (no `| null` in `src/gen/models`); `grants` on `UpdateChannelTypeRequest` / `UpdateAppRequest` is patched locally to allow `null`. +- A correct fence that the SDK types reject can't be parked with `it.skip`: skipped bodies are still typechecked, so `yarn types:docs` fails for everyone. Stop and report it under API spec / node SDK issues. The generator drops `nullable` everywhere (no `| null` in `src/gen/models`), e.g. `grants: null` on `updateChannelType` fails TS2322. - `verifyAndParseWebhook` fences: build `req = { rawBody, headers: { 'x-signature': sig } }` before the region, where `sig` is the HMAC-SHA256 hex of the uncompressed JSON keyed with `STREAM_SECRET` and `rawBody` is the JSON or `zlib.gzipSync(json)` (the signature always covers the decompressed bytes). - Optional response fields (`GetTaskResponse.result`, `MessageResponse.i18n`) aren't narrowed by a status check: a fence reading `response.result.url` fails TS18048, and the docs fix is `?.`. - `getApp().app.enforce_unique_usernames` is typed `string` but `updateApp` expects `'no' | 'app' | 'team'`: cast the saved value when restoring it. `"team"` is accepted with multi-tenancy off. diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 0a0a8d7..1beb5f3 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -21341,7 +21341,7 @@ export interface UpdateAppRequest { datadog_info?: DataDogInfo; file_upload_config?: FileUploadConfig; firebase_config?: FirebaseConfig; - grants?: Record> | null; + grants?: Record>; huawei_config?: HuaweiConfig; image_upload_config?: FileUploadConfig; moderation_dashboard_preferences?: ModerationDashboardPreferences; @@ -21673,7 +21673,7 @@ export interface UpdateChannelTypeRequest { */ automod_thresholds?: Thresholds; chat_preferences?: ChatPreferences; - grants?: Record> | null; + grants?: Record>; } export interface UpdateChannelTypeResponse { From 68a1cd69ab54582aee199043fe5c81933201eb6f Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 14 Oct 2026 14:30:06 -0500 Subject: [PATCH 6/9] chore: Add platform docs tests --- __tests__/docs-snippets/README.md | 35 +- .../docs-snippets-agent-prompt.md | 22 +- __tests__/docs-snippets/docs-snippets-todo.md | 91 ++- __tests__/docs-snippets/helpers/push.ts | 18 + __tests__/docs-snippets/learnings.md | 34 +- .../02-init_and_users/04-user_groups.test.ts | 29 +- .../03-channels/01-creating_channels.test.ts | 14 +- .../03-channels/02-query_channels.test.ts | 37 +- .../03-channels/03-channel_update.test.ts | 12 +- .../03-channels/04-query_members.test.ts | 14 +- .../03-channels/05-channel_pagination.test.ts | 14 +- .../03-channels/06-channel_members.test.ts | 30 +- .../08-archiving.test.ts | 14 +- .../07-channel_management/09-pinning.test.ts | 14 +- .../07-channel_management/10-muting.test.ts | 16 +- .../07-channel_management/11-hiding.test.ts | 15 +- .../12-disabling.test.ts | 14 +- .../07-channel_management/13-deleting.test.ts | 20 +- .../07-channel_management/14-freezing.test.ts | 22 +- .../15-truncating.test.ts | 19 +- .../16-channel_invites.test.ts | 23 +- .../17-batch-updates.test.ts | 21 +- .../03-channels/08-get_channel.test.ts | 14 +- .../04-messages/01-send_message.test.ts | 30 +- .../02-file_uploads.standalone.test.ts | 10 +- .../04-messages/02-file_uploads.test.ts | 16 +- .../{ => chat}/04-messages/03-threads.test.ts | 30 +- .../04-messages/04-send_reaction.test.ts | 22 +- .../04-messages/05-pinned_messages.test.ts | 18 +- .../{ => chat}/04-messages/06-search.test.ts | 18 +- .../04-messages/07-silent_messages.test.ts | 16 +- .../04-messages/08-unread_reminders.test.ts | 14 +- .../04-messages/09-message_reminders.test.ts | 26 +- .../04-messages/10-message_receipts.test.ts | 20 +- .../11-ai-message-streaming.test.ts | 14 +- .../{ => chat}/05-features/02-events.test.ts | 14 +- .../{ => chat}/05-features/03-unread.test.ts | 26 +- .../05-features/06-campaign_api.test.ts | 48 +- .../05-features/07-polls_api.test.ts | 44 +- .../05-features/08-location_sharing.test.ts | 24 +- .../05-features/09-translation.test.ts | 16 +- .../11-slow_mode_and_throttling.test.ts | 15 +- .../05-features/10-advanced/12-drafts.test.ts | 16 +- .../10-advanced/13-private_messaging.test.ts | 12 +- .../14-user_average_response_time.test.ts | 14 +- .../10-advanced/15-pending_messages.test.ts | 26 +- .../16-dynamic_partitioning.test.ts | 22 +- .../10-advanced/17-audit_logs.test.ts | 20 +- .../18-data_retention_policy.test.ts | 20 +- .../01-channel_types.test.ts | 32 +- .../02-app_settings.test.ts | 22 +- .../03-chat_permission_policies.test.ts | 38 +- .../06-channel-level_settings.test.ts | 14 +- .../07-push/07-legacy_push_system.test.ts | 16 +- .../02-before_message_send_webhook.test.ts | 14 +- .../03-custom_commands_webhook.test.ts | 32 +- .../03-exporting_channels.test.ts | 22 +- .../12-best_practices/02-moderation.test.ts | 40 +- .../05-marketplace_best_practices.test.ts | 22 +- .../01-backend_quickstart.test.ts | 20 +- .../01-start-here/01-backend-sdks.test.ts | 30 + .../01-authentication.test.ts | 284 +++++++++ .../02-identity-and-access/02-users.test.ts | 572 ++++++++++++++++++ .../03-permissions.test.ts | 316 ++++++++++ .../04-multi-tenancy.test.ts | 382 ++++++++++++ .../02-identity-and-access/05-gdpr.test.ts | 177 ++++++ .../01-webhooks.stateless.test.ts | 38 ++ .../01-webhooks.test.ts | 506 ++++++++++++++++ .../02-push-notifications.test.ts | 246 ++++++++ .../03-push-providers.test.ts | 165 +++++ .../04-push-devices.test.ts | 251 ++++++++ .../05-push-preferences.test.ts | 276 +++++++++ .../06-push-troubleshooting.test.ts | 142 +++++ .../01-rate-limits.429.test.ts | 72 +++ .../04-using-the-api/01-rate-limits.test.ts | 134 ++++ .../03-async-operations.test.ts | 61 ++ .../06-connection-pooling.test.ts | 45 ++ .../07-api-error-codes.test.ts | 78 +++ .../docs-snippets/sync/docs-sync.test.ts | 19 +- __tests__/docs-snippets/sync/parse.ts | 64 +- index.ts | 1 + package.json | 1 + yarn.lock | 8 + 83 files changed, 4598 insertions(+), 635 deletions(-) rename __tests__/docs-snippets/pages/{ => chat}/02-init_and_users/04-user_groups.test.ts (85%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/01-creating_channels.test.ts (80%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/02-query_channels.test.ts (81%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/03-channel_update.test.ts (87%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/04-query_members.test.ts (89%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/05-channel_pagination.test.ts (85%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/06-channel_members.test.ts (87%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/08-archiving.test.ts (82%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/09-pinning.test.ts (86%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/10-muting.test.ts (86%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/11-hiding.test.ts (86%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/12-disabling.test.ts (82%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/13-deleting.test.ts (87%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/14-freezing.test.ts (83%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/15-truncating.test.ts (89%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/16-channel_invites.test.ts (79%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/17-batch-updates.test.ts (88%) rename __tests__/docs-snippets/pages/{ => chat}/03-channels/08-get_channel.test.ts (85%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/01-send_message.test.ts (87%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/02-file_uploads.standalone.test.ts (89%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/02-file_uploads.test.ts (92%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/03-threads.test.ts (87%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/04-send_reaction.test.ts (86%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/05-pinned_messages.test.ts (87%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/06-search.test.ts (90%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/07-silent_messages.test.ts (82%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/08-unread_reminders.test.ts (82%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/09-message_reminders.test.ts (90%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/10-message_receipts.test.ts (86%) rename __tests__/docs-snippets/pages/{ => chat}/04-messages/11-ai-message-streaming.test.ts (81%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/02-events.test.ts (81%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/03-unread.test.ts (87%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/06-campaign_api.test.ts (92%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/07-polls_api.test.ts (84%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/08-location_sharing.test.ts (90%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/09-translation.test.ts (83%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/10-advanced/11-slow_mode_and_throttling.test.ts (73%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/10-advanced/12-drafts.test.ts (88%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/10-advanced/13-private_messaging.test.ts (80%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/10-advanced/14-user_average_response_time.test.ts (81%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/10-advanced/15-pending_messages.test.ts (88%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/10-advanced/16-dynamic_partitioning.test.ts (86%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/10-advanced/17-audit_logs.test.ts (81%) rename __tests__/docs-snippets/pages/{ => chat}/05-features/10-advanced/18-data_retention_policy.test.ts (73%) rename __tests__/docs-snippets/pages/{ => chat}/06-app_and_channel_settings/01-channel_types.test.ts (85%) rename __tests__/docs-snippets/pages/{ => chat}/06-app_and_channel_settings/02-app_settings.test.ts (91%) rename __tests__/docs-snippets/pages/{ => chat}/06-app_and_channel_settings/03-chat_permission_policies.test.ts (86%) rename __tests__/docs-snippets/pages/{ => chat}/06-app_and_channel_settings/06-channel-level_settings.test.ts (81%) rename __tests__/docs-snippets/pages/{ => chat}/07-push/07-legacy_push_system.test.ts (85%) rename __tests__/docs-snippets/pages/{ => chat}/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts (81%) rename __tests__/docs-snippets/pages/{ => chat}/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts (82%) rename __tests__/docs-snippets/pages/{ => chat}/10-migrating/03-exporting_channels.test.ts (83%) rename __tests__/docs-snippets/pages/{ => chat}/12-best_practices/02-moderation.test.ts (88%) rename __tests__/docs-snippets/pages/{ => chat}/12-best_practices/05-marketplace_best_practices.test.ts (88%) rename __tests__/docs-snippets/pages/{ => chat}/node/01-quick_start/01-backend_quickstart.test.ts (85%) create mode 100644 __tests__/docs-snippets/pages/platform/01-start-here/01-backend-sdks.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/02-identity-and-access/01-authentication.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/02-identity-and-access/02-users.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/02-identity-and-access/03-permissions.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/02-identity-and-access/05-gdpr.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.stateless.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/03-events-and-notifications/02-push-notifications.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/03-events-and-notifications/03-push-providers.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/03-events-and-notifications/06-push-troubleshooting.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/04-using-the-api/01-rate-limits.429.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/04-using-the-api/01-rate-limits.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/04-using-the-api/03-async-operations.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/04-using-the-api/06-connection-pooling.test.ts create mode 100644 __tests__/docs-snippets/pages/platform/04-using-the-api/07-api-error-codes.test.ts diff --git a/__tests__/docs-snippets/README.md b/__tests__/docs-snippets/README.md index c91c90a..ab63d64 100644 --- a/__tests__/docs-snippets/README.md +++ b/__tests__/docs-snippets/README.md @@ -1,14 +1,16 @@ # Docs snippet tests Typed tests that run the server-side Node.js snippets of the chat docs (the pages of the Node sidebar, -`getstream.io/content/docs/_sidebars/[chat][node].json`) against a real Stream app. +`getstream.io/content/docs/_sidebars/[chat][node].json`) and of the platform docs (the pages of +`getstream.io/content/docs/_sidebars/[platform][default].json`) against a real Stream app. Only fences labelled `Node.js` are in scope (docs fences must always have a label): they are fixed when they are -wrong. `JavaScript` fences (client-side `stream-chat` code) are out of scope and are neither tested nor changed. +wrong. `JavaScript`, `React` and `React Native` fences (client-side code) and `Node.js` fences inside a `` block (reference +implementations without the SDK) are out of scope and are neither tested nor changed. ```sh # credentials: STREAM_API_KEY / STREAM_SECRET in the repo root .env (the node SDK test app, see .env-example) yarn test:docs # all docs tests -yarn test:docs pages/node/01-quick_start/01-backend_quickstart.test.ts # one file +yarn test:docs pages/chat/node/01-quick_start/01-backend_quickstart.test.ts # one file yarn types:docs # typecheck the docs tests yarn test:docs:sync # test regions == docs fences (no network) yarn test:docs:sweep # remove docs-test leftovers older than 30 min, incl. channels, and turns off retention policies (DOCS_SWEEP_DRY_RUN=1 to list only, DOCS_SWEEP_MIN_AGE=0 for all) @@ -22,14 +24,17 @@ at the same time as `yarn test`: both use the same app, and the drift check woul `DOCS_PUSH_FIREBASE_CREDENTIALS` in the repo root `.env`: a Firebase service account JSON, on one line. Snippets that need real push credentials run in `describe.skipIf(!hasFirebaseCredentials)` (`helpers/push.ts`), so the -suite passes without it (those tests are reported as skipped). Push and webhook tests only call the API and -check the response, or read the setting back (`getApp`, `listPushProviders`, ...). They don't check that -notifications or webhook events arrive. +suite passes without it (those tests are reported as skipped). `sampleFirebaseCredentials` (`helpers/push.ts`) is +the sample service account JSON from the platform push providers page, for snippets with a credentials +placeholder; when the API rejects it, the snippet needs real credentials and is skipped. Push and webhook tests only call the API and +check the response, or read the setting back (`getApp`, `listPushProviders`, push preferences, ...). They don't +check that notifications or webhook events arrive. ## Layout ``` -pages//.test.ts # Node.js snippets, run with the server client +pages/chat//.test.ts # chat Node.js snippets, run with the server client +pages/platform//.test.ts # platform Node.js snippets helpers/ # server client, unique ids, cleanup registry, server-side helpers, # waits, leak check, app snapshot, push credentials env.ts # .env loading (fails without STREAM_API_KEY / STREAM_SECRET) @@ -42,8 +47,10 @@ docs-snippets-agent-prompt.md # prompt for an agent that covers on learnings.md # lessons from earlier agent runs, read before working on a page ``` -The path mirrors the docs path, e.g. `_default/04-messages/01-send_message.md` maps to -`pages/04-messages/01-send_message.test.ts`. Pages under `chat/node/` go under `pages/node/...`. +The path mirrors the docs path (relative to `content/docs`) without `/_default`, e.g. +`chat/_default/04-messages/01-send_message.md` maps to `pages/chat/04-messages/01-send_message.test.ts`, +`chat/node/01-quick_start/...` to `pages/chat/node/01-quick_start/...` and +`platform/02-identity-and-access/02-users.md` to `pages/platform/02-identity-and-access/02-users.test.ts`. ## Conventions @@ -69,7 +76,7 @@ The path mirrors the docs path, e.g. `_default/04-messages/01-send_message.md` m Each docs fence has exactly one marked region in a test. `yarn test:docs:sync` (no network) compares them, so later edits on either side fail until they agree again: ```ts -// #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Syncing channels" tab="Node.js" index=1 +// #region snippet docs="chat/node/01-quick_start/01-backend_quickstart.md" heading="Syncing channels" tab="Node.js" index=1 // COPY: channelId="kung-fu", creatorId="4645" const channel = client.chat.channel("messaging", channelId); @@ -79,14 +86,14 @@ await channel.getOrCreate({ data: { created_by_id: creatorId } }); ``` - **Fence key**: - - `docs` is the page path relative to `getstream.io/content/docs/chat/`. + - `docs` is the page path relative to `getstream.io/content/docs/` (`chat/...` or `platform/...`). - `heading` is the text of the nearest heading above the fence. - `tab` is the fence `label` (or `unlabelled`). - `index` is 1-based among fences with the same heading text and tab (default 1). - **COPY**: `name="literal"` pairs, applied to the region before comparing (identifier `name` becomes the docs literal). Don't use COPY variables as object keys or shorthand properties. - **Comparison**: both sides are formatted with the same prettier options, so quote style and line wrapping don't matter, but any code or comment change does. -- **Coverage** (checked first): every `Node.js` fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the chat Node sidebar (`content/docs/_sidebars/[chat][node].json`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `JavaScript` fences and comment-only fences are ignored. -- **Labels**: every JS fence on those pages must be labelled `JavaScript` or `Node.js`; unlabelled or otherwise labelled JS fences fail. +- **Coverage** (checked first): every `Node.js` fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the chat Node sidebar and the platform sidebar (`content/docs/_sidebars/[chat][node].json`, `[platform][default].json`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `JavaScript` / `React` / `React Native` fences and comment-only fences are ignored. +- **Labels**: every JS fence on those pages must be labelled `JavaScript`, `React`, `React Native` or `Node.js`; unlabelled or otherwise labelled JS fences fail. - **Excluded pages**: `EXCLUDED_PAGES` in `sync/parse.ts` lists sidebar pages that are deliberately not validated, with a reason. Both checks skip them. - **`DOCS_SYNC_WRITE= yarn test:docs:sync`** writes the regions of that page into the docs fences (docs prettier style). Use it to apply a fix to the docs. `DOCS_SYNC_WRITE=1` writes all pages. -- **`DOCS_CHAT_DIR`** points at another docs checkout (default: `../getstream.io/content/docs/chat` next to this repo). The check is skipped if the directory doesn't exist. +- **`DOCS_DIR`** points at another docs checkout (default: `../getstream.io/content/docs` next to this repo). The check is skipped if the directory doesn't exist. diff --git a/__tests__/docs-snippets/docs-snippets-agent-prompt.md b/__tests__/docs-snippets/docs-snippets-agent-prompt.md index 03c52d1..671fa8c 100644 --- a/__tests__/docs-snippets/docs-snippets-agent-prompt.md +++ b/__tests__/docs-snippets/docs-snippets-agent-prompt.md @@ -1,10 +1,10 @@ # Agent prompt: test the docs snippets of one page -Replace `{{PAGE}}` with a path from `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`, e.g. `chat/_default/04-messages/01-send_message.md`. +Replace `{{PAGE}}` with a path from `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`, e.g. `chat/_default/04-messages/01-send_message.md` or `platform/02-identity-and-access/02-users.md`. --- -You are verifying the server-side Node.js code snippets of one Stream Chat docs page by writing typed tests that run them against a real Stream app with the `@stream-io/node-sdk` server client (API key + secret). Only fences labelled `Node.js` are in scope. Docs fences must always have a label: if you find an unlabelled `js` fence, label it (`Node.js` for `@stream-io/node-sdk` code, `JavaScript` for client code) and report it. `JavaScript` fences are out of scope: don't test, add or change them. +You are verifying the server-side Node.js code snippets of one Stream docs page (chat or platform) by writing typed tests that run them against a real Stream app with the `@stream-io/node-sdk` server client (API key + secret). Only fences labelled `Node.js` are in scope. Docs fences must always have a label: if you find an unlabelled `js` fence, label it (`Node.js` for `@stream-io/node-sdk` code, `JavaScript` for client code) and report it. `JavaScript`, `React` and `React Native` fences (client-side code) and `Node.js` fences inside a `` block (reference implementations that don't use the SDK) are out of scope: don't test, add or change them. **Page:** `/Users/zitaszupera/Stream/getstream.io/content/docs/{{PAGE}}` @@ -24,7 +24,7 @@ You are verifying the server-side Node.js code snippets of one Stream Chat docs ## Read first 1. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`: issues that earlier agents ran into. Follow it. -2. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/node/01-quick_start/01-backend_quickstart.test.ts` as an example. +2. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/chat/node/01-quick_start/01-backend_quickstart.test.ts` as an example. 3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`. Also read the page's entries in the TODO's **Blockers detail** and **Setup notes** sections, and the **App facts** line. 4. The SDK source for every method a snippet calls: `src/StreamClient.ts`, `src/StreamChatClient.ts`, `src/StreamChannel.ts`, `src/StreamModerationClient.ts` and the generated `src/gen/` clients and models (`src/gen/models/index.ts`), for the method name, its request object and its response type. When it's unclear whether a server-side endpoint exists or what it accepts, check the OpenAPI spec: https://github.com/GetStream/protocol/tree/main/openapi/v2 (`chat-serverside-api.json`). @@ -34,7 +34,7 @@ You are verifying the server-side Node.js code snippets of one Stream Chat docs - Snippets act on behalf of users through `user_id` / `created_by_id` / `user`: create those users first (`client.upsertUsers`) with `uniqueId` ids. - If a `Node.js` snippet is client-side code (it needs a connected user or a WebSocket, or calls a method the node SDK doesn't have, e.g. `connectUser`, `channel.watch()`, `client.on(...)`), it doesn't belong in a `Node.js` tab. Don't invent a server equivalent: put it in `it.skip('BLOCKED: client-side code: ...')` if it still typechecks, otherwise make the smallest docs fix that turns it into the server-side call the node SDK has, and report it either way. - If no `Node.js` fence of the page can be tested, don't write a test file; record why in the report and the TODO. -- **Mirror paths.** The test paths mirror the docs path without the `chat/_default/` prefix. Pages under `chat/node/` go under `pages/node/...`. +- **Mirror paths.** The test paths mirror the docs path (relative to `content/docs`) without `/_default`: `chat/_default/04-messages/01-send_message.md` goes to `pages/chat/04-messages/01-send_message.test.ts`, `chat/node/...` to `pages/chat/node/...`, `platform/02-identity-and-access/02-users.md` to `pages/platform/02-identity-and-access/02-users.test.ts`. - **Test structure:** - `describe` per page. - `beforeAll` creates the shared users and channels with `uniqueId(...)` ids and registers them on a `Cleanup`. @@ -43,18 +43,18 @@ You are verifying the server-side Node.js code snippets of one Stream Chat docs - **Snippet markers.** Every `Node.js` fence on the page needs exactly one marker in a test (`JavaScript` fences are ignored). `yarn test:docs:sync` checks the format and the coverage. - Wrap the docs code like this: ```ts - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 // COPY: channelId="general", userId="john" ...docs code... // #endregion snippet ``` - - `docs` is the page path relative to `content/docs/chat/`. + - `docs` is the page path relative to `content/docs/` (`chat/...` or `platform/...`), i.e. `{{PAGE}}`. - `heading` is the exact text of the nearest heading above the fence. - `tab` is the fence label, or `unlabelled`. - `index` (default 1) counts fences with the same heading text and tab on the page. If a key is wrong, the sync check lists the page's fence keys. - Everything between the markers must be the docs code. Setup goes before the region and assertions after it. - **COPY** (optional, first line(s) of the region): `name="docs literal"` pairs. Where the docs use fixed values ("john", "general", "messageID", `"{{ api_key }}"`), use variables in the test, and the sync check replaces each variable with its literal before comparing. Don't use COPY variables as object keys or in shorthand properties (`{ userId }`). - - Comment-only fences are skipped by the sync check and need no marker. Every other `Node.js` fence gets a real region: blocked or untestable snippets go inside `it.skip('BLOCKED: ...')` so they are still typechecked. A fence that isn't code (e.g. a bare data shape) is a docs bug: rewrite it as code that runs (e.g. reading and logging the fields), and report it. + - Comment-only fences are skipped by the sync check and need no marker. Every other `Node.js` fence gets a real region: blocked or untestable snippets go inside `it.skip('BLOCKED: ...')` so they are still typechecked. A fence that isn't code (e.g. a bare data shape) is a docs bug: rewrite it as code (a TypeScript `type` when the sibling tabs show type definitions, otherwise code that reads and logs the fields), and report it. - **Assertions.** After each snippet, add basic `expect`s proving that it did what the docs say. For example, check that the response contains the new field, or re-query and check that the state changed. Don't over-assert on volatile fields. - **Naming**: the id or name of every resource you create comes from `uniqueId(...)` (campaigns: `name`; polls: create them as a `uniqueId` user). The automatic leak check after each file only finds resources named this way. If it reports a `LEAK`, fix your cleanup; don't work around the check. - **App-level side effects** (app settings, built-in channel types, grants, roles, retention policies, multi-tenancy, push config, webhook URLs, ...): pages are run **one at a time**, so changing them is allowed. @@ -62,9 +62,11 @@ You are verifying the server-side Node.js code snippets of one Stream Chat docs - The check after each file fails with `DRIFT` if app settings or pre-existing channel types differ from before the file ran. - Prefer `uniqueId` channel types or `config_overrides` when the snippet allows it. - **Push notifications and webhooks**: don't test that notifications or webhook events arrive. Calling the API and asserting the response (where there is one) is enough; for a setting, read it back (`getApp()`, `listPushProviders()`, `getPushTemplates`, ...). - - Snippets that need real push credentials (creating a push provider, `firebase_config`, ...) go in `describe.skipIf(!hasFirebaseCredentials)(...)` (`helpers/push.ts`), so the suite still passes without `DOCS_PUSH_FIREBASE_CREDENTIALS`. Pass `firebaseCredentials` through a COPY variable that maps to the docs placeholder (e.g. `credentials="your service worker config"`). Mark the page 🔑 in the TODO. + - Snippets that need push provider credentials (creating a push provider, `firebase_config`, ...): pass `sampleFirebaseCredentials` (`helpers/push.ts`, the sample service account JSON from the platform push providers page) through a COPY variable that maps to the docs placeholder (e.g. `credentials="my-service-account-information"`). If the API rejects it (e.g. code 4 "firebase credentials are invalid"), the snippet needs real credentials: put it in `describe.skipIf(!hasFirebaseCredentials)(...)` with `firebaseCredentials` instead, so it runs when `DOCS_PUSH_FIREBASE_CREDENTIALS` is set and is skipped otherwise. A snippet that needs another provider's config (APN, Huawei, Xiaomi) can't run: `it.skip('BLOCKED: needs credentials')`. Record the probe result in the TODO and mark the page 🔑. + - Push preferences: assert the response, or read the preference back (the user's / channel member's preferences, the channel config or channel type). Don't check which pushes are sent. + - The test app has its own push provider `firebase:firebase`: never touch or use it (don't update or delete it, register devices on it or run `checkPush` against it). Only use providers your test created with `uniqueId` names; if a snippet can't run without an existing provider, skip it. - Snippets that work without credentials (listing providers, push templates, `checkPush` with a payload, ...) run unconditionally. - - Webhook URL settings: set them to an obvious placeholder (e.g. `https://example.com/...`), assert them with `getApp()`, and restore the original value. + - Webhook settings (URLs, `event_hooks`, ...): set them to an obvious placeholder (e.g. `https://example.com/...`), assert them with `getApp()`, and restore the original value. - **Channel types are eventually consistent.** After `createChannelType` or `updateChannelType`, API nodes can disagree for up to ~30s. - Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after the create/update, outside the snippet region. - Push the name to `cleanup.channelTypes` straight after `createChannelType`. `Cleanup` deletes it after the channels are gone. Never delete channel types in your own cleanup code. @@ -75,7 +77,7 @@ You are verifying the server-side Node.js code snippets of one Stream Chat docs ## Editing the docs page -- Write fixes from the test with `DOCS_SYNC_WRITE= yarn test:docs:sync`. This writes every region of that page into its fence, applying the COPY values and the docs prettier style (double quotes). Don't paste snippets by hand. +- Write fixes from the test with `DOCS_SYNC_WRITE= yarn test:docs:sync`. This writes every region of that page into its fence, applying the COPY values and the docs prettier style (double quotes). Don't paste snippets by hand. - Follow `/Users/zitaszupera/Stream/getstream.io/content/docs/AGENTS.md`. In particular, no em-dashes in prose or comments. - Then run, from `/Users/zitaszupera/Stream/getstream.io/content/docs`: - `npx prettier --write ` diff --git a/__tests__/docs-snippets/docs-snippets-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md index bfa76c8..8614efd 100644 --- a/__tests__/docs-snippets/docs-snippets-todo.md +++ b/__tests__/docs-snippets/docs-snippets-todo.md @@ -1,8 +1,8 @@ # Docs snippet tests: todo -Pages of the chat Node sidebar (`getstream.io/content/docs/_sidebars/[chat][node].json`) that contain `Node.js` snippets. Give each page to an agent with `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-agent-prompt.md`. **Run pages one at a time**: tests may change app settings (they must restore them). +Pages of the chat Node sidebar (`getstream.io/content/docs/_sidebars/[chat][node].json`) and the platform sidebar (`getstream.io/content/docs/_sidebars/[platform][default].json`) that contain `Node.js` snippets. Chat tests go under `pages/chat/...`, platform tests under `pages/platform/...`. Give each page to an agent with `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-agent-prompt.md`. **Run pages one at a time**: tests may change app settings (they must restore them). -- Only server-side snippets are in scope: fences labelled `Node.js`. Unlabelled `js` fences get a label (`JavaScript` or `Node.js`). `JavaScript` fences (client-side) are out of scope. +- Only server-side snippets are in scope: fences labelled `Node.js`. Unlabelled `js` fences get a label (`JavaScript` or `Node.js`). `JavaScript`, `React` and `React Native` fences (client-side) are out of scope. - **Node.js / Unlabelled**: number of `js` fences labelled `Node.js` / with no label (before the docs were changed). - **Test**: `yes` when the page gets a test under `__tests__/docs-snippets/pages/...`; `-` when nothing on it can be tested. @@ -17,7 +17,7 @@ Status: `[ ]` todo, `[x]` done, `[~]` done but some snippets couldn't run (a blo Issues are kept in three lists below: [API behavior issues](#api-behavior-issues) (the API itself may be faulty), [API spec / node SDK issues](#api-spec--node-sdk-issues) (the OpenAPI spec or the generated node SDK is wrong) and [Open docs bugs](#open-docs-bugs) (the docs are wrong and not fixed yet). -App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `firebase:firebase` (not a docs-test one, leave it), permissions v2, multi-tenancy off, `disable_auth_checks` off, auto-translation off, campaigns on (segments, user groups and polls endpoints answer), no webhook / SQS / SNS / custom action handler URLs set (`webhook_url`, `custom_action_handler_url`, `sqs_url`, `sns_topic_arn` empty), one entry in `event_hooks`, no retention policies. Channel types: `livestream`, `messaging`, `team`, `agent`, `commerce`, `gaming`, plus `streamnodetest*` types left by the integration tests. Delivery receipts: `delivery_events: true` is accepted on create/update channel type without support enablement (2026-10-12). Location sharing: enabled (2026-10-12); channels still need `shared_locations` (`config_overrides` or channel type), otherwise `sendMessage` with `shared_location` fails with code 17 "location sharing is not enabled for this channel". Translation: `translateMessage` works with auto-translation off; `updateApp({ auto_translation_enabled: true })` is accepted (restored after, 2026-10-12). Restricted visibility messages: enabled (`restricted_visibility` on `sendMessage` works, 2026-10-12). User response time: `updateApp({ user_response_time_enabled: true })` is accepted and `avg_response_time` appears on the replying user within seconds (restored after, 2026-10-12). Dynamic partitioning: `partition_size` / `partition_ttl` accepted on create/update channel type without support enablement (2026-10-12). Message history (audit logs): enabled; `queryMessageHistory` is rate limited to 6 requests per minute (2026-10-12). Not probed yet: feature availability per page (each agent probes what its page needs and records it here). +App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `firebase:firebase` (not a docs-test one: tests must not touch or use it, e.g. no `upsertPushProvider` / `deletePushProvider` on it and no devices or `checkPush` with `push_provider_name: "firebase"`), permissions v2, multi-tenancy off, `disable_auth_checks` off, auto-translation off, campaigns on (segments, user groups and polls endpoints answer), no webhook / SQS / SNS / custom action handler URLs set (`webhook_url`, `custom_action_handler_url`, `sqs_url`, `sns_topic_arn` empty), one entry in `event_hooks`, no retention policies. Channel types: `livestream`, `messaging`, `team`, `agent`, `commerce`, `gaming`, plus `streamnodetest*` types left by the integration tests. Delivery receipts: `delivery_events: true` is accepted on create/update channel type without support enablement (2026-10-12). Location sharing: enabled (2026-10-12); channels still need `shared_locations` (`config_overrides` or channel type), otherwise `sendMessage` with `shared_location` fails with code 17 "location sharing is not enabled for this channel". Translation: `translateMessage` works with auto-translation off; `updateApp({ auto_translation_enabled: true })` is accepted (restored after, 2026-10-12). Restricted visibility messages: enabled (`restricted_visibility` on `sendMessage` works, 2026-10-12). User response time: `updateApp({ user_response_time_enabled: true })` is accepted and `avg_response_time` appears on the replying user within seconds (restored after, 2026-10-12). Dynamic partitioning: `partition_size` / `partition_ttl` accepted on create/update channel type without support enablement (2026-10-12). Message history (audit logs): enabled; `queryMessageHistory` is rate limited to 6 requests per minute (2026-10-12). Not probed yet: feature availability per page (each agent probes what its page needs and records it here). ## chat/node @@ -123,6 +123,42 @@ App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `fir | [x] | `chat/_default/12-best_practices/02-moderation.md` | 13 | 0 | yes | | | [x] | `chat/_default/12-best_practices/05-marketplace_best_practices.md` | 5 | 0 | yes | | +## platform/01-start-here + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ------------------------------------------- | ------- | ---------- | ---- | -------- | +| [x] | `platform/01-start-here/01-backend-sdks.md` | 1 | 0 | yes | | + +## platform/02-identity-and-access + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ------------------------------------------------------ | ------- | ---------- | ---- | -------- | +| [~] | `platform/02-identity-and-access/01-authentication.md` | 8 | 0 | yes | ◐ | +| [x] | `platform/02-identity-and-access/02-users.md` | 13 | 0 | yes | | +| [x] | `platform/02-identity-and-access/03-permissions.md` | 8 | 0 | yes | | +| [x] | `platform/02-identity-and-access/04-multi-tenancy.md` | 9 | 0 | yes | | +| [x] | `platform/02-identity-and-access/05-gdpr.md` | 4 | 0 | yes | | + +## platform/03-events-and-notifications + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ----------------------------------------------------------------- | ------- | ---------- | ---- | -------- | +| [~] | `platform/03-events-and-notifications/01-webhooks.md` | 15 | 1 | yes | ◐ ⛔ | +| [x] | `platform/03-events-and-notifications/02-push-notifications.md` | 3 | 0 | yes | | +| [~] | `platform/03-events-and-notifications/03-push-providers.md` | 5 | 0 | yes | ◐ 🔑 | +| [~] | `platform/03-events-and-notifications/04-push-devices.md` | 10 | 0 | yes | ◐ 🔑 | +| [x] | `platform/03-events-and-notifications/05-push-preferences.md` | 5 | 0 | yes | | +| [~] | `platform/03-events-and-notifications/06-push-troubleshooting.md` | 2 | 0 | yes | 🔑 | + +## platform/04-using-the-api + +| Status | Page | Node.js | Unlabelled | Test | Blockers | +| ------ | ---------------------------------------------------- | ------- | ---------- | ---- | -------- | +| [x] | `platform/04-using-the-api/01-rate-limits.md` | 3 | 0 | yes | | +| [x] | `platform/04-using-the-api/03-async-operations.md` | 1 | 0 | yes | | +| [x] | `platform/04-using-the-api/06-connection-pooling.md` | 0 | 1 | yes | | +| [x] | `platform/04-using-the-api/07-api-error-codes.md` | 2 | 0 | yes | | + ## Pages without Node.js snippets These sidebar pages have no `Node.js` (or unlabelled) JS fences: nothing to test. The sync coverage check still reads them, so a `Node.js` fence added later shows up as uncovered. @@ -143,10 +179,19 @@ These sidebar pages have no `Node.js` (or unlabelled) JS fences: nothing to test - `chat/_default/12-best_practices/08-storage_and_bandwidth.md` - `chat/_default/12-best_practices/01-best_practices.md` - `chat/_default/12-best_practices/06-livestream_best_practices.md` +- `platform/00-index.md` +- `platform/04-using-the-api/02-query-syntax-operators.md` +- `platform/04-using-the-api/04-error-handling.md` +- `platform/04-using-the-api/05-logging.md` +- `platform/04-using-the-api/08-cdn-domains.md` ## Blockers detail -None. +- **platform/02-identity-and-access/01-authentication** (◐): "Token Revocation by Application" and its "Undoing the revoke" (Node.js #2) are in `it.skip`: `updateApp({ revoke_tokens_issued_before })` revokes every user token of the shared test app, so it is kept skipped by decision (2026-10-14: other users of the app would get 401s while it is set). To run it later (restore with `revoke_tokens_issued_before: null`; `getApp()` returns `null` for it when unset, so no DRIFT is expected). The undo region also fails `yarn types:docs` (see API spec / node SDK issues). +- **platform/03-events-and-notifications/01-webhooks** (◐ ⛔): (1) "Configuration" Node.js #1 (GCS failover) is in `it.skip`: `updateApp` rejects the placeholder `gcs_credentials` with code 4 "failover_config for hook has invalid gcs_credentials: dialing: auth: email must be provided" (needs a real GCS service account). (2) "Configuration" Node.js #2 (S3 failover) is in `it.skip` (⛔): code 4 "failover_config type 's3' is not enabled for hook" (probed 2026-10-14). The stateless `verifyAndParseWebhook` import fence runs in `01-webhooks.stateless.test.ts` (module-level region); the three "Reference implementation (no SDK)" fences are `Node.js` fences inside the disclosure and out of scope. +- **platform/03-events-and-notifications/03-push-providers** (◐ 🔑): only "List Push Providers" runs. "Upsert a Push Provider", "Delete a Push Provider" and "Linking Devices to Providers" (each on its own `uniqueId` Firebase provider) and "Single-Provider Configuration" (`updateApp({ firebase_config })`, restored like the legacy push page) are in `describe.skipIf(!hasFirebaseCredentials)`: the page's sample service account JSON (`sampleFirebaseCredentials`) is rejected with code 4 "firebase credentials are invalid" by both `upsertPushProvider` and `updateApp({ firebase_config })` (probed 2026-10-14). `DOCS_PUSH_FIREBASE_CREDENTIALS` is not set in `.env`, so these four have never run: their assertions (device `push_provider_name`, `push_notifications.firebase.enabled` turning `true`) and the cleanup are unverified. The "apn or firebase or huawei or xiaomi" placeholders run as `firebase` through COPY. +- **platform/03-events-and-notifications/04-push-devices** (◐ 🔑): only the three list fences and the device type check run (the lists on a new user, so `devices` is `[]`). The three register fences ("Register a Device", "Registering from the chat client SDKs", "Multi-Bundle Device Registration") use `push_provider: "apn"`: `it.skip('BLOCKED: needs APN credentials')`, because `createDevice` checks that the provider exists (code 4 'push provider "production-ios" with type "apn" not found, available push providers for this application: type: "firebase" name: "firebase"', also without `push_provider_name`: 'push provider "" with type "apn" not found'; probed 2026-10-14). The three remove fences need a device, which can only be registered on a `uniqueId` Firebase provider (never the app's own): they are in `describe.skipIf(!hasFirebaseCredentials)` (the sample service account JSON is rejected, see 03-push-providers) and have never run. The "device object" Node.js fence (bare data shape) is now a TypeScript `type Device`, checked at compile time to match `DeviceResponse` exactly. +- **platform/03-events-and-notifications/06-push-troubleshooting** (🔑): both "Testing Push" Node.js fences call `checkPush` without a provider, which would run against the app's own `firebase:firebase` provider (not allowed). The test creates a `uniqueId` Firebase provider and a spy on `client.checkPush` adds `push_provider_type: "firebase"` / `push_provider_name` (both in `CheckPushRequest`), so the regions stay as written. Creating the provider needs a real service account (`sampleFirebaseCredentials` is rejected, see 03-push-providers), so the whole file is in `describe.skipIf(!hasFirebaseCredentials)`; not yet run with credentials (2026-10-14), so the assertions (`skip_devices: true` passed back, `duration` string) are unverified. ## Setup notes (not blockers) @@ -166,6 +211,17 @@ None. - **05-features/07-polls_api**: Polls work on the built-in `messaging` type with no config change. All polls are created as one `uniqueId` user (john) and removed with `deletePollsCreatedBy` before the user. Votes need the poll sent in a message first (`sendMessage` with `poll_id`). The "Querying polls" filter (`is_closed: true`) matches every closed poll of the app, so the test asserts only on the order of its own two closed polls. - **05-features/10-advanced/17-audit_logs**: Message history is enabled on the test app. `queryMessageHistory` allows 6 requests per minute (code 9, 429): the test spies on it to retry on 429 so back-to-back runs pass. - **05-features/10-advanced/15-pending_messages**: Pending messages are enabled on the test app (2026-10-12). The `mark_messages_pending` snippet runs on a `uniqueId` type (COPY-mapped to `messaging`). Server-side, `pending_messages` is only returned for a user: `queryChannels` needs `user_id`, `channel.get` needs `state: true` and `user_id`. The pending message hook snippet adds a hook with an `https://example.com` URL to `event_hooks`; the test restores the original `event_hooks` array. +- **platform/02-identity-and-access/01-authentication**: Tokens are checked by verifying their claims with `jsonwebtoken` and by a client-side `POST /channels` (query channels) with the token (201 accepted, 401 code 40 "This token has been revoked, please generate a new token" when revoked). Revocation takes effect within a few seconds (the test retries). Tokens to revoke are issued with `iat` two minutes in the past; the SDK's default `iat` is one second in the past, so a token generated right after the revocation date can still be revoked (retry). `revoke_tokens_issued_before` is stored rounded to the millisecond (±1 ms). +- **platform/02-identity-and-access/02-users**: `createGuest` stores the user as `guest--` (register `response.user.id` for cleanup, not the requested id). `last_active` is only set by a WebSocket connection (`wss://chat.stream-io-api.com/connect`, within ~3s), not by REST calls with a user token; the "Querying Inactive Users" test opens one. Bulk `deactivateUsers` / `reactivateUsers` and `deleteUsers` return a `task_id`: `waitForTask` before asserting. Soft-deleted users are not returned by `queryUsers`; deactivated ones only with `include_deactivated_users: true`. The "Unique usernames" test restores `enforce_unique_usernames` (original `""`) at its end, not only in cleanup, because of the reactivation issue under API behavior issues. +- **platform/02-identity-and-access/04-multi-tenancy**: The "Enable Teams" test runs the docs `updateApp({ multi_tenant_enabled: true })` and the rest of the file runs with multi-tenancy on (a nested `describe` turns it on again if needed); the original value is restored in cleanup with `waitForAppSetting` + `waitForTeamIdSupport`, as on the chat user groups page (it settles in ~4s). Teams are plain strings: the docs' `"red"` / `"blue"` are COPY-mapped to `uniqueId` team names so the team queries only see the file's channels, calls and users. The "Team based roles" fence has `teams_role` keys `red` / `blue` (object keys can't be COPY-mapped), so that user (a `uniqueId` id) uses the literal team names. Calls aren't tracked by the leak check: the test hard-deletes its `uniqueId` calls (`call.delete({ hard: true })`) before multi-tenancy is restored. `queryTeamUsageStats` returns `teams: []` on the test app, so the docs' pagination branch (`if (response.next)`) doesn't run (see API behavior issues). +- **platform pages (all)**: The platform docs are shared by Chat, Video and Feeds, so their Node.js snippets may call `client.chat`, `client.video` or `client.feeds`. The same isolation, cleanup and restore rules apply. +- **platform/02-identity-and-access/03-permissions**: Feeds v3 works on the test app (feed groups `user`, `timeline`, ... exist; probed 2026-10-14). The feed snippets run on `uniqueId` feeds (owned by `uniqueId` users) that the test hard-deletes in cleanup: the leak check doesn't track feeds. Feed visibilities are app-wide and not covered by the DRIFT check: the "Feeds visibility grants" test saves the `visible` grants, restores `feed_member` and checks the whole grants object is back. `updateApp({ grants })` (`.app` scope) is restored like on the chat permission policies page. Custom roles need the retry spies from the chat page (`provided but not defined` / `does not exist`). +- **platform/02-identity-and-access/05-gdpr**: `exportUsers` and `feeds.exportFeedUserData` tasks finish within seconds (`waitForTask`); `result.url` downloads without auth and contains the user id, their chat message text and their feed activity (id and text). `deleteFeedUserData({ hard_delete: false })` finishes within seconds; the user's activity is then 404 for `getActivity` and the user account stays. The feeds snippets run on a `uniqueId` `user` feed with one activity, hard-deleted in cleanup (ignoring "not found"). +- **platform/03-events-and-notifications/02-push-notifications**: All three Node.js fences are Feeds v3 calls (`serverClient.feeds.addComment` / `addReaction` / `addActivityReaction` / `addActivity`); no push provider, device or credentials are involved, and pushes aren't checked (the users have no devices). Each test adds its own activity on a `uniqueId` `user` feed; every `uniqueId` user gets a `notification` feed in setup, because `create_notification_activity` only creates the notification when the activity owner's notification feed exists (see API behavior issues). The custom `alerts` feed group is app-wide: the test creates a `uniqueId` group (hyphens replaced by `_`; whether hyphens are allowed wasn't probed), COPY-mapped to `alerts`, and hard-deletes it in cleanup. The leak check doesn't track feeds or feed groups, so cleanup hard-deletes them (ignoring "not found"). +- **platform/03-events-and-notifications/03-push-providers, 04-push-devices**: Use `sampleFirebaseCredentials` (`helpers/push.ts`, the sample service account JSON from the "Upload your credentials" section of the push providers page) for the credentials placeholder (`"my-service-account-information"`) through a COPY variable. If the API rejects it (the chat legacy push page saw code 4 "firebase credentials are invalid" for fake credentials, see `learnings.md`), skip the snippet (`describe.skipIf(!hasFirebaseCredentials)` with `firebaseCredentials`) and mark the page 🔑. Snippets that need another provider config (APN, Huawei, Xiaomi; e.g. the "apn or firebase or huawei or xiaomi" placeholders) are skipped too, unless they can run against a Firebase provider the test created. Name providers with `uniqueId`, and never touch or use the app's own `firebase:firebase` provider (no devices registered on it, no `checkPush` against it). `updateApp({ firebase_config })` changes app-level push config: save it with `getApp()` and restore it (DRIFT). Devices go away with their `uniqueId` user. Probed 2026-10-14 (push-providers): `sampleFirebaseCredentials` is rejected (code 4 "firebase credentials are invalid") by `upsertPushProvider` and `updateApp({ firebase_config })`. `push_provider.name` is limited to 36 characters (code 4 "push_provider.name must be a maximum of 36 characters in length"): keep `uniqueId` names short (`uniqueId('fcm')`). +- **platform/03-events-and-notifications/05-push-preferences**: Don't check which pushes are sent: assert the response, or read the preference back (user / channel member push preferences, channel `config_overrides`, channel type `push_level`). The snippets on `messaging` and `"announcements"` run on a `uniqueId` channel type / channel (COPY-mapped), or the built-in type is restored. `user-1` / `user-2` are `uniqueId` users. +- **platform/03-events-and-notifications/01-webhooks**: Don't check that webhooks arrive. Every `updateApp({ event_hooks })` replaces all hooks: `beforeAll` saves `getApp().app.event_hooks` and cleanup puts them back (same ids); the "preserve existing hooks" tests (webhook, SQS, SNS) restore the originals first so they can assert the original ids are kept. `waitForAppSetting(client, 'event_hooks', original)` never settles after a restore (`updated_at` changes), so compare the hooks without `created_at` / `updated_at` (the DRIFT check ignores them). `event_types: []` reads back as the full list of event types plus `"*"`. SQS / SNS hooks with placeholder keys are accepted; `checkSQS` / `checkSNS` with them return `status: "error"` ("InvalidClientTokenId"), not a rejection. `verifyAndParseWebhook`, `parseSqs` and `parseSns` are tested locally with an HMAC of a sample body (`STREAM_SECRET`) and a gzip + base64 body. +- **platform/04-using-the-api/06-connection-pooling**: The fence imports `undici` (devDependency `undici@^7.30.0`, the major Node.js 22 and 24 bundle). The real call is checked with a pass-through `vi.spyOn(Agent.prototype, 'dispatch')`. ## API behavior issues @@ -178,7 +234,16 @@ The API itself may be faulty, or behaves differently from what the docs say. Not - **06-app_and_channel_settings/06-channel-level_settings**: the channel-level blocklist override is set and reads back (`config.blocklist`, `blocklist_behavior: "block"`), but a message with a blocklisted word is not blocked, neither sent server-side nor as the user (60s of retries), and a nonexistent blocklist name is accepted without error (2026-10-12). Possibly because the app has `moderation_enabled: true` and blocklists are enforced through moderation configs; the test only asserts the setting. - **08-webhooks/01-webhooks_overview/03-custom_commands_webhook**: (1) "Edit a Command" says fields not sent stay unchanged, but `updateCommand({ name, description })` resets `args` and `set` to `""` (the SDK leaves undefined fields out of the body, so it is the API). (2) "Performance considerations" says a failing or slow endpoint lets the message through as usual, but when the custom command handler returns an error `sendMessage` is rejected with code 45 "custom command endpoint returned an error" (code 44 "missing custom command endpoint url" without a handler URL). The paragraph looks copied from the Before Message Send page. Fix the prose or the API? - **12-best_practices/02-moderation**: `client.moderation.flag` by the same user on the same entity again raises `flags_count`, but the review item's `flags` keep only the first flag, so the later `reason` and `custom` data are silently dropped (probed 2026-10-12). The docs flagged one message several times with different reasons; they now flag separate messages. Intended (one flag per user) or should the later flag update or error? +- **platform/02-identity-and-access/01-authentication**: `updateUsersPartial({ users: [{ id, unset: ["revoke_tokens_issued_before"] }] })` is silently ignored: the response and `queryUsers` still return the date and old tokens stay revoked (also with `set: {}` alongside, probed 2026-10-14). `set: { revoke_tokens_issued_before: null }` clears it (field gone, old tokens work again). The Node.js fence now uses `set: null` (as the prose says); should `unset` work too? +- **platform/02-identity-and-access/02-users**: with `enforce_unique_usernames` set to `"team"` (or `"app"`), `reactivateUser({ user_id, restore_messages: true })` fails with code 4 "user ... is not deactivated" although the user is deactivated (`deactivateUser` response and `queryUsers({ include_deactivated_users: true })` show `deactivated_at`). Without `restore_messages` it works, and with `enforce_unique_usernames: ""` every variant works (probed 2026-10-14, one factor at a time: messages, `mark_messages_deleted`, `created_by_id`, `name` make no difference). The test restores the setting before the deactivation tests. +- **platform/02-identity-and-access/02-users**: with `enforce_unique_usernames: "team"` and multi-tenancy off, users without a team can share a name (a duplicate `upsertUsers` is accepted; with `"app"` it fails with code 6 "username(s) '...' already exist(s)"). Plausible (no team to compare), but the docs don't say what `"team"` does for users without teams. Once (out of ~6 runs) the `"app"` check still accepted a duplicate for 60s after `getApp()` agreed on `"app"` 5 times. +- **platform/02-identity-and-access/04-multi-tenancy**: `queryTeamUsageStats` returns `teams: []` for every query on the test app: no params, `limit: 1`, `month` (`2024-01`, `2026-09`), a date range (`2024-01`, `2026-10-01..13`) and `team: ""`, with multi-tenancy off and on (probed 2026-10-14). The page says "The API returns a row for `team=""` (empty string)" for users and messages without a team, and the app has many of both. Maybe the stats are only aggregated for apps that had multi-tenancy on, or daily with a delay; the docs don't say. The test asserts only the response shape, so the pagination branch is not exercised. - **All pages that update channel types** (not page-specific, the tests work around it): after `updateChannelType`, the update is enforced on every API node within ~3s, but `getChannelType` / `listChannelTypes` reads flip between the new and the old value for over a minute (probed 2026-10-13: correct for ~15s, then the old value for ~40s, then mixed past 90s). Tests read types back with a retry or assert the update response. +- **platform/03-events-and-notifications/01-webhooks**: `getApp()` never returns `enable_hook_payload_compression` (absent before and after `updateApp({ enable_hook_payload_compression: true })` and `false`, 2026-10-14; `AppResponseFields` has no such field either), so the setting can't be read back, the DRIFT check can't see it, and the original value has to be known from elsewhere: `false` on the test app (confirmed by the user, 2026-10-14), which the test restores. +- **platform/03-events-and-notifications/02-push-notifications**: (1) `feeds.addComment({ ..., create_notification_activity: true })` returns `notification_created: false` with no error when the activity owner has no `notification` feed yet; with the feed created first it returns `true` (probed 2026-10-14). The page says the comment "creates notification activity and sends via notification feed" without mentioning that the feed must exist. Intended (should the API create it, or the docs say so)? (2) A new feed group is eventually consistent: right after `createFeedGroup`, `getOrCreate` of a feed in it and then `addActivity` to it failed on some calls with code 16 "Feed group with ID ... not found", even after a `getOrCreate` in that group had succeeded (2026-10-14). The test retries on "not found". +- **platform/03-events-and-notifications/03-push-providers**: `getApp()` returns every push provider's full credentials in `app.push_notifications.providers[].firebase_credentials` (the app's own `firebase` provider: the whole service account JSON, private key included; probed 2026-10-14), while the legacy app-level config never returns credentials. Intended for a server-side response, or should secrets be masked? Tests must not log `getApp().app.push_notifications`. +- **platform/03-events-and-notifications/04-push-devices**: server-side `deleteDevice({ id, user_id })` with a `user_id` that doesn't exist fails with code 16 'user ... does not have device with id ...' but creates that user anyway: the leak check found the user afterwards (probed 2026-10-14). A failed delete shouldn't upsert the user. +- **platform/03-events-and-notifications/05-push-preferences**: (1) server-side `queryChannels({ filter_conditions: { cid }, user_id })` never returns `push_preferences` (`ChannelStateResponse.push_preferences`), even right after `updatePushNotificationPreferences` set channel-member `chat_preferences` for that member, and still not after 7s of re-queries (the response has `channel, messages, pinned_messages, read, members, membership, threads`; `membership` is also missing, probed 2026-10-14). So channel-member preferences can only be read back from the upsert response server-side. (2) The upsert response's `user_preferences` is documented as "always returned regardless if you edited it", but a call with only a channel-member preference returns `user_preferences: { "": null }` (probed 2026-10-14), although the user has user-level `chat_preferences` from the call before. The test asserts both as observed. ## API spec / node SDK issues @@ -188,7 +253,14 @@ The OpenAPI spec or the generated node SDK is wrong; the API itself works. Not f - **05-features/06-campaign_api** (API spec): `updateCampaign` and `stopCampaign` are typed as returning `CampaignResponse`, but the API returns `{ campaign: CampaignResponse, ... }` (like `StartCampaignResponse`), so `response.campaign` is a TS2339 error and `response.name` etc. are undefined at runtime. The response schemas of `PUT /campaigns/{id}` and `POST /campaigns/{id}/stop`. - **04-messages/08-unread_reminders** (API spec, to check): `CreateChannelTypeRequest` has no `reminders` field, while `UpdateChannelTypeRequest` has it, so a channel type can't be created with reminders on in one call from typed code. Check whether the spec (`CreateChannelType`) lacks it or the API doesn't accept it. - **05-features/10-advanced/12-drafts** (API spec): `getDraft` / `deleteDraft` / `queryDrafts` type `user_id` as optional, but a server-side client must pass it (code 4 "either user or user_id must be provided when using server side auth"). +- **platform/02-identity-and-access/01-authentication** (node SDK generator): `UpdateAppRequest.revoke_tokens_issued_before` is typed `Date` without `| null`, so the docs' `updateApp({ revoke_tokens_issued_before: null })` ("Undoing the revoke", Node.js #2) fails TS2322 and `yarn types:docs` reports it. Same root cause as the `grants: null` entry above (the generator drops `nullable`); check that the spec marks the field nullable. Not run against the API (see Blockers detail). `UpdateUserPartialRequest.set` is `Record`, so the user-level `set: { revoke_tokens_issued_before: null }` typechecks. - **12-best_practices/02-moderation** (API spec): `getApp().app.enforce_unique_usernames` is typed `string`, while `UpdateAppRequest.enforce_unique_usernames` is `'no' | 'app' | 'team'`, so restoring a saved value needs a cast. +- **platform/03-events-and-notifications/01-webhooks** (node SDK, to check): `client.parseSqs` / `parseSns` always base64-decode and reject a plain JSON body with "invalid base64 encoding", while the docs say uncompressed SQS / SNS bodies are plain JSON ("When compression is on, the message body is gzipped + base64-encoded", and the reference implementation falls back to the raw UTF-8 body) and that "the same call works whether or not compression is on". Check what Stream sends with compression off: either the SDK needs the fallback or the docs are wrong. +- **platform/03-events-and-notifications/04-push-devices** (API spec): `deleteDevice` and `listDevices` type `user_id` as optional, but server-side calls without it fail with code 4 "either user or user_id must be provided when using server side auth." (probed 2026-10-14), like the drafts endpoints. +- **platform/03-events-and-notifications/05-push-preferences** (API spec): `UpsertPushPreferencesResponse.user_preferences` is typed `Record`, but the API returns `null` values (`{ "": null }` for a call with only a channel-member preference, see API behavior issues): either the value should be nullable in the spec, or the API should return the user's preferences. + +- **platform/04-using-the-api/06-connection-pooling** (node SDK): `StreamClientOptions.agent` is passed to the built-in `fetch` as `dispatcher`, which only works with an `undici` Agent of the same major as the one Node bundles. `npm install undici` gives 8.x (latest 8.12.1), and its Agent fails every request on Node 22 (bundled undici 6.21.2) and Node 24 (bundled 7.21.0) with `TypeError: fetch failed`, cause `InvalidArgumentError: invalid onRequestStart method` (`UND_ERR_INVALID_ARG`); an undici 7.21.0 Agent works on both (probed 2026-10-14; Node 26 not checked, no binary available). `agent` is typed `unknown`, so nothing catches this. The SDK could use undici's own `fetch` when an agent is given, or document the version constraint. +- **platform/04-using-the-api/06-connection-pooling** (node SDK): `ApiClient.sendRequest` wraps network errors as `new StreamError("The request failed due to an unexpected error", metadata, error as number)`: the original `Error` ends up in `code` (typed `number`) and is not passed as `cause`, so the real reason ("fetch failed", "invalid onRequestStart method") is hidden. ## Open docs bugs @@ -196,8 +268,17 @@ The docs are wrong and the fix wasn't made (out of scope, e.g. prose or non-Node - **05-features/10-advanced/17-audit_logs** (`JSON` response fences, not Node.js): both fences are invalid JSON (`"message_updated_at" "..."` lacks a colon; the second lacks a comma after `"is_deleted": false`). The attribution in them depends on the API behavior issue above. - **05-features/10-advanced/12-drafts** (prose): a stray `"` at the end of "Draft creation is a client-side operation; there is no server-side alternative." +- **platform/02-identity-and-access/01-authentication** (Python, Ruby, PHP, Go, C#, Java fences of the user-level "Undoing the revoke"): they `unset` `revoke_tokens_issued_before`, which the API ignores (see API behavior issues); the Node.js fence was changed to `set: { revoke_tokens_issued_before: null }`. Not probed in those SDKs. The Java "Token Revocation by Application" comment says the date "must be at least 60 seconds in the past"; the user-level revocation accepted `new Date()` (not probed at app level). - **08-webhooks/01-webhooks_overview/03-custom_commands_webhook** (prose): "Edit a Command" and "Performance considerations" depend on the API behavior issue above. +- **platform/02-identity-and-access/02-users** (Python and Ruby "Partial updates" fences): the first update sets `field` and unsets `field.unset`, which the API rejects with code 4 "set and unset for same path: [field, field.unset]". The Node.js fence now unsets `field2.unset`. PHP and C# don't set `field`, so they are fine; Go has no unset. +- **platform/02-identity-and-access/02-users** (prose): "Unique usernames" says `enforce_unique_username` (the setting is `enforce_unique_usernames`). "Deleting users" says "deletion is not reversible", while "Restoring deleted users" restores soft-deleted users. The "Deactivating users" comment only calls bulk deactivation asynchronous, but `reactivateUsers` returns a `task_id` and finishes later too. +- **platform/02-identity-and-access/03-permissions** (non-Node.js fences, not probed): the Go "updateFollow" fence has a stray `)` (`}),` closing `Custom`), so it doesn't compile. The Go `.app` grants fence leaves `mute-user` out of `user` and `admin`, unlike every other language. The C# fences use `"user-id"` (Role Management) and `"special_agent"` (delete role) where the others use `"james_bond"` and `"agent_006"`. +- **platform/04-using-the-api/07-api-error-codes** (error table, prose): the "No Access to Channels" row (code 70) gives HTTP status 403 as "Unauthorised"; 403 is "Forbidden" (as in the other 403 rows). The table rows aren't probed. +- **platform/02-identity-and-access/04-multi-tenancy** (prose): "Empty Team" says the API returns a row for `team=""`; on the test app the response has no rows at all (see API behavior issues). Waits for that decision. +- **platform/03-events-and-notifications/01-webhooks** (`Node.js` reference implementations in the no-SDK disclosures, out of scope, not fixed): the SQS and SNS reference fences call `parseEvent`, which no fence defines (ReferenceError when run as plain Node.js; the HTTP reference inlines `JSON.parse`), and the SNS fence also needs `decodeSqsPayload` from the SQS fence. The SQS fence has an em-dash in a comment ("not base64+gzip — fall back"). +- **platform/03-events-and-notifications/06-push-troubleshooting** (prose, "Push Test Parameters" / "Push Test Payload" tables, not probed): the parameter table omits `push_provider_type`, `push_provider_name` and `event_type`, which `CheckPushRequest` has (the provider fields are how to test one provider on a multi-provider app). The payload table types `rendered_message` as a string; the node SDK types it `Record`. The "Pre-requirements" also say "pass `skip_devices=true`" while the Bash fence doesn't. + ## Notes -None yet. +- Proposed shared helper (from platform/02-identity-and-access/03-permissions): add feeds (by `docs-test` id) to the leak check / sweep (`helpers/leaks.ts`) and feed visibility grants to the DRIFT snapshot (`helpers/snapshot.ts`), so Feeds snippets get LEAK and DRIFT coverage without hand-written checks. Not done yet. diff --git a/__tests__/docs-snippets/helpers/push.ts b/__tests__/docs-snippets/helpers/push.ts index 1baf246..f9f2297 100644 --- a/__tests__/docs-snippets/helpers/push.ts +++ b/__tests__/docs-snippets/helpers/push.ts @@ -10,3 +10,21 @@ export const firebaseCredentials = process.env.DOCS_PUSH_FIREBASE_CREDENTIALS ?? ''; export const hasFirebaseCredentials = firebaseCredentials !== ''; + +/** + * The sample Firebase service account JSON of the platform docs ("Upload your + * credentials" on `platform/03-events-and-notifications/03-push-providers.md`), as one + * string. Push snippets pass it where the docs have a credentials placeholder (through a + * COPY variable). If the API rejects it, the snippet needs real credentials: skip it. + */ +export const sampleFirebaseCredentials = JSON.stringify({ + type: 'service_account', + project_id: 'your-project-id', + private_key_id: 'key-id', + private_key: + '-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY\n-----END PRIVATE KEY-----\n', + client_email: 'firebase-adminsdk-xxxxx@your-project.iam.gserviceaccount.com', + client_id: 'client-id', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', +}); diff --git a/__tests__/docs-snippets/learnings.md b/__tests__/docs-snippets/learnings.md index 6c2a0f5..14cf3b0 100644 --- a/__tests__/docs-snippets/learnings.md +++ b/__tests__/docs-snippets/learnings.md @@ -11,20 +11,27 @@ Entries are added only after the user approves them (agents propose them in thei - Fences that start with `import` statements are module-level regions. If two fences import the same names, put them in separate test files (`.test.ts`, `..test.ts`): duplicate import bindings in one module are a SyntaxError. - Snippets that read local files (`./image1.jpg`): write small fixtures to an `os.tmpdir()` dir (a base64 JPEG works for image uploads) and COPY-map the paths (`image1Path="./image1.jpg"`). +- A module-level region that needs spies (e.g. a mocked `fetch` for a fake 429) can set them up at module level above the region; afterwards save `mock.calls` / `mock.results` into consts and call `vi.restoreAllMocks()` before the `describe`, then assert on the saved values inside the `it`. - If module-level setup or a module-level region throws, the file fails while loading and none of its hooks run (no cleanup, no per-file leak check). The end-of-run sweep (`global-setup.ts`) then removes what it created and fails the run with a `LEAK` list: fix the load-time error, the leftovers are already gone. - A spy whose `mockImplementation` calls the bound original must be restored even if the test fails: add `afterEach(() => vi.restoreAllMocks())`. Otherwise the next test binds the old spy, and its own spy recurses until "Maximum call stack size exceeded". +- The permission check can deny calls that affect every user of the shared test app (e.g. app-wide token revocation, `updateApp({ revoke_tokens_issued_before })`). Don't work around a denial: put the snippet in `it.skip('BLOCKED: ...')`, mark the page `[~]` and report it at the top of the report, so the user can approve it. +- Before relying on a package that is in `node_modules` but not in `package.json`, run `yarn why ` and check the chain's `conditions:` in `yarn.lock` (`undici` only arrives through `fsevents`, `os=darwin`, so it's missing on Linux CI, where `yarn lint` lints `__tests__/docs-snippets` with type info and fails with `no-unsafe-call`). To probe a package version without adding a dependency, `npm pack @` into the scratchpad and `require` it by absolute path. - In zsh, `echo =====` fails ("not found": `=word` is command expansion) and stops the rest of a `;` chain. Use another separator (`echo ----`). BSD `sed` basic regex has no `\|` alternation (`sed '/a\|b/d'` silently matches nothing): use `sed -E '/(a|b)/d'` or `grep -v`. +- `StreamFeedsClient`'s deprecated wrappers (`addReaction`, `followBatch`, `followUnbatch`) call `super.`, so a spy on the new method (`addActivityReaction`) doesn't see calls made through the wrapper: spy on the wrapper itself. +- `client.feeds.feed(group, id)` returns a `StreamFeed` that calls the same `client.feeds` instance, so `vi.spyOn(client.feeds, 'updateFeedMembers')` also sees calls made through the feed object. - To check state between calls inside a region (e.g. after each of several `hide()` calls), spy on the generated method (`client.chat.hideChannel`) with a `mockImplementation` that awaits the bound original, records a probe query, and returns the response. The region stays as is, and `mock.calls[i][0]` is typed (no cast). - To tell an API behavior from a snippet bug, write a throwaway probe test under `pages/` that logs the results of request variants (e.g. with and without a flag). It still runs the leak check, so name and register what it creates; delete the file before finishing. - To assert on calls a region awaits without keeping the result, `vi.spyOn(client.chat, '')` (no mock implementation, calls go through) before it and await `spy.mock.results.map((r) => r.value)` after it. `StreamChannel` methods call `client.chat.*`, so the spy sees them too. `mock.results[i].value` is `any`: cast it (`r.value as Promise`); a return-type annotation still fails `no-unsafe-return`. +- A region that builds its own client and then rejects (e.g. `new StreamClient("key", "secret")` plus a failing call) can't be spied on in advance, and `StreamChannel` methods are arrow properties (not on the prototype): wrap the region in `const run = async () => { /* region */ }` and `const error: unknown = await run().catch((e: unknown) => e)`. `getServerChannel(...)` returns `ChannelStateResponseFields`: channel fields are under `.channel`. - A region whose last call rejects (e.g. "a missing channel returns a 404"): don't wrap it in `try/catch` (its `const`s would be out of scope after it). Spy on the method with `mockImplementation` that calls the original (`client.chat.getChannel.bind(client.chat)`), records the error for that one request and returns `undefined as never`, rethrowing anything else. The region then runs as is and its results are asserted directly after it. Assert on the spy's calls before `mockRestore()` (it clears them). Example: `pages/03-channels/08-get_channel.test.ts`. - A snippet that assigns a result it never reads (`const update = await channel.update(...)`) fails `unused-imports/no-unused-vars`: assert on that variable after the region instead of disabling the rule. +- A docs placeholder that has to fit a string-literal union (e.g. `push_provider: "apn or firebase or huawei or xiaomi"`) can stay in the docs: COPY-map it to a variable with a valid literal type (`const pushProvider = 'firebase'`). - COPY replaces the identifier everywhere in the region that isn't a property access or object key, including comments and strings (`sender="..."` also rewrote `{{sender.id}}`). Pick COPY names that appear nowhere else in the fence (`campaignSender`). - `DOCS_SYNC_WRITE` keeps line breaks that the test's prettier added because of longer COPY names, and docs prettier doesn't rejoin them. Check `git diff` after writing and rejoin untouched lines by hand (the sync check ignores wrapping). - To read the result of a call the region doesn't keep, spy on the method and `await` its `mock.results[0].value` (a promise for async methods), e.g. `client.updateLiveLocation`. @@ -86,6 +93,9 @@ Entries are added only after the user approves them (agents propose them in thei - A soft-deleted channel is 404 (code 16 "Can't find channel") for single-channel endpoints, e.g. a following `channel.delete({ hard_delete: true })`. Batch `deleteChannels({ hard_delete: true })` works on soft-deleted channels. A fence that soft- then hard-deletes the same channel is a docs bug: give the hard delete its own channel. - `skip_truncate` (`channel.delete`, `deleteChannels`) only works on distinct channels (`client.chat.channel('messaging').getOrCreate({ data: { members } })`). To prove history was kept, send a message first, then recreate the channel with the same members and `state: true` and check `messages`. - Server-side `deleteChannels` returns a `task_id` for soft and hard deletes: `waitForTask` on it before asserting. +- `deactivateUsers`, `reactivateUsers` (bulk) and `deleteUsers` also return a `task_id`: `waitForTask` before asserting. Soft-deleted users are missing from `queryUsers`; deactivated users only appear with `include_deactivated_users: true`. +- `createGuest` stores the user as `guest--`: register `response.user.id` for cleanup, or the leak check reports it. +- `last_active` is set only by a WebSocket connection, not by REST calls with a user token. To make a user active, open `new WebSocket('wss://chat.stream-io-api.com/connect?json=...&api_key=...&authorization=&stream-auth-type=jwt')` (Node 22's global `WebSocket` typechecks), wait for the first message, close it, then `retry` `queryUsers` until `last_active` is set (about 3s). - `muteChannel` with one cid in `channel_cids` returns the mute as `channel_mute` (`channel_mutes` is absent): assert on `channel_mute` or `own_user.channel_mutes`. @@ -118,13 +128,24 @@ Entries are added only after the user approves them (agents propose them in thei - A new channel type starts with empty `guest` grants (no `messaging` defaults). To test removing guest grants on a `uniqueId` type, first copy the guest grants from the built-in type (read only), then `waitForChannelTypePropagation()`; with create + update waits, give that `beforeAll` a 180s timeout. - Uploads are checked by content as well as extension: a tiny CSV (`a,b`) is read as `text/plain` and fails "File type text/plain is not supported" under `allowed_mime_types: ["text/csv"]`. Use a few rows of real CSV. - To prove `disable_auth_checks`, send a dev token (`
..devtoken`, base64url JSON) to `POST /channels?api_key=...` with `stream-auth-type: jwt`: 201 while checks are off, 401 when on. The 401 message differs per API node ("development tokens are not allowed for this application" or "Token signature is invalid", the latter for over a minute on some nodes): accept both, and `retry` until the status matches. +- The same request proves a user token works or is revoked: `POST /channels?api_key=...` with `Authorization: ` and `stream-auth-type: jwt` returns 201 when accepted and 401 code 40 "This token has been revoked, please generate a new token" after a revocation (takes effect within seconds: `retry`). The SDK sets `iat` one second in the past by default, so a token made right after revoking with `new Date()` can still count as revoked: retry the fresh-token check, or give the tokens to revoke an `iat` well in the past. `revoke_tokens_issued_before` reads back as a `Date` that can be 1 ms off: compare with a ±1 ms tolerance. - `file_upload_config` / `image_upload_config` come back with every list present (`[]`) and `size_limit: 0`: restore the saved original and `waitForAppSetting` (deep compare) for it. - A new custom role isn't known to every API node right away: `updateUsersPartial({ set: { role } })` just after `createRole` can fail with code 4 `role "..." provided but not defined`. Spy on `client.updateUsersPartial` with a `mockImplementation` that calls the bound original through `retry(..., { retryIf: })`. `deleteRole` right after `createRole` fails the same way with `role "..." does not exist`: retry it like that on `'does not exist'`. - `updateChannelType({ grants: null })` resets grants to the API defaults, which differ from a new type's initial grants (they add e.g. `create-attachment`, `send-poll`, `create-mention`): assert against the update response. New and built-in types start with `guest: []` and `anonymous: []`: to prove a removal snippet works, give those roles grants first. - `updateChannelPartial({ set: { config_overrides: {...} } })` replaces the whole overrides object, it doesn't merge: a later override drops the earlier ones. With `config_overrides.replies: false`, a reply (`parent_id`) fails with code 19 (400), a direct check for that override. - Channel-level `blocklist` overrides aren't validated (a nonexistent name is accepted) and, on this moderation-enabled app, don't block messages: only the config read back can be asserted. `createChannelType` with a just-created blocklist can fail with "invalid block list name" for ~20s. +- `getApp().app.push_notifications.providers` returns every push provider's credentials in plain text, including the app provider's full Firebase service account and private key. Never log, print or write `push_notifications` (or the whole app) in tests or probes. The DRIFT check only prints the changed key names. +- Push provider names are at most 36 characters (code 4 "push_provider.name must be a maximum of 36 characters in length"), and a `uniqueId` already takes about 26: use short prefixes (`uniqueId('fcm')`). `sampleFirebaseCredentials` is rejected like any fake credentials (code 4 "firebase credentials are invalid", 2026-10-14), so every snippet that needs a test-created Firebase provider (upsert, delete, linking devices) goes in `describe.skipIf(!hasFirebaseCredentials)`. +- `createDevice` checks that the provider exists: code 4 'push provider "" with type "" not found, available push providers ...', also without `push_provider_name` (it looks up name ""). On the test app devices can only be registered on a test-created Firebase provider, which needs real credentials. Server-side `deleteDevice` / `listDevices` need `user_id` (code 4 "either user or user_id must be provided when using server side auth"), although the types mark it optional: a Node.js fence without it is a docs bug. +- Push preferences: `client.updatePushNotificationPreferences` returns `user_preferences[userId]` and `user_channel_preferences[userId][cid]`, the only server-side read-back (server-side `queryChannels` with `user_id` returns neither `push_preferences` nor `membership`; check membership with `members.map((m) => m.user_id)`). Spy on the method and await `mock.results`. A channel `config_overrides.chat_preferences` override removes `push_level` from the merged `channel.config`: assert `config.push_level` is undefined to show they are mutually exclusive. +- A helper that reads a spy's results can take `spy: { mock: { results: Array<{ value: unknown }> } }`: typing it as `ReturnType'>>` fails (TS2344) for methods inherited from the generated base classes (`CommonApi`). +- A Node.js fence that calls `checkPush` without a provider would hit the app's own provider: spy on `client.checkPush` with a `mockImplementation` that calls the bound original with `push_provider_type` / `push_provider_name` of a `uniqueId` provider the test created, so the region stays as written (needs real credentials, so `describe.skipIf(!hasFirebaseCredentials)`). +- To test 429 handling without flooding the shared app, `vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ code: 9, message: 'Too many requests, check response headers for more information.', StatusCode: 429 }), { status: 429, headers: { 'x-ratelimit-limit': ..., 'x-ratelimit-remaining': ..., 'x-ratelimit-reset': ... } }))`: the SDK's `ApiClient` turns it into a real `StreamError` (`code`, `metadata.responseCode`, `metadata.rateLimit.rateLimitReset` as a `Date`). +- To check what the SDK passes to `fetch` (e.g. `dispatcher` from the `agent` option) without the network, `vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({...})))` and read `fetchSpy.mock.calls[0][1]?.dispatcher` (typed, no cast). An undici `Agent` only works as `agent` when its undici major matches `process.versions.undici` (undici 8 fails on Node 22 / 24 with "fetch failed", cause "invalid onRequestStart method"). To prove a real request went through an Agent the region creates inline, use a pass-through `vi.spyOn(Agent.prototype, 'dispatch')`. +- SDK network failures surface as `StreamError` "The request failed due to an unexpected error" with the original error in `.code` (not `.cause`): look in `code.cause` for the reason. +- A server-side call that takes a `user_id` can create that user even when the call fails (seen with `deleteDevice` on an unknown user): register probe user ids on `cleanup.users` anyway. - Firebase calls validate the credentials on every request: `upsertPushProvider` and `updateApp({ firebase_config })` fail with code 4 "firebase credentials are invalid" without a real service account, even when only a template changes, so the whole test goes in `describe.skipIf(!hasFirebaseCredentials)`. On this push v3 app `getApp().app.push_notifications` legacy Firebase config is just `{ enabled: false }` and credentials are never returned: cleanup can only restore templates and the enabled flag. -- Export tasks (`exportChannels`, `exportUsers`) finish within seconds: `waitForTask` is enough, and `result.url` downloads with plain `fetch` (no auth), so assert the export's contents (v2: one JSON object per line). +- Export tasks (`exportChannels`, `exportUsers`, `feeds.exportFeedUserData`; also `feeds.deleteFeedUserData`) finish within seconds: `waitForTask` is enough, and `result.url` downloads with plain `fetch` (no auth), so assert the export's contents (v2: one JSON object per line). - `client.moderation.flag`: a repeat flag by the same user on the same entity raises `flags_count`, but the review item's `flags` keep only the first one (later `reason` / `custom` are dropped). A fence that flags one placeholder id several times from one user with different reasons is a docs bug: give each call its own entity id. Check flags by `retry`ing `client.moderation.queryReviewQueue({ filter: { entity_id } })` until it returns one item (about a second), then assert `items[0].flags[i]`. ## Server-side vs client-side @@ -133,6 +154,8 @@ Entries are added only after the user approves them (agents propose them in thei ## Docs conventions +- A Node.js fence that reads `error.metadata` in a `catch` fails TS18046 (`unknown`): the docs fix is `error instanceof StreamError && ...` (`metadata` is `Partial`, so `rateLimit` needs `?.`). Placeholder calls (`client....`, `client.queryUsers(...)`) are syntax errors: replace them with the call the sibling tabs use, with a COPY-mapped literal id. +- Unawaited calls used as statements in a Node.js fence (`client.updateApp(...)`, `client.queryUsers(...)`) fail `@typescript-eslint/no-floating-promises`: the docs fix is to add `await` (to assert on the results, spy on the method and await `mock.results`). A fence that declares the same `const` several times (TS2451): give each its own name; switching to `let` and reassigning fails `no-useless-assignment` when the earlier values are never read. - A fence that assigns a value it never reads, where the test can't read it either (block-scoped, e.g. `if (page1.next) { const page2 = ... }`), fails `no-unused-vars`. Fix the docs minimally by logging the field the page talks about (`console.log(taskResponse.result?.status)`, `console.log(page2.threads)`). Never use `eslint-disable`. In the test, assert the variables that are in scope after the region directly, and read the block-scoped value from the log: `vi.spyOn(console, 'log').mockImplementation(() => undefined)` before the region, `log.mock.calls[0]` after it (example: `pages/04-messages/03-threads.test.ts`, "Supported Sort Fields"). An unused catch parameter (`catch (err) { // comment }`) fails the same rule (`caughtErrors: "all"`): add `console.error(err)` and, in the test, spy on `console.error`. - A fence that runs conflicting operations on one placeholder id (soft- then hard-delete a channel, truncate again with an earlier `truncated_at`, delete a message after hard-deleting it) is a docs bug: give the later call its own id (`otherChannel` / `"random"`, `anotherMessageID`), reusing a name another tab already uses when there is one. @@ -163,8 +186,15 @@ Entries are added only after the user approves them (agents propose them in thei - Docs segment filters ("all users created after 2020") target the whole app: COPY-map them to something only the test's users match (the test start time, a `uniqueId` custom field value) before a campaign is started. - Channels the API creates from templates (campaign channels named `{{receiver.id}}-{{sender.id}}`) aren't found by the leak check: find them by member in cleanup and push them to `cleanup.channels`. Keep the `uniqueId` names short so the combined id stays under 64 characters. - `queryPolls` filters (`is_closed: true`) match every poll in the app. When the fence can't be scoped by COPY, create two matching polls and assert their relative order under the docs sort instead of the full list. -- `event_hooks` changes: save `getApp().app.event_hooks` before the region and `cleanup.add` an `updateApp({ event_hooks: original })` followed by `waitForAppSetting(client, 'event_hooks', original)`; passing the returned hooks back keeps their ids (no DRIFT). +- `event_hooks` changes: `updateApp({ event_hooks })` replaces every hook. Save `getApp().app.event_hooks` before the region and `cleanup.add` an `updateApp({ event_hooks: original })`; passing the saved hooks back keeps their ids (no DRIFT). `waitForAppSetting(client, 'event_hooks', original)` can fail to settle because `updated_at` changes on restore: compare the hooks without `created_at` / `updated_at` in a `retry`. Restore the originals before each snippet that claims to preserve existing hooks, so it can assert their ids are kept. `event_types: []` reads back as every event type plus `"*"`: assert with `arrayContaining`. +- SQS / SNS hooks with placeholder keys are accepted; `checkSQS` / `checkSNS` with fake keys resolve with `status: "error"` and an AWS "InvalidClientTokenId" message instead of rejecting (spy on the method to assert the result). Failover configs are validated on save: placeholder GCS credentials fail with code 4, and `type: "s3"` is not enabled on the test app (code 4). Probe with one `updateApp` call before writing setup. - `waitForChannelTypePropagation()` waits only for what changed since its last call (`getServerClient()` records channel type creates and updates): a new type ≥10s after creation plus 10 agreeing lookups, an update of a docs-test type 10s, an update of a built-in or pre-existing type 30s (the DRIFT check reads those back). Measured 2026-10-13: updates are enforced everywhere within ~3s, but `getChannelType` / `listChannelTypes` reads can return the old value for over a minute, so read a type back with a retry (`waitForChannelType`-style) or assert the update response. A test with two waits on a built-in type needs an explicit timeout above the 90s default. +- An app setting that changes API behavior (e.g. `enforce_unique_usernames`) can break later tests in the same file: restore it at the end of the test that changes it, and keep the `cleanup.add` restore as a fallback. +- The leak check doesn't track video calls: create them with `uniqueId` ids and `cleanup.add(() => client.video.call(type, id).delete({ hard: true }))`, ignoring "not found". +- Feeds v3 works on the test app (feed groups `user`, `timeline`, `notification`, ...), but the leak check and the end-of-run sweep don't track feeds: create them with `uniqueId` ids (`client.feeds.feed(group, id).getOrCreate({ user_id })`) and `cleanup.add(() => feed.delete({ hard_delete: true }))` (ignore "not found" when a snippet may already have deleted it). After a soft `deleteFeedUserData`, `getActivity` on the user's activity rejects with `metadata.responseCode: 404`. +- Feed groups are app-wide and eventually consistent: create them with a `uniqueId` id and `cleanup.add(() => client.feeds.deleteFeedGroup({ id, hard_delete: true }))`. Calls into a new group can fail with code 16 "Feed group with ID ... not found" for a while: spy on the call with a `mockImplementation` that retries the bound original on "not found". +- `notification` feeds aren't created automatically: `create_notification_activity` silently returns `notification_created: false` unless the target user's `notification` feed exists, so create it in setup. Deleting a feed that doesn't exist fails with "Feed ...: can't be found": match both "not found" and "can't be found" when cleanup ignores missing feeds. +- Feed visibilities (`updateFeedVisibility`) are app-wide and not in the DRIFT snapshot. Save `getFeedVisibility({ name }).feed_visibility.grants`, restore only the roles the snippet changed (an update only touches the roles it names), and `retry` until the whole grants object equals the original. - App-level resources that the leak and drift checks don't track (e.g. retention policies): save the original list in `beforeAll`, and `cleanup.add` a restore that puts the originals back and deletes anything the file added. Check that the app is back to its original state after the run. - A role can't be deleted while a user has it: in cleanup, set that user's role back to `'user'` before `deleteRole` (`Cleanup` deletes users only after the custom steps). - Unset optional app settings (`before_message_send_hook_url`, `..._attempt_timeout_ms`) are absent from `getApp()`; restoring with `''` / `0` makes them absent again, so `waitForAppSetting(client, key, undefined)` settles with no DRIFT. diff --git a/__tests__/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts b/__tests__/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts similarity index 85% rename from __tests__/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts rename to __tests__/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts index 51ab538..d79b194 100644 --- a/__tests__/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts +++ b/__tests__/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts @@ -1,10 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { waitForAppSetting, waitForTeamIdSupport } from '../../helpers/server'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { + waitForAppSetting, + waitForTeamIdSupport, +} from '../../../helpers/server'; -const DOCS = '_default/02-init_and_users/04-user_groups.md'; +const DOCS = 'chat/_default/02-init_and_users/04-user_groups.md'; const isNotFound = (error: unknown) => error instanceof Error && /not.?found|404/i.test(error.message); @@ -53,7 +56,7 @@ describe(DOCS, () => { it('creates a user group', async () => { const designTeamName = uniqueId('design-team-name'); - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Creating a User Group" tab="Node.js" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Creating a User Group" tab="Node.js" index=1 // COPY: designTeamName="Design Team", alice="alice", bob="bob", charlie="charlie" const { user_group } = await client.createUserGroup({ name: designTeamName, @@ -75,7 +78,7 @@ describe(DOCS, () => { it('updates a user group', async () => { const newName = uniqueId('design-product-team'); - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Updating a User Group" tab="Node.js" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Updating a User Group" tab="Node.js" index=1 // COPY: groupId="design-team", newName="Design & Product Team" const { user_group } = await client.updateUserGroup({ id: groupId, @@ -89,7 +92,7 @@ describe(DOCS, () => { }); it('adds members to a user group', async () => { - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Adding Members" tab="Node.js" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Adding Members" tab="Node.js" index=1 // COPY: groupId="design-team", dave="dave", eve="eve", frank="frank", grace="grace" // Add members as regular members const { user_group } = await client.addUserGroupMembers({ @@ -139,7 +142,7 @@ describe(DOCS, () => { member_ids: [dave, eve, frank], }); - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Removing Members" tab="Node.js" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Removing Members" tab="Node.js" index=1 // COPY: groupId="design-team", dave="dave", eve="eve" const { user_group } = await client.removeUserGroupMembers({ id: groupId, @@ -188,7 +191,7 @@ describe(DOCS, () => { }); it('retrieves a user group', async () => { - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Retrieving a User Group" tab="Node.js" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Retrieving a User Group" tab="Node.js" index=1 // COPY: teamGroupId="design-team", teamId="my-team" const { user_group } = await client.getUserGroup({ id: teamGroupId, @@ -203,7 +206,7 @@ describe(DOCS, () => { it('lists user groups', async () => { const lastId = ''; // no cursor: start from the first group - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Listing User Groups" tab="Node.js" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Listing User Groups" tab="Node.js" index=1 // COPY: lastId="last-id", teamId="my-team" const { user_groups } = await client.listUserGroups({ limit: 20, @@ -222,7 +225,7 @@ describe(DOCS, () => { const nameGt = ''; // no cursors: start from the first match const lastId = ''; - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Searching User Groups" tab="Node.js" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Searching User Groups" tab="Node.js" index=1 // COPY: searchQuery="design", nameGt="abc", lastId="last-id", teamId="my-team" const { user_groups } = await client.searchUserGroups({ query: searchQuery, // prefix search on group name @@ -240,7 +243,7 @@ describe(DOCS, () => { const groupToDelete = uniqueId('design-team'); await createTeamGroup(groupToDelete, uniqueId('design')); - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Deleting a User Group" tab="Node.js" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Deleting a User Group" tab="Node.js" index=1 // COPY: groupToDelete="design-team", teamId="my-team" await client.deleteUserGroup({ id: groupToDelete, diff --git a/__tests__/docs-snippets/pages/03-channels/01-creating_channels.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts similarity index 80% rename from __tests__/docs-snippets/pages/03-channels/01-creating_channels.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts index 37cc7de..32f7894 100644 --- a/__tests__/docs-snippets/pages/03-channels/01-creating_channels.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerChannel } from '../../helpers/server'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; -describe('_default/03-channels/01-creating_channels.md', () => { +describe('chat/_default/03-channels/01-creating_channels.md', () => { const client = getServerClient(); const cleanup = new Cleanup(client); const myUserId = uniqueId('myuserid'); @@ -24,7 +24,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { const travelId = uniqueId('travel'); cleanup.channels.push(`messaging:${travelId}`); - // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Creating a Channel Using a Channel ID" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/01-creating_channels.md" heading="Creating a Channel Using a Channel ID" tab="Node.js" index=1 // COPY: travelId="travel", myUserId="myuserid" const channel = client.chat.channel('messaging', travelId); // Here, 'travel' will be the channel ID @@ -46,7 +46,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { }); it('creates a distinct channel', async () => { - // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Distinct Channels" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/01-creating_channels.md" heading="Distinct Channels" tab="Node.js" index=1 // COPY: myUserId="myuserid", thierryId="thierry", tommasoId="tommaso" // Omitting the channel ID routes to a distinct channel, keyed on the member list. const channel = client.chat.channel('messaging'); diff --git a/__tests__/docs-snippets/pages/03-channels/02-query_channels.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts similarity index 81% rename from __tests__/docs-snippets/pages/03-channels/02-query_channels.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts index 2c46df6..cf44d74 100644 --- a/__tests__/docs-snippets/pages/03-channels/02-query_channels.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts @@ -1,9 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { ChannelResponse, QueryChannelsResponse } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; +import type { + ChannelResponse, + QueryChannelsResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; const CHANNEL_COUNT = 12; @@ -41,7 +44,7 @@ const channelCID = cids[0]; const userID = thierryId; const userId = thierryId; -// #region snippet docs="_default/03-channels/02-query_channels.md" heading="Filter Best Practices" tab="Node.js" index=1 +// #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Filter Best Practices" tab="Node.js" index=1 // Most performant: filter by CID await client.chat.queryChannels({ filter_conditions: { cid: channelCID } }); @@ -55,7 +58,7 @@ await client.chat.queryChannels({ filter_conditions: { type: 'messaging' } }); // #endregion snippet const filterBestPracticesRan = true; -// #region snippet docs="_default/03-channels/02-query_channels.md" heading="Bad Pattern: Overly Broad or Complex Filters" tab="Node.js" index=1 +// #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Bad Pattern: Overly Broad or Complex Filters" tab="Node.js" index=1 // ❌ BAD: Type-only filter (too broad) await client.chat.queryChannels({ filter_conditions: { type: 'messaging' } }); @@ -72,7 +75,7 @@ await client.chat.queryChannels({ // #endregion snippet const badPatternsRan = true; -describe('_default/03-channels/02-query_channels.md', () => { +describe('chat/_default/03-channels/02-query_channels.md', () => { const idsOf = (response: QueryChannelsResponse) => response.channels .map((c) => c.channel) @@ -85,7 +88,7 @@ describe('_default/03-channels/02-query_channels.md', () => { const byLastMessage = () => [...channelIds].reverse(); it('queries channels', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="" tab="Node.js" index=1 // COPY: thierryId="thierry" const response = await client.chat.queryChannels({ filter_conditions: { type: 'messaging', members: { $in: [thierryId] } }, @@ -98,7 +101,7 @@ describe('_default/03-channels/02-query_channels.md', () => { }); it('filters messaging channels by member', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Messaging and Team Channels" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Messaging and Team Channels" tab="Node.js" index=1 // COPY: thierryId="thierry" // In the Node SDK the filter goes under `filter_conditions`. const response = await client.chat.queryChannels({ @@ -112,7 +115,7 @@ describe('_default/03-channels/02-query_channels.md', () => { }); it('paginates', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Pagination" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Pagination" tab="Node.js" index=1 // COPY: thierryId="thierry" // Get channels 11-30 const response = await client.chat.queryChannels({ @@ -139,7 +142,7 @@ describe('_default/03-channels/02-query_channels.md', () => { it('sorts by last_message_at', async () => { const userID = thierryId; - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Sort Best Practices" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Sort Best Practices" tab="Node.js" index=1 // The Node SDK takes an array of explicit field/direction pairs. const response = await client.chat.queryChannels({ filter_conditions: { type: 'messaging', members: { $in: [userID] } }, @@ -156,7 +159,7 @@ describe('_default/03-channels/02-query_channels.md', () => { Date.now() - 30 * 24 * 60 * 60 * 1000, ).toISOString(); - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Good Pattern: Selective Filter with Indexed Fields" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Good Pattern: Selective Filter with Indexed Fields" tab="Node.js" index=1 // ✅ GOOD: Selective filter using indexed fields const response = await client.chat.queryChannels({ filter_conditions: { @@ -194,7 +197,7 @@ describe('_default/03-channels/02-query_channels.md', () => { it('uses a predefined filter in production', async () => { const userId = thierryId; - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Using Predefined Filters in Production" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Using Predefined Filters in Production" tab="Node.js" index=1 // COPY: filterName="user_messaging_channels" // Production-ready: use a predefined filter // `filter_conditions` is ignored when `predefined_filter` is set. @@ -212,7 +215,7 @@ describe('_default/03-channels/02-query_channels.md', () => { it('uses a predefined filter', async () => { const user123 = thierryId; - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Using Predefined Filters" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Using Predefined Filters" tab="Node.js" index=1 // COPY: filterName="user_messaging_channels", user123="user123" // `filter_conditions` is ignored when `predefined_filter` is set. const response = await client.chat.queryChannels({ @@ -233,7 +236,7 @@ describe('_default/03-channels/02-query_channels.md', () => { Date.now() - 30 * 24 * 60 * 60 * 1000, ).toISOString(); - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Query Complexity" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Query Complexity" tab="Node.js" index=1 // RECOMMENDED: simple, selective filter with indexed fields const response = await client.chat.queryChannels({ filter_conditions: { @@ -252,7 +255,7 @@ describe('_default/03-channels/02-query_channels.md', () => { it('paginates with a members filter', async () => { const userId = thierryId; - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Pagination Best Practices" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Pagination Best Practices" tab="Node.js" index=1 // Efficient pagination with members filter const response = await client.chat.queryChannels({ filter_conditions: { type: 'messaging', members: { $in: [userId] } }, diff --git a/__tests__/docs-snippets/pages/03-channels/03-channel_update.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts similarity index 87% rename from __tests__/docs-snippets/pages/03-channels/03-channel_update.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts index d20212e..515a68e 100644 --- a/__tests__/docs-snippets/pages/03-channels/03-channel_update.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts @@ -1,9 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; -const DOCS = '_default/03-channels/03-channel_update.md'; +const DOCS = 'chat/_default/03-channels/03-channel_update.md'; describe(DOCS, () => { const client = getServerClient(); @@ -44,7 +44,7 @@ describe(DOCS, () => { const type = 'messaging'; const id = partialChannelId; - // #region snippet docs="_default/03-channels/03-channel_update.md" heading="Partial Update" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/03-channel_update.md" heading="Partial Update" tab="Node.js" index=1 // COPY: userId="myuserid" // Here's a channel with some custom field data that might be useful const channel = client.chat.channel(type, id); @@ -83,7 +83,7 @@ describe(DOCS, () => { it('fully updates a channel', async () => { const channel = client.chat.channel('messaging', fullChannelId); - // #region snippet docs="_default/03-channels/03-channel_update.md" heading="Full Update" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/03-channel_update.md" heading="Full Update" tab="Node.js" index=1 // `name` and `color` are custom data, so they go under `data.custom`. const update = await channel.update({ data: { diff --git a/__tests__/docs-snippets/pages/03-channels/04-query_members.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/04-query_members.test.ts similarity index 89% rename from __tests__/docs-snippets/pages/03-channels/04-query_members.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/04-query_members.test.ts index 7cb47b1..11a4eae 100644 --- a/__tests__/docs-snippets/pages/03-channels/04-query_members.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/04-query_members.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { MembersResponse } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import type { MembersResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; -const DOCS = '_default/03-channels/04-query_members.md'; +const DOCS = 'chat/_default/03-channels/04-query_members.md'; describe(DOCS, () => { const client = getServerClient(); @@ -58,7 +58,7 @@ describe(DOCS, () => { it('queries members by name, autocomplete and all', async () => { const spy = spyOnQueryMembers(); - // #region snippet docs="_default/03-channels/04-query_members.md" heading="" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/04-query_members.md" heading="" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); @@ -87,7 +87,7 @@ describe(DOCS, () => { it('paginates channel members', async () => { const spy = spyOnQueryMembers(); - // #region snippet docs="_default/03-channels/04-query_members.md" heading="Paginating Channel Members" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/04-query_members.md" heading="Paginating Channel Members" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/03-channels/05-channel_pagination.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts similarity index 85% rename from __tests__/docs-snippets/pages/03-channels/05-channel_pagination.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts index 1ffa3c8..f0f552b 100644 --- a/__tests__/docs-snippets/pages/03-channels/05-channel_pagination.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; -const DOCS = '_default/03-channels/05-channel_pagination.md'; +const DOCS = 'chat/_default/03-channels/05-channel_pagination.md'; describe(DOCS, () => { const client = getServerClient(); @@ -47,7 +47,7 @@ describe(DOCS, () => { it('paginates messages by id', async () => { const messageId = messageIds[10]; - // #region snippet docs="_default/03-channels/05-channel_pagination.md" heading="Pagination Parameters" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/05-channel_pagination.md" heading="Pagination Parameters" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); @@ -86,7 +86,7 @@ describe(DOCS, () => { }); it('paginates members and watchers with limit and offset', async () => { - // #region snippet docs="_default/03-channels/05-channel_pagination.md" heading="Member and Watcher Pagination" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/05-channel_pagination.md" heading="Member and Watcher Pagination" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/03-channels/06-channel_members.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts similarity index 87% rename from __tests__/docs-snippets/pages/03-channels/06-channel_members.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts index da76649..b370a6a 100644 --- a/__tests__/docs-snippets/pages/03-channels/06-channel_members.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; import { getServerChannel, getServerMember, sendServerMessage, -} from '../../helpers/server'; +} from '../../../helpers/server'; -const DOCS = '_default/03-channels/06-channel_members.md'; +const DOCS = 'chat/_default/03-channels/06-channel_members.md'; describe(DOCS, () => { const client = getServerClient(); @@ -66,7 +66,7 @@ describe(DOCS, () => { const channelId = await newChannel(); const cid = `messaging:${channelId}`; - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Members" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Adding Members" tab="Node.js" index=1 // COPY: channelId="general", thierryId="thierry", joshId="josh", jamesBondId="james_bond", alecId="alec_trevelyan" const channel = client.chat.channel('messaging', channelId); @@ -101,7 +101,7 @@ describe(DOCS, () => { const cid = `messaging:${channelId}`; cleanup.channels.push(cid); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Members" tab="Node.js" index=2 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Adding Members" tab="Node.js" index=2 // COPY: channelId="general", myUserId="myuserid", jamesBondId="james_bond", alecId="alec_trevelyan" const channel = client.chat.channel('messaging', channelId); await channel.getOrCreate({ @@ -125,7 +125,7 @@ describe(DOCS, () => { const channelId = await newChannel([tommasoId, thierryId]); const cid = `messaging:${channelId}`; - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Removing Members" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Removing Members" tab="Node.js" index=1 // COPY: channelId="general", tommasoId="tommaso" const channel = client.chat.channel('messaging', channelId); await channel.update({ remove_members: [tommasoId] }); @@ -144,7 +144,7 @@ describe(DOCS, () => { user_id: myUserId, }); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Hide History" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Hide History" tab="Node.js" index=1 // COPY: channelId="general", thierryId="thierry" const channel = client.chat.channel('messaging', channelId); await channel.update({ @@ -174,7 +174,7 @@ describe(DOCS, () => { user_id: myUserId, }); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Hide History Before a Specific Date" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Hide History Before a Specific Date" tab="Node.js" index=1 // COPY: channelId="general", thierryId="thierry" const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 7); // Last 7 days @@ -198,7 +198,7 @@ describe(DOCS, () => { const channelId = await newChannel([myUserId]); const cid = `messaging:${channelId}`; - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="System Message Parameter" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="System Message Parameter" tab="Node.js" index=1 // COPY: channelId="general", tommasoId="tommaso" const channel = client.chat.channel('messaging', channelId); @@ -235,7 +235,7 @@ describe(DOCS, () => { const channelId = await newChannel([thierryId, joshId]); const cid = `messaging:${channelId}`; - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Add Moderators" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Add Moderators" tab="Node.js" index=1 // COPY: channelId="general", thierryId="thierry", joshId="josh" const channel = client.chat.channel('messaging', channelId); await channel.update({ add_moderators: [thierryId, joshId] }); @@ -257,7 +257,7 @@ describe(DOCS, () => { 'channel_moderator', ); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Remove Moderators" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Remove Moderators" tab="Node.js" index=1 // COPY: channelId="general", tommasoId="tommaso" const channel = client.chat.channel('messaging', channelId); await channel.update({ demote_moderators: [tommasoId] }); @@ -272,7 +272,7 @@ describe(DOCS, () => { const cid = `messaging:${channelId}`; cleanup.channels.push(cid); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Custom Data" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Adding Custom Data" tab="Node.js" index=1 // COPY: channelId="general", myUserId="myuserid", userId1="userid1", userId2="userid2", userId3="userid3" // Add custom data while creating the channel const channel = client.chat.channel('messaging', channelId); @@ -308,7 +308,7 @@ describe(DOCS, () => { const channelId = await newChannel([janeId]); const cid = `messaging:${channelId}`; - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Updating Member Data" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Updating Member Data" tab="Node.js" index=1 // COPY: channelId="general", janeId="jane" const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts similarity index 82% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts index d245ead..56a2f93 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts @@ -2,13 +2,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { QueryChannelsResponse, UpdateMemberPartialResponse, -} from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerMember } from '../../../helpers/server'; +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerMember } from '../../../../helpers/server'; -const DOCS = '_default/03-channels/07-channel_management/08-archiving.md'; +const DOCS = 'chat/_default/03-channels/07-channel_management/08-archiving.md'; describe(DOCS, () => { const client = getServerClient(); @@ -37,7 +37,7 @@ describe(DOCS, () => { const memberSpy = vi.spyOn(client.chat, 'updateMemberPartial'); const querySpy = vi.spyOn(client.chat, 'queryChannels'); - // #region snippet docs="_default/03-channels/07-channel_management/08-archiving.md" heading="Archive a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/08-archiving.md" heading="Archive a Channel" tab="Node.js" index=1 // COPY: channelId="general", amyId="amy" // Get a channel const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts similarity index 86% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts index 1145c2f..2ff6541 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts @@ -2,13 +2,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { QueryChannelsResponse, UpdateMemberPartialResponse, -} from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerMember } from '../../../helpers/server'; +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerMember } from '../../../../helpers/server'; -const DOCS = '_default/03-channels/07-channel_management/09-pinning.md'; +const DOCS = 'chat/_default/03-channels/07-channel_management/09-pinning.md'; describe(DOCS, () => { const client = getServerClient(); @@ -43,7 +43,7 @@ describe(DOCS, () => { const memberSpy = vi.spyOn(client.chat, 'updateMemberPartial'); const querySpy = vi.spyOn(client.chat, 'queryChannels'); - // #region snippet docs="_default/03-channels/07-channel_management/09-pinning.md" heading="Pin a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/09-pinning.md" heading="Pin a Channel" tab="Node.js" index=1 // COPY: channelId="general", amyId="amy", benId="ben" // Get a channel const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts similarity index 86% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts index 4fe9f13..dd09d2a 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts @@ -2,12 +2,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { MuteChannelResponse, QueryChannelsResponse, -} from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; -const DOCS = '_default/03-channels/07-channel_management/10-muting.md'; +const DOCS = 'chat/_default/03-channels/07-channel_management/10-muting.md'; describe(DOCS, () => { const client = getServerClient(); @@ -44,7 +44,7 @@ describe(DOCS, () => { it('mutes a channel, with and without expiration', async () => { const spy = vi.spyOn(client.chat, 'muteChannel'); - // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Mute a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/10-muting.md" heading="Mute a Channel" tab="Node.js" index=1 // COPY: cid="messaging:general", johnId="john" // Channel mutes are per-user, so a server-side call always names the user. // `channel_cids` accepts several channels in one call. @@ -99,7 +99,7 @@ describe(DOCS, () => { const userId = johnId; const spy = vi.spyOn(client.chat, 'queryChannels'); - // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Query Muted Channels" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/10-muting.md" heading="Query Muted Channels" tab="Node.js" index=1 // Mutes are per-user, so pass `user_id` to evaluate the `muted` filter. // retrieve all channels excluding muted ones await client.chat.queryChannels({ @@ -127,7 +127,7 @@ describe(DOCS, () => { await client.chat.muteChannel({ channel_cids: [cid], user_id: johnId }); expect(await mutedCids()).toEqual([cid]); - // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Remove a Channel Mute" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/10-muting.md" heading="Remove a Channel Mute" tab="Node.js" index=1 // COPY: cid="messaging:general", johnId="john" await client.chat.unmuteChannel({ channel_cids: [cid], diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts similarity index 86% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts index d5901f9..aaffc06 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts @@ -1,10 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel, sendServerMessage } from '../../../helpers/server'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { + getServerChannel, + sendServerMessage, +} from '../../../../helpers/server'; -const DOCS = '_default/03-channels/07-channel_management/11-hiding.md'; +const DOCS = 'chat/_default/03-channels/07-channel_management/11-hiding.md'; describe(DOCS, () => { const client = getServerClient(); @@ -56,7 +59,7 @@ describe(DOCS, () => { return response; }); - // #region snippet docs="_default/03-channels/07-channel_management/11-hiding.md" heading="Hide a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/11-hiding.md" heading="Hide a Channel" tab="Node.js" index=1 // COPY: channelId="general", johnId="john" // Get a channel const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts similarity index 82% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts index 3131973..a140144 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts @@ -2,13 +2,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { UpdateChannelPartialResponse, UpdateChannelResponse, -} from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel } from '../../../helpers/server'; +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel } from '../../../../helpers/server'; -const DOCS = '_default/03-channels/07-channel_management/12-disabling.md'; +const DOCS = 'chat/_default/03-channels/07-channel_management/12-disabling.md'; describe(DOCS, () => { const client = getServerClient(); @@ -33,7 +33,7 @@ describe(DOCS, () => { const updateSpy = vi.spyOn(client.chat, 'updateChannel'); const partialSpy = vi.spyOn(client.chat, 'updateChannelPartial'); - // #region snippet docs="_default/03-channels/07-channel_management/12-disabling.md" heading="Disable a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/12-disabling.md" heading="Disable a Channel" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts similarity index 87% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts index 986d8af..2166fa5 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts @@ -2,14 +2,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { DeleteChannelResponse, DeleteChannelsResponse, -} from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { sendServerMessage } from '../../../helpers/server'; -import { waitForTask } from '../../../helpers/wait'; +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { sendServerMessage } from '../../../../helpers/server'; +import { waitForTask } from '../../../../helpers/wait'; -const DOCS = '_default/03-channels/07-channel_management/13-deleting.md'; +const DOCS = 'chat/_default/03-channels/07-channel_management/13-deleting.md'; describe(DOCS, () => { const client = getServerClient(); @@ -80,7 +80,7 @@ describe(DOCS, () => { const deleteSpy = vi.spyOn(client.chat, 'deleteChannel'); - // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting a Channel" tab="Node.js" index=1 // COPY: channelId="general", otherChannelId="random" // Get a channel const channel = client.chat.channel('messaging', channelId); @@ -112,7 +112,7 @@ describe(DOCS, () => { const { cid, messageId } = await createDistinctChannel(memberId); const channelId = cid.split(':')[1]; - // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Preserving Message History" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/13-deleting.md" heading="Preserving Message History" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); @@ -140,7 +140,7 @@ describe(DOCS, () => { ]; const [distinctCid1, distinctCid2] = distinct.map((d) => d.cid); - // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting Many Channels" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting Many Channels" tab="Node.js" index=1 // soft delete const response = await client.chat.deleteChannels({ cids: [cid1, cid2] }); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts similarity index 83% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts index 1416b1c..5fd3ffe 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts @@ -2,15 +2,15 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { UpdateChannelPartialResponse, UpdateChannelResponse, -} from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { updateChannelType } from '../../../helpers/channel-types'; -import { getServerChannel } from '../../../helpers/server'; -import { waitForChannelTypePropagation } from '../../../helpers/wait'; +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { updateChannelType } from '../../../../helpers/channel-types'; +import { getServerChannel } from '../../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../../helpers/wait'; -const DOCS = '_default/03-channels/07-channel_management/14-freezing.md'; +const DOCS = 'chat/_default/03-channels/07-channel_management/14-freezing.md'; describe(DOCS, () => { const client = getServerClient(); @@ -35,7 +35,7 @@ describe(DOCS, () => { const updateSpy = vi.spyOn(client.chat, 'updateChannel'); const partialSpy = vi.spyOn(client.chat, 'updateChannelPartial'); - // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Freeze a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/14-freezing.md" heading="Freeze a Channel" tab="Node.js" index=1 // COPY: channelId="general", userId="thierry" const channel = client.chat.channel('messaging', channelId); @@ -79,7 +79,7 @@ describe(DOCS, () => { .updateChannelPartial({ set: { frozen: true } }); const updateSpy = vi.spyOn(client.chat, 'updateChannel'); - // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Unfreeze a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/14-freezing.md" heading="Unfreeze a Channel" tab="Node.js" index=1 // COPY: channelId="general", userId="thierry" const channel = client.chat.channel('messaging', channelId); @@ -116,7 +116,7 @@ describe(DOCS, () => { await waitForChannelTypePropagation(); }); - // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Granting the Frozen Channel Permission" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/14-freezing.md" heading="Granting the Frozen Channel Permission" tab="Node.js" index=1 const channelType = await client.chat.getChannelType({ name: 'messaging' }); const adminGrants = [...channelType.grants.admin, 'use-frozen-channel']; diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts similarity index 89% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts index 1722938..6bc5bff 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts @@ -7,13 +7,16 @@ import { it, vi, } from 'vitest'; -import type { TruncateChannelResponse } from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel, sendServerMessage } from '../../../helpers/server'; +import type { TruncateChannelResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { + getServerChannel, + sendServerMessage, +} from '../../../../helpers/server'; -const DOCS = '_default/03-channels/07-channel_management/15-truncating.md'; +const DOCS = 'chat/_default/03-channels/07-channel_management/15-truncating.md'; describe(DOCS, () => { const client = getServerClient(); @@ -81,7 +84,7 @@ describe(DOCS, () => { return response; }); - // #region snippet docs="_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate a Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate a Channel" tab="Node.js" index=1 // COPY: channelId="general", otherChannelId="random" // Get a channel const channel = client.chat.channel('messaging', channelId); @@ -160,7 +163,7 @@ describe(DOCS, () => { return response; }); - // #region snippet docs="_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate for Specific Members" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate for Specific Members" tab="Node.js" index=1 // COPY: channelId="general", janeId="jane", johnId="john" const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts similarity index 79% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts index 06f5b79..aeeb291 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts @@ -1,10 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerMember } from '../../../helpers/server'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerMember } from '../../../../helpers/server'; -const DOCS = '_default/03-channels/07-channel_management/16-channel_invites.md'; +const DOCS = + 'chat/_default/03-channels/07-channel_management/16-channel_invites.md'; describe(DOCS, () => { const client = getServerClient(); @@ -48,7 +49,7 @@ describe(DOCS, () => { afterAll(() => cleanup.run()); it('invites a user', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Invite Users" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Invite Users" tab="Node.js" index=1 // COPY: inviteChannelId="general", nickId="nick" // Get a channel const channel = client.chat.channel('messaging', inviteChannelId); @@ -67,7 +68,7 @@ describe(DOCS, () => { }); it('accepts an invite with a system message', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Accept an Invite" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Accept an Invite" tab="Node.js" index=1 // COPY: acceptChannelId="awesome-chat", nickId="nick" // initialize the channel const channel = client.chat.channel('messaging', acceptChannelId); @@ -99,7 +100,7 @@ describe(DOCS, () => { }); it('rejects an invite', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Reject an Invite" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Reject an Invite" tab="Node.js" index=1 // COPY: rejectChannelId="general", robId="rob" const channel = client.chat.channel('messaging', rejectChannelId); @@ -115,7 +116,7 @@ describe(DOCS, () => { }); it('queries accepted invites', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Accepted Invites" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Accepted Invites" tab="Node.js" index=1 // COPY: robId="rob" // Query accepted invites for user rob const invites = await client.chat.queryChannels({ @@ -130,7 +131,7 @@ describe(DOCS, () => { }); it('queries rejected invites', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Rejected Invites" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Rejected Invites" tab="Node.js" index=1 // COPY: robId="rob" // Query rejected invites for user rob const invites = await client.chat.queryChannels({ @@ -145,7 +146,7 @@ describe(DOCS, () => { }); it('queries pending invites', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Pending Invites" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Pending Invites" tab="Node.js" index=1 // COPY: robId="rob" // Query pending invites for user rob const invites = await client.chat.queryChannels({ diff --git a/__tests__/docs-snippets/pages/03-channels/07-channel_management/17-batch-updates.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/17-batch-updates.test.ts similarity index 88% rename from __tests__/docs-snippets/pages/03-channels/07-channel_management/17-batch-updates.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/07-channel_management/17-batch-updates.test.ts index bbc719e..787ff5e 100644 --- a/__tests__/docs-snippets/pages/03-channels/07-channel_management/17-batch-updates.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/17-batch-updates.test.ts @@ -1,15 +1,16 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { GetTaskResponse } from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel, getServerMember } from '../../../helpers/server'; +import type { GetTaskResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel, getServerMember } from '../../../../helpers/server'; import { waitForBatchTask, waitForChannelTypePropagation, -} from '../../../helpers/wait'; +} from '../../../../helpers/wait'; -const DOCS = '_default/03-channels/07-channel_management/17-batch-updates.md'; +const DOCS = + 'chat/_default/03-channels/07-channel_management/17-batch-updates.md'; describe(DOCS, () => { const client = getServerClient(); @@ -67,7 +68,7 @@ describe(DOCS, () => { afterAll(() => cleanup.run()); it('targets channels by type and by CID', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/17-batch-updates.md" heading="Filter examples" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/17-batch-updates.md" heading="Filter examples" tab="Node.js" index=1 // COPY: messagingType="messaging", cid1="messaging:3b11838a-7734-4ece-8547-4b8524257671", cid2="messaging:a266bee6-dc3c-4188-a37d-e554d4bfac34", cid3="messaging:40fef12a-0b7c-4bcf-bd97-3ddf604efed5", cid4="messaging:2a58963e-d769-4ce3-9309-bff93c14db57" // 1) Filter by type const filters = { @@ -118,7 +119,7 @@ describe(DOCS, () => { }, 240000); it('adds members and updates channel data', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/17-batch-updates.md" heading="Config overrides" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/17-batch-updates.md" heading="Config overrides" tab="Node.js" index=1 // COPY: messagingType="messaging", teamType="team", userId="user-123" // Name the operation explicitly. @@ -178,7 +179,7 @@ describe(DOCS, () => { const getTaskSpy = vi.spyOn(client, 'getTask'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - // #region snippet docs="_default/03-channels/07-channel_management/17-batch-updates.md" heading="Status" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/17-batch-updates.md" heading="Status" tab="Node.js" index=1 // task_id is optional on the batch update response, so check it before polling if (response.task_id) { const taskResponse = await client.getTask({ id: response.task_id }); diff --git a/__tests__/docs-snippets/pages/03-channels/08-get_channel.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/08-get_channel.test.ts similarity index 85% rename from __tests__/docs-snippets/pages/03-channels/08-get_channel.test.ts rename to __tests__/docs-snippets/pages/chat/03-channels/08-get_channel.test.ts index 584b802..c979ec8 100644 --- a/__tests__/docs-snippets/pages/03-channels/08-get_channel.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/08-get_channel.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { StreamError } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; +import { StreamError } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; -const DOCS = '_default/03-channels/08-get_channel.md'; +const DOCS = 'chat/_default/03-channels/08-get_channel.md'; describe(DOCS, () => { const client = getServerClient(); @@ -48,7 +48,7 @@ describe(DOCS, () => { } }); - // #region snippet docs="_default/03-channels/08-get_channel.md" heading="Using an SDK" tab="Node.js" index=1 + // #region snippet docs="chat/_default/03-channels/08-get_channel.md" heading="Using an SDK" tab="Node.js" index=1 // COPY: channelId="general", missingId="does-not-exist" // Get a channel handle const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/04-messages/01-send_message.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/01-send_message.test.ts similarity index 87% rename from __tests__/docs-snippets/pages/04-messages/01-send_message.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/01-send_message.test.ts index 16852da..b766611 100644 --- a/__tests__/docs-snippets/pages/04-messages/01-send_message.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/01-send_message.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; -const DOCS = '_default/04-messages/01-send_message.md'; +const DOCS = 'chat/_default/04-messages/01-send_message.md'; const isNotFound = (error: unknown) => error instanceof Error && /not.?found|404/i.test(error.message); @@ -49,7 +49,7 @@ describe(DOCS, () => { afterAll(() => cleanup.run()); it('sends a message', async () => { - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 // COPY: channelId="general", john="john" const channel = client.chat.channel('messaging', channelId); @@ -69,7 +69,7 @@ describe(DOCS, () => { it('sends messages with mentions', async () => { const sendMessage = vi.spyOn(client.chat, 'sendMessage'); - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Mentions" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Mentions" tab="Node.js" index=1 // COPY: channelId="general", john="john", designTeamId="design-team-id", josh="josh" const channel = client.chat.channel('messaging', channelId); @@ -136,7 +136,7 @@ describe(DOCS, () => { it('sends a message with attachments', async () => { const sendMessage = vi.spyOn(client.chat, 'sendMessage'); - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending Messages with Attachments" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending Messages with Attachments" tab="Node.js" index=1 // COPY: channelId="general", john="john", josh="josh" const channel = client.chat.channel('messaging', channelId); @@ -175,7 +175,7 @@ describe(DOCS, () => { }); it('enriches URLs', async () => { - // #region snippet docs="_default/04-messages/01-send_message.md" heading="URL Enrichment" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="URL Enrichment" tab="Node.js" index=1 // COPY: channelId="general", john="john" const channel = client.chat.channel('messaging', channelId); @@ -213,7 +213,7 @@ describe(DOCS, () => { ).id; await client.chat.deleteMessage({ id: messageID }); - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Retrieving a Message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Retrieving a Message" tab="Node.js" index=1 const { message } = await client.chat.getMessage({ id: messageID }); // For soft-deleted messages, retrieve the original content (server-side only) @@ -237,7 +237,7 @@ describe(DOCS, () => { }); const messageId = original.id; - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Updating a Message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Updating a Message" tab="Node.js" index=1 // COPY: john="john" // A full update replaces the message, so send every field you want to keep. // Use the partial update below to change one field in place. @@ -264,7 +264,7 @@ describe(DOCS, () => { custom: { color: 'red', details: { status: 'pending', owner: john } }, }); - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Partial Update" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Partial Update" tab="Node.js" index=1 // COPY: john="john" // Update text await client.chat.updateMessagePartial({ @@ -307,7 +307,7 @@ describe(DOCS, () => { }) ).id; - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Deleting a Message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Deleting a Message" tab="Node.js" index=1 // COPY: john="john" // Soft delete await client.chat.deleteMessage({ id: messageID }); @@ -337,7 +337,7 @@ describe(DOCS, () => { ).id; await client.chat.deleteMessage({ id: messageID }); - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Undeleting a Message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Undeleting a Message" tab="Node.js" index=1 await client.chat.undeleteMessage({ id: messageID, undeleted_by: userID, diff --git a/__tests__/docs-snippets/pages/04-messages/02-file_uploads.standalone.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.standalone.test.ts similarity index 89% rename from __tests__/docs-snippets/pages/04-messages/02-file_uploads.standalone.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.standalone.test.ts index 71b1044..084999f 100644 --- a/__tests__/docs-snippets/pages/04-messages/02-file_uploads.standalone.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.standalone.test.ts @@ -3,10 +3,10 @@ import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerUser } from '../../helpers/server'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser } from '../../../helpers/server'; // The "Uploading Standalone Files" fence of 02-file_uploads.md. It is in its own file // because it imports the same names as the "Uploading Files to a Channel" fence, and @@ -28,7 +28,7 @@ cleanup.users.push(userId); cleanup.add(() => rm(fixtureDir, { recursive: true, force: true })); await client.upsertUsers([{ id: userId }]); -// #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Uploading Standalone Files" tab="Node.js" index=1 +// #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Uploading Standalone Files" tab="Node.js" index=1 // COPY: avatarPath="./avatar.jpg" import { readFile } from 'node:fs/promises'; import { File } from 'node:buffer'; diff --git a/__tests__/docs-snippets/pages/04-messages/02-file_uploads.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts similarity index 92% rename from __tests__/docs-snippets/pages/04-messages/02-file_uploads.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts index ddb66d2..3f8f96b 100644 --- a/__tests__/docs-snippets/pages/04-messages/02-file_uploads.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts @@ -3,10 +3,10 @@ import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; -import { StreamError } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import { StreamError } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; // A 4x4 JPEG and a minimal PDF, written to a temp dir: the docs read `./image1.jpg`, // `./image2.jpg` and `./report.pdf`, COPY-mapped to these paths. @@ -44,7 +44,7 @@ await client.chat .channel('messaging', channelId) .getOrCreate({ data: { created_by_id: johnId } }); -// #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Uploading Files to a Channel" tab="Node.js" index=1 +// #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Uploading Files to a Channel" tab="Node.js" index=1 // COPY: image1Path="./image1.jpg", image2Path="./image2.jpg", reportPath="./report.pdf", channelId="general", johnId="john" import { readFile } from 'node:fs/promises'; import { File } from 'node:buffer'; @@ -98,7 +98,7 @@ await channel.sendMessage({ }); // #endregion snippet -describe('_default/04-messages/02-file_uploads.md', () => { +describe('chat/_default/04-messages/02-file_uploads.md', () => { afterAll(() => cleanup.run()); it('uploads files to a channel and attaches them to messages', async () => { @@ -157,7 +157,7 @@ describe('_default/04-messages/02-file_uploads.md', () => { expect(url).toMatch(/^https:\/\//); } - // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Deleting Files" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Deleting Files" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); @@ -191,7 +191,7 @@ describe('_default/04-messages/02-file_uploads.md', () => { const cdnUrl = 'https://example.com/cdn/image.jpg'; const customCDN = { upload: (_path: string) => Promise.resolve(cdnUrl) }; - // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="Node.js" index=1 // COPY: channelId="general", johnId="john" // An attachment URL can point anywhere. Upload to your own CDN and build the // attachment yourself. diff --git a/__tests__/docs-snippets/pages/04-messages/03-threads.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/03-threads.test.ts similarity index 87% rename from __tests__/docs-snippets/pages/04-messages/03-threads.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/03-threads.test.ts index 2c1feb1..c96bb5a 100644 --- a/__tests__/docs-snippets/pages/04-messages/03-threads.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/03-threads.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { ThreadStateResponse } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; +import type { ThreadStateResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; -const DOCS = '_default/04-messages/03-threads.md'; +const DOCS = 'chat/_default/04-messages/03-threads.md'; describe(DOCS, () => { const client = getServerClient(); @@ -41,7 +41,7 @@ describe(DOCS, () => { afterAll(() => cleanup.run()); it('starts a thread', async () => { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Starting a Thread" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Starting a Thread" tab="Node.js" index=1 // COPY: channelId="general", john="john" const channel = client.chat.channel('messaging', channelId); @@ -80,7 +80,7 @@ describe(DOCS, () => { // Page back from the second reply: it and the first reply are returned. const olderThanId = allIds[1]; - // #region snippet docs="_default/04-messages/03-threads.md" heading="Paginating Thread Replies" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Paginating Thread Replies" tab="Node.js" index=1 // COPY: olderThanId="42" // Get the first 20 replies const replies = await client.chat.getReplies({ @@ -116,7 +116,7 @@ describe(DOCS, () => { const originalMessageId = original.id; const sendMessage = vi.spyOn(client.chat, 'sendMessage'); - // #region snippet docs="_default/04-messages/03-threads.md" heading="Inline Replies" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Inline Replies" tab="Node.js" index=1 // COPY: channelId="general", john="john" const channel = client.chat.channel('messaging', channelId); @@ -145,7 +145,7 @@ describe(DOCS, () => { it('queries threads', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - // #region snippet docs="_default/04-messages/03-threads.md" heading="Querying Threads" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Querying Threads" tab="Node.js" index=1 // COPY: john="john" // Threads are per user, so a server-side query names the user it queries for. const { threads } = await client.chat.queryThreads({ user_id: john }); @@ -186,7 +186,7 @@ describe(DOCS, () => { // `page2` only exists inside the snippet's `if`: read it from the snippet's log. const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/04-messages/03-threads.md" heading="Supported Sort Fields" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Supported Sort Fields" tab="Node.js" index=1 // COPY: threadCreator="user-1", john="john", pageSize=10 const filter = { created_by_user_id: { $eq: threadCreator }, @@ -227,7 +227,7 @@ describe(DOCS, () => { }); it('gets a thread by id', async () => { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Getting a Thread by ID" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Getting a Thread by ID" tab="Node.js" index=1 const { thread } = await client.chat.getThread({ message_id: parentMessageId, reply_limit: 10, @@ -244,7 +244,7 @@ describe(DOCS, () => { it('updates thread title and custom data', async () => { const updateThreadPartial = vi.spyOn(client.chat, 'updateThreadPartial'); - // #region snippet docs="_default/04-messages/03-threads.md" heading="Updating Thread Title and Custom Data" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Updating Thread Title and Custom Data" tab="Node.js" index=1 // Set properties. The thread is addressed by its parent message ID. const { thread } = await client.chat.updateThreadPartial({ message_id: parentMessageId, @@ -296,7 +296,7 @@ describe(DOCS, () => { return response; }); - // #region snippet docs="_default/04-messages/03-threads.md" heading="Marking Threads as Read or Unread" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Marking Threads as Read or Unread" tab="Node.js" index=1 // COPY: channelId="general", john="john" const channel = client.chat.channel('messaging', channelId); @@ -320,7 +320,7 @@ describe(DOCS, () => { await channel.markUnread({ thread_id: parentMessageId, user_id: john }); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - // #region snippet docs="_default/04-messages/03-threads.md" heading="Unread Count Per Thread" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Unread Count Per Thread" tab="Node.js" index=1 // COPY: john="john" const response = await client.chat.unreadCounts({ user_id: john }); diff --git a/__tests__/docs-snippets/pages/04-messages/04-send_reaction.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts similarity index 86% rename from __tests__/docs-snippets/pages/04-messages/04-send_reaction.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts index d07585c..715d51d 100644 --- a/__tests__/docs-snippets/pages/04-messages/04-send_reaction.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { SendReactionResponse } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; +import type { SendReactionResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; -const DOCS = '_default/04-messages/04-send_reaction.md'; +const DOCS = 'chat/_default/04-messages/04-send_reaction.md'; describe(DOCS, () => { const client = getServerClient(); @@ -46,7 +46,7 @@ describe(DOCS, () => { }); const spy = vi.spyOn(client.chat, 'sendReaction'); - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Sending a Reaction" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Sending a Reaction" tab="Node.js" index=1 // COPY: john="john" // Add a reaction. Server-side calls must name the reacting user. const { reaction } = await client.chat.sendReaction({ @@ -94,7 +94,7 @@ describe(DOCS, () => { }); expect(await reactionsOf(messageID)).toHaveLength(1); - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Removing a Reaction" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Removing a Reaction" tab="Node.js" index=1 // COPY: john="john" await client.chat.deleteReaction({ id: messageID, @@ -116,7 +116,7 @@ describe(DOCS, () => { }); } - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Paginating Reactions" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Paginating Reactions" tab="Node.js" index=1 // Get the first 10 reactions const response = await client.chat.getReactions({ id: messageID, @@ -153,7 +153,7 @@ describe(DOCS, () => { }); } - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Querying Reactions" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Querying Reactions" tab="Node.js" index=1 // Query reactions by type await client.chat.queryReactions({ id: message.id, @@ -197,7 +197,7 @@ describe(DOCS, () => { it('sends cumulative reactions', async () => { const messageID = (await newMessage('Clap for me')).id; - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Cumulative Reactions" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Cumulative Reactions" tab="Node.js" index=1 // COPY: john="john" // User claps 5 times await client.chat.sendReaction({ diff --git a/__tests__/docs-snippets/pages/04-messages/05-pinned_messages.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts similarity index 87% rename from __tests__/docs-snippets/pages/04-messages/05-pinned_messages.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts index 8272b24..5f95840 100644 --- a/__tests__/docs-snippets/pages/04-messages/05-pinned_messages.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import type { UpdateMessagePartialResponse } from '../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import type { UpdateMessagePartialResponse } from '../../../../../index'; -const DOCS = '_default/04-messages/05-pinned_messages.md'; +const DOCS = 'chat/_default/04-messages/05-pinned_messages.md'; describe(DOCS, () => { const client = getServerClient(); @@ -35,7 +35,7 @@ describe(DOCS, () => { const updateMessagePartial = vi.spyOn(client.chat, 'updateMessagePartial'); const before = Date.now(); - // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Pinning and Unpinning Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Pinning and Unpinning Messages" tab="Node.js" index=1 // COPY: channelId="general", userId="john" const channel = client.chat.channel('messaging', channelId); @@ -112,7 +112,7 @@ describe(DOCS, () => { pinned: true, }); - // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Retrieving Pinned Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Retrieving Pinned Messages" tab="Node.js" index=1 const channel = client.chat.channel('messaging', channelId); const channelState = await channel.getOrCreate({ state: true }); const pinnedMessages = channelState.pinned_messages; @@ -138,7 +138,7 @@ describe(DOCS, () => { const aroundSize = 4; const [first, second, third] = pinnedIds; - // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Paginating Pinned Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Paginating Pinned Messages" tab="Node.js" index=1 // COPY: channelId="general", pageSize=10, aroundSize=4 const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/04-messages/06-search.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/06-search.test.ts similarity index 90% rename from __tests__/docs-snippets/pages/04-messages/06-search.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/06-search.test.ts index 34c7a24..cb44b10 100644 --- a/__tests__/docs-snippets/pages/04-messages/06-search.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/06-search.test.ts @@ -1,12 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; -import type { SearchPayload, SearchResponse } from '../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; +import type { SearchPayload, SearchResponse } from '../../../../../index'; -const DOCS = '_default/04-messages/06-search.md'; +const DOCS = 'chat/_default/04-messages/06-search.md'; describe(DOCS, () => { const client = getServerClient(); @@ -73,7 +73,7 @@ describe(DOCS, () => { 3, ); - // #region snippet docs="_default/04-messages/06-search.md" heading="Searching Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/06-search.md" heading="Searching Messages" tab="Node.js" index=1 // COPY: userId="john" // Search for messages containing text const results = await client.chat.search({ @@ -125,7 +125,7 @@ describe(DOCS, () => { 3, ); - // #region snippet docs="_default/04-messages/06-search.md" heading="Pagination" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/06-search.md" heading="Pagination" tab="Node.js" index=1 // COPY: channelCid="messaging:my-channel", pageSize=10 const channelFilters = { cid: channelCid }; const messageFilters = { text: { $autocomplete: 'supercali' } }; diff --git a/__tests__/docs-snippets/pages/04-messages/07-silent_messages.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts similarity index 82% rename from __tests__/docs-snippets/pages/04-messages/07-silent_messages.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts index ca141b1..50b4435 100644 --- a/__tests__/docs-snippets/pages/04-messages/07-silent_messages.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerChannel, sendServerMessage } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; -const DOCS = '_default/04-messages/07-silent_messages.md'; +const DOCS = 'chat/_default/04-messages/07-silent_messages.md'; describe(DOCS, () => { const client = getServerClient(); @@ -41,7 +41,7 @@ describe(DOCS, () => { const before = await unreadFor(jane); expect(before).toBe(0); - // #region snippet docs="_default/04-messages/07-silent_messages.md" heading="Silent Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/07-silent_messages.md" heading="Silent Messages" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); @@ -78,7 +78,7 @@ describe(DOCS, () => { }); it('sends a system message', async () => { - // #region snippet docs="_default/04-messages/07-silent_messages.md" heading="System Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/07-silent_messages.md" heading="System Messages" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/04-messages/08-unread_reminders.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/08-unread_reminders.test.ts similarity index 82% rename from __tests__/docs-snippets/pages/04-messages/08-unread_reminders.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/08-unread_reminders.test.ts index 11554ae..66f3cf7 100644 --- a/__tests__/docs-snippets/pages/04-messages/08-unread_reminders.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/08-unread_reminders.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { waitForAppSetting } from '../../helpers/server'; -import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { waitForAppSetting } from '../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; -const DOCS = '_default/04-messages/08-unread_reminders.md'; +const DOCS = 'chat/_default/04-messages/08-unread_reminders.md'; describe(DOCS, () => { const client = getServerClient(); @@ -39,7 +39,7 @@ describe(DOCS, () => { const before = await client.chat.getChannelType({ name: channelType }); expect(before.reminders).toBe(false); - // #region snippet docs="_default/04-messages/08-unread_reminders.md" heading="Enabling Unread Reminders" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/08-unread_reminders.md" heading="Enabling Unread Reminders" tab="Node.js" index=1 // COPY: channelType="messaging" // automod, automod_behavior and max_message_length are required here, so read // the current values and carry them through. diff --git a/__tests__/docs-snippets/pages/04-messages/09-message_reminders.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts similarity index 90% rename from __tests__/docs-snippets/pages/04-messages/09-message_reminders.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts index 0085018..22be9d3 100644 --- a/__tests__/docs-snippets/pages/04-messages/09-message_reminders.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerChannel, sendServerMessage } from '../../helpers/server'; -import { waitForChannelTypePropagation } from '../../helpers/wait'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; -const DOCS = '_default/04-messages/09-message_reminders.md'; +const DOCS = 'chat/_default/04-messages/09-message_reminders.md'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -91,7 +91,7 @@ describe(DOCS, () => { const before = await client.chat.getChannelType({ name: channelType }); expect(before.user_message_reminders).toBe(false); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Enabling Reminders" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Enabling Reminders" tab="Node.js" index=1 // COPY: channelType="messaging" // Enabling it for a channel const channel = client.chat.channel(channelType, channelId); @@ -144,7 +144,7 @@ describe(DOCS, () => { const userId = creator; const startedAt = Date.now(); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Creating a Message Reminder" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Creating a Message Reminder" tab="Node.js" index=1 // COPY: messageId="message-id", anotherMessageId="another-message-id", userId="user-id" // Create a reminder with a specific due date await client.chat.createReminder({ @@ -185,7 +185,7 @@ describe(DOCS, () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Updating a Message Reminder" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Updating a Message Reminder" tab="Node.js" index=1 // COPY: messageId="message-id", userId="user-id" // Update a reminder with a new due date const updated = await client.chat.updateReminder({ @@ -227,7 +227,7 @@ describe(DOCS, () => { user_id: userId, }); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Deleting a Message Reminder" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Deleting a Message Reminder" tab="Node.js" index=1 // COPY: messageId="message-id", userId="user-id" // Delete the reminder for the message await client.chat.deleteReminder({ @@ -257,7 +257,7 @@ describe(DOCS, () => { await client.chat.createReminder({ message_id, user_id: userId }); } - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Querying Message Reminders" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Querying Message Reminders" tab="Node.js" index=1 // COPY: userId="user-id", channelCid="messaging:general" // Query reminders for a user const reminders = await client.chat.queryReminders({ user_id: userId }); @@ -300,7 +300,7 @@ describe(DOCS, () => { const waitMs = overdueAt.getTime() + 2000 - Date.now(); if (waitMs > 0) await sleep(waitMs); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Filtering Reminders" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Filtering Reminders" tab="Node.js" index=1 // COPY: userId="user-id" // Filter overdue reminders, most recently expired first const overdue = await client.chat.queryReminders({ @@ -358,7 +358,7 @@ describe(DOCS, () => { const query = vi.spyOn(client.chat, 'queryReminders'); try { - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Pagination" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Pagination" tab="Node.js" index=1 // COPY: userId="user-id", pageSize=10 const page1 = await client.chat.queryReminders({ user_id: userId, diff --git a/__tests__/docs-snippets/pages/04-messages/10-message_receipts.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts similarity index 86% rename from __tests__/docs-snippets/pages/04-messages/10-message_receipts.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts index 273182d..e9e68f2 100644 --- a/__tests__/docs-snippets/pages/04-messages/10-message_receipts.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts @@ -1,11 +1,11 @@ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerUser } from '../../helpers/server'; -import { waitForChannelTypePropagation } from '../../helpers/wait'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser } from '../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; -const DOCS = '_default/04-messages/10-message_receipts.md'; +const DOCS = 'chat/_default/04-messages/10-message_receipts.md'; describe(DOCS, () => { const client = getServerClient(); @@ -34,7 +34,7 @@ describe(DOCS, () => { const create = waitAfterCreate(); const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); - // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="Channel Type Configuration" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/10-message_receipts.md" heading="Channel Type Configuration" tab="Node.js" index=1 // COPY: targetChannelType="targetChannelType" // When creating a channel type await client.chat.createChannelType({ @@ -84,7 +84,7 @@ describe(DOCS, () => { const userId = uniqueId('user'); cleanup.users.push(userId); - // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="Node.js" index=1 // COPY: userId="user-id" await client.upsertUsers([ { @@ -108,7 +108,7 @@ describe(DOCS, () => { const create = waitAfterCreate(); const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); - // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="Channel Type Configuration" tab="Node.js" index=2 + // #region snippet docs="chat/_default/04-messages/10-message_receipts.md" heading="Channel Type Configuration" tab="Node.js" index=2 // COPY: targetChannelType="targetChannelType" // When creating a channel type await client.chat.createChannelType({ @@ -157,7 +157,7 @@ describe(DOCS, () => { const userId = uniqueId('user'); cleanup.users.push(userId); - // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="Node.js" index=2 + // #region snippet docs="chat/_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="Node.js" index=2 // COPY: userId="user-id" await client.upsertUsers([ { diff --git a/__tests__/docs-snippets/pages/04-messages/11-ai-message-streaming.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/11-ai-message-streaming.test.ts similarity index 81% rename from __tests__/docs-snippets/pages/04-messages/11-ai-message-streaming.test.ts rename to __tests__/docs-snippets/pages/chat/04-messages/11-ai-message-streaming.test.ts index c1c254e..f915d29 100644 --- a/__tests__/docs-snippets/pages/04-messages/11-ai-message-streaming.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/11-ai-message-streaming.test.ts @@ -7,12 +7,12 @@ import { it, vi, } from 'vitest'; -import type { UpdateMessagePartialResponse } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import type { UpdateMessagePartialResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; -const DOCS = '_default/04-messages/11-ai-message-streaming.md'; +const DOCS = 'chat/_default/04-messages/11-ai-message-streaming.md'; describe(DOCS, () => { const client = getServerClient(); @@ -52,7 +52,7 @@ describe(DOCS, () => { const msgId = await sendPlaceholder(); const finalText = 'The full assistant reply.'; - // #region snippet docs="_default/04-messages/11-ai-message-streaming.md" heading="Persisted partial update" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/11-ai-message-streaming.md" heading="Persisted partial update" tab="Node.js" index=1 await client.chat.updateMessagePartial({ id: msgId, set: { text: finalText, generating: false }, @@ -73,7 +73,7 @@ describe(DOCS, () => { const buffer = 'The assistant is typ'; const spy = vi.spyOn(client.chat, 'ephemeralMessageUpdate'); - // #region snippet docs="_default/04-messages/11-ai-message-streaming.md" heading="Ephemeral partial update" tab="Node.js" index=1 + // #region snippet docs="chat/_default/04-messages/11-ai-message-streaming.md" heading="Ephemeral partial update" tab="Node.js" index=1 await client.chat.ephemeralMessageUpdate({ id: msgId, set: { text: buffer, generating: true }, diff --git a/__tests__/docs-snippets/pages/05-features/02-events.test.ts b/__tests__/docs-snippets/pages/chat/05-features/02-events.test.ts similarity index 81% rename from __tests__/docs-snippets/pages/05-features/02-events.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/02-events.test.ts index 08e4c7f..d7255c6 100644 --- a/__tests__/docs-snippets/pages/05-features/02-events.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/02-events.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { EventResponse, Response } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import type { EventResponse, Response } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; -const DOCS = '_default/05-features/02-events.md'; +const DOCS = 'chat/_default/05-features/02-events.md'; describe(DOCS, () => { const client = getServerClient(); @@ -29,7 +29,7 @@ describe(DOCS, () => { it('sends a custom event to a channel', async () => { const spy = vi.spyOn(client.chat, 'sendEvent'); - // #region snippet docs="_default/05-features/02-events.md" heading="To a channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/02-events.md" heading="To a channel" tab="Node.js" index=1 // COPY: channelId="general", johnId="john" const channel = client.chat.channel('messaging', channelId); @@ -57,7 +57,7 @@ describe(DOCS, () => { it('sends a custom event to a user', async () => { const spy = vi.spyOn(client.chat, 'sendUserCustomEvent'); - // #region snippet docs="_default/05-features/02-events.md" heading="To a user" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/02-events.md" heading="To a user" tab="Node.js" index=1 await client.chat.sendUserCustomEvent({ user_id: targetUserID, event: { diff --git a/__tests__/docs-snippets/pages/05-features/03-unread.test.ts b/__tests__/docs-snippets/pages/chat/05-features/03-unread.test.ts similarity index 87% rename from __tests__/docs-snippets/pages/05-features/03-unread.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/03-unread.test.ts index a917d77..f434306 100644 --- a/__tests__/docs-snippets/pages/05-features/03-unread.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/03-unread.test.ts @@ -7,13 +7,13 @@ import { it, vi, } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerChannel, sendServerMessage } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; -const DOCS = '_default/05-features/03-unread.md'; +const DOCS = 'chat/_default/05-features/03-unread.md'; describe(DOCS, () => { const client = getServerClient(); @@ -87,7 +87,7 @@ describe(DOCS, () => { await waitForTotalUnread(userID, 2); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/05-features/03-unread.md" heading="Unread Counts - Server side" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Unread Counts - Server side" tab="Node.js" index=1 const response = await client.chat.unreadCounts({ user_id: userID }); console.log(response.total_unread_count); // total unread count for user console.log(response.channels); // distribution of unread counts across channels @@ -120,7 +120,7 @@ describe(DOCS, () => { const userID1 = userID; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/05-features/03-unread.md" heading="Batch Fetch Unread" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Batch Fetch Unread" tab="Node.js" index=1 const response = await client.chat.unreadCountsBatch({ user_ids: [userID1, userID2], }); @@ -152,7 +152,7 @@ describe(DOCS, () => { }); { - // #region snippet docs="_default/05-features/03-unread.md" heading="Mark Read" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Mark Read" tab="Node.js" index=1 // COPY: generalId="general", fooId="foo" const channel = client.chat.channel('messaging', generalId); @@ -168,7 +168,7 @@ describe(DOCS, () => { const messageId = first.id; const userId = fooId; - // #region snippet docs="_default/05-features/03-unread.md" heading="Mark Read" tab="Node.js" index=2 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Mark Read" tab="Node.js" index=2 // COPY: generalId="general", messageId="", userId="" const channel = client.chat.channel('messaging', generalId); @@ -189,7 +189,7 @@ describe(DOCS, () => { for (const id of allReadIds) await send(id, 'unread'); await waitForTotalUnread(markAllId, 2); - // #region snippet docs="_default/05-features/03-unread.md" heading="Mark All As Read" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Mark All As Read" tab="Node.js" index=1 // COPY: markAllId="myid" // mark all channels as read for one user await client.chat.markChannelsRead({ user_id: markAllId }); @@ -212,7 +212,7 @@ describe(DOCS, () => { }); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/05-features/03-unread.md" heading="Read State - Showing how far other users have read" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Read State - Showing how far other users have read" tab="Node.js" index=1 // COPY: testChannelId="test" const channel = client.chat.channel('messaging', testChannelId); const { read } = await channel.get({ state: true }); @@ -238,7 +238,7 @@ describe(DOCS, () => { await waitForTotalUnread(perChannelId, 3); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/05-features/03-unread.md" heading="Unread Messages Per Channel" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Unread Messages Per Channel" tab="Node.js" index=1 // COPY: perChannelId="myid" // Read the per-channel distribution from the user's unread counts. const { channels } = await client.chat.unreadCounts({ diff --git a/__tests__/docs-snippets/pages/05-features/06-campaign_api.test.ts b/__tests__/docs-snippets/pages/chat/05-features/06-campaign_api.test.ts similarity index 92% rename from __tests__/docs-snippets/pages/05-features/06-campaign_api.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/06-campaign_api.test.ts index e73a332..45b5140 100644 --- a/__tests__/docs-snippets/pages/05-features/06-campaign_api.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/06-campaign_api.test.ts @@ -7,19 +7,19 @@ import { it, vi, } from 'vitest'; -import { StreamError } from '../../../../index'; +import { StreamError } from '../../../../../index'; import type { CampaignResponse, CreateSegmentResponse, QuerySegmentTargetsResponse, -} from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { deletePollsCreatedBy } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { deletePollsCreatedBy } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; -const DOCS = '_default/05-features/06-campaign_api.md'; +const DOCS = 'chat/_default/05-features/06-campaign_api.md'; const isNotFound = (error: unknown) => error instanceof StreamError && error.code === 16; @@ -206,7 +206,7 @@ describe(DOCS, () => { campaigns.push(testCampaignId); const anotherCampaignId = await createDraftCampaign(); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Sending a Campaign" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Sending a Campaign" tab="Node.js" index=1 // COPY: anotherCampaignId="", testSegmentId="", createdAfter="2020-01-01T00:00:00Z", testCampaignId="", campaignSender="", campaignName="Campaign name", pollId="poll-id", member1="user-id-1", member2="user-id-2" const segmentId = testSegmentId; // Optional, generated server side when omitted // Create a dynamic user segment based on the filter provided. @@ -325,7 +325,7 @@ describe(DOCS, () => { const encodedNext = ''; const anotherCampaignId = await createDraftCampaign(); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Sending a Campaign" tab="Node.js" index=2 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Sending a Campaign" tab="Node.js" index=2 // COPY: anotherCampaignId="", testCampaignId="", sharedSegmentId="a869fc0f-2e7e-4fe0-8651-775c892c1718", updatedSender="Updated-user-id-of-sender", updatedName="Updated name (optional)", listedSegmentId="", encodedNext="" const campaignId = testCampaignId; @@ -404,7 +404,7 @@ describe(DOCS, () => { }); it('queries campaigns with pagination', async () => { - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Querying Campaigns" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Querying Campaigns" tab="Node.js" index=1 // Query campaigns with which are scheduled or in progress const filter = { status: { $in: ['scheduled', 'in_progress'] } }; const sort = [{ field: 'created_at', direction: -1 }]; @@ -443,7 +443,7 @@ describe(DOCS, () => { const otherName = uniqueId('camp-name'); const otherSegmentId = uniqueId('seg'); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Querying Campaigns" tab="Node.js" index=2 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Querying Campaigns" tab="Node.js" index=2 // COPY: listedCampaignId="campaign_id_1", otherCampaignId="campaign_id_2", sharedSegmentId="segment_id_1", otherSegmentId="segment_id_2", listedCampaignName="campaign_name_1", otherName="campaign_name_2", campaignSender="sender_1", updatedSender="sender_2" const filterStatus = { status: { $in: ['scheduled', 'in_progress'] } }; const filterID = { id: { $in: [listedCampaignId, otherCampaignId] } }; @@ -494,7 +494,7 @@ describe(DOCS, () => { // Two users, one per page, so the second page has something to fetch. const pageSize = 1; - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Paginating Campaign Users" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Paginating Campaign Users" tab="Node.js" index=1 // COPY: testCampaignId="", pageSize=1000 // Let's say you have a campaign with 2000 users const campaignId = testCampaignId; @@ -535,7 +535,7 @@ describe(DOCS, () => { const segmentName = uniqueId('seg-name'); segments.push(testSegmentId); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=1 // COPY: testSegmentId="", segmentName="segment_name" const { segment } = await client.chat.createSegment({ type: 'user', // mandatory, "user" or "channel" @@ -567,7 +567,7 @@ describe(DOCS, () => { const firstTarget = ''; const encodedNext = ''; - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=2 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=2 // COPY: testSegmentId="", firstTarget="", encodedNext="" const segmentId = testSegmentId; // optional on create, generated server side when omitted @@ -631,7 +631,7 @@ describe(DOCS, () => { const everyoneName = uniqueId('everyone'); const createSegment = spyOnCreateSegment(); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=3 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="User Segments" tab="Node.js" index=3 // COPY: everyoneName="everyone" await client.chat.createSegment({ type: 'user', @@ -660,7 +660,7 @@ describe(DOCS, () => { const getSegment = vi.spyOn(client.chat, 'getSegment'); const segmentTargetExists = vi.spyOn(client.chat, 'segmentTargetExists'); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Channel Segments" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Channel Segments" tab="Node.js" index=1 // COPY: channelSegmentId="segmentId" // note: channel template is not required for channel segment const segmentId = channelSegmentId; @@ -708,7 +708,7 @@ describe(DOCS, () => { const segmentName = uniqueId('all-chats'); const createSegment = spyOnCreateSegment(); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Channel Segments" tab="Node.js" index=2 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Channel Segments" tab="Node.js" index=2 // COPY: segmentName="All my existing chats" await client.chat.createSegment({ type: 'channel', @@ -727,7 +727,7 @@ describe(DOCS, () => { it('gets a segment', async () => { const segmentId = sharedSegmentId; - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Getting Segment" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Getting Segment" tab="Node.js" index=1 // The segment type is not needed, the ID identifies the segment on its own const response = await client.chat.getSegment({ id: segmentId }); // #endregion snippet @@ -745,7 +745,7 @@ describe(DOCS, () => { campaigns.push(usaCampaignId); it('creates a segment for users in the USA', async () => { - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="**Create a segment for user’s in the USA**" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="**Create a segment for user’s in the USA**" tab="Node.js" index=1 // COPY: usaSegmentId="", usaCountry="USA" const segmentId = usaSegmentId; // optional, generated server side when omitted await client.chat.createSegment({ @@ -767,7 +767,7 @@ describe(DOCS, () => { const startCampaign = vi.spyOn(client.chat, 'startCampaign'); const anotherCampaignId = await createDraftCampaign(); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="**Message the above segment**" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="**Message the above segment**" tab="Node.js" index=1 // COPY: anotherCampaignId="", usaCampaignId="", campaignSender="user-id-of-sender", usaCampaignName="Campaign name (optional)" const campaignId = usaCampaignId; // optional, generated server side when omitted await client.chat.createCampaign({ @@ -805,7 +805,7 @@ describe(DOCS, () => { const campaignId = usaCampaignId; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="**Check the status of the campaign**" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="**Check the status of the campaign**" tab="Node.js" index=1 const res = await client.chat.getCampaign({ id: campaignId }); console.log(res.campaign?.status); // "draft" | "scheduled" | "stopped" | "completed" | "in_progress" // #endregion snippet @@ -815,7 +815,7 @@ describe(DOCS, () => { }); it('reads the campaign stats', async () => { - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Campaign Stats" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Campaign Stats" tab="Node.js" index=1 // COPY: usaCampaignId="" const res = await client.chat.getCampaign({ id: usaCampaignId }); @@ -839,7 +839,7 @@ describe(DOCS, () => { it('queries segments by name', async () => { const encodedNext = ''; - // #region snippet docs="_default/05-features/06-campaign_api.md" heading="Updating a large Segment" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/06-campaign_api.md" heading="Updating a large Segment" tab="Node.js" index=1 // COPY: sharedSegmentName="", encodedNext="" const response = await client.chat.querySegments({ filter: { name: sharedSegmentName }, // mandatory, pass {} to match every segment diff --git a/__tests__/docs-snippets/pages/05-features/07-polls_api.test.ts b/__tests__/docs-snippets/pages/chat/05-features/07-polls_api.test.ts similarity index 84% rename from __tests__/docs-snippets/pages/05-features/07-polls_api.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/07-polls_api.test.ts index a94e036..d635e95 100644 --- a/__tests__/docs-snippets/pages/05-features/07-polls_api.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/07-polls_api.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { CreatePollRequest } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { deletePollsCreatedBy } from '../../helpers/server'; +import type { CreatePollRequest } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { deletePollsCreatedBy } from '../../../helpers/server'; -const DOCS = '_default/05-features/07-polls_api.md'; +const DOCS = 'chat/_default/05-features/07-polls_api.md'; describe(DOCS, () => { const client = getServerClient(); @@ -49,7 +49,7 @@ describe(DOCS, () => { }; it('Polls at a Quick Glance', async () => { - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Polls at a Quick Glance" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Polls at a Quick Glance" tab="Node.js" index=1 // COPY: johnId="john", channelId="general" // Create a poll and send it in a message const { poll } = await client.createPoll({ @@ -81,7 +81,7 @@ describe(DOCS, () => { }); it('Creating a poll and sending it as part of a message', async () => { - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Creating a poll and sending it as part of a message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Creating a poll and sending it as part of a message" tab="Node.js" index=1 // COPY: johnId="john", channelId="general" const { poll } = await client.createPoll({ name: 'Where should we host our next company event?', @@ -116,7 +116,7 @@ describe(DOCS, () => { }); it('Poll options', async () => { - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Poll options" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Poll options" tab="Node.js" index=1 // COPY: johnId="john" // custom properties live under `custom`, on the poll and on each option const { poll } = await client.createPoll({ @@ -143,7 +143,7 @@ describe(DOCS, () => { const messageId = await sendPoll(pollId); const someOptionId = created.option1Id; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Send vote on option" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Send vote on option" tab="Node.js" index=1 // COPY: johnId="john", someOptionId="some-option-id-328904342" const { vote } = await client.chat.castPollVote({ message_id: messageId, @@ -162,7 +162,7 @@ describe(DOCS, () => { const pollId = created.poll.id; const messageId = await sendPoll(pollId); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Send an answer (if answers are configured to be allowed)" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Send an answer (if answers are configured to be allowed)" tab="Node.js" index=1 // COPY: johnId="john" const { vote } = await client.chat.castPollVote({ message_id: messageId, @@ -191,7 +191,7 @@ describe(DOCS, () => { const voteId = cast.vote?.id ?? ''; expect(voteId).not.toBe(''); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Removing a vote" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Removing a vote" tab="Node.js" index=1 // COPY: johnId="john" const { vote } = await client.chat.deletePollVote({ message_id: messageId, @@ -209,7 +209,7 @@ describe(DOCS, () => { it('Closing a poll', async () => { const pollId = (await createPoll()).poll.id; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Closing a poll" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Closing a poll" tab="Node.js" index=1 // COPY: johnId="john" // close a poll by setting `is_closed` const { poll } = await client.updatePollPartial({ @@ -233,7 +233,7 @@ describe(DOCS, () => { user_id: johnId, }); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Retrieving a poll" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Retrieving a poll" tab="Node.js" index=1 // COPY: johnId="john" // user_id is optional, provide it when you want the response to include // that user's own votes @@ -251,7 +251,7 @@ describe(DOCS, () => { const pollId = created.poll.id; const { option1Id, option2Id } = created; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Full update" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Full update" tab="Node.js" index=1 // COPY: johnId="john" // `name` is required on a full update, and every property you leave out is // reset to its default, so send the complete poll. @@ -288,7 +288,7 @@ describe(DOCS, () => { }); const pollId = created.poll.id; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Partial update" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Partial update" tab="Node.js" index=1 // COPY: johnId="john" const { poll } = await client.updatePollPartial({ poll_id: pollId, @@ -305,7 +305,7 @@ describe(DOCS, () => { it('Deleting a poll', async () => { const pollId = (await createPoll()).poll.id; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Deleting a poll" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Deleting a poll" tab="Node.js" index=1 // COPY: johnId="john" await client.deletePoll({ poll_id: pollId, user_id: johnId }); // #endregion snippet @@ -319,7 +319,7 @@ describe(DOCS, () => { it('Add poll option', async () => { const pollId = (await createPoll()).poll.id; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="**Add poll option**" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="**Add poll option**" tab="Node.js" index=1 // COPY: johnId="john" const { poll_option } = await client.createPollOption({ poll_id: pollId, @@ -338,7 +338,7 @@ describe(DOCS, () => { const pollId = created.poll.id; const optionId = created.option1Id; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Update poll option" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Update poll option" tab="Node.js" index=1 // COPY: johnId="john" const { poll_option } = await client.updatePollOption({ poll_id: pollId, @@ -361,7 +361,7 @@ describe(DOCS, () => { const pollId = created.poll.id; const optionId = created.option1Id; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Delete poll option" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Delete poll option" tab="Node.js" index=1 // COPY: johnId="john" await client.deletePollOption({ poll_id: pollId, @@ -395,7 +395,7 @@ describe(DOCS, () => { }); } - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Querying votes" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Querying votes" tab="Node.js" index=1 // COPY: johnId="john" // retrieve all votes on either option1Id or option2Id const { votes } = await client.queryPollVotes({ @@ -415,7 +415,7 @@ describe(DOCS, () => { const closed1 = (await createPoll({ is_closed: true })).poll.id; const closed2 = (await createPoll({ is_closed: true })).poll.id; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Querying polls" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Querying polls" tab="Node.js" index=1 // COPY: johnId="john" // retrieve all polls that are closed for voting sorted by created_at const { polls } = await client.queryPolls({ diff --git a/__tests__/docs-snippets/pages/05-features/08-location_sharing.test.ts b/__tests__/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts similarity index 90% rename from __tests__/docs-snippets/pages/05-features/08-location_sharing.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts index 4c86570..f72f065 100644 --- a/__tests__/docs-snippets/pages/05-features/08-location_sharing.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts @@ -8,14 +8,14 @@ import { vi, type MockInstance, } from 'vitest'; -import type { StreamClient } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerChannel } from '../../helpers/server'; -import { waitForChannelTypePropagation } from '../../helpers/wait'; +import type { StreamClient } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; -const DOCS = '_default/05-features/08-location_sharing.md'; +const DOCS = 'chat/_default/05-features/08-location_sharing.md'; describe(DOCS, () => { const client = getServerClient(); @@ -131,7 +131,7 @@ describe(DOCS, () => { sendLocation(channelType, typeChannelId), ).rejects.toMatchObject(notEnabled); - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Enabling location sharing" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Enabling location sharing" tab="Node.js" index=1 // COPY: channelType="messaging", otherChannelId="general" const channel = client.chat.channel(channelType, otherChannelId); @@ -183,7 +183,7 @@ describe(DOCS, () => { it('Sending static location', async () => { const spy = vi.spyOn(client.chat, 'sendMessage'); - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Sending static location" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Sending static location" tab="Node.js" index=1 // COPY: channelId="general", johnId="john" const channel = client.chat.channel('messaging', channelId); @@ -220,7 +220,7 @@ describe(DOCS, () => { it('Starting live location sharing', async () => { const spy = vi.spyOn(client.chat, 'sendMessage'); - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Starting live location sharing" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Starting live location sharing" tab="Node.js" index=1 // COPY: channelId="general", johnId="john" const channel = client.chat.channel('messaging', channelId); @@ -257,7 +257,7 @@ describe(DOCS, () => { expect(await activeLiveLocationIds()).toContain(messageID); const spy = vi.spyOn(client, 'updateLiveLocation'); - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Stopping live location sharing" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Stopping live location sharing" tab="Node.js" index=1 // COPY: messageID="message-id", johnId="john" // Stop live location sharing by setting end_at to now await client.updateLiveLocation({ @@ -284,7 +284,7 @@ describe(DOCS, () => { it('Updating live location', async () => { const messageID = await sendLiveLocation(10, 10); - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Updating live location" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Updating live location" tab="Node.js" index=1 // COPY: messageID="message-id", johnId="john" // Get the user's active live locations const { active_live_locations } = await client.getUserLiveLocations({ diff --git a/__tests__/docs-snippets/pages/05-features/09-translation.test.ts b/__tests__/docs-snippets/pages/chat/05-features/09-translation.test.ts similarity index 83% rename from __tests__/docs-snippets/pages/05-features/09-translation.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/09-translation.test.ts index 9670536..6a74662 100644 --- a/__tests__/docs-snippets/pages/05-features/09-translation.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/09-translation.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerUser, waitForAppSetting } from '../../helpers/server'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser, waitForAppSetting } from '../../../helpers/server'; -const DOCS = '_default/05-features/09-translation.md'; +const DOCS = 'chat/_default/05-features/09-translation.md'; describe(DOCS, () => { const client = getServerClient(); @@ -26,7 +26,7 @@ describe(DOCS, () => { cleanup.channels.push(`messaging:${channelId}`); const messageID = uniqueId('message'); - // #region snippet docs="_default/05-features/09-translation.md" heading="Message Translation Endpoint" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Message Translation Endpoint" tab="Node.js" index=1 // COPY: channelId="general", johnId="john" const channel = client.chat.channel('messaging', channelId); @@ -70,7 +70,7 @@ describe(DOCS, () => { await waitForAppSetting(client, 'auto_translation_enabled', original); }); - // #region snippet docs="_default/05-features/09-translation.md" heading="Enabling automatic translation" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Enabling automatic translation" tab="Node.js" index=1 // COPY: channelId="melting-pot" const channel = client.chat.channel('messaging', channelId); @@ -99,7 +99,7 @@ describe(DOCS, () => { const userId = uniqueId('user'); cleanup.users.push(userId); - // #region snippet docs="_default/05-features/09-translation.md" heading="Set user language" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Set user language" tab="Node.js" index=1 // COPY: userId="userId" // sets the user language await client.updateUsers({ diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts similarity index 73% rename from __tests__/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts index 04ce156..53c09a1 100644 --- a/__tests__/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts @@ -1,11 +1,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { UpdateChannelResponse } from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel } from '../../../helpers/server'; +import type { UpdateChannelResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel } from '../../../../helpers/server'; -const DOCS = '_default/05-features/10-advanced/11-slow_mode_and_throttling.md'; +const DOCS = + 'chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md'; describe(DOCS, () => { const client = getServerClient(); @@ -29,7 +30,7 @@ describe(DOCS, () => { it('enables, changes and disables slow mode', async () => { const updateSpy = vi.spyOn(client.chat, 'updateChannel'); - // #region snippet docs="_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts similarity index 88% rename from __tests__/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts index 5ad1460..b3ffcea 100644 --- a/__tests__/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts @@ -1,9 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { createUserToken, getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; +import { createUserToken, getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; -describe('_default/05-features/10-advanced/12-drafts.md', () => { +describe('chat/_default/05-features/10-advanced/12-drafts.md', () => { const client = getServerClient(); const cleanup = new Cleanup(client); const ownerId = uniqueId('owner'); @@ -75,7 +75,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const before = await client.chat.queryDrafts({ user_id: userId }); expect(before.drafts).toHaveLength(2); - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Deleting a draft message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Deleting a draft message" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); @@ -93,7 +93,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { it('loads a channel and a thread draft', async () => { const { userId, channelId, parentMessageId } = await setupUserWithDrafts(); - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Loading a draft message" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Loading a draft message" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); @@ -127,7 +127,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const cid1 = `messaging:${channel1}`; const cid2 = `messaging:${channel2}`; - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Querying draft messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Querying draft messages" tab="Node.js" index=1 // COPY: cid1="messaging:channel-1", cid2="messaging:channel-2" // Query all user drafts const response = await client.chat.queryDrafts({ @@ -165,7 +165,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { // Two drafts, one per page. const pageSize = 1; - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Pagination" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Pagination" tab="Node.js" index=1 // COPY: pageSize=5 // Query drafts with a limit const firstPage = await client.chat.queryDrafts({ diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts similarity index 80% rename from __tests__/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts index 5bc1286..f0ee877 100644 --- a/__tests__/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel } from '../../../helpers/server'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel } from '../../../../helpers/server'; -const DOCS = '_default/05-features/10-advanced/13-private_messaging.md'; +const DOCS = 'chat/_default/05-features/10-advanced/13-private_messaging.md'; describe(DOCS, () => { const client = getServerClient(); @@ -36,7 +36,7 @@ describe(DOCS, () => { afterAll(() => cleanup.run()); it('sends a message with restricted visibility', async () => { - // #region snippet docs="_default/05-features/10-advanced/13-private_messaging.md" heading="Sending a message with restricted visibility" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/13-private_messaging.md" heading="Sending a message with restricted visibility" tab="Node.js" index=1 // COPY: rideId="ride-08467339", adminId="admin", janeId="jane" // Get a channel const channel = client.chat.channel('messaging', rideId); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/14-user_average_response_time.test.ts b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/14-user_average_response_time.test.ts similarity index 81% rename from __tests__/docs-snippets/pages/05-features/10-advanced/14-user_average_response_time.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/10-advanced/14-user_average_response_time.test.ts index 296ebbc..60bfbcb 100644 --- a/__tests__/docs-snippets/pages/05-features/10-advanced/14-user_average_response_time.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/14-user_average_response_time.test.ts @@ -1,16 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; import { getServerUser, sendServerMessage, waitForAppSetting, -} from '../../../helpers/server'; -import { retry } from '../../../helpers/wait'; +} from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; const DOCS = - '_default/05-features/10-advanced/14-user_average_response_time.md'; + 'chat/_default/05-features/10-advanced/14-user_average_response_time.md'; describe(DOCS, () => { const client = getServerClient(); @@ -42,7 +42,7 @@ describe(DOCS, () => { await waitForAppSetting(client, 'user_response_time_enabled', original); }); - // #region snippet docs="_default/05-features/10-advanced/14-user_average_response_time.md" heading="Configuration" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/14-user_average_response_time.md" heading="Configuration" tab="Node.js" index=1 // Enable user response time tracking await client.updateApp({ user_response_time_enabled: true, diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts similarity index 88% rename from __tests__/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts index 6c68d6f..62ee1e0 100644 --- a/__tests__/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts @@ -7,14 +7,14 @@ import { it, vi, } from 'vitest'; -import type { StreamClient } from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel } from '../../../helpers/server'; -import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; +import type { StreamClient } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel } from '../../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../../helpers/wait'; -const DOCS = '_default/05-features/10-advanced/15-pending_messages.md'; +const DOCS = 'chat/_default/05-features/10-advanced/15-pending_messages.md'; const PENDING_HOOK_URL = 'https://example.com/pending-messages'; @@ -81,7 +81,7 @@ describe(DOCS, () => { const before = await client.chat.getChannelType({ name: channelType }); expect(before.mark_messages_pending).toBe(false); - // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Sending Pending Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Sending Pending Messages" tab="Node.js" index=1 // COPY: channelType="messaging" // automod, automod_behavior and max_message_length are required here, so read // the current values and carry them through. @@ -122,7 +122,7 @@ describe(DOCS, () => { }); it('Sending Pending Messages (per message)', async () => { - // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Sending Pending Messages" tab="Node.js" index=2 + // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Sending Pending Messages" tab="Node.js" index=2 // COPY: channelId="channel-id", userId="user-id" const channel = client.chat.channel('messaging', channelId); @@ -164,7 +164,7 @@ describe(DOCS, () => { const pendingId2 = await sendPending(client, channelId, senderId, 'two'); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Querying pending messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Querying pending messages" tab="Node.js" index=1 // COPY: pendingId="pending_message_id", pendingId1="pending_message_id_1", pendingId2="pending_message_id_2", channelId="channel-id" // To retrieve single message const response = await client.chat.getMessage({ id: pendingId }); @@ -190,7 +190,7 @@ describe(DOCS, () => { const filters = { cid: `messaging:${channelId}` }; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Query channels" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Query channels" tab="Node.js" index=1 // COPY: channelId="channel_id", userId="user-id" // Quering multiple channels const response = await client.chat.queryChannels({ @@ -225,7 +225,7 @@ describe(DOCS, () => { }); expect(otherBefore.messages.map((m) => m.id)).not.toContain(messageId); - // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Committing pending messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Committing pending messages" tab="Node.js" index=1 // COPY: messageId="message-1" await client.chat.commitMessage({ id: messageId }); // #endregion snippet @@ -254,7 +254,7 @@ describe(DOCS, () => { }); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Using Server-Side SDKs" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Using Server-Side SDKs" tab="Node.js" index=1 // Note: Any previously existing hooks not included in event_hooks array will be deleted. // Get current settings first to preserve your existing configuration. diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/16-dynamic_partitioning.test.ts b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/16-dynamic_partitioning.test.ts similarity index 86% rename from __tests__/docs-snippets/pages/05-features/10-advanced/16-dynamic_partitioning.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/10-advanced/16-dynamic_partitioning.test.ts index 1c68bfd..ca72376 100644 --- a/__tests__/docs-snippets/pages/05-features/10-advanced/16-dynamic_partitioning.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/16-dynamic_partitioning.test.ts @@ -11,14 +11,14 @@ import type { CreateChannelTypeResponse, GetChannelTypeResponse, UpdateChannelTypeResponse, -} from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel } from '../../../helpers/server'; -import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel } from '../../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../../helpers/wait'; -const DOCS = '_default/05-features/10-advanced/16-dynamic_partitioning.md'; +const DOCS = 'chat/_default/05-features/10-advanced/16-dynamic_partitioning.md'; describe(DOCS, () => { const client = getServerClient(); @@ -68,7 +68,7 @@ describe(DOCS, () => { return response; }); - // #region snippet docs="_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Enabling Dynamic Partitioning" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Enabling Dynamic Partitioning" tab="Node.js" index=1 // COPY: channelType="example", channelId="test", adminId="admin" await client.chat.createChannelType({ name: channelType, @@ -105,7 +105,7 @@ describe(DOCS, () => { it('updates the partition size', async () => { const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); - // #region snippet docs="_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Updating dynamic partitioning" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Updating dynamic partitioning" tab="Node.js" index=1 // COPY: channelType="example" // automod, automod_behavior and max_message_length are required here, so read // the current values and carry them through. @@ -146,7 +146,7 @@ describe(DOCS, () => { const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); - // #region snippet docs="_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Disabling dynamic partitioning" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Disabling dynamic partitioning" tab="Node.js" index=1 // COPY: channelType="example" const current = await client.chat.getChannelType({ name: channelType }); @@ -186,7 +186,7 @@ describe(DOCS, () => { const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); - // #region snippet docs="_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Partition TTL" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/16-dynamic_partitioning.md" heading="Partition TTL" tab="Node.js" index=1 // COPY: channelType="example" const current = await client.chat.getChannelType({ name: channelType }); diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/17-audit_logs.test.ts b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/17-audit_logs.test.ts similarity index 81% rename from __tests__/docs-snippets/pages/05-features/10-advanced/17-audit_logs.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/10-advanced/17-audit_logs.test.ts index 784f9c0..87fd96d 100644 --- a/__tests__/docs-snippets/pages/05-features/10-advanced/17-audit_logs.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/17-audit_logs.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { retryOnRateLimit } from '../../../helpers/wait'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retryOnRateLimit } from '../../../../helpers/wait'; -const DOCS = '_default/05-features/10-advanced/17-audit_logs.md'; +const DOCS = 'chat/_default/05-features/10-advanced/17-audit_logs.md'; const ORIGINAL_TEXT = 'Hello bob! give me a call.'; const WITH_NUMBER = @@ -53,7 +53,7 @@ describe(DOCS, () => { }); it('Example', async () => { - // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=1 // COPY: messageId="message-1", aliceId="alice" const message = await channel.sendMessage({ message: { @@ -70,7 +70,7 @@ describe(DOCS, () => { }); expect(afterSend.message_history).toEqual([]); - // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=2 + // #region snippet docs="chat/_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=2 // COPY: messageId="message-1", aliceId="alice" await client.chat.updateMessage({ id: messageId, @@ -81,7 +81,7 @@ describe(DOCS, () => { }); // #endregion snippet - // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=3 + // #region snippet docs="chat/_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=3 // COPY: messageId="message-1", adminId="admin" await client.chat.updateMessage({ id: messageId, @@ -95,7 +95,7 @@ describe(DOCS, () => { const current = await client.chat.getMessage({ id: messageId }); expect(current.message.text).toBe(REMOVED); - // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=4 + // #region snippet docs="chat/_default/05-features/10-advanced/17-audit_logs.md" heading="Example" tab="Node.js" index=4 // COPY: messageId="message-1" const history = await client.chat.queryMessageHistory({ filter: { message_id: messageId }, @@ -127,7 +127,7 @@ describe(DOCS, () => { }); it('Querying message history', async () => { - // #region snippet docs="_default/05-features/10-advanced/17-audit_logs.md" heading="Querying message history" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/17-audit_logs.md" heading="Querying message history" tab="Node.js" index=1 // COPY: messageId="message-1" // sorted by `message_updated_at` ascending: const history = await client.chat.queryMessageHistory({ diff --git a/__tests__/docs-snippets/pages/05-features/10-advanced/18-data_retention_policy.test.ts b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/18-data_retention_policy.test.ts similarity index 73% rename from __tests__/docs-snippets/pages/05-features/10-advanced/18-data_retention_policy.test.ts rename to __tests__/docs-snippets/pages/chat/05-features/10-advanced/18-data_retention_policy.test.ts index 6e19637..f014078 100644 --- a/__tests__/docs-snippets/pages/05-features/10-advanced/18-data_retention_policy.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/10-advanced/18-data_retention_policy.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { RetentionPolicy } from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; +import type { RetentionPolicy } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; import { RETENTION_POLICY_NAMES as POLICY_NAMES, restoreRetentionPolicies, -} from '../../../helpers/retention'; -import type { RetentionPolicyName as PolicyName } from '../../../helpers/retention'; +} from '../../../../helpers/retention'; +import type { RetentionPolicyName as PolicyName } from '../../../../helpers/retention'; -describe('_default/05-features/10-advanced/18-data_retention_policy.md', () => { +describe('chat/_default/05-features/10-advanced/18-data_retention_policy.md', () => { const client = getServerClient(); const cleanup = new Cleanup(client); @@ -27,7 +27,7 @@ describe('_default/05-features/10-advanced/18-data_retention_policy.md', () => { afterAll(() => cleanup.run()); it('sets retention policies', async () => { - // #region snippet docs="_default/05-features/10-advanced/18-data_retention_policy.md" heading="Setting a Retention Policy" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/18-data_retention_policy.md" heading="Setting a Retention Policy" tab="Node.js" index=1 // Delete messages older than 6 months (4,380 hours) const oldMsgResponse = await client.chat.setRetentionPolicy({ policy: 'old-messages', @@ -48,7 +48,7 @@ describe('_default/05-features/10-advanced/18-data_retention_policy.md', () => { }); it('retrieves retention policies', async () => { - // #region snippet docs="_default/05-features/10-advanced/18-data_retention_policy.md" heading="Retrieving Retention Policies" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/18-data_retention_policy.md" heading="Retrieving Retention Policies" tab="Node.js" index=1 const response = await client.chat.getRetentionPolicy(); console.log(response.policies); // #endregion snippet @@ -59,7 +59,7 @@ describe('_default/05-features/10-advanced/18-data_retention_policy.md', () => { }); it('deletes a retention policy', async () => { - // #region snippet docs="_default/05-features/10-advanced/18-data_retention_policy.md" heading="Deleting a Retention Policy" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/18-data_retention_policy.md" heading="Deleting a Retention Policy" tab="Node.js" index=1 await client.chat.deleteRetentionPolicy({ policy: 'old-messages', }); @@ -70,7 +70,7 @@ describe('_default/05-features/10-advanced/18-data_retention_policy.md', () => { }); it('queries cleanup run history', async () => { - // #region snippet docs="_default/05-features/10-advanced/18-data_retention_policy.md" heading="Viewing Cleanup Run History" tab="Node.js" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/18-data_retention_policy.md" heading="Viewing Cleanup Run History" tab="Node.js" index=1 const response = await client.chat.getRetentionPolicyRuns({ filter_conditions: { policy: { $eq: 'old-messages' }, diff --git a/__tests__/docs-snippets/pages/06-app_and_channel_settings/01-channel_types.test.ts b/__tests__/docs-snippets/pages/chat/06-app_and_channel_settings/01-channel_types.test.ts similarity index 85% rename from __tests__/docs-snippets/pages/06-app_and_channel_settings/01-channel_types.test.ts rename to __tests__/docs-snippets/pages/chat/06-app_and_channel_settings/01-channel_types.test.ts index 656b772..9bd9281 100644 --- a/__tests__/docs-snippets/pages/06-app_and_channel_settings/01-channel_types.test.ts +++ b/__tests__/docs-snippets/pages/chat/06-app_and_channel_settings/01-channel_types.test.ts @@ -10,14 +10,14 @@ import { import type { GetChannelTypeResponse, UpdateChannelTypeResponse, -} from '../../../../index'; -import { updateChannelType } from '../../helpers/channel-types'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../../../index'; +import { updateChannelType } from '../../../helpers/channel-types'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; -const DOCS = '_default/06-app_and_channel_settings/01-channel_types.md'; +const DOCS = 'chat/_default/06-app_and_channel_settings/01-channel_types.md'; describe(DOCS, () => { const client = getServerClient(); @@ -92,7 +92,7 @@ describe(DOCS, () => { const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Restricting guest access" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="Restricting guest access" tab="Node.js" index=1 // COPY: agentType="agent" // automod, automod_behavior and max_message_length are required here, so read // the current values and carry them through. @@ -129,7 +129,7 @@ describe(DOCS, () => { const createSpy = waitAfterCreate(); const updateSpy = vi.spyOn(client.chat, 'updateChannelType'); - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Updating or creating a channel type" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="Updating or creating a channel type" tab="Node.js" index=1 // COPY: myChannelType="my-channel-type" // Create a new channel type await client.chat.createChannelType({ @@ -173,7 +173,7 @@ describe(DOCS, () => { it('creates the "public" channel type', async () => { const createSpy = waitAfterCreate(); - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Creating a Channel Type" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="Creating a Channel Type" tab="Node.js" index=1 // COPY: publicType="public" await client.chat.createChannelType({ name: publicType, @@ -193,7 +193,7 @@ describe(DOCS, () => { }); it('lists the channel types', async () => { - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="List Channel Types" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="List Channel Types" tab="Node.js" index=1 const { channel_types } = await client.chat.listChannelTypes(); // #endregion snippet @@ -204,7 +204,7 @@ describe(DOCS, () => { }); it('gets a channel type', async () => { - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Get a Channel Type" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="Get a Channel Type" tab="Node.js" index=1 // COPY: publicType="public" const channelType = await client.chat.getChannelType({ name: publicType }); // #endregion snippet @@ -214,7 +214,7 @@ describe(DOCS, () => { }); it('edits commands and replies', async () => { - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=1 // COPY: publicType="public" const current = await client.chat.getChannelType({ name: publicType }); @@ -243,7 +243,7 @@ describe(DOCS, () => { }); it('updates feature flags', async () => { - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=2 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=2 // COPY: publicType="public" const current = await client.chat.getChannelType({ name: publicType }); @@ -281,7 +281,7 @@ describe(DOCS, () => { }); it('updates settings with new values', async () => { - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=3 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="Edit a Channel Type" tab="Node.js" index=3 // COPY: publicType="public" const current = await client.chat.getChannelType({ name: publicType }); @@ -306,7 +306,7 @@ describe(DOCS, () => { }); it('removes a channel type', async () => { - // #region snippet docs="_default/06-app_and_channel_settings/01-channel_types.md" heading="Remove a Channel Type" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/01-channel_types.md" heading="Remove a Channel Type" tab="Node.js" index=1 // COPY: publicType="public" const destroy = await client.chat.deleteChannelType({ name: publicType }); // #endregion snippet diff --git a/__tests__/docs-snippets/pages/06-app_and_channel_settings/02-app_settings.test.ts b/__tests__/docs-snippets/pages/chat/06-app_and_channel_settings/02-app_settings.test.ts similarity index 91% rename from __tests__/docs-snippets/pages/06-app_and_channel_settings/02-app_settings.test.ts rename to __tests__/docs-snippets/pages/chat/06-app_and_channel_settings/02-app_settings.test.ts index ae7e8ff..5d9080b 100644 --- a/__tests__/docs-snippets/pages/06-app_and_channel_settings/02-app_settings.test.ts +++ b/__tests__/docs-snippets/pages/chat/06-app_and_channel_settings/02-app_settings.test.ts @@ -7,15 +7,15 @@ import { it, vi, } from 'vitest'; -import { StreamError } from '../../../../index'; -import type { StreamChannel, UpdateAppRequest } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { waitForAppSetting } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +import { StreamError } from '../../../../../index'; +import type { StreamChannel, UpdateAppRequest } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { waitForAppSetting } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; -const DOCS = '_default/06-app_and_channel_settings/02-app_settings.md'; +const DOCS = 'chat/_default/06-app_and_channel_settings/02-app_settings.md'; // A 4x4 JPEG, uploaded once as `.jpeg` (allowed) and once as `.jpg` (not in the docs list). const JPEG_BASE64 = @@ -122,7 +122,7 @@ describe(DOCS, () => { }, ); - // #region snippet docs="_default/06-app_and_channel_settings/02-app_settings.md" heading="Authentication Behaviour" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/02-app_settings.md" heading="Authentication Behaviour" tab="Node.js" index=1 // disable auth checks, allows dev token usage await client.updateApp({ disable_auth_checks: true, @@ -150,7 +150,7 @@ describe(DOCS, () => { }); }); - // #region snippet docs="_default/06-app_and_channel_settings/02-app_settings.md" heading="File Uploads" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/02-app_settings.md" heading="File Uploads" tab="Node.js" index=1 // Only accept .CSV files await client.updateApp({ file_upload_config: { @@ -201,7 +201,7 @@ describe(DOCS, () => { }); }); - // #region snippet docs="_default/06-app_and_channel_settings/02-app_settings.md" heading="Image Uploads" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/02-app_settings.md" heading="Image Uploads" tab="Node.js" index=1 // Only accept gif, jpeg, or png files. await client.updateApp({ image_upload_config: { diff --git a/__tests__/docs-snippets/pages/06-app_and_channel_settings/03-chat_permission_policies.test.ts b/__tests__/docs-snippets/pages/chat/06-app_and_channel_settings/03-chat_permission_policies.test.ts similarity index 86% rename from __tests__/docs-snippets/pages/06-app_and_channel_settings/03-chat_permission_policies.test.ts rename to __tests__/docs-snippets/pages/chat/06-app_and_channel_settings/03-chat_permission_policies.test.ts index c790603..915ad1c 100644 --- a/__tests__/docs-snippets/pages/06-app_and_channel_settings/03-chat_permission_policies.test.ts +++ b/__tests__/docs-snippets/pages/chat/06-app_and_channel_settings/03-chat_permission_policies.test.ts @@ -10,20 +10,20 @@ import { import type { UpdateChannelResponse, UpdateChannelTypeResponse, -} from '../../../../index'; -import { updateChannelType } from '../../helpers/channel-types'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../../../index'; +import { updateChannelType } from '../../../helpers/channel-types'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; import { getServerMember, getServerUser, waitForAppSetting, -} from '../../helpers/server'; -import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; const DOCS = - '_default/06-app_and_channel_settings/03-chat_permission_policies.md'; + 'chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md'; describe(DOCS, () => { const client = getServerClient(); @@ -97,7 +97,7 @@ describe(DOCS, () => { }), ); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=1 // COPY: jamesBond="james_bond", specialAgent="special_agent" await client.updateUsersPartial({ users: [ @@ -117,7 +117,7 @@ describe(DOCS, () => { const channel = await createChannel(jamesBond); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=2 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=2 // COPY: jamesBond="james_bond" const result = await channel.update({ add_members: [{ user_id: jamesBond }], @@ -136,7 +136,7 @@ describe(DOCS, () => { const channel = await createChannel(jamesBond); const updateChannel = vi.spyOn(client.chat, 'updateChannel'); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=3 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Role Management" tab="Node.js" index=3 // COPY: jamesBond="james_bond" // Add user to the channel with role set await channel.update({ @@ -175,7 +175,7 @@ describe(DOCS, () => { const specialAgent = uniqueId('special-agent'); cleanup.add(() => client.deleteRole({ name: specialAgent })); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Custom Roles" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Custom Roles" tab="Node.js" index=1 // COPY: specialAgent="special_agent" await client.createRole({ name: specialAgent }); // #endregion snippet @@ -200,7 +200,7 @@ describe(DOCS, () => { }), ); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Custom Roles" tab="Node.js" index=2 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Custom Roles" tab="Node.js" index=2 // COPY: agent006="agent_006" await client.deleteRole({ name: agent006 }); // #endregion snippet @@ -211,7 +211,7 @@ describe(DOCS, () => { describe('Granting permissions', () => { it('lists permissions', async () => { - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=1 const { permissions } = await client.listPermissions(); // List of Permission objects // #endregion snippet @@ -239,7 +239,7 @@ describe(DOCS, () => { [...memberGrants].sort(), ); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=2 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=2 // COPY: channelType="messaging" // observe current grants of the channel type const { grants } = await client.chat.getChannelType({ @@ -285,7 +285,7 @@ describe(DOCS, () => { expect(before.grants.guest).toEqual(['read-channel']); expect(before.grants.anonymous).toEqual(['read-channel']); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=3 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=3 // COPY: channelType="messaging" const current = await client.chat.getChannelType({ name: channelType }); @@ -318,7 +318,7 @@ describe(DOCS, () => { ); const updateChannelType = vi.spyOn(client.chat, 'updateChannelType'); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=4 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=4 // COPY: channelType="messaging" // automod, automod_behavior and max_message_length are required here, so read // the current values and carry them through. @@ -358,7 +358,7 @@ describe(DOCS, () => { }); expect(original.user).toContain('flag-user'); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=5 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Granting permissions" tab="Node.js" index=5 // update grants of multiple roles in ".app" scope await client.updateApp({ grants: { @@ -393,7 +393,7 @@ describe(DOCS, () => { const before = await channel.getOrCreate({}); expect(before.channel?.config?.grants).toBeUndefined(); - // #region snippet docs="_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Channel-level permissions" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md" heading="Channel-level permissions" tab="Node.js" index=1 await channel.updateChannelPartial({ set: { config_overrides: { diff --git a/__tests__/docs-snippets/pages/06-app_and_channel_settings/06-channel-level_settings.test.ts b/__tests__/docs-snippets/pages/chat/06-app_and_channel_settings/06-channel-level_settings.test.ts similarity index 81% rename from __tests__/docs-snippets/pages/06-app_and_channel_settings/06-channel-level_settings.test.ts rename to __tests__/docs-snippets/pages/chat/06-app_and_channel_settings/06-channel-level_settings.test.ts index ab1bbb8..e584d0b 100644 --- a/__tests__/docs-snippets/pages/06-app_and_channel_settings/06-channel-level_settings.test.ts +++ b/__tests__/docs-snippets/pages/chat/06-app_and_channel_settings/06-channel-level_settings.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; const DOCS = - '_default/06-app_and_channel_settings/06-channel-level_settings.md'; + 'chat/_default/06-app_and_channel_settings/06-channel-level_settings.md'; describe(DOCS, () => { const client = getServerClient(); @@ -44,7 +44,7 @@ describe(DOCS, () => { expect(before.blocklist).toBeUndefined(); expect(before.blocklist_behavior).toBe('flag'); - // #region snippet docs="_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Use a different blocklist" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Use a different blocklist" tab="Node.js" index=1 // COPY: blocklistName="medical_blocklist" await channel.updateChannelPartial({ set: { @@ -68,7 +68,7 @@ describe(DOCS, () => { const { message: parent } = await sendText('parent'); await sendText('reply before', parent.id); - // #region snippet docs="_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Disables replies" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Disables replies" tab="Node.js" index=1 await channel.updateChannelPartial({ set: { config_overrides: { replies: false } }, }); @@ -88,7 +88,7 @@ describe(DOCS, () => { const before = await channelConfig(); expect(before.replies).toBe(false); - // #region snippet docs="_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Remove overrides and go back to default settings" tab="Node.js" index=1 + // #region snippet docs="chat/_default/06-app_and_channel_settings/06-channel-level_settings.md" heading="Remove overrides and go back to default settings" tab="Node.js" index=1 await channel.updateChannelPartial({ set: { config_overrides: {} } }); // #endregion snippet diff --git a/__tests__/docs-snippets/pages/07-push/07-legacy_push_system.test.ts b/__tests__/docs-snippets/pages/chat/07-push/07-legacy_push_system.test.ts similarity index 85% rename from __tests__/docs-snippets/pages/07-push/07-legacy_push_system.test.ts rename to __tests__/docs-snippets/pages/chat/07-push/07-legacy_push_system.test.ts index 2e95e7b..be41ded 100644 --- a/__tests__/docs-snippets/pages/07-push/07-legacy_push_system.test.ts +++ b/__tests__/docs-snippets/pages/chat/07-push/07-legacy_push_system.test.ts @@ -1,18 +1,18 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { FirebaseConfigFields } from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import type { FirebaseConfigFields } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; import { firebaseCredentials, hasFirebaseCredentials, -} from '../../helpers/push'; +} from '../../../helpers/push'; // Both fences create a push provider and set the app-level (non-multi-bundle) // `firebase_config`: the API rejects both with code 4 "firebase credentials are // invalid" unless the credentials are a real Firebase service account. describe.skipIf(!hasFirebaseCredentials)( - '_default/07-push/07-legacy_push_system.md', + 'chat/_default/07-push/07-legacy_push_system.md', () => { const client = getServerClient(); const cleanup = new Cleanup(client); @@ -42,7 +42,7 @@ describe.skipIf(!hasFirebaseCredentials)( const providerName = uniqueId('apn-provider'); registerProvider(providerName); - // #region snippet docs="_default/07-push/07-legacy_push_system.md" heading="Updating Firebase APN template" tab="Node.js" index=1 + // #region snippet docs="chat/_default/07-push/07-legacy_push_system.md" heading="Updating Firebase APN template" tab="Node.js" index=1 // COPY: providerName="my-name", firebaseCredentials="your service worker config" // for multi-bundle support await client.upsertPushProvider({ @@ -79,7 +79,7 @@ describe.skipIf(!hasFirebaseCredentials)( const providerName = uniqueId('notification-provider'); registerProvider(providerName); - // #region snippet docs="_default/07-push/07-legacy_push_system.md" heading="Updating Firebase Notification template" tab="Node.js" index=1 + // #region snippet docs="chat/_default/07-push/07-legacy_push_system.md" heading="Updating Firebase Notification template" tab="Node.js" index=1 // COPY: providerName="my-name", firebaseCredentials="your service worker config" // for multi-bundle support await client.upsertPushProvider({ diff --git a/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts b/__tests__/docs-snippets/pages/chat/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts similarity index 81% rename from __tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts rename to __tests__/docs-snippets/pages/chat/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts index 9ff54ea..f8c23d9 100644 --- a/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts +++ b/__tests__/docs-snippets/pages/chat/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.test.ts @@ -1,12 +1,12 @@ import crypto from 'crypto'; import zlib from 'zlib'; import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { waitForAppSetting } from '../../../helpers/server'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { waitForAppSetting } from '../../../../helpers/server'; const DOCS = - '_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md'; + 'chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md'; describe(DOCS, () => { const client = getServerClient(); @@ -23,7 +23,7 @@ describe(DOCS, () => { await waitForAppSetting(client, 'before_message_send_hook_url', original); }); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Configuration" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Configuration" tab="Node.js" index=1 await client.updateApp({ before_message_send_hook_url: 'https://example.com/webhooks/stream/before-message-send', @@ -52,7 +52,7 @@ describe(DOCS, () => { ); }); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Per-attempt HTTP timeout" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Per-attempt HTTP timeout" tab="Node.js" index=1 await client.updateApp({ before_message_send_hook_attempt_timeout_ms: 2000, }); @@ -91,7 +91,7 @@ describe(DOCS, () => { .spyOn(console, 'error') .mockImplementation(() => undefined); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Payload Compression" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md" heading="Payload Compression" tab="Node.js" index=1 try { const event = client.verifyAndParseWebhook( req.rawBody, diff --git a/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts b/__tests__/docs-snippets/pages/chat/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts similarity index 82% rename from __tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts rename to __tests__/docs-snippets/pages/chat/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts index 22a6244..6b6e6aa 100644 --- a/__tests__/docs-snippets/pages/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts +++ b/__tests__/docs-snippets/pages/chat/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.test.ts @@ -13,20 +13,20 @@ import type { GetCommandResponse, ListCommandsResponse, UpdateCommandResponse, -} from '../../../../../index'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { updateChannelType } from '../../../helpers/channel-types'; -import { waitForAppSetting } from '../../../helpers/server'; +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { updateChannelType } from '../../../../helpers/channel-types'; +import { waitForAppSetting } from '../../../../helpers/server'; import { retry, retryWhileChannelTypePropagates, waitForChannelTypePropagation, -} from '../../../helpers/wait'; +} from '../../../../helpers/wait'; const DOCS = - '_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md'; + 'chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md'; const handlerUrl = 'https://example.com/webhooks/stream/custom-commands?type={type}'; @@ -59,7 +59,7 @@ describe(DOCS, () => { it('Creating a Command', async () => { const createCommand = vi.spyOn(client.chat, 'createCommand'); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Creating a Command" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Creating a Command" tab="Node.js" index=1 // COPY: ticketCommand="ticket" await client.chat.createCommand({ name: ticketCommand, @@ -82,7 +82,7 @@ describe(DOCS, () => { it('List Commands', async () => { const listCommands = vi.spyOn(client.chat, 'listCommands'); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="List Commands" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="List Commands" tab="Node.js" index=1 await client.chat.listCommands(); // #endregion snippet @@ -94,7 +94,7 @@ describe(DOCS, () => { it('Get a Command', async () => { const getCommand = vi.spyOn(client.chat, 'getCommand'); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Get a Command" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Get a Command" tab="Node.js" index=1 // COPY: ticketCommand="ticket" await client.chat.getCommand({ name: ticketCommand }); // #endregion snippet @@ -110,7 +110,7 @@ describe(DOCS, () => { it('Edit a Command', async () => { const updateCommand = vi.spyOn(client.chat, 'updateCommand'); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Edit a Command" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Edit a Command" tab="Node.js" index=1 // COPY: ticketCommand="ticket" await client.chat.updateCommand({ name: ticketCommand, @@ -137,7 +137,7 @@ describe(DOCS, () => { it('Remove a Command', async () => { const deleteCommand = vi.spyOn(client.chat, 'deleteCommand'); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Remove a Command" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Remove a Command" tab="Node.js" index=1 // COPY: ticketCommand="ticket" await client.chat.deleteCommand({ name: ticketCommand }); // #endregion snippet @@ -157,7 +157,7 @@ describe(DOCS, () => { description: 'Create a support ticket', }); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Configure a Channel Type" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Configure a Channel Type" tab="Node.js" index=1 // COPY: supportChannelType="support-channel-type", ticketCommand="ticket" await client.chat.createChannelType({ name: supportChannelType, @@ -200,7 +200,7 @@ describe(DOCS, () => { await waitForAppSetting(client, 'custom_action_handler_url', original); }); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Configure a Custom Action URL" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="Configure a Custom Action URL" tab="Node.js" index=1 await client.updateApp({ custom_action_handler_url: 'https://example.com/webhooks/stream/custom-commands?type={type}', @@ -234,7 +234,7 @@ describe(DOCS, () => { } }); - // #region snippet docs="_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="" tab="Node.js" index=1 + // #region snippet docs="chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md" heading="" tab="Node.js" index=1 // COPY: supportChannelType="messaging", supportChannelId="channel-id", ticketText="/ticket suspicious transaction with id 1234" const channel = client.chat.channel(supportChannelType, supportChannelId); await channel.sendMessage({ diff --git a/__tests__/docs-snippets/pages/10-migrating/03-exporting_channels.test.ts b/__tests__/docs-snippets/pages/chat/10-migrating/03-exporting_channels.test.ts similarity index 83% rename from __tests__/docs-snippets/pages/10-migrating/03-exporting_channels.test.ts rename to __tests__/docs-snippets/pages/chat/10-migrating/03-exporting_channels.test.ts index d71a1b7..7be9bb1 100644 --- a/__tests__/docs-snippets/pages/10-migrating/03-exporting_channels.test.ts +++ b/__tests__/docs-snippets/pages/chat/10-migrating/03-exporting_channels.test.ts @@ -7,13 +7,13 @@ import { it, vi, } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import { waitForTask } from '../../helpers/wait'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { waitForTask } from '../../../helpers/wait'; -const DOCS = '_default/10-migrating/03-exporting_channels.md'; +const DOCS = 'chat/_default/10-migrating/03-exporting_channels.md'; describe(DOCS, () => { const client = getServerClient(); @@ -52,7 +52,7 @@ describe(DOCS, () => { afterAll(() => cleanup.run()); it('exports one and several channels, then checks the export status', async () => { - // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Exporting Channels" tab="Node.js" index=1 + // #region snippet docs="chat/_default/10-migrating/03-exporting_channels.md" heading="Exporting Channels" tab="Node.js" index=1 // COPY: channelId="white-room", channelId2="white-room2" // Export a single channel const response = await client.chat.exportChannels({ @@ -101,7 +101,7 @@ describe(DOCS, () => { { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Checking Export Status" tab="Node.js" index=1 + // #region snippet docs="chat/_default/10-migrating/03-exporting_channels.md" heading="Checking Export Status" tab="Node.js" index=1 const response = await client.getTask({ id: taskId }); console.log(response.status); // Task status @@ -123,7 +123,7 @@ describe(DOCS, () => { }); it('exports a channel in the v2 format', async () => { - // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Export Format (v2)" tab="Node.js" index=1 + // #region snippet docs="chat/_default/10-migrating/03-exporting_channels.md" heading="Export Format (v2)" tab="Node.js" index=1 // COPY: channelId="white-room" const response = await client.chat.exportChannels({ channels: [{ type: 'livestream', id: channelId }], @@ -146,7 +146,7 @@ describe(DOCS, () => { }); it('exports users, then checks the export status', async () => { - // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Exporting Users" tab="Node.js" index=1 + // #region snippet docs="chat/_default/10-migrating/03-exporting_channels.md" heading="Exporting Users" tab="Node.js" index=1 const response = await client.exportUsers({ user_ids: [user1.id, user2.id], }); @@ -158,7 +158,7 @@ describe(DOCS, () => { { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/10-migrating/03-exporting_channels.md" heading="Checking Export Status" tab="Node.js" index=2 + // #region snippet docs="chat/_default/10-migrating/03-exporting_channels.md" heading="Checking Export Status" tab="Node.js" index=2 const response = await client.getTask({ id: taskId }); if (response.status === 'completed') { diff --git a/__tests__/docs-snippets/pages/12-best_practices/02-moderation.test.ts b/__tests__/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts similarity index 88% rename from __tests__/docs-snippets/pages/12-best_practices/02-moderation.test.ts rename to __tests__/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts index bbd2e77..235908f 100644 --- a/__tests__/docs-snippets/pages/12-best_practices/02-moderation.test.ts +++ b/__tests__/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts @@ -11,20 +11,20 @@ import type { FlagItemResponse, UpdateAppRequest, UpdateChannelResponse, -} from '../../../../index'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; import { getServerChannel, getServerMember, getServerUser, sendServerMessage, waitForAppSetting, -} from '../../helpers/server'; -import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; -const DOCS = '_default/12-best_practices/02-moderation.md'; +const DOCS = 'chat/_default/12-best_practices/02-moderation.md'; describe(DOCS, () => { const client = getServerClient(); @@ -101,7 +101,7 @@ describe(DOCS, () => { ); const channelType = grantsType; - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Disabling the permission to post links or add attachments" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Disabling the permission to post links or add attachments" tab="Node.js" index=1 // COPY: channelType="messaging" // automod, automod_behavior and max_message_length are required here, so read // the current values and carry them through. @@ -152,7 +152,7 @@ describe(DOCS, () => { await waitForAppSetting(client, 'file_upload_config', originalFile); }); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Image & Video file types" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Image & Video file types" tab="Node.js" index=1 // Restrict images to common formats only await client.updateApp({ image_upload_config: { @@ -200,7 +200,7 @@ describe(DOCS, () => { const channelId = await createChannel(authorId); const updateChannel = vi.spyOn(client.chat, 'updateChannel'); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="Node.js" index=1 // COPY: channelId="channel-id", jamesBond="james_bond" const channel = client.chat.channel('messaging', channelId); @@ -241,7 +241,7 @@ describe(DOCS, () => { const jamesBond = await createUser('james-bond'); expect((await getServerUser(client, jamesBond)).role).toBe('user'); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="Node.js" index=2 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="Node.js" index=2 // COPY: jamesBond="james_bond" await client.updateUsersPartial({ users: [{ id: jamesBond, set: { role: 'admin' } }], @@ -256,7 +256,7 @@ describe(DOCS, () => { const channelId = await createChannel(authorId); const updateChannel = vi.spyOn(client.chat, 'updateChannel'); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Slow mode" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Slow mode" tab="Node.js" index=1 // COPY: channelId="general" const channel = client.chat.channel('messaging', channelId); @@ -293,7 +293,7 @@ describe(DOCS, () => { }); const updateApp = vi.spyOn(client, 'updateApp'); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Enforce unique usernames" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Enforce unique usernames" tab="Node.js" index=1 // Enable uniqueness constraints on App level await client.updateApp({ enforce_unique_usernames: 'app', @@ -318,7 +318,7 @@ describe(DOCS, () => { it('enables the ban and unban commands', async () => { const channelType = commandsType; - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Slash commands for banning" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Slash commands for banning" tab="Node.js" index=1 // COPY: channelType="messaging" // automod, automod_behavior and max_message_length are required here, so read // the current values and carry them through. @@ -360,7 +360,7 @@ describe(DOCS, () => { const anotherMessageId = await createMessage(); const flag = vi.spyOn(client.moderation, 'flag'); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Flag" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Flag" tab="Node.js" index=1 // COPY: reportingUserId="reporting-user-id" // Flag a message. Server-side the flag must name the reporting user. await client.moderation.flag({ @@ -400,7 +400,7 @@ describe(DOCS, () => { const anotherMessageID = await createMessage(); const thirdMessageID = await createMessage(); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Reasons & custom data" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Reasons & custom data" tab="Node.js" index=1 // COPY: reportingUserId="reporting-user-id" // flag with a reason await client.moderation.flag({ @@ -459,7 +459,7 @@ describe(DOCS, () => { await waitForFlag(messageId); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Query Flagged Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Query Flagged Messages" tab="Node.js" index=1 const response = await client.moderation.queryReviewQueue({ filter: { entity_type: 'stream:chat:v1:message' }, sort: [{ field: 'created_at', direction: -1 }], @@ -495,7 +495,7 @@ describe(DOCS, () => { const user2Id = await createUser('user2'); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=1 // COPY: user1Id="user1", user2Id="user2" const blockingUser = user1Id; const blockedUser = user2Id; @@ -512,7 +512,7 @@ describe(DOCS, () => { expect(blocks.map((b) => b.blocked_user_id)).toEqual([user2Id]); { - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=3 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=3 const { blocks } = await client.getBlockedUsers({ user_id: blockingUser, }); @@ -525,7 +525,7 @@ describe(DOCS, () => { expect(log.mock.calls).toEqual([[user2Id]]); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=2 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Server Side" tab="Node.js" index=2 await client.unblockUsers({ blocked_user_id: blockedUser, user_id: blockingUser, diff --git a/__tests__/docs-snippets/pages/12-best_practices/05-marketplace_best_practices.test.ts b/__tests__/docs-snippets/pages/chat/12-best_practices/05-marketplace_best_practices.test.ts similarity index 88% rename from __tests__/docs-snippets/pages/12-best_practices/05-marketplace_best_practices.test.ts rename to __tests__/docs-snippets/pages/chat/12-best_practices/05-marketplace_best_practices.test.ts index fbf67f7..1a3ad23 100644 --- a/__tests__/docs-snippets/pages/12-best_practices/05-marketplace_best_practices.test.ts +++ b/__tests__/docs-snippets/pages/chat/12-best_practices/05-marketplace_best_practices.test.ts @@ -1,16 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; import { getServerChannel, getServerUser, sendServerMessage, waitForAppSetting, -} from '../../helpers/server'; -import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; -const DOCS = '_default/12-best_practices/05-marketplace_best_practices.md'; +const DOCS = 'chat/_default/12-best_practices/05-marketplace_best_practices.md'; describe(DOCS, () => { const client = getServerClient(); @@ -76,7 +76,7 @@ describe(DOCS, () => { expect(app.reminders_interval).not.toBe(3600); const channelType = featuresType; - // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="Unread Message Reminders" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/05-marketplace_best_practices.md" heading="Unread Message Reminders" tab="Node.js" index=1 // COPY: channelType="messaging" // Enable reminders for the messaging channel type. automod, automod_behavior and // max_message_length are required on this endpoint, so carry the current values @@ -113,7 +113,7 @@ describe(DOCS, () => { }, }); - // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="User Average Response Time" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/05-marketplace_best_practices.md" heading="User Average Response Time" tab="Node.js" index=1 await client.updateApp({ user_response_time_enabled: true, }); @@ -149,7 +149,7 @@ describe(DOCS, () => { expect(before.mark_messages_pending).toBe(false); const channelType = featuresType; - // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="Pending Messages" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/05-marketplace_best_practices.md" heading="Pending Messages" tab="Node.js" index=1 // COPY: channelType="messaging" // automod, automod_behavior and max_message_length are required on this endpoint, // so carry the current values through to avoid overwriting them. @@ -183,7 +183,7 @@ describe(DOCS, () => { }); const channelType = settingsType; - // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="Recommended Settings" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/05-marketplace_best_practices.md" heading="Recommended Settings" tab="Node.js" index=1 // COPY: channelType="messaging" // automod, automod_behavior and max_message_length are required on this endpoint, // so carry the current values through to avoid overwriting them. @@ -216,7 +216,7 @@ describe(DOCS, () => { }); it('stores listing information on a distinct channel', async () => { - // #region snippet docs="_default/12-best_practices/05-marketplace_best_practices.md" heading="Custom Data" tab="Node.js" index=1 + // #region snippet docs="chat/_default/12-best_practices/05-marketplace_best_practices.md" heading="Custom Data" tab="Node.js" index=1 // Store listing information on the channel. // Omitting the channel ID creates a distinct channel, keyed on the member list. const channel = client.chat.channel('messaging'); diff --git a/__tests__/docs-snippets/pages/node/01-quick_start/01-backend_quickstart.test.ts b/__tests__/docs-snippets/pages/chat/node/01-quick_start/01-backend_quickstart.test.ts similarity index 85% rename from __tests__/docs-snippets/pages/node/01-quick_start/01-backend_quickstart.test.ts rename to __tests__/docs-snippets/pages/chat/node/01-quick_start/01-backend_quickstart.test.ts index 4bb378f..c14aa4f 100644 --- a/__tests__/docs-snippets/pages/node/01-quick_start/01-backend_quickstart.test.ts +++ b/__tests__/docs-snippets/pages/chat/node/01-quick_start/01-backend_quickstart.test.ts @@ -1,16 +1,16 @@ import jwt from 'jsonwebtoken'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getServerClient } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerMember, getServerUser } from '../../../helpers/server'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerMember, getServerUser } from '../../../../helpers/server'; // Values the module-level region below uses (it must come after them: TDZ). const apiKey = process.env.STREAM_API_KEY as string; const apiSecret = process.env.STREAM_SECRET as string; const johnId = uniqueId('john'); -// #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Generating user tokens" tab="Node.js" index=1 +// #region snippet docs="chat/node/01-quick_start/01-backend_quickstart.md" heading="Generating user tokens" tab="Node.js" index=1 // COPY: apiKey="{{ api_key }}", apiSecret="{{ api_secret }}", johnId="john" // npm install @stream-io/node-sdk import { StreamClient } from '@stream-io/node-sdk'; @@ -25,7 +25,7 @@ const token = client.generateUserToken({ user_id: johnId }); // next, hand this token to the client in your login or registration response // #endregion snippet -describe('node/01-quick_start/01-backend_quickstart.md', () => { +describe('chat/node/01-quick_start/01-backend_quickstart.md', () => { const serverClient = getServerClient(); const cleanup = new Cleanup(serverClient); const creatorId = uniqueId('creator'); @@ -66,7 +66,7 @@ describe('node/01-quick_start/01-backend_quickstart.md', () => { const userID = uniqueId('user'); cleanup.users.push(userID); - // #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Syncing users" tab="Node.js" index=1 + // #region snippet docs="chat/node/01-quick_start/01-backend_quickstart.md" heading="Syncing users" tab="Node.js" index=1 const response = await client.upsertUsers([ { id: userID, @@ -83,7 +83,7 @@ describe('node/01-quick_start/01-backend_quickstart.md', () => { }); it('syncs channels', async () => { - // #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Syncing channels" tab="Node.js" index=1 + // #region snippet docs="chat/node/01-quick_start/01-backend_quickstart.md" heading="Syncing channels" tab="Node.js" index=1 // COPY: channelId="kung-fu", creatorId="4645" const channel = client.chat.channel('messaging', channelId); @@ -113,7 +113,7 @@ describe('node/01-quick_start/01-backend_quickstart.md', () => { const channel = await getChannel([tommasoId]); await getServerMember(serverClient, cid, tommasoId); - // #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Adding members or moderators" tab="Node.js" index=1 + // #region snippet docs="chat/node/01-quick_start/01-backend_quickstart.md" heading="Adding members or moderators" tab="Node.js" index=1 // COPY: thierryId="thierry", joshId="josh", tommasoId="tommaso" await channel.update({ add_members: [{ user_id: thierryId }, { user_id: joshId }], @@ -136,7 +136,7 @@ describe('node/01-quick_start/01-backend_quickstart.md', () => { it('sends a message', async () => { const channel = await getChannel([johnId, joshId]); - // #region snippet docs="node/01-quick_start/01-backend_quickstart.md" heading="Sending messages" tab="Node.js" index=1 + // #region snippet docs="chat/node/01-quick_start/01-backend_quickstart.md" heading="Sending messages" tab="Node.js" index=1 // COPY: johnId="john", joshId="josh" const { message } = await channel.sendMessage({ message: { diff --git a/__tests__/docs-snippets/pages/platform/01-start-here/01-backend-sdks.test.ts b/__tests__/docs-snippets/pages/platform/01-start-here/01-backend-sdks.test.ts new file mode 100644 index 0000000..d3c7691 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/01-start-here/01-backend-sdks.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +// Values the module-level region below uses (it must come after them: TDZ). +const apiKey = process.env.STREAM_API_KEY as string; +const apiSecret = process.env.STREAM_SECRET as string; + +// #region snippet docs="platform/01-start-here/01-backend-sdks.md" heading="Creating a client" tab="Node.js" index=1 +// COPY: apiKey="your_api_key", apiSecret="your_api_secret" +import { StreamClient } from '@stream-io/node-sdk'; +// or: const { StreamClient } = require("@stream-io/node-sdk"); + +const client = new StreamClient(apiKey, apiSecret); +// #endregion snippet + +describe('platform/01-start-here/01-backend-sdks.md', () => { + it('creates a client that reaches the API with the key and secret', async () => { + expect(client).toBeInstanceOf(StreamClient); + expect(client.apiKey).toBe(apiKey); + + // App-level operations live on the client. + const response = await client.getApp(); + expect(typeof response.app.name).toBe('string'); + + // Product namespaces hang off the same client. + expect(client.chat).toBeDefined(); + expect(client.video).toBeDefined(); + expect(client.feeds).toBeDefined(); + expect(client.moderation).toBeDefined(); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/02-identity-and-access/01-authentication.test.ts b/__tests__/docs-snippets/pages/platform/02-identity-and-access/01-authentication.test.ts new file mode 100644 index 0000000..e3f93b0 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/02-identity-and-access/01-authentication.test.ts @@ -0,0 +1,284 @@ +import jwt from 'jsonwebtoken'; +import type { JwtPayload } from 'jsonwebtoken'; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; + +const DOCS = 'platform/02-identity-and-access/01-authentication.md'; + +const apiSecret = process.env.STREAM_SECRET as string; + +/** The claims of `token`, after checking its signature with the API secret. */ +const claims = (token: string) => jwt.verify(token, apiSecret) as JwtPayload; + +/** Client-side query channels with `token`: the HTTP status and the Stream error code. */ +const queryChannelsAs = async (token: string, userId: string) => { + const response = await fetch( + `https://chat.stream-io-api.com/channels?api_key=${process.env.STREAM_API_KEY ?? ''}`, + { + method: 'POST', + headers: { + Authorization: token, + 'stream-auth-type': 'jwt', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + filter_conditions: { members: { $in: [userId] } }, + }), + }, + ); + const body = (await response.json()) as { code?: number; message?: string }; + return { status: response.status, code: body.code, message: body.message }; +}; + +/** Retries until every token gets `status` from the API (revocation is eventually consistent). */ +const waitForStatus = ( + tokens: { token: string; userId: string }[], + status: number, +) => + retry(async () => { + const results = await Promise.all( + tokens.map(({ token, userId }) => queryChannelsAs(token, userId)), + ); + expect(results.map((r) => r.status)).toEqual(tokens.map(() => status)); + return results; + }); + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + const revokedUserId = uniqueId('user'); + const user1Id = uniqueId('user1'); + const user2Id = uniqueId('user2'); + const iatUserId = uniqueId('user-iat'); + + /** Tokens issued two minutes ago, so a revocation date of "now" applies to them. */ + const oldTokens = () => { + const iat = Math.floor(Date.now() / 1000) - 120; + return [revokedUserId, user1Id, user2Id].map((userId) => ({ + userId, + token: client.generateUserToken({ user_id: userId, iat }), + })); + }; + let revokedTokens: { token: string; userId: string }[] = []; + + beforeAll(async () => { + const ids = [johnId, revokedUserId, user1Id, user2Id, iatUserId]; + await client.upsertUsers(ids.map((id) => ({ id }))); + cleanup.users.push(...ids); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('generates a user token valid for one day', async () => { + const generate = vi.spyOn(client, 'generateUserToken'); + + // #region snippet docs="platform/02-identity-and-access/01-authentication.md" heading="Generating Tokens" tab="Node.js" index=1 + // COPY: johnId="john" + const userId = johnId; + // validity is optional, in this case we set it to 1 day + const validity = 24 * 60 * 60; + client.generateUserToken({ + user_id: userId, + validity_in_seconds: validity, + }); + // #endregion snippet + + const token = generate.mock.results[0].value as string; + const payload = claims(token); + expect(payload.user_id).toBe(johnId); + expect(payload).not.toHaveProperty('validity_in_seconds'); + expect((payload.exp ?? 0) - (payload.iat ?? 0)).toBe(validity); + + // The API accepts it for that user. + expect((await queryChannelsAs(token, johnId)).status).toBe(201); + }); + + it('generates call tokens, optionally with a role', async () => { + const generate = vi.spyOn(client, 'generateCallToken'); + + // #region snippet docs="platform/02-identity-and-access/01-authentication.md" heading="Call tokens" tab="Node.js" index=1 + // COPY: johnId="john" + const user_id = johnId; + // validity is optional (by default the token is valid for an hour) + const validity_in_seconds = 60 * 60; + + const call_cids = ['default:call1', 'livestream:call2']; + + client.generateCallToken({ user_id, call_cids, validity_in_seconds }); + + // Optionally provide a role for the call(s) + client.generateCallToken({ user_id, call_cids, role: 'admin' }); + // #endregion snippet + + const [callToken, adminToken] = generate.mock.results.map( + (r) => r.value as string, + ); + const payload = claims(callToken); + expect(payload).toMatchObject({ user_id: johnId, call_cids }); + expect(payload).not.toHaveProperty('role'); + expect((payload.exp ?? 0) - (payload.iat ?? 0)).toBe(validity_in_seconds); + + const adminPayload = claims(adminToken); + expect(adminPayload).toMatchObject({ + user_id: johnId, + call_cids, + role: 'admin', + }); + // Without a validity, the token is valid for an hour. + expect((adminPayload.exp ?? 0) - (adminPayload.iat ?? 0)).toBe(60 * 60); + + expect((await queryChannelsAs(callToken, johnId)).status).toBe(201); + }); + + it('sets the token expiration with exp or validity_in_seconds', () => { + const before = Math.floor(Date.now() / 1000); + + // #region snippet docs="platform/02-identity-and-access/01-authentication.md" heading="Setting Automatic Token Expiration" tab="Node.js" index=1 + // COPY: johnId="john" + // creates a token that expires in 1 hour + const token1 = client.generateUserToken({ + user_id: johnId, + exp: Math.floor(Date.now() / 1000) + 60 * 60, + }); + + // the same can be done with validity_in_seconds + const token2 = client.generateUserToken({ + user_id: johnId, + validity_in_seconds: 60 * 60, + }); + // #endregion snippet + + const after = Math.floor(Date.now() / 1000); + const payload1 = claims(token1); + const payload2 = claims(token2); + expect(payload1.user_id).toBe(johnId); + expect(payload1.exp).toBeGreaterThanOrEqual(before + 3600); + expect(payload1.exp).toBeLessThanOrEqual(after + 3600); + expect(payload2.user_id).toBe(johnId); + expect((payload2.exp ?? 0) - (payload2.iat ?? 0)).toBe(3600); + }); + + it('revokes the tokens of one user and of a list of users', async () => { + revokedTokens = oldTokens(); + await waitForStatus(revokedTokens, 201); + const revokeDate = new Date(); + + // #region snippet docs="platform/02-identity-and-access/01-authentication.md" heading="Token Revocation by User" tab="Node.js" index=1 + // COPY: revokedUserId="user-id", user1Id="user1-id", user2Id="user2-id" + await client.updateUsersPartial({ + users: [ + { id: revokedUserId, set: { revoke_tokens_issued_before: revokeDate } }, + ], + }); + await client.updateUsersPartial({ + users: [ + { id: user1Id, set: { revoke_tokens_issued_before: revokeDate } }, + { id: user2Id, set: { revoke_tokens_issued_before: revokeDate } }, + ], + }); + // #endregion snippet + + for (const userId of [revokedUserId, user1Id, user2Id]) { + const user = await getServerUser(client, userId); + // Stored with sub-millisecond rounding: compare to the millisecond. + expect( + Math.abs( + (user.revoke_tokens_issued_before?.getTime() ?? 0) - + revokeDate.getTime(), + ), + ).toBeLessThanOrEqual(1); + } + // Tokens issued before the date are rejected, new ones still work. + const results = await waitForStatus(revokedTokens, 401); + for (const result of results) { + expect(result.code).toBe(40); + expect(result.message).toContain('This token has been revoked'); + } + // (the SDK's default `iat` is a second in the past: retry until it is after the date) + await retry(async () => { + const fresh = client.generateUserToken({ user_id: revokedUserId }); + expect((await queryChannelsAs(fresh, revokedUserId)).status).toBe(201); + }); + }); + + it('undoes the user-level revocation', async () => { + expect(revokedTokens).toHaveLength(3); + + // #region snippet docs="platform/02-identity-and-access/01-authentication.md" heading="Undoing the revoke" tab="Node.js" index=1 + // COPY: revokedUserId="user-id", user1Id="user1-id", user2Id="user2-id" + await client.updateUsersPartial({ + users: [ + { id: revokedUserId, set: { revoke_tokens_issued_before: null } }, + ], + }); + await client.updateUsersPartial({ + users: [ + { id: user1Id, set: { revoke_tokens_issued_before: null } }, + { id: user2Id, set: { revoke_tokens_issued_before: null } }, + ], + }); + // #endregion snippet + + for (const userId of [revokedUserId, user1Id, user2Id]) { + const user = await getServerUser(client, userId); + expect(user.revoke_tokens_issued_before).toBeUndefined(); + } + // The old tokens work again. + await waitForStatus(revokedTokens, 201); + }); + + it.skip('BLOCKED: app-wide revocation of every token of the shared test app is not run (decision: it would revoke every user token of the shared test app)', async () => { + const revokeDate = new Date(); + + // #region snippet docs="platform/02-identity-and-access/01-authentication.md" heading="Token Revocation by Application" tab="Node.js" index=1 + await client.updateApp({ revoke_tokens_issued_before: revokeDate }); + // revokeDate is a Date object + // #endregion snippet + + // #region snippet docs="platform/02-identity-and-access/01-authentication.md" heading="Undoing the revoke" tab="Node.js" index=2 + await client.updateApp({ revoke_tokens_issued_before: null }); + // #endregion snippet + + expect((await client.getApp()).app.revoke_tokens_issued_before).toBeNull(); + }); + + it('generates a token with an iat claim', async () => { + const generate = vi.spyOn(client, 'generateUserToken'); + const issuedAt = Math.floor(Date.now() / 1000) - 30; + const expireTime = issuedAt + 60 * 60; + + // #region snippet docs="platform/02-identity-and-access/01-authentication.md" heading="Adding iat claim to token" tab="Node.js" index=1 + // COPY: iatUserId="user-id" + client.generateUserToken({ + user_id: iatUserId, + exp: expireTime, + iat: issuedAt, + }); + // issuedAt should be unix timestamp, by default it's set to the current time + // issuedAt = Math.floor(Date.now() / 1000) + // #endregion snippet + + const token = generate.mock.results[0].value as string; + expect(claims(token)).toMatchObject({ + user_id: iatUserId, + iat: issuedAt, + exp: expireTime, + }); + expect((await queryChannelsAs(token, iatUserId)).status).toBe(201); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/02-identity-and-access/02-users.test.ts b/__tests__/docs-snippets/pages/platform/02-identity-and-access/02-users.test.ts new file mode 100644 index 0000000..6ac4459 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/02-identity-and-access/02-users.test.ts @@ -0,0 +1,572 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + DeactivateUserResponse, + ReactivateUsersResponse, + UpdateAppRequest, + UserResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { + getServerUser, + sendServerMessage, + waitForAppSetting, +} from '../../../helpers/server'; +import { retry, waitForTask } from '../../../helpers/wait'; + +const DOCS = 'platform/02-identity-and-access/02-users.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + /** Looks a user up by id, deactivated or not (undefined when the API returns none). */ + const findUser = async (id: string): Promise => { + const { users } = await client.queryUsers({ + payload: { + filter_conditions: { id }, + include_deactivated_users: true, + }, + }); + return users[0]; + }; + + /** Creates `ids` as plain users and registers them for cleanup. */ + const createUsers = async (...ids: string[]) => { + await client.upsertUsers(ids.map((id) => ({ id }))); + cleanup.users.push(...ids); + }; + + /** Opens a WebSocket connection as `userId` (like a client SDK) until the API answers. */ + const connectAs = async (userId: string) => { + const json = JSON.stringify({ + user_id: userId, + user_details: { id: userId }, + server_determines_connection_id: true, + }); + const params = new URLSearchParams({ + json, + api_key: process.env.STREAM_API_KEY ?? '', + authorization: client.generateUserToken({ user_id: userId }), + 'stream-auth-type': 'jwt', + }); + const ws = new WebSocket( + `wss://chat.stream-io-api.com/connect?${params.toString()}`, + ); + try { + const first = await new Promise((resolve, reject) => { + ws.onmessage = (event) => resolve(String(event.data)); + ws.onerror = () => reject(new Error('WebSocket error')); + }); + expect(first).toContain('connection_id'); + } finally { + ws.close(); + } + }; + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates a user with upsertUsers', async () => { + const userID = uniqueId('user'); + cleanup.users.push(userID); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Creating users" tab="Node.js" index=1 + const updateResponse = await client.upsertUsers([ + { + id: userID, + role: 'admin', + custom: { book: 'dune' }, + }, + ]); + // #endregion snippet + + expect(updateResponse.users[userID]).toMatchObject({ + id: userID, + role: 'admin', + custom: { book: 'dune' }, + }); + expect(await getServerUser(client, userID)).toMatchObject({ + role: 'admin', + custom: { book: 'dune' }, + }); + }); + + it('creates users in bulk with upsertUsers', async () => { + const userID1 = uniqueId('user1'); + const userID2 = uniqueId('user2'); + const userID3 = uniqueId('user3'); + cleanup.users.push(userID1, userID2, userID3); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Creating users" tab="Node.js" index=2 + const updateResponse = await client.upsertUsers([ + { id: userID1, role: 'admin', custom: { book: 'dune' } }, + { id: userID2, role: 'user', custom: { book: '1984' } }, + { id: userID3, role: 'admin', custom: { book: 'Fahrenheit 451' } }, + ]); + // each user object is updated accordingly + // #endregion snippet + + expect(updateResponse.users[userID1]).toMatchObject({ + role: 'admin', + custom: { book: 'dune' }, + }); + expect(updateResponse.users[userID2]).toMatchObject({ + role: 'user', + custom: { book: '1984' }, + }); + expect(updateResponse.users[userID3]).toMatchObject({ + role: 'admin', + custom: { book: 'Fahrenheit 451' }, + }); + }); + + it('updates users partially with set and unset', async () => { + const partialUser = uniqueId('partial1'); + const partialUser2 = uniqueId('partial2'); + await client.upsertUsers([ + { + id: partialUser, + custom: { + field: { kept: 'old' }, + field2: { unset: 'gone', kept: 'yes' }, + other: 'kept', + }, + }, + { id: partialUser2, custom: { field: { value: 'gone', kept: 'yes' } } }, + ]); + cleanup.users.push(partialUser, partialUser2); + const updateUsersPartial = vi.spyOn(client, 'updateUsersPartial'); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Partial updates" tab="Node.js" index=1 + // COPY: partialUser="userID", partialUser2="userID2" + // partial update for a single user setting and unsetting multiple fields + const update = { + id: partialUser, + set: { + role: 'admin', + field: { + text: 'value', + }, + 'field2.subfield': 'test', + }, + unset: ['field2.unset'], + }; + + const response = await client.updateUsersPartial({ users: [update] }); + + // partial update for multiple users + const updates = [ + { + id: partialUser, + set: { + field: 'value', + }, + }, + { + id: partialUser2, + unset: ['field.value'], + }, + ]; + + const response2 = await client.updateUsersPartial({ users: updates }); + // #endregion snippet + + expect(updateUsersPartial).toHaveBeenCalledTimes(2); + // The first update sets role and custom fields. + // The first update sets role and custom fields (a nested object replaces `field`, a + // dotted path sets one subfield) and unsets a nested field. + expect(response.users[partialUser].role).toBe('admin'); + expect(response.users[partialUser].custom).toEqual({ + field: { text: 'value' }, + field2: { subfield: 'test', kept: 'yes' }, + other: 'kept', + }); + // The second one replaces `field` of the first user and unsets a nested field of + // the second one. + expect(response2.users[partialUser].custom).toEqual({ + field: 'value', + field2: { subfield: 'test', kept: 'yes' }, + other: 'kept', + }); + expect(response2.users[partialUser2].custom).toEqual({ + field: { kept: 'yes' }, + }); + }); + + it('enforces unique usernames on app and team level', async () => { + const { app } = await client.getApp(); + const original = app.enforce_unique_usernames; + const restore = async () => { + await client.updateApp({ + enforce_unique_usernames: + original as UpdateAppRequest['enforce_unique_usernames'], + }); + await waitForAppSetting(client, 'enforce_unique_usernames', original); + }; + cleanup.add(restore); + // Users with the same normalized name: the second one only fails while the + // check is on. + const name = uniqueId('unique-name'); + const first = uniqueId('unique1'); + const second = uniqueId('unique2'); + cleanup.users.push(first, second); + await client.upsertUsers([{ id: first, name }]); + const upsertDuplicate = () => + client.upsertUsers([{ id: second, name: name.toUpperCase() }]).then( + () => undefined, + (error: unknown) => error, + ); + + // After the app-level call, wait until a duplicate name is rejected. + const original_updateApp = client.updateApp.bind(client); + let appLevelError: unknown; + const updateApp = vi + .spyOn(client, 'updateApp') + .mockImplementation(async (request?: UpdateAppRequest) => { + const result = await original_updateApp(request); + if (request?.enforce_unique_usernames === 'app') { + await waitForAppSetting(client, 'enforce_unique_usernames', 'app', { + agreeing: 5, + }); + appLevelError = await retry(async () => { + const error = await upsertDuplicate(); + if (error === undefined) throw new Error('duplicate accepted'); + return error; + }); + } + return result; + }); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Unique usernames" tab="Node.js" index=1 + // Enable uniqueness constraints on App level + await client.updateApp({ + enforce_unique_usernames: 'app', + }); + + // Enable uniqueness constraints on Team level + await client.updateApp({ + enforce_unique_usernames: 'team', + }); + // #endregion snippet + + expect( + updateApp.mock.calls.map( + ([request]) => request?.enforce_unique_usernames, + ), + ).toEqual(['app', 'team']); + expect(appLevelError).toMatchObject({ code: 6 }); + expect(String(appLevelError)).toContain('already exist'); + await waitForAppSetting(client, 'enforce_unique_usernames', 'team', { + agreeing: 5, + }); + // On team level the check is per team: users without a team (this app has no + // multi-tenancy) can share a name. + await retry(async () => { + expect(await upsertDuplicate()).toBeUndefined(); + }); + + // Restore it now: with the check on, reactivateUser with restore_messages fails + // (see the deactivation test below). + await restore(); + }); + + it('creates a guest user', async () => { + const guestId = uniqueId('tommaso'); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Guest users" tab="Node.js" index=1 + // COPY: guestId="tommaso" + const response = await client.createGuest({ + user: { id: guestId, name: 'Tommaso' }, + }); + // Return response.user and response.access_token to your client + // #endregion snippet + + // The API prefixes the id: `guest--`. + cleanup.users.push(response.user.id); + expect(response.user).toMatchObject({ name: 'Tommaso', role: 'guest' }); + expect(response.user.id).toMatch(new RegExp(`^guest-.+-${guestId}$`)); + const claims = JSON.parse( + Buffer.from(response.access_token.split('.')[1], 'base64url').toString(), + ) as { user_id?: string }; + expect(claims.user_id).toBe(response.user.id); + }); + + it('queries users by role, sorted by creation date', async () => { + const adminId = uniqueId('admin'); + await client.upsertUsers([{ id: adminId, role: 'admin' }]); + cleanup.users.push(adminId); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Querying users" tab="Node.js" index=1 + const response = await client.queryUsers({ + payload: { + filter_conditions: { + role: 'admin', + }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 10, + offset: 0, + }, + }); + + console.log(response.users); + // #endregion snippet + + expect(log).toHaveBeenCalledWith(response.users); + expect(response.users.length).toBeGreaterThan(0); + expect(response.users.length).toBeLessThanOrEqual(10); + expect(response.users.every((u) => u.role === 'admin')).toBe(true); + const created = response.users.map((u) => u.created_at.getTime()); + expect(created).toEqual([...created].sort((a, b) => b - a)); + // The newest admin is the one created just now. + expect(response.users.map((u) => u.id)).toContain(adminId); + }); + + it('queries users by custom fields and multiple conditions', async () => { + const color = uniqueId('red'); + const plan = uniqueId('premium'); + const redUser = uniqueId('red-user'); + const otherColor = uniqueId('blue-user'); + const premiumAdmin = uniqueId('premium-admin'); + const premiumModerator = uniqueId('premium-moderator'); + const premiumUser = uniqueId('premium-user'); + await client.upsertUsers([ + { id: redUser, custom: { color } }, + { id: otherColor, custom: { color: `${color}-not` } }, + { id: premiumAdmin, role: 'admin', custom: { plan } }, + { id: premiumModerator, role: 'moderator', custom: { plan } }, + { id: premiumUser, role: 'user', custom: { plan } }, + ]); + cleanup.users.push( + redUser, + otherColor, + premiumAdmin, + premiumModerator, + premiumUser, + ); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Filter examples" tab="Node.js" index=1 + // COPY: color="red", plan="premium" + // Query users by custom field + const response = await client.queryUsers({ + payload: { + filter_conditions: { + 'custom.color': color, + }, + }, + }); + + // Query users with multiple conditions + const response2 = await client.queryUsers({ + payload: { + filter_conditions: { + role: { $in: ['admin', 'moderator'] }, + 'custom.plan': plan, + }, + }, + }); + // #endregion snippet + + expect(response.users.map((u) => u.id)).toEqual([redUser]); + expect(response2.users.map((u) => u.id).sort()).toEqual( + [premiumAdmin, premiumModerator].sort(), + ); + }); + + it('queries users by name with autocomplete', async () => { + const autocompleteUser = uniqueId('rory'); + const name = `Ro${autocompleteUser}`; + await client.upsertUsers([{ id: autocompleteUser, name }]); + cleanup.users.push(autocompleteUser); + const prefix = name.slice(0, -2); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Querying with Autocomplete" tab="Node.js" index=1 + // COPY: prefix="ro" + const response = await client.queryUsers({ + payload: { + filter_conditions: { + name: { $autocomplete: prefix }, + }, + }, + }); + // #endregion snippet + + expect(response.users.map((u) => u.id)).toEqual([autocompleteUser]); + }); + + it('queries users that were never active', async () => { + const activeUser = uniqueId('active'); + const neverActiveUser = uniqueId('never-active'); + await createUsers(activeUser, neverActiveUser); + // A WebSocket connection as activeUser sets its last_active (REST calls don't). + await connectAs(activeUser); + await retry(async () => { + expect( + (await getServerUser(client, activeUser)).last_active, + ).toBeDefined(); + }); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Querying Inactive Users" tab="Node.js" index=1 + const response = await client.queryUsers({ + payload: { + filter_conditions: { + id: { $in: [activeUser, neverActiveUser] }, + last_active: { $exists: false }, + }, + }, + }); + // #endregion snippet + + expect(response.users.map((u) => u.id)).toEqual([neverActiveUser]); + }); + + it('deactivates and reactivates users', async () => { + const userId = uniqueId('deactivate'); + const bulkUser1 = uniqueId('bulk1'); + const bulkUser2 = uniqueId('bulk2'); + await createUsers(userId, bulkUser1, bulkUser2); + const deactivateUser = vi.spyOn(client, 'deactivateUser'); + const reactivateUsers = vi.spyOn(client, 'reactivateUsers'); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Deactivating users" tab="Node.js" index=1 + // COPY: userId="", bulkUser1="", bulkUser2="" + await client.deactivateUser({ + user_id: userId, + }); + + // reactivate + await client.reactivateUsers({ + user_ids: [userId], + }); + + // deactivating users in bulk is performed asynchronously + const deactivateResponse = await client.deactivateUsers({ + user_ids: [bulkUser1, bulkUser2], + }); + // #endregion snippet + + const [deactivated] = await Promise.all( + deactivateUser.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(deactivated.user?.deactivated_at).toBeInstanceOf(Date); + // Bulk reactivation is asynchronous too. + const [reactivated] = await Promise.all( + reactivateUsers.mock.results.map( + (r) => r.value as Promise, + ), + ); + await waitForTask(client, reactivated.task_id); + expect((await findUser(userId))?.deactivated_at).toBeUndefined(); + await waitForTask(client, deactivateResponse.task_id); + for (const id of [bulkUser1, bulkUser2]) { + expect((await findUser(id))?.deactivated_at).toBeInstanceOf(Date); + } + }); + + it('deactivates a user with their messages and reactivates them', async () => { + const joe = uniqueId('joe'); + const userID = uniqueId('deactivated'); + await createUsers(joe, userID); + const channelId = uniqueId('channel'); + const channel = client.chat.channel('messaging', channelId); + await channel.getOrCreate({ + data: { created_by_id: userID, members: [{ user_id: userID }] }, + }); + cleanup.channels.push(channel.cid); + const message = await sendServerMessage(client, channel.cid, { + text: 'hello', + user_id: userID, + }); + + // After the deactivation, wait until the message is deleted. + const original_deactivateUser = client.deactivateUser.bind(client); + vi.spyOn(client, 'deactivateUser').mockImplementation(async (request) => { + const result = await original_deactivateUser(request); + await retry(async () => { + const { message: stored } = await client.chat.getMessage({ + id: message.id, + }); + expect(stored.deleted_at).toBeInstanceOf(Date); + }); + return result; + }); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Deactivating users" tab="Node.js" index=2 + // COPY: joe="joe" + const deactivate = await client.deactivateUser({ + user_id: userID, + mark_messages_deleted: true, + created_by_id: joe, + }); + + const reactivate = await client.reactivateUser({ + user_id: userID, + restore_messages: true, + name: 'I am back', + created_by_id: joe, + }); + // #endregion snippet + + expect(deactivate.user).toMatchObject({ id: userID }); + expect(deactivate.user?.deactivated_at).toBeInstanceOf(Date); + expect(reactivate.user).toMatchObject({ id: userID, name: 'I am back' }); + expect(reactivate.user?.deactivated_at).toBeUndefined(); + await retry(async () => { + const { message: stored } = await client.chat.getMessage({ + id: message.id, + }); + expect(stored.deleted_at).toBeUndefined(); + expect(stored.type).toBe('regular'); + }); + }); + + it('deletes and restores users', async () => { + const userId = uniqueId('delete'); + await createUsers(userId); + // Wait for the delete task and check the user is gone before the restore. + const original_deleteUsers = client.deleteUsers.bind(client); + vi.spyOn(client, 'deleteUsers').mockImplementation(async (request) => { + const result = await original_deleteUsers(request); + await waitForTask(client, result.task_id); + expect(await findUser(userId)).toBeUndefined(); + return result; + }); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Deleting users" tab="Node.js" index=1 + // COPY: userId="" + await client.deleteUsers({ user_ids: [userId] }); + + //restore + await client.restoreUsers({ user_ids: [userId] }); + // #endregion snippet + + expect(await findUser(userId)).toMatchObject({ id: userId }); + expect((await findUser(userId))?.deleted_at).toBeUndefined(); + }); + + it('restores soft-deleted users', async () => { + const userID1 = uniqueId('restore1'); + const userID2 = uniqueId('restore2'); + await createUsers(userID1, userID2); + const { task_id } = await client.deleteUsers({ + user_ids: [userID1, userID2], + }); + await waitForTask(client, task_id); + expect(await findUser(userID1)).toBeUndefined(); + expect(await findUser(userID2)).toBeUndefined(); + + // #region snippet docs="platform/02-identity-and-access/02-users.md" heading="Restoring deleted users" tab="Node.js" index=1 + // COPY: userID1="userID1", userID2="userID2" + await client.restoreUsers({ user_ids: [userID1, userID2] }); + // #endregion snippet + + for (const id of [userID1, userID2]) { + const restored = await findUser(id); + expect(restored).toMatchObject({ id }); + expect(restored?.deleted_at).toBeUndefined(); + } + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/02-identity-and-access/03-permissions.test.ts b/__tests__/docs-snippets/pages/platform/02-identity-and-access/03-permissions.test.ts new file mode 100644 index 0000000..903240a --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/02-identity-and-access/03-permissions.test.ts @@ -0,0 +1,316 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + UpdateFeedMembersResponse, + UpdateFeedVisibilityResponse, + UpdateFollowResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser, waitForAppSetting } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; + +const DOCS = 'platform/02-identity-and-access/03-permissions.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const roleNames = async () => + (await client.listRoles()).roles.map((role) => role.name); + + /** A new user, registered for cleanup. */ + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + /** A new feed owned by `ownerId`, hard-deleted in cleanup (the leak check doesn't track feeds). */ + const createFeed = async (group: string, ownerId: string) => { + const id = uniqueId(`${group}-feed`); + const feed = client.feeds.feed(group, id); + await feed.getOrCreate({ user_id: ownerId }); + cleanup.add(() => feed.delete({ hard_delete: true })); + return id; + }; + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Role Management', async () => { + const specialAgent = uniqueId('special-agent'); + await client.createRole({ name: specialAgent }); + const jamesBond = await createUser('james-bond'); + // The role can only be deleted once no user has it: reset the user's role first. + cleanup.add(async () => { + await client.updateUsersPartial({ + users: [{ id: jamesBond, set: { role: 'user' } }], + }); + await retry(() => client.deleteRole({ name: specialAgent }), { + timeout: 30000, + interval: 2000, + }); + }); + // A new role takes a moment to reach every API node ("role ... provided but not + // defined"): retry the snippet's call until it is known. + const updateUsersPartial = client.updateUsersPartial.bind(client); + vi.spyOn(client, 'updateUsersPartial').mockImplementation((request) => + retry(() => updateUsersPartial(request), { + timeout: 30000, + interval: 1000, + retryIf: (error) => + error instanceof Error && + error.message.includes('provided but not defined'), + }), + ); + + // #region snippet docs="platform/02-identity-and-access/03-permissions.md" heading="Role Management" tab="Node.js" index=1 + // COPY: jamesBond="james_bond", specialAgent="special_agent" + await client.updateUsersPartial({ + users: [ + { + id: jamesBond, + set: { role: specialAgent }, + }, + ], + }); + // #endregion snippet + + expect((await getServerUser(client, jamesBond)).role).toBe(specialAgent); + }); + + describe('Feeds member and follower roles', () => { + it('adds a feed member with a role', async () => { + const ownerId = await createUser('owner'); + const jamesBond = await createUser('james-bond'); + const communityId = await createFeed('user', ownerId); + const updateFeedMembers = vi.spyOn(client.feeds, 'updateFeedMembers'); + + // #region snippet docs="platform/02-identity-and-access/03-permissions.md" heading="Feeds member and follower roles" tab="Node.js" index=1 + // COPY: communityId="community_id", jamesBond="james_bond" + await client.feeds.feed('user', communityId).updateFeedMembers({ + operation: 'upsert', + members: [{ user_id: jamesBond, role: 'feed_moderator' }], + }); + // #endregion snippet + + const response = await (updateFeedMembers.mock.results[0] + .value as Promise); + expect(response.added).toHaveLength(1); + expect(response.added[0]).toMatchObject({ + role: 'feed_moderator', + user: { id: jamesBond }, + }); + const { members } = await client.feeds + .feed('user', communityId) + .queryFeedMembers({}); + expect( + members.map((member) => [member.user.id, member.role]), + ).toContainEqual([jamesBond, 'feed_moderator']); + }); + + it('updates the role of a follower', async () => { + const followerId = await createUser('follower'); + const targetOwnerId = await createUser('target-owner'); + const sourceFeedId = await createFeed('timeline', followerId); + const targetFeedId = await createFeed('user', targetOwnerId); + const follow = await client.feeds.follow({ + source: `timeline:${sourceFeedId}`, + target: `user:${targetFeedId}`, + }); + expect(follow.follow.follower_role).toBe('feed_follower'); + const updateFollow = vi.spyOn(client.feeds, 'updateFollow'); + + // #region snippet docs="platform/02-identity-and-access/03-permissions.md" heading="Feeds member and follower roles" tab="Node.js" index=2 + await client.feeds.updateFollow({ + source: `timeline:${sourceFeedId}`, + target: `user:${targetFeedId}`, + push_preference: 'none', + follower_role: 'feed_member_viewer', + custom: { + note: 'Updated follow', + }, + }); + // #endregion snippet + + const response = await (updateFollow.mock.results[0] + .value as Promise); + expect(response.follow).toMatchObject({ + follower_role: 'feed_member_viewer', + push_preference: 'none', + custom: { note: 'Updated follow' }, + }); + }); + }); + + describe('Custom Roles', () => { + it('creates a role', async () => { + const specialAgent = uniqueId('special-agent'); + cleanup.add(() => + retry(() => client.deleteRole({ name: specialAgent }), { + timeout: 30000, + interval: 2000, + }), + ); + + // #region snippet docs="platform/02-identity-and-access/03-permissions.md" heading="Custom Roles" tab="Node.js" index=1 + // COPY: specialAgent="special_agent" + await client.createRole({ + name: specialAgent, + }); + // #endregion snippet + + expect(await roleNames()).toContain(specialAgent); + }); + + it('deletes a role', async () => { + const agent006 = uniqueId('agent-006'); + await client.createRole({ name: agent006 }); + expect(await roleNames()).toContain(agent006); + + // The new role takes a moment to reach every API node (`role "..." does not + // exist`): retry the snippet's call until it is known. + const deleteRole = client.deleteRole.bind(client); + vi.spyOn(client, 'deleteRole').mockImplementation((request) => + retry(() => deleteRole(request), { + timeout: 30000, + interval: 1000, + retryIf: (error) => + error instanceof Error && error.message.includes('does not exist'), + }), + ); + + // #region snippet docs="platform/02-identity-and-access/03-permissions.md" heading="Custom Roles" tab="Node.js" index=2 + // COPY: agent006="agent_006" + await client.deleteRole({ + name: agent006, + }); + // #endregion snippet + + expect(await roleNames()).not.toContain(agent006); + }); + }); + + describe('Granting permissions', () => { + it('lists permissions', async () => { + // #region snippet docs="platform/02-identity-and-access/03-permissions.md" heading="Granting permissions" tab="Node.js" index=1 + const { permissions } = await client.listPermissions(); // List of Permission objects + // #endregion snippet + + expect(permissions.length).toBeGreaterThan(0); + const permission = permissions.find( + (p) => p.id === 'create-message-owner', + ); + expect(permission).toMatchObject({ + action: 'CreateMessage', + owner: true, + }); + expect(typeof permission?.name).toBe('string'); + expect(typeof permission?.description).toBe('string'); + expect(typeof permission?.same_team).toBe('boolean'); + }); + + it('updates grants of the .app scope', async () => { + const roles = ['anonymous', 'guest', 'user', 'admin']; + const { app } = await client.getApp(); + const original = app.grants; + cleanup.add(async () => { + await client.updateApp({ + grants: Object.fromEntries( + roles.map((role) => [role, original[role] ?? []]), + ), + }); + await waitForAppSetting(client, 'grants', original); + }); + // Make sure the snippet changes something. + expect(original.user).toContain('flag-user'); + expect(original.guest.length).toBeGreaterThan(0); + + // #region snippet docs="platform/02-identity-and-access/03-permissions.md" heading="Granting permissions" tab="Node.js" index=2 + await client.updateApp({ + grants: { + anonymous: [], + guest: [], + user: ['search-user', 'mute-user'], + admin: ['search-user', 'mute-user', 'ban-user'], + }, + }); + // #endregion snippet + + const { app: updated } = await client.getApp(); + expect(updated.grants.anonymous ?? []).toEqual([]); + expect(updated.grants.guest ?? []).toEqual([]); + expect([...updated.grants.user].sort()).toEqual([ + 'mute-user', + 'search-user', + ]); + expect([...updated.grants.admin].sort()).toEqual([ + 'ban-user', + 'mute-user', + 'search-user', + ]); + // Only the roles in the request changed. + expect(updated.grants.moderator).toEqual(original.moderator); + }); + }); + + it('Feeds visibility grants', async () => { + // Feed visibilities are app-wide and not covered by the DRIFT check: restore the + // original feed_member grants of "visible" and check they are back. + const original = (await client.feeds.getFeedVisibility({ name: 'visible' })) + .feed_visibility.grants; + cleanup.add(async () => { + await client.feeds.updateFeedVisibility({ + name: 'visible', + grants: { feed_member: original.feed_member }, + }); + await retry(async () => { + const { feed_visibility } = await client.feeds.getFeedVisibility({ + name: 'visible', + }); + expect(feed_visibility.grants).toEqual(original); + }); + }); + const memberGrants = [ + 'read-feed', + 'add-activities', + 'update-activities-owner', + 'delete-activities-owner', + ]; + expect([...original.feed_member].sort()).not.toEqual( + [...memberGrants].sort(), + ); + const updateFeedVisibility = vi.spyOn(client.feeds, 'updateFeedVisibility'); + + // #region snippet docs="platform/02-identity-and-access/03-permissions.md" heading="Feeds visibility grants" tab="Node.js" index=1 + // Get current grants for a feed visibility + const response = await client.feeds.getFeedVisibility({ name: 'visible' }); + + // Update "feed_member" role grants for this feed visibility + await client.feeds.updateFeedVisibility({ + name: 'visible', + grants: { + feed_member: [ + 'read-feed', + 'add-activities', + 'update-activities-owner', + 'delete-activities-owner', + ], + }, + }); + // #endregion snippet + + expect(response.feed_visibility.grants).toEqual(original); + const updated = await (updateFeedVisibility.mock.results[0] + .value as Promise); + expect([...updated.feed_visibility.grants.feed_member].sort()).toEqual( + [...memberGrants].sort(), + ); + // Only the role in the request changed. + expect(updated.feed_visibility.grants.feed_follower).toEqual( + original.feed_follower, + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts b/__tests__/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts new file mode 100644 index 0000000..5c5b21d --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts @@ -0,0 +1,382 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + QueryCallsResponse, + QueryChannelsResponse, + QueryUsersResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { + waitForAppSetting, + waitForTeamIdSupport, +} from '../../../helpers/server'; + +const DOCS = 'platform/02-identity-and-access/04-multi-tenancy.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + /** Teams are plain strings, not resources: `uniqueId` names keep the team queries to this file's data. */ + const redTeam = uniqueId('red'); + const blueTeam = uniqueId('blue'); + /** Used only to wait for multi-tenancy to settle (`waitForTeamIdSupport`). */ + const probeTeam = uniqueId('probe'); + + /** Deletes a call (hard), ignoring "not found". */ + const registerCall = (type: string, id: string) => + cleanup.add(async () => { + try { + await client.video.call(type, id).delete({ hard: true }); + } catch (error) { + if (!(error instanceof Error && /not.?found|404/i.test(error.message))) + throw error; + } + }); + + const enableMultiTenancy = async () => { + await waitForAppSetting(client, 'multi_tenant_enabled', true); + await waitForTeamIdSupport(client, probeTeam, true); + }; + + beforeAll(async () => { + const { app } = await client.getApp(); + const original = app.multi_tenant_enabled; + // Registered first, so it runs after every other custom cleanup step + // (the team calls are deleted while multi-tenancy is still on). + cleanup.add(async () => { + await client.updateApp({ multi_tenant_enabled: original }); + await waitForAppSetting(client, 'multi_tenant_enabled', original); + await waitForTeamIdSupport(client, probeTeam, original); + }); + }); + + afterAll(() => cleanup.run()); + afterEach(() => vi.restoreAllMocks()); + + it('enables teams', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Enable Teams for your application" tab="Node.js" index=1 + // shows the current status + const appSettings = await client.getApp(); + console.log(appSettings.app.multi_tenant_enabled); + + // enables teams + await client.updateApp({ + multi_tenant_enabled: true, + }); + // #endregion snippet + + expect(log.mock.calls[0]).toEqual([appSettings.app.multi_tenant_enabled]); + await enableMultiTenancy(); + const { app } = await client.getApp(); + expect(app.multi_tenant_enabled).toBe(true); + }); + + describe('with multi-tenancy', () => { + const sara = uniqueId('sara'); + const creator = uniqueId('creator'); + const nickRed = uniqueId('nick-red'); + const nickNoTeam = uniqueId('nick-no-team'); + const tomNoTeam = uniqueId('tom-no-team'); + const tomBlue = uniqueId('tom-blue'); + const jane = uniqueId('jane'); + /** Shared `name`s of the user search snippet ("Nick", "Tom"). */ + const nick = uniqueId('nick'); + const tom = uniqueId('tom'); + + /** A red team channel and a channel without a team for the channel queries. */ + const redChannelId = uniqueId('red-channel'); + const plainChannelId = uniqueId('plain-channel'); + /** A blue team call and a call without a team for the call queries. */ + const blueCallId = uniqueId('blue-call'); + const plainCallId = uniqueId('plain-call'); + + beforeAll(async () => { + // The "enables teams" test turns it on; make sure, in case it was run alone. + await client.updateApp({ multi_tenant_enabled: true }); + await enableMultiTenancy(); + + cleanup.users.push( + sara, + creator, + nickRed, + nickNoTeam, + tomNoTeam, + tomBlue, + jane, + ); + await client.upsertUsers([ + { id: creator, teams: [redTeam, blueTeam] }, + { id: nickRed, name: nick, teams: [redTeam] }, + { id: nickNoTeam, name: nick }, + { id: tomNoTeam, name: tom }, + { id: tomBlue, name: tom, teams: [blueTeam] }, + ]); + + cleanup.channels.push( + `messaging:${redChannelId}`, + `messaging:${plainChannelId}`, + ); + await client.chat + .channel('messaging', redChannelId) + .getOrCreate({ data: { team: redTeam, created_by_id: creator } }); + await client.chat + .channel('messaging', plainChannelId) + .getOrCreate({ data: { created_by_id: creator } }); + + registerCall('default', blueCallId); + await client.video + .call('default', blueCallId) + .create({ data: { team: blueTeam, created_by_id: creator } }); + registerCall('default', plainCallId); + await client.video + .call('default', plainCallId) + .create({ data: { created_by_id: creator } }); + }); + + it('creates users with teams', async () => { + const userId = sara; + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="User teams" tab="Node.js" index=1 + // COPY: userId="", redTeam="red", blueTeam="blue" + await client.upsertUsers([ + { + id: userId, + name: 'Sara', + teams: [redTeam, blueTeam], + }, + ]); + // #endregion snippet + + const { users } = await client.queryUsers({ + payload: { filter_conditions: { id: sara } }, + }); + expect(users[0]?.name).toBe('Sara'); + expect([...(users[0]?.teams ?? [])].sort()).toEqual( + [redTeam, blueTeam].sort(), + ); + }); + + it('creates a channel with a team', async () => { + const redGeneral = uniqueId('red-general'); + const userId = creator; + cleanup.channels.push(`messaging:${redGeneral}`); + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Creating chat channels with a team" tab="Node.js" index=1 + // COPY: redGeneral="red-general", redTeam="red", userId="" + // Creates the channel red-general for team red + const channel = client.chat.channel('messaging', redGeneral); + await channel.getOrCreate({ + data: { team: redTeam, created_by_id: userId }, + }); + // #endregion snippet + + const { channels } = await client.chat.queryChannels({ + filter_conditions: { cid: `messaging:${redGeneral}` }, + }); + expect(channels).toHaveLength(1); + expect(channels[0]?.channel?.team).toBe(redTeam); + expect(channels[0]?.channel?.created_by?.id).toBe(creator); + }); + + it('creates a call with a team', async () => { + const redTeamWeekly = uniqueId('red-team-weekly'); + const userId = creator; + registerCall('default', redTeamWeekly); + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Creating video calls with a team" tab="Node.js" index=1 + // COPY: redTeamWeekly="red-team-weekly", redTeam="red", userId="" + await client.video.call('default', redTeamWeekly).create({ + data: { team: redTeam, created_by_id: userId }, + }); + // #endregion snippet + + const { call } = await client.video.call('default', redTeamWeekly).get(); + expect(call.team).toBe(redTeam); + expect(call.created_by.id).toBe(creator); + }); + + it('searches users by team', async () => { + const queryUsers = vi.spyOn(client, 'queryUsers'); + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="User search" tab="Node.js" index=1 + // COPY: nick="Nick", tom="Tom", redTeam="red", blueTeam="blue" + // search for users by name and team + await client.queryUsers({ + payload: { + filter_conditions: { + name: nick, + teams: { $in: [redTeam, blueTeam] }, + }, + }, + }); + + // search for users that are not part of any team + await client.queryUsers({ + payload: { + filter_conditions: { + name: tom, + teams: null, + }, + }, + }); + // #endregion snippet + + const [byTeam, noTeam] = await Promise.all( + queryUsers.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(byTeam?.users.map((u) => u.id)).toEqual([nickRed]); + expect(noTeam?.users.map((u) => u.id)).toEqual([tomNoTeam]); + }); + + it('queries channels by team', async () => { + const queryChannels = vi.spyOn(client.chat, 'queryChannels'); + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Querying chat channels" tab="Node.js" index=1 + // COPY: redTeam="red" + // server-side queries are not limited to the teams of a user + // query all channels from all teams + await client.chat.queryChannels({ filter_conditions: {} }); + + // query all channels of a team + await client.chat.queryChannels({ filter_conditions: { team: redTeam } }); + + // query all channels with no teams + await client.chat.queryChannels({ + filter_conditions: { team: { $eq: null } }, + }); + // #endregion snippet + + const [all, red, none] = await Promise.all( + queryChannels.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(all?.channels.length).toBeGreaterThan(0); + const redIds = red?.channels.map((c) => c.channel?.id) ?? []; + expect(redIds).toContain(redChannelId); + expect(red?.channels.every((c) => c.channel?.team === redTeam)).toBe( + true, + ); + expect(none?.channels.length).toBeGreaterThan(0); + expect(none?.channels.every((c) => !c.channel?.team)).toBe(true); + }); + + it('queries calls by team', async () => { + const queryCalls = vi.spyOn(client.video, 'queryCalls'); + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Querying video calls" tab="Node.js" index=1 + // COPY: blueTeam="blue" + // All calls + await client.video.queryCalls(); + + // Calls without team + await client.video.queryCalls({ + filter_conditions: { + team: null, + }, + }); + + // Calls with specific team + await client.video.queryCalls({ + filter_conditions: { + team: blueTeam, + }, + }); + // #endregion snippet + + const [all, none, blue] = await Promise.all( + queryCalls.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(all?.calls.length).toBeGreaterThan(0); + expect(none?.calls.length).toBeGreaterThan(0); + expect(none?.calls.every((c) => !c.call.team)).toBe(true); + expect(blue?.calls.map((c) => c.call.id)).toEqual([blueCallId]); + }); + + it('sets team based roles', async () => { + const janeId = jane; + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Team based roles" tab="Node.js" index=1 + // COPY: janeId="Jane" + await client.upsertUsers([ + { + id: janeId, + role: 'user', + teams: ['red', 'blue'], + teams_role: { + red: 'admin', + blue: 'user', + }, + }, + ]); + // #endregion snippet + + const { users } = await client.queryUsers({ + payload: { filter_conditions: { id: jane } }, + }); + expect(users[0]?.role).toBe('user'); + expect(users[0]?.teams_role).toEqual({ red: 'admin', blue: 'user' }); + }); + + it('queries team usage stats', async () => { + const pageSize = 1; // so the docs' pagination branch runs + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Querying Team Usage Stats" tab="Node.js" index=1 + // COPY: pageSize=10 + // Query current month's stats + const currentMonth = await client.chat.queryTeamUsageStats(); + + // Query specific month + const january = await client.chat.queryTeamUsageStats({ + month: '2024-01', + }); + + // Query date range + const dateRange = await client.chat.queryTeamUsageStats({ + start_date: '2024-01-01', + end_date: '2024-01-31', + }); + + // With pagination + const response = await client.chat.queryTeamUsageStats({ + limit: pageSize, + }); + if (response.next) { + const nextPage = await client.chat.queryTeamUsageStats({ + limit: pageSize, + next: response.next, + }); + console.log(nextPage.teams); + } + // #endregion snippet + + for (const stats of [currentMonth, january, dateRange, response]) { + expect(Array.isArray(stats.teams)).toBe(true); + expect(stats.teams.length).toBeLessThanOrEqual(30); + } + // Monthly mode: totals only, no daily breakdown. + expect(january.teams.every((t) => !t.users_daily.daily)).toBe(true); + // The test app returns no rows at all (not even the docs' team "" row, see the + // TODO's API behavior issues), so the pagination branch only runs once it does. + expect(response.teams.length).toBeLessThanOrEqual(pageSize); + expect(log).toHaveBeenCalledTimes(response.next ? 1 : 0); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/02-identity-and-access/05-gdpr.test.ts b/__tests__/docs-snippets/pages/platform/02-identity-and-access/05-gdpr.test.ts new file mode 100644 index 0000000..b3519ed --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/02-identity-and-access/05-gdpr.test.ts @@ -0,0 +1,177 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { UserResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { retry, waitForTask } from '../../../helpers/wait'; + +const DOCS = 'platform/02-identity-and-access/05-gdpr.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + /** Creates a plain user with a `uniqueId` id and registers it for cleanup. */ + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + /** Looks a user up by id (undefined when the API returns none). */ + const findUser = async (id: string): Promise => { + const { users } = await client.queryUsers({ + payload: { filter_conditions: { id } }, + }); + return users[0]; + }; + + /** + * A `user` feed owned by `ownerId` with one activity, hard-deleted in cleanup (the leak + * check doesn't track feeds). The feed may already be gone after a delete snippet. + */ + const createFeedWithActivity = async (ownerId: string, text: string) => { + const feedId = uniqueId('user-feed'); + const feed = client.feeds.feed('user', feedId); + await feed.getOrCreate({ user_id: ownerId }); + cleanup.add(async () => { + await feed.delete({ hard_delete: true }).catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }); + }); + const { activity } = await client.feeds.addActivity({ + id: uniqueId('activity'), + type: 'post', + feeds: [`user:${feedId}`], + text, + user_id: ownerId, + }); + return activity.id; + }; + + /** Downloads an export file (line-separated JSON, no auth needed). */ + const download = async (url: unknown) => { + expect(typeof url).toBe('string'); + const response = await fetch(String(url)); + expect(response.ok).toBe(true); + return response.text(); + }; + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('exports users', async () => { + const userID = await createUser('export'); + const channel = client.chat.channel('messaging', uniqueId('channel')); + await channel.getOrCreate({ + data: { created_by_id: userID, members: [{ user_id: userID }] }, + }); + cleanup.channels.push(channel.cid); + const text = uniqueId('exported-message'); + await sendServerMessage(client, channel.cid, { text, user_id: userID }); + + // #region snippet docs="platform/02-identity-and-access/05-gdpr.md" heading="The Right to Access Data" tab="Node.js" index=1 + const response = await client.exportUsers({ + user_ids: [userID], + }); + const taskID = response.task_id; + // #endregion snippet + + expect(typeof taskID).toBe('string'); + const task = await waitForTask(client, taskID); + const contents = await download(task.result?.url); + expect(contents).toContain(userID); + expect(contents).toContain(text); + }); + + it('exports feeds data', async () => { + const userToExport = { id: await createUser('feeds-export') }; + const text = uniqueId('exported-activity'); + const activityId = await createFeedWithActivity(userToExport.id, text); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="platform/02-identity-and-access/05-gdpr.md" heading="Exporting feeds data" tab="Node.js" index=1 + // Start the export task + const response = await client.feeds.exportFeedUserData({ + user_id: userToExport.id, + }); + + // You have to poll this endpoint + const taskResponse = await client.getTask({ id: response.task_id }); + console.log(taskResponse.status === 'completed'); + // #endregion snippet + + expect(taskResponse.task_id).toBe(response.task_id); + expect(log).toHaveBeenCalledWith(taskResponse.status === 'completed'); + const task = await waitForTask(client, response.task_id); + const contents = await download(task.result?.url); + expect(contents).toContain(userToExport.id); + expect(contents).toContain(activityId); + expect(contents).toContain(text); + }); + + it('deletes and restores users', async () => { + const userId = await createUser('delete'); + // Wait for the delete task and check the user is gone before the restore. + const original_deleteUsers = client.deleteUsers.bind(client); + vi.spyOn(client, 'deleteUsers').mockImplementation(async (request) => { + const result = await original_deleteUsers(request); + await waitForTask(client, result.task_id); + expect(await findUser(userId)).toBeUndefined(); + return result; + }); + + // #region snippet docs="platform/02-identity-and-access/05-gdpr.md" heading="Deleting users" tab="Node.js" index=1 + // COPY: userId="" + await client.deleteUsers({ user_ids: [userId] }); + + //restore + await client.restoreUsers({ user_ids: [userId] }); + // #endregion snippet + + const restored = await findUser(userId); + expect(restored).toMatchObject({ id: userId }); + expect(restored?.deleted_at).toBeUndefined(); + }); + + it('deletes feeds data', async () => { + const userToDelete = { id: await createUser('feeds-delete') }; + const activityId = await createFeedWithActivity( + userToDelete.id, + uniqueId('deleted-activity'), + ); + expect( + (await client.feeds.getActivity({ id: activityId })).activity.id, + ).toBe(activityId); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="platform/02-identity-and-access/05-gdpr.md" heading="Deleting feeds data" tab="Node.js" index=1 + // Start the delete task + const response = await client.feeds.deleteFeedUserData({ + user_id: userToDelete.id, + hard_delete: false, + }); + + // You have to poll this endpoint + const taskResponse = await client.getTask({ id: response.task_id }); + console.log(taskResponse.status === 'completed'); + // #endregion snippet + + expect(taskResponse.task_id).toBe(response.task_id); + expect(log).toHaveBeenCalledWith(taskResponse.status === 'completed'); + await waitForTask(client, response.task_id); + // The user's activity is (soft-)deleted, the user account itself stays. + await retry(async () => { + const error = await client.feeds.getActivity({ id: activityId }).then( + () => undefined, + (e: unknown) => e, + ); + expect(error).toMatchObject({ metadata: { responseCode: 404 } }); + }); + expect(await findUser(userToDelete.id)).toMatchObject({ + id: userToDelete.id, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.stateless.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.stateless.test.ts new file mode 100644 index 0000000..cccb2d9 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.stateless.test.ts @@ -0,0 +1,38 @@ +import crypto from 'crypto'; +import zlib from 'zlib'; +import { describe, expect, it } from 'vitest'; + +// Values the module-level region below uses (it must come after them: TDZ). +// A gzipped webhook body (payload compression on), signed over the uncompressed bytes. +const sampleEvent = { + type: 'message.new', + cid: 'messaging:general', + message: { id: 'message-id', text: 'hello', type: 'regular' }, + user: { id: 'john', role: 'user' }, +}; +const sampleJson = JSON.stringify(sampleEvent); +const secret = process.env.STREAM_SECRET as string; +const signature = crypto + .createHmac('sha256', secret) + .update(sampleJson) + .digest('hex'); +const rawBody = zlib.gzipSync(sampleJson); + +// #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Where each argument comes from" tab="Node.js" index=1 +// Node.js (@stream-io/node-sdk) +import { verifyAndParseWebhook } from '@stream-io/node-sdk'; +const event = verifyAndParseWebhook(rawBody, signature, secret); +// #endregion snippet + +describe('platform/03-events-and-notifications/01-webhooks.md (stateless helper)', () => { + it('Where each argument comes from', () => { + expect(event).toEqual(sampleEvent); + // Same result for the uncompressed body; a wrong secret fails the signature check. + expect(verifyAndParseWebhook(sampleJson, signature, secret)).toEqual( + sampleEvent, + ); + expect(() => + verifyAndParseWebhook(rawBody, signature, 'wrong-secret'), + ).toThrow('signature mismatch'); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts new file mode 100644 index 0000000..b37d9d5 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts @@ -0,0 +1,506 @@ +import crypto from 'crypto'; +import { isDeepStrictEqual } from 'util'; +import zlib from 'zlib'; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + CheckSNSResponse, + CheckSQSResponse, + EventHook, +} from '../../../../../index'; +import { InvalidWebhookError } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { retry } from '../../../helpers/wait'; + +const DOCS = 'platform/03-events-and-notifications/01-webhooks.md'; + +/** A hook without its server-managed timestamps (they change on every update). */ +const withoutTimestamps = (hooks: EventHook[]) => + hooks.map((hook) => { + const copy = { ...hook }; + delete copy.created_at; + delete copy.updated_at; + return copy; + }); + +/** A sample webhook event and its serialized form, as Stream would send it. */ +const sampleEvent = { + type: 'message.new', + cid: 'messaging:general', + message: { id: 'message-id', text: 'hello', type: 'regular' }, + user: { id: 'john', role: 'user' }, + created_at: '2026-10-14T12:00:00.000Z', +}; +const sampleJson = JSON.stringify(sampleEvent); +/** The SQS / SNS wire format with payload compression on: gzip, then base64. */ +const compressedBase64 = zlib.gzipSync(sampleJson).toString('base64'); + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + let originalHooks: EventHook[] = []; + + /** Reads `event_hooks` until `check` accepts them (getApp is eventually consistent). */ + const waitForHooks = (check: (hooks: EventHook[]) => boolean) => + retry(async () => { + const { app } = await client.getApp(); + if (!check(app.event_hooks)) { + throw new Error( + `event_hooks not updated yet: ${JSON.stringify(app.event_hooks.map((h) => h.webhook_url ?? h.sqs_queue_url ?? h.sns_topic_arn))}`, + ); + } + return app.event_hooks; + }); + + /** Puts the app's original hooks back (same ids) and waits until every read agrees. */ + const restoreHooks = async () => { + await client.updateApp({ event_hooks: originalHooks }); + for (let agreeing = 0; agreeing < 5; agreeing++) { + await waitForHooks((hooks) => + isDeepStrictEqual( + withoutTimestamps(hooks), + withoutTimestamps(originalHooks), + ), + ); + } + }; + + /** The hooks that are there before a "preserve existing hooks" snippet runs. */ + const expectOriginalHooksKept = (hooks: EventHook[]) => { + expect(hooks.map((h) => h.id)).toEqual( + expect.arrayContaining(originalHooks.map((h) => h.id)), + ); + expect(hooks).toHaveLength(originalHooks.length + 1); + }; + + beforeAll(async () => { + originalHooks = (await client.getApp()).app.event_hooks; + cleanup.add(restoreHooks); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Subscribe to Specific Events', async () => { + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Subscribe to Specific Events" tab="Node.js" index=1 + // Subscribe to message.new and message.updated events only + await client.updateApp({ + event_hooks: [ + { + enabled: true, + hook_type: 'webhook', + webhook_url: 'https://example.com/webhooks/stream/messages', + event_types: ['message.new', 'message.updated'], + }, + ], + }); + // #endregion snippet + + // The array replaces every existing hook. + const hooks = await waitForHooks( + (h) => + h.length === 1 && + h[0].webhook_url === 'https://example.com/webhooks/stream/messages', + ); + expect(hooks[0]).toMatchObject({ + enabled: true, + hook_type: 'webhook', + event_types: ['message.new', 'message.updated'], + }); + }); + + it('Subscribe to All Events', async () => { + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Subscribe to All Events" tab="Node.js" index=1 + // Subscribe to all events (empty array = all events) + await client.updateApp({ + event_hooks: [ + { + enabled: true, + hook_type: 'webhook', + webhook_url: 'https://example.com/webhooks/stream/all', + event_types: [], // empty array = all events + }, + ], + }); + // #endregion snippet + + const hooks = await waitForHooks( + (h) => + h.length === 1 && + h[0].webhook_url === 'https://example.com/webhooks/stream/all', + ); + expect(hooks[0]).toMatchObject({ enabled: true, hook_type: 'webhook' }); + // The empty list reads back as every event type, plus the "*" wildcard. + expect(hooks[0].event_types).toEqual( + expect.arrayContaining(['*', 'message.new', 'call.created']), + ); + }); + + it('Adding a Hook Without Deleting Existing Ones', async () => { + await restoreHooks(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Adding a Hook Without Deleting Existing Ones" tab="Node.js" index=1 + // Note: Any previously existing hooks not included in event_hooks array will be deleted. + // Get current settings first to preserve your existing configuration. + + // STEP 1: Get current app settings to preserve existing hooks + const response = await client.getApp(); + console.log('Current event hooks:', response.app.event_hooks); + + // STEP 2: Add webhook hook while preserving existing hooks + const existingHooks = response.app.event_hooks ?? []; + const newWebhookHook = { + enabled: true, + hook_type: 'webhook', + webhook_url: 'https://example.com/webhooks/stream/push', + event_types: [], // empty array = all events + }; + + // STEP 3: Update with complete array including existing hooks + await client.updateApp({ + event_hooks: [...existingHooks, newWebhookHook], + }); + + // Test webhook delivery using the Stream Dashboard + // #endregion snippet + + expect(log).toHaveBeenCalledWith( + 'Current event hooks:', + response.app.event_hooks, + ); + const hooks = await waitForHooks((h) => + h.some((hook) => hook.webhook_url === newWebhookHook.webhook_url), + ); + expectOriginalHooksKept(hooks); + }); + + it('Verifying and Parsing Events', () => { + // A gzipped webhook body (payload compression on), signed over the uncompressed bytes. + const signature = crypto + .createHmac('sha256', process.env.STREAM_SECRET as string) + .update(sampleJson) + .digest('hex'); + const req = { + rawBody: zlib.gzipSync(sampleJson), + headers: { 'x-signature': signature }, + }; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Verifying and Parsing Events" tab="Node.js" index=1 + // rawBody is the request body as Buffer or string (NOT a parsed JSON object). + // signature is the value of the x-signature header. + // Returns the parsed event object. Throws InvalidWebhookError on any failure. + try { + const event = client.verifyAndParseWebhook( + req.rawBody, + req.headers['x-signature'], + ); + // event.type, event.user, ... + console.log(event); + } catch (err) { + // err instanceof InvalidWebhookError; err.message identifies the mode + console.error(err); + } + // #endregion snippet + + expect(error).not.toHaveBeenCalled(); + expect(log.mock.calls[0]?.[0]).toEqual(sampleEvent); + + // The same call accepts the uncompressed body. + expect(client.verifyAndParseWebhook(sampleJson, signature)).toEqual( + sampleEvent, + ); + // Each failure mode throws an InvalidWebhookError whose message names it. + expect(() => + client.verifyAndParseWebhook(req.rawBody, 'wrong-signature'), + ).toThrow(InvalidWebhookError); + expect(() => + client.verifyAndParseWebhook(req.rawBody, 'wrong-signature'), + ).toThrow('signature mismatch'); + expect(() => + client.verifyAndParseWebhook( + Buffer.from([0x1f, 0x8b, 0x00, 0x01]), + signature, + ), + ).toThrow('gzip decompression failed'); + const notJson = 'not json'; + const notJsonSignature = crypto + .createHmac('sha256', process.env.STREAM_SECRET as string) + .update(notJson) + .digest('hex'); + expect(() => + client.verifyAndParseWebhook(notJson, notJsonSignature), + ).toThrow('invalid JSON payload'); + }); + + it('Enabling compression', async () => { + // getApp() doesn't return enable_hook_payload_compression, so the original value + // can't be read: the test app predates the May 2026 default (off), restore that. + cleanup.add(() => + client.updateApp({ enable_hook_payload_compression: false }), + ); + const updateApp = vi.spyOn(client, 'updateApp'); + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Enabling compression" tab="Node.js" index=1 + await client.updateApp({ + enable_hook_payload_compression: true, + }); + // #endregion snippet + + const [result] = await Promise.all( + updateApp.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(typeof result.duration).toBe('string'); + }); + + it('Authentication (SQS)', async () => { + await restoreHooks(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const checkSQS = vi.spyOn(client, 'checkSQS'); + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Authentication" tab="Node.js" index=1 + // Note: Any previously existing hooks not included in event_hooks array will be deleted. + // Get current settings first to preserve your existing configuration. + + // STEP 1: Get current app settings to preserve existing hooks + const response = await client.getApp(); + console.log('Current event hooks:', response.app.event_hooks); + + // STEP 2: Add SQS hook while preserving existing hooks + const existingHooks = response.app.event_hooks ?? []; + const newSQSHook = { + enabled: true, + hook_type: 'sqs', + sqs_queue_url: 'https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue', + sqs_region: 'us-east-1', + sqs_auth_type: 'keys', // or "resource" for role-based auth + sqs_key: 'yourkey', + sqs_secret: 'yoursecret', + event_types: [], // empty array = all events + }; + + // STEP 3: Update with complete array including existing hooks + await client.updateApp({ + event_hooks: [...existingHooks, newSQSHook], + }); + + // Test the SQS connection + await client.checkSQS({ + sqs_url: 'https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue', + sqs_key: 'yourkey', + sqs_secret: 'yoursecret', + }); + // #endregion snippet + + expect(log).toHaveBeenCalledWith( + 'Current event hooks:', + response.app.event_hooks, + ); + const hooks = await waitForHooks((h) => + h.some((hook) => hook.sqs_queue_url === newSQSHook.sqs_queue_url), + ); + expectOriginalHooksKept(hooks); + expect( + hooks.find((hook) => hook.sqs_queue_url === newSQSHook.sqs_queue_url), + ).toMatchObject({ + hook_type: 'sqs', + sqs_region: 'us-east-1', + sqs_auth_type: 'keys', + sqs_key: 'yourkey', + }); + // The placeholder keys aren't real AWS credentials: the check reports the AWS error. + const check = await (checkSQS.mock.results[0] + .value as Promise); + expect(check.status).toBe('error'); + expect(check.error).toContain('InvalidClientTokenId'); + }); + + it('Reading messages from SQS', () => { + const message = { Body: compressedBase64 }; + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Reading messages from SQS" tab="Node.js" index=1 + // message is the SQS Message object you received from ReceiveMessageCommand + const event = client.parseSqs(message.Body); + // event.type, event.user, ... + // #endregion snippet + + expect(event).toEqual(sampleEvent); + // Uncompressed payloads are base64 too in the SDK's view: plain JSON is rejected. + expect(() => client.parseSqs(sampleJson)).toThrow( + 'invalid base64 encoding', + ); + expect(client.parseSqs(Buffer.from(sampleJson).toString('base64'))).toEqual( + sampleEvent, + ); + }); + + it('Authentication (SNS)', async () => { + await restoreHooks(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const checkSNS = vi.spyOn(client, 'checkSNS'); + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Authentication" tab="Node.js" index=2 + // Note: Any previously existing hooks not included in event_hooks array will be deleted. + // Get current settings first to preserve your existing configuration. + + // STEP 1: Get current app settings to preserve existing hooks + const response = await client.getApp(); + console.log('Current event hooks:', response.app.event_hooks); + + // STEP 2: Add SNS hook while preserving existing hooks + const existingHooks = response.app.event_hooks ?? []; + const newSNSHook = { + enabled: true, + hook_type: 'sns', + sns_topic_arn: 'arn:aws:sns:us-east-1:123456789012:sns-topic', + sns_region: 'us-east-1', + sns_auth_type: 'keys', // or "resource" for role-based auth + sns_key: 'yourkey', + sns_secret: 'yoursecret', + event_types: [], // empty array = all events + }; + + // STEP 3: Update with complete array including existing hooks + await client.updateApp({ + event_hooks: [...existingHooks, newSNSHook], + }); + + // Test the SNS connection + await client.checkSNS({ + sns_topic_arn: 'arn:aws:sns:us-east-1:123456789012:sns-topic', + sns_key: 'yourkey', + sns_secret: 'yoursecret', + }); + // #endregion snippet + + expect(log).toHaveBeenCalledWith( + 'Current event hooks:', + response.app.event_hooks, + ); + const hooks = await waitForHooks((h) => + h.some((hook) => hook.sns_topic_arn === newSNSHook.sns_topic_arn), + ); + expectOriginalHooksKept(hooks); + expect( + hooks.find((hook) => hook.sns_topic_arn === newSNSHook.sns_topic_arn), + ).toMatchObject({ + hook_type: 'sns', + sns_region: 'us-east-1', + sns_auth_type: 'keys', + sns_key: 'yourkey', + }); + const check = await (checkSNS.mock.results[0] + .value as Promise); + expect(check.status).toBe('error'); + expect(check.error).toContain('InvalidClientTokenId'); + }); + + it('Reading notifications from SNS', () => { + const notification = { Message: compressedBase64 }; + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Reading notifications from SNS" tab="Node.js" index=1 + const event = client.parseSns(notification.Message); + // event.type, event.user, ... + // #endregion snippet + + expect(event).toEqual(sampleEvent); + // The raw envelope JSON works as well. + expect( + client.parseSns( + JSON.stringify({ Type: 'Notification', Message: compressedBase64 }), + ), + ).toEqual(sampleEvent); + }); + + it.skip('BLOCKED: needs a real GCS service account (Configuration, GCS failover)', async () => { + // updateApp rejects the placeholder credentials with code 4 "failover_config for hook + // has invalid gcs_credentials: dialing: auth: email must be provided" (2026-10-14). + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Configuration" tab="Node.js" index=1 + await client.updateApp({ + event_hooks: [ + { + enabled: true, + hook_type: 'webhook', + webhook_url: 'https://example.com/webhooks/stream', + event_types: ['message.new'], + failover_config: { + type: 'gcs', + gcs_bucket: 'my-failover-bucket', + gcs_path: 'stream/failed-events', + gcs_credentials: '{"type":"service_account","project_id":"..."}', + }, + }, + ], + }); + // #endregion snippet + }); + + it.skip('BLOCKED: S3 failover is not enabled for the app (Configuration, S3 failover)', async () => { + // updateApp fails with code 4 "failover_config type 's3' is not enabled for hook" + // (2026-10-14). + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Configuration" tab="Node.js" index=2 + await client.updateApp({ + event_hooks: [ + { + enabled: true, + hook_type: 'sqs', + sqs_queue_url: + 'https://sqs.us-east-1.amazonaws.com/1111111111/my-queue', + sqs_auth_type: 'role', + sqs_role_arn: 'arn:aws:iam::1111111111:role/stream-events', + event_types: ['message.new'], + failover_config: { + type: 's3', + s3_bucket: 'my-failover-bucket', + s3_path: 'stream/failed-events', + s3_region: 'us-east-1', + // Recommended: a role in your account that Stream assumes + // (see S3 Permissions below for the trust policy). Alternatively + // set s3_api_key + s3_secret to use a dedicated access key pair. + s3_role_arn: 'arn:aws:iam::1111111111:role/stream-event-failover', + }, + }, + ], + }); + // #endregion snippet + }); + + it('Disabling Failover', async () => { + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Disabling Failover" tab="Node.js" index=1 + await client.updateApp({ + event_hooks: [ + { + enabled: true, + hook_type: 'webhook', + webhook_url: 'https://example.com/webhooks/stream', + event_types: [], + // no failover_config = failover disabled + }, + ], + }); + // #endregion snippet + + const hooks = await waitForHooks( + (h) => + h.length === 1 && + h[0].webhook_url === 'https://example.com/webhooks/stream', + ); + expect(hooks[0].failover_config).toBeUndefined(); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/02-push-notifications.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/02-push-notifications.test.ts new file mode 100644 index 0000000..8002cd6 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/02-push-notifications.test.ts @@ -0,0 +1,246 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + AddActivityResponse, + AddCommentResponse, + AddReactionResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { retry } from '../../../helpers/wait'; + +/** The feed (or feed group) is already gone, or was never created. */ +const isNotFound = (error: unknown) => + /not found|can't be found/.test(String(error)); + +const DOCS = 'platform/03-events-and-notifications/02-push-notifications.md'; + +/** + * Feeds push snippets. Pushes aren't checked (the test users have no devices): the tests + * assert the API responses and the notification activities they create. + */ +describe(DOCS, () => { + // The page calls the server client `serverClient`. + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + + let ownerId: string; + let userId: string; + + /** Hard-deletes a feed in cleanup (the leak check doesn't track feeds); it may not exist. */ + const deleteFeedLater = (group: string, id: string) => { + cleanup.add(async () => { + await serverClient.feeds + .feed(group, id) + .delete({ hard_delete: true }) + .catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); + }); + }; + + /** A user with a `uniqueId` id and its `notification` feed (where notification activities go). */ + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await serverClient.upsertUsers([{ id }]); + await serverClient.feeds + .feed('notification', id) + .getOrCreate({ user_id: id }); + deleteFeedLater('notification', id); + return id; + }; + + /** A new activity by the owner on a `user` feed of the owner. */ + const createActivity = async () => { + const feedId = uniqueId('user-feed'); + await serverClient.feeds + .feed('user', feedId) + .getOrCreate({ user_id: ownerId }); + deleteFeedLater('user', feedId); + const { activity } = await serverClient.feeds.addActivity({ + type: 'post', + feeds: [`user:${feedId}`], + text: 'A post', + user_id: ownerId, + }); + return activity; + }; + + beforeAll(async () => { + ownerId = await createUser('owner'); + userId = await createUser('user-456'); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Direct Push Notifications', async () => { + const activity = await createActivity(); + const addComment = vi.spyOn(serverClient.feeds, 'addComment'); + const addReaction = vi.spyOn(serverClient.feeds, 'addActivityReaction'); + + // #region snippet docs="platform/03-events-and-notifications/02-push-notifications.md" heading="Direct Push Notifications" tab="Node.js" index=1 + // COPY: userId="user-456" + // Comment with direct push notification (default behavior) + await serverClient.feeds.addComment({ + object_id: activity.id, + object_type: 'activity', + comment: 'Great post!', + user_id: userId, + // skip_push: false (default) - sends push immediately + }); + + // Reaction with direct push notification + await serverClient.feeds.addActivityReaction({ + activity_id: activity.id, + type: 'like', + user_id: userId, + // skip_push: false (default) - sends push immediately + }); + // #endregion snippet + + const comment = await (addComment.mock.results[0] + .value as Promise); + expect(comment.comment).toMatchObject({ + object_id: activity.id, + text: 'Great post!', + user: { id: userId }, + }); + expect(addComment.mock.calls[0][0]?.skip_push).toBeUndefined(); + const reaction = await (addReaction.mock.results[0] + .value as Promise); + expect(reaction.reaction).toMatchObject({ + activity_id: activity.id, + type: 'like', + user: { id: userId }, + }); + }); + + it('Notification Feed Push Notifications', async () => { + const activity = await createActivity(); + const addComment = vi.spyOn(serverClient.feeds, 'addComment'); + const addReaction = vi.spyOn(serverClient.feeds, 'addActivityReaction'); + + // #region snippet docs="platform/03-events-and-notifications/02-push-notifications.md" heading="Notification Feed Push Notifications" tab="Node.js" index=1 + // COPY: userId="user-456" + // Comment that creates notification activity and sends via notification feed + await serverClient.feeds.addComment({ + object_id: activity.id, + object_type: 'activity', + comment: 'Great post!', + user_id: userId, + create_notification_activity: true, + skip_push: true, // Send via notification feed instead of direct push + }); + + // Reaction that creates notification activity + await serverClient.feeds.addActivityReaction({ + activity_id: activity.id, + type: 'like', + user_id: userId, + create_notification_activity: true, + skip_push: true, // Send via notification feed + }); + // #endregion snippet + + const comment = await (addComment.mock.results[0] + .value as Promise); + expect(comment.comment).toMatchObject({ + object_id: activity.id, + text: 'Great post!', + }); + expect(comment.notification_created).toBe(true); + const reaction = await (addReaction.mock.results[0] + .value as Promise); + expect(reaction.reaction).toMatchObject({ + activity_id: activity.id, + type: 'like', + }); + expect(reaction.notification_accepted).toBe(true); + }); + + it('Custom Notification Feeds', async () => { + const recipientId = await createUser('user-123'); + const notificationFid = `notification:${recipientId}`; + + // A custom feed group (app-wide): give it a `uniqueId` name and delete it after. + const alertsGroup = uniqueId('alerts').replace(/-/g, '_'); + await serverClient.feeds.createFeedGroup({ id: alertsGroup }); + cleanup.add(() => + serverClient.feeds.deleteFeedGroup({ + id: alertsGroup, + hard_delete: true, + }), + ); + const alertsFid = `${alertsGroup}:${recipientId}`; + // A new feed group isn't known to every API node right away ("Feed group ... not found"). + await retry( + () => + serverClient.feeds + .feed(alertsGroup, recipientId) + .getOrCreate({ user_id: recipientId }), + { retryIf: isNotFound }, + ); + deleteFeedLater(alertsGroup, recipientId); + + const activityUserId = await createUser('activity-user'); + // Some API nodes still answer "Feed group ... not found" for a while after the + // getOrCreate above succeeded: retry the region's calls on that error. + const originalAddActivity = serverClient.feeds.addActivity.bind( + serverClient.feeds, + ); + const addActivity = vi + .spyOn(serverClient.feeds, 'addActivity') + .mockImplementation((request) => + retry(() => originalAddActivity(request), { retryIf: isNotFound }), + ); + + // #region snippet docs="platform/03-events-and-notifications/02-push-notifications.md" heading="Custom Notification Feeds" tab="Node.js" index=1 + // COPY: notificationFid="notification:user-123", alertsFid="alerts:user-123", activityUserId="user_id" + // Add a custom notification directly to a user's notification feed + await serverClient.feeds.addActivity({ + feeds: [notificationFid], // Target user's notification feed + type: 'milestone', // Custom activity type + text: "You've reached 1000 followers!", + user_id: activityUserId, + custom: { + milestone_type: 'followers', + count: 1000, + }, + }); + + // Add activity to custom notification feed group + await serverClient.feeds.addActivity({ + feeds: [alertsFid], // Custom notification feed group + type: 'system_alert', + text: 'Your subscription expires in 3 days', + user_id: activityUserId, + }); + // #endregion snippet + + const [milestone, alert] = await Promise.all( + addActivity.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(milestone.activity).toMatchObject({ + type: 'milestone', + feeds: [notificationFid], + custom: { milestone_type: 'followers', count: 1000 }, + }); + expect(alert.activity).toMatchObject({ + type: 'system_alert', + feeds: [alertsFid], + text: 'Your subscription expires in 3 days', + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/03-push-providers.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/03-push-providers.test.ts new file mode 100644 index 0000000..3f2ea39 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/03-push-providers.test.ts @@ -0,0 +1,165 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FirebaseConfigFields } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { + firebaseCredentials, + hasFirebaseCredentials, +} from '../../../helpers/push'; + +const DOCS = 'platform/03-events-and-notifications/03-push-providers.md'; + +/** + * Push provider snippets. Provider names are `uniqueId`s (the API allows at most 36 + * characters), and the app's own `firebase:firebase` provider is never touched. + */ +describe(DOCS, () => { + const client = getServerClient(); + + it('List Push Providers', async () => { + // #region snippet docs="platform/03-events-and-notifications/03-push-providers.md" heading="List Push Providers" tab="Node.js" index=1 + const { push_providers } = await client.listPushProviders(); + // #endregion snippet + + expect(Array.isArray(push_providers)).toBe(true); + for (const provider of push_providers) { + expect(typeof provider.name).toBe('string'); + expect(typeof provider.type).toBe('string'); + } + }); + + // Every other fence creates a Firebase provider or sets the app-level `firebase_config`: + // the API rejects the page's sample service account JSON (`sampleFirebaseCredentials`) + // with code 4 "firebase credentials are invalid" (both `upsertPushProvider` and + // `updateApp`, probed 2026-10-14), so they need a real service account. + describe.skipIf(!hasFirebaseCredentials)('with Firebase credentials', () => { + const cleanup = new Cleanup(client); + let originalFirebase: FirebaseConfigFields; + + const providerExists = async (name: string) => { + const { push_providers } = await client.listPushProviders(); + return push_providers.some( + (p) => p.type === 'firebase' && p.name === name, + ); + }; + + /** Deletes a test provider in cleanup, unless a snippet already deleted it. */ + const registerProvider = (name: string) => + cleanup.add(async () => { + if (await providerExists(name)) + await client.deletePushProvider({ type: 'firebase', name }); + }); + + const createProvider = async (name: string) => { + registerProvider(name); + await client.upsertPushProvider({ + push_provider: { + name, + type: 'firebase', + firebase_credentials: firebaseCredentials, + }, + }); + }; + + beforeAll(async () => { + originalFirebase = (await client.getApp()).app.push_notifications + .firebase; + // Restore the app-level (single-provider) Firebase config that "Single-Provider + // Configuration" overwrites. Credentials are never returned, so only the + // templates and the enabled flag can be put back. + cleanup.add(() => + client.updateApp({ + firebase_config: { + apn_template: originalFirebase.apn_template ?? '', + notification_template: originalFirebase.notification_template ?? '', + disabled: !originalFirebase.enabled, + }, + }), + ); + }); + + afterAll(() => cleanup.run()); + + it('Upsert a Push Provider', async () => { + const providerName = uniqueId('fcm'); + registerProvider(providerName); + + // #region snippet docs="platform/03-events-and-notifications/03-push-providers.md" heading="Upsert a Push Provider" tab="Node.js" index=1 + // COPY: providerName="my-custom-name", firebaseCredentials="my-service-account-information" + await client.upsertPushProvider({ + push_provider: { + name: providerName, + type: 'firebase', + firebase_credentials: firebaseCredentials, + }, + }); + // #endregion snippet + + expect(await providerExists(providerName)).toBe(true); + }); + + it('Delete a Push Provider', async () => { + const providerType = 'firebase'; + const providerName = uniqueId('fcm'); + await createProvider(providerName); + expect(await providerExists(providerName)).toBe(true); + + // #region snippet docs="platform/03-events-and-notifications/03-push-providers.md" heading="Delete a Push Provider" tab="Node.js" index=1 + // COPY: providerType="apn or firebase or huawei or xiaomi", providerName="your given custom name while creating" + await client.deletePushProvider({ + type: providerType, + name: providerName, + }); + // #endregion snippet + + expect(await providerExists(providerName)).toBe(false); + }); + + it('Linking Devices to Providers', async () => { + const providerName = uniqueId('fcm'); + await createProvider(providerName); + const userId = uniqueId('user'); + await client.upsertUsers([{ id: userId }]); + cleanup.users.push(userId); + const deviceId = uniqueId('device'); + const pushProvider = 'firebase'; + // Remove the device before its provider (cleanup runs in reverse order). + cleanup.add(() => client.deleteDevice({ id: deviceId, user_id: userId })); + + // #region snippet docs="platform/03-events-and-notifications/03-push-providers.md" heading="Linking Devices to Providers" tab="Node.js" index=1 + // COPY: deviceId="your client side generated device token to receive pushes", userId="your user id for server side calls", pushProvider="apn or firebase or huawei or xiaomi", providerName="the name of the provider you created while configuring your app" + await client.createDevice({ + id: deviceId, + user_id: userId, + push_provider: pushProvider, + push_provider_name: providerName, + }); + // #endregion snippet + + const { devices } = await client.listDevices({ user_id: userId }); + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ + id: deviceId, + push_provider: 'firebase', + push_provider_name: providerName, + }); + }); + + it('Single-Provider Configuration', async () => { + // #region snippet docs="platform/03-events-and-notifications/03-push-providers.md" heading="Single-Provider Configuration" tab="Node.js" index=1 + // COPY: firebaseCredentials="my-service-account-information" + // this configuration is same with upsert push provider + // except type is inherent and naming is missing so it's set to empty string + await client.updateApp({ + firebase_config: { + credentials_json: firebaseCredentials, + }, + }); + // #endregion snippet + + const { app } = await client.getApp(); + expect(app.push_notifications.firebase.enabled).toBe(true); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts new file mode 100644 index 0000000..e427bb8 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts @@ -0,0 +1,251 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { DeviceResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { + firebaseCredentials, + hasFirebaseCredentials, +} from '../../../helpers/push'; + +const DOCS = 'platform/03-events-and-notifications/04-push-devices.md'; + +/** + * Device snippets. `createDevice` checks that the push provider exists (code 4 'push + * provider "" with type "apn" not found', also without `push_provider_name`, probed + * 2026-10-14), and the app only has its own `firebase:firebase` provider, which tests + * must not use. So devices can only be registered on a `uniqueId` Firebase provider the + * test creates, which needs a real service account. Devices go away with their user. + */ +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const createUser = async () => { + const id = uniqueId('user'); + await client.upsertUsers([{ id }]); + cleanup.users.push(id); + return id; + }; + + afterAll(() => cleanup.run()); + + // The page's register snippets all use `push_provider: "apn"`: the app has no APN + // provider, and creating one needs APN credentials. + it.skip('BLOCKED: needs APN credentials (Register a Device)', async () => { + const userId = await createUser(); + const pushToken = uniqueId('device'); + const providerName = uniqueId('apn'); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Register a Device" tab="Node.js" index=1 + // COPY: pushToken="", providerName="production-ios", userId="user_id" + await client.createDevice({ + id: pushToken, + push_provider: 'apn', + push_provider_name: providerName, + user_id: userId, + }); + // #endregion snippet + + const { devices } = await client.listDevices({ user_id: userId }); + expect(devices.map((d) => d.id)).toEqual([pushToken]); + }); + + it.skip('BLOCKED: needs APN credentials (Registering from the chat client SDKs)', async () => { + const userId = await createUser(); + const deviceToken = uniqueId('device'); + const providerName = uniqueId('apn'); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Registering from the chat client SDKs" tab="Node.js" index=1 + // COPY: deviceToken="2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207", userId="Taquito", providerName="production-ios" + await client.createDevice({ + id: deviceToken, + push_provider: 'apn', + user_id: userId, + // if multi providers + push_provider_name: providerName, + }); + // #endregion snippet + + const { devices } = await client.listDevices({ user_id: userId }); + expect(devices.map((d) => d.id)).toEqual([deviceToken]); + }); + + it.skip('BLOCKED: needs APN credentials (Multi-Bundle Device Registration)', async () => { + const userId = await createUser(); + const pushToken = uniqueId('device'); + const providerName = uniqueId('apn'); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Multi-Bundle Device Registration" tab="Node.js" index=1 + // COPY: pushToken="", providerName="production-ios", userId="user_id" + await client.createDevice({ + id: pushToken, + push_provider: 'apn', + push_provider_name: providerName, + user_id: userId, + }); + // #endregion snippet + + const { devices } = await client.listDevices({ user_id: userId }); + expect(devices.map((d) => d.id)).toEqual([pushToken]); + }); + + // Without a test-created provider no device can be registered, so these list a new + // user's (empty) device list. + it('List Devices', async () => { + const userId = await createUser(); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="List Devices" tab="Node.js" index=1 + // COPY: userId="" + const devices = await client.listDevices({ user_id: userId }); + // #endregion snippet + + expect(devices.devices).toEqual([]); + }); + + it('Listing from the chat client SDKs', async () => { + const userId = await createUser(); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Listing from the chat client SDKs" tab="Node.js" index=1 + // COPY: userId="Taquito" + const { devices } = await client.listDevices({ user_id: userId }); + // #endregion snippet + + expect(devices).toEqual([]); + }); + + it('Listing from the feeds client SDKs', async () => { + const userId = await createUser(); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Listing from the feeds client SDKs" tab="Node.js" index=1 + // COPY: userId="" + const devices = await client.listDevices({ user_id: userId }); + // #endregion snippet + + expect(devices.devices).toEqual([]); + }); + + it('device object (Listing from the feeds client SDKs)', () => { + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Listing from the feeds client SDKs" tab="Node.js" index=2 + type Device = { + created_at: Date; + id: string; + push_provider: string; + user_id: string; + disabled?: boolean; + disabled_reason?: string; + push_provider_name?: string; + voip?: boolean; + hardware_id?: string; + }; + // #endregion snippet + + // Compile-time check: the docs type and the SDK's `DeviceResponse` have exactly the + // same fields, optionality and field types (mutual assignability alone would miss a + // missing optional field). + type Same = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + const matchesSdk: Same = true; + expect(matchesSdk).toBe(true); + }); + + // Removing needs a device, so a `uniqueId` Firebase provider: the API rejects the + // sample service account JSON (code 4 "firebase credentials are invalid", see + // 03-push-providers), so these need a real one. + describe.skipIf(!hasFirebaseCredentials)('with Firebase credentials', () => { + const providerName = uniqueId('fcm'); + + beforeAll(async () => { + cleanup.add(async () => { + const { push_providers } = await client.listPushProviders(); + if (push_providers.some((p) => p.name === providerName)) + await client.deletePushProvider({ + type: 'firebase', + name: providerName, + }); + }); + await client.upsertPushProvider({ + push_provider: { + name: providerName, + type: 'firebase', + firebase_credentials: firebaseCredentials, + }, + }); + }); + + /** Registers a device on the test provider (never the app's own) and checks it. */ + const createTestDevice = async () => { + const userId = await createUser(); + const deviceId = uniqueId('device'); + // Remove the device before its provider (custom steps run in reverse order), unless + // the snippet already did. + cleanup.add(() => + client + .deleteDevice({ id: deviceId, user_id: userId }) + .catch(() => undefined), + ); + await client.createDevice({ + id: deviceId, + push_provider: 'firebase', + push_provider_name: providerName, + user_id: userId, + }); + const { devices } = await client.listDevices({ user_id: userId }); + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ + id: deviceId, + push_provider_name: providerName, + }); + return { userId, deviceId }; + }; + + const expectNoDevices = async (userId: string) => { + const { devices } = await client.listDevices({ user_id: userId }); + expect(devices).toEqual([]); + }; + + it('Remove a Device', async () => { + const { userId, deviceId } = await createTestDevice(); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Remove a Device" tab="Node.js" index=1 + // COPY: deviceId="", userId="" + await client.deleteDevice({ + id: deviceId, + user_id: userId, + }); + // #endregion snippet + + await expectNoDevices(userId); + }); + + it('Removing from the chat client SDKs', async () => { + const { userId, deviceId } = await createTestDevice(); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Removing from the chat client SDKs" tab="Node.js" index=1 + // COPY: deviceId="2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207", userId="Taquito" + await client.deleteDevice({ + id: deviceId, + user_id: userId, + }); + // #endregion snippet + + await expectNoDevices(userId); + }); + + it('Removing from the feeds client SDKs', async () => { + const { userId, deviceId } = await createTestDevice(); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Removing from the feeds client SDKs" tab="Node.js" index=1 + // COPY: deviceId="", userId="" + await client.deleteDevice({ + id: deviceId, + user_id: userId, + }); + // #endregion snippet + + await expectNoDevices(userId); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts new file mode 100644 index 0000000..63fb4f1 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts @@ -0,0 +1,276 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { UpsertPushPreferencesResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; + +const DOCS = 'platform/03-events-and-notifications/05-push-preferences.md'; + +/** + * Push preference snippets. Pushes aren't checked: the tests assert the responses and read + * the preferences back (channel type, channel config, the member's `push_preferences`). + */ +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + // The snippets change the channel type they name: use a uniqueId type instead of the + // built-in `messaging` type. + const channelType = uniqueId('messaging'); + const channelId = uniqueId('announcements'); + const userId1 = uniqueId('user-1'); + const userId2 = uniqueId('user-2'); + // A channel on the built-in `messaging` type for the channel-member preference. + const generalId = uniqueId('general'); + const channelCid = `messaging:${generalId}`; + + /** The push preference responses of the region's calls. */ + const upsertResults = (spy: { + mock: { results: Array<{ value: unknown }> }; + }) => + Promise.all( + spy.mock.results.map( + (r) => r.value as Promise, + ), + ); + + beforeAll(async () => { + cleanup.users.push(userId1, userId2); + await client.upsertUsers([{ id: userId1 }, { id: userId2 }]); + + await client.chat.createChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + }); + cleanup.channelTypes.push(channelType); + await waitForChannelTypePropagation(); + + await client.chat + .channel(channelType, channelId) + .getOrCreate({ data: { created_by_id: userId1 } }); + cleanup.channels.push(`${channelType}:${channelId}`); + + await client.chat.channel('messaging', generalId).getOrCreate({ + data: { created_by_id: userId1, members: [{ user_id: userId1 }] }, + }); + cleanup.channels.push(channelCid); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Channel Configuration Push Level: push_level', async () => { + const before = await client.chat.getChannelType({ name: channelType }); + expect(before.push_level).not.toBe('all_mentions'); + + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Channel Configuration Push Level" tab="Node.js" index=1 + // COPY: channelType="messaging", channelId="announcements" + // Set push_level on a channel type + // automod, automod_behavior and max_message_length are required on this endpoint, + // so carry the current values through to avoid overwriting them. + const current = await client.chat.getChannelType({ name: channelType }); + + await client.chat.updateChannelType({ + name: channelType, + automod: current.automod, + automod_behavior: current.automod_behavior, + max_message_length: current.max_message_length, + push_level: 'all_mentions', + }); + + // Set push_level as a channel config override on a specific channel + const channel = client.chat.channel(channelType, channelId); + await channel.updateChannelPartial({ + set: { config_overrides: { push_level: 'all' } }, + }); + // #endregion snippet + + await waitForChannelTypePropagation(); + const updated = await retry(async () => { + const response = await client.chat.getChannelType({ name: channelType }); + if (response.push_level !== 'all_mentions') { + throw new Error('push_level not updated yet'); + } + return response; + }); + expect(updated.push_level).toBe('all_mentions'); + expect(updated.automod).toBe(current.automod); + expect(updated.max_message_length).toBe(current.max_message_length); + + // The override wins over the type's `all_mentions` in the merged channel config. + const { channel: response } = await channel.getOrCreate({}); + expect(response?.config?.push_level).toBe('all'); + }); + + it('Channel Configuration Push Level: chat_preferences', async () => { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Channel Configuration Push Level" tab="Node.js" index=2 + // COPY: channelType="messaging", channelId="announcements" + // Set granular chat_preferences as a channel config override + const channel = client.chat.channel(channelType, channelId); + await channel.updateChannelPartial({ + set: { + config_overrides: { + chat_preferences: { + default_preference: 'none', + channel_mentions: 'all', + direct_mentions: 'all', + }, + }, + }, + }); + // #endregion snippet + + const { channel: response } = await channel.getOrCreate({}); + expect(response?.config?.chat_preferences).toMatchObject({ + default_preference: 'none', + channel_mentions: 'all', + direct_mentions: 'all', + }); + // "setting one clears the other": the merged config has no push_level any more, not + // even the type's `all_mentions`. + expect(response?.config?.push_level).toBeUndefined(); + }); + + it('Server-Side Usage (chat)', async () => { + const upsert = vi.spyOn(client, 'updatePushNotificationPreferences'); + + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Server-Side Usage" tab="Node.js" index=1 + // COPY: userId1="user-1", userId2="user-2" + // Server-side - can update preferences for any user + await client.updatePushNotificationPreferences({ + preferences: [ + { + user_id: userId1, + chat_level: 'mentions', + }, + { + user_id: userId2, + chat_level: 'all', + }, + ], + }); + // #endregion snippet + + const [response] = await upsertResults(upsert); + expect(response.user_preferences[userId1]?.chat_level).toBe('mentions'); + expect(response.user_preferences[userId2]?.chat_level).toBe('all'); + // The other levels come back with their defaults. + expect(response.user_preferences[userId1]).toMatchObject({ + call_level: 'all', + feeds_level: 'all', + }); + }); + + it('Setting granular preferences', async () => { + const upsert = vi.spyOn(client, 'updatePushNotificationPreferences'); + + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Setting granular preferences" tab="Node.js" index=1 + // COPY: userId1="user-1", channelCid="messaging:general" + // User-level: only receive pushes for direct mentions and thread replies + await client.updatePushNotificationPreferences({ + preferences: [ + { + user_id: userId1, + chat_preferences: { + default_preference: 'none', + direct_mentions: 'all', + thread_replies: 'all', + }, + }, + ], + }); + + // Channel-member level: mute @channel and @here in a noisy channel + await client.updatePushNotificationPreferences({ + preferences: [ + { + user_id: userId1, + channel_cid: channelCid, + chat_preferences: { + default_preference: 'all', + channel_mentions: 'none', + here_mentions: 'none', + }, + }, + ], + }); + // #endregion snippet + + const [userLevel, memberLevel] = await upsertResults(upsert); + expect(userLevel.user_preferences[userId1]?.chat_preferences).toMatchObject( + { + default_preference: 'none', + direct_mentions: 'all', + thread_replies: 'all', + }, + ); + expect( + memberLevel.user_channel_preferences[userId1]?.[channelCid] + ?.chat_preferences, + ).toMatchObject({ + default_preference: 'all', + channel_mentions: 'none', + here_mentions: 'none', + }); + + // A user-level-only call returns no channel preferences, and a channel-only call + // returns the user's entry as null (typed non-null, see the TODO). + expect(userLevel.user_channel_preferences).toEqual({}); + expect(memberLevel.user_preferences[userId1]).toBeNull(); + // Server-side queryChannels doesn't return `push_preferences`, even with a user_id + // (see the TODO), so the responses above are the read back. If this starts + // failing, assert the preferences here instead. + const member = await getServerChannel(client, channelCid, { + user_id: userId1, + }); + expect(member.members.map((m) => m.user_id)).toContain(userId1); + expect(member.push_preferences).toBeUndefined(); + }); + + it('Server-Side Usage (feeds)', async () => { + const upsert = vi.spyOn(client, 'updatePushNotificationPreferences'); + + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Server-Side Usage" tab="Node.js" index=2 + // COPY: userId1="user-1", userId2="user-2" + // Server-side: can update preferences for any user + await client.updatePushNotificationPreferences({ + preferences: [ + { + user_id: userId1, + feeds_level: 'none', + }, + { + user_id: userId2, + feeds_preferences: { + reaction: 'all', + comment: 'none', + follow: 'all', + }, + }, + ], + }); + // #endregion snippet + + const [response] = await upsertResults(upsert); + expect(response.user_preferences[userId1]?.feeds_level).toBe('none'); + expect(response.user_preferences[userId2]?.feeds_preferences).toMatchObject( + { + reaction: 'all', + comment: 'none', + follow: 'all', + }, + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/06-push-troubleshooting.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/06-push-troubleshooting.test.ts new file mode 100644 index 0000000..3d1f8b5 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/06-push-troubleshooting.test.ts @@ -0,0 +1,142 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { CheckPushRequest, CheckPushResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { + firebaseCredentials, + hasFirebaseCredentials, +} from '../../../helpers/push'; +import { sendServerMessage } from '../../../helpers/server'; + +const DOCS = 'platform/03-events-and-notifications/06-push-troubleshooting.md'; + +/** + * `checkPush` snippets. Both fences call `checkPush` without a provider, which would run + * against the app's own `firebase:firebase` provider: tests must not use it. So each call + * is redirected to a `uniqueId` Firebase provider the test creates (a spy adds + * `push_provider_type` / `push_provider_name`, the region stays as written). Creating + * that provider needs a real service account (the API rejects `sampleFirebaseCredentials` + * with code 4 "firebase credentials are invalid", see 03-push-providers), so everything + * is skipped without `DOCS_PUSH_FIREBASE_CREDENTIALS`. Pushes aren't checked + * (`skip_devices: true`, and the user has no devices). + */ +describe.skipIf(!hasFirebaseCredentials)(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const providerName = uniqueId('fcm'); + let userId: string; + let messageId: string; + + beforeAll(async () => { + cleanup.add(async () => { + const { push_providers } = await client.listPushProviders(); + if (push_providers.some((p) => p.name === providerName)) + await client.deletePushProvider({ + type: 'firebase', + name: providerName, + }); + }); + await client.upsertPushProvider({ + push_provider: { + name: providerName, + type: 'firebase', + firebase_credentials: firebaseCredentials, + }, + }); + + userId = uniqueId('user'); + await client.upsertUsers([{ id: userId, name: 'Push Tester' }]); + cleanup.users.push(userId); + + const channel = client.chat.channel('messaging', uniqueId('channel')); + await channel.getOrCreate({ + data: { + created_by_id: userId, + members: [{ user_id: userId }], + custom: { name: 'Push test' }, + }, + }); + cleanup.channels.push(`messaging:${channel.id}`); + + const message = await sendServerMessage(client, `messaging:${channel.id}`, { + text: 'hello push', + user_id: userId, + }); + messageId = message.id; + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + /** Sends every `checkPush` to the test's provider, never the app's own. */ + const spyOnCheckPush = () => { + const original = client.checkPush.bind(client); + return vi + .spyOn(client, 'checkPush') + .mockImplementation((request?: CheckPushRequest) => + original({ + ...request, + push_provider_type: 'firebase', + push_provider_name: providerName, + }), + ); + }; + + const checkPushResult = async (spy: ReturnType) => { + expect(spy).toHaveBeenCalledTimes(1); + return spy.mock.results[0].value as Promise; + }; + + it('Testing Push', async () => { + const spy = spyOnCheckPush(); + + // #region snippet docs="platform/03-events-and-notifications/06-push-troubleshooting.md" heading="Testing Push" tab="Node.js" index=1 + await client.checkPush({ + user_id: userId, + message_id: messageId, + skip_devices: true, + }); + // #endregion snippet + + expect(spy.mock.calls[0][0]).toEqual({ + user_id: userId, + message_id: messageId, + skip_devices: true, + }); + const response = await checkPushResult(spy); + expect(response.skip_devices).toBe(true); + expect(typeof response.duration).toBe('string'); + }); + + it('Testing Push (APN template)', async () => { + const spy = spyOnCheckPush(); + + // #region snippet docs="platform/03-events-and-notifications/06-push-troubleshooting.md" heading="Testing Push" tab="Node.js" index=2 + await client.checkPush({ + user_id: userId, + message_id: messageId, + skip_devices: true, + apn_template: + '{"aps":{"alert":{"title":"{{ sender.name }} @ {{ channel.name }}","body":"testing out new stuff:{{ message.text }}"},"category":"NEW_MESSAGE_2"}}', + }); + // #endregion snippet + + expect(spy.mock.calls[0][0]).toMatchObject({ + user_id: userId, + message_id: messageId, + skip_devices: true, + }); + const response = await checkPushResult(spy); + expect(response.skip_devices).toBe(true); + expect(typeof response.duration).toBe('string'); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/04-using-the-api/01-rate-limits.429.test.ts b/__tests__/docs-snippets/pages/platform/04-using-the-api/01-rate-limits.429.test.ts new file mode 100644 index 0000000..be47170 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/04-using-the-api/01-rate-limits.429.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { uniqueId } from '../../../helpers/ids'; + +// The "Catching 429s" fence starts with an import, so its region runs at module level. +// Values it uses are declared above it (TDZ). The request never reaches the API: never +// flood the shared test app. The next `fetch` answers with a 429 shaped like the real +// API one (error code 9, X-RateLimit-* headers), and the SDK's own ApiClient turns it +// into the StreamError. +const client = getServerClient(); +const userId = uniqueId('john'); +const reset = Math.floor(Date.now() / 1000) + 30; +const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + code: 9, + message: + 'QueryUsers failed with error: "Too many requests, check response headers for more information."', + StatusCode: 429, + duration: '0.00ms', + more_info: 'https://getstream.io/chat/docs/api_errors_response', + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'x-ratelimit-limit': '1000', + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': String(reset), + }, + }, + ), +); +const queryUsers = vi.spyOn(client, 'queryUsers'); +const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + +// #region snippet docs="platform/04-using-the-api/01-rate-limits.md" heading="Catching 429s in backend SDKs" tab="Node.js" index=1 +// COPY: userId="john" +import { StreamError } from '@stream-io/node-sdk'; + +try { + await client.queryUsers({ + payload: { filter_conditions: { id: userId } }, + }); +} catch (error) { + if (error instanceof StreamError && error.metadata.responseCode === 429) { + const rateLimit = error.metadata.rateLimit; + // Wait until rateLimit.rateLimitReset, then retry + console.log(rateLimit?.rateLimitReset); + } +} +// #endregion snippet + +// Capture what the region did, then restore the spies before any test runs. +const fetchCalls = fetchSpy.mock.calls.length; +const rejection: unknown = await ( + queryUsers.mock.results[0].value as Promise +).catch((e: unknown) => e); +const logCalls = [...log.mock.calls]; +vi.restoreAllMocks(); + +describe('platform/04-using-the-api/01-rate-limits.md (429)', () => { + it('handles a 429 from the API (simulated)', () => { + expect(fetchCalls).toBe(1); + expect(rejection).toBeInstanceOf(StreamError); + expect(rejection).toMatchObject({ + code: 9, + metadata: { responseCode: 429 }, + }); + expect(logCalls).toEqual([[new Date(reset * 1000)]]); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/04-using-the-api/01-rate-limits.test.ts b/__tests__/docs-snippets/pages/platform/04-using-the-api/01-rate-limits.test.ts new file mode 100644 index 0000000..141290c --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/04-using-the-api/01-rate-limits.test.ts @@ -0,0 +1,134 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +describe('platform/04-using-the-api/01-rate-limits.md', () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const userId = uniqueId('john'); + + beforeAll(async () => { + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + await cleanup.run(); + }); + + it('reads the rate limit headers from a response', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="platform/04-using-the-api/01-rate-limits.md" heading="Rate Limit Headers" tab="Node.js" index=1 + // COPY: userId="john" + const response = await client.queryUsers({ + payload: { filter_conditions: { id: userId } }, + }); + const rateLimit = response.metadata.rateLimit; + // the total limit allowed for the resource requested + console.log(rateLimit.rateLimit); + // the remaining limit + console.log(rateLimit.rateLimitRemaining); + // when the current limit will reset - Date + console.log(rateLimit.rateLimitReset); + // #endregion snippet + + expect(response.users.map((u) => u.id)).toEqual([userId]); + + // The values come from the X-RateLimit-* response headers. + const headers = response.metadata.responseHeaders; + expect(rateLimit.rateLimit).toBe(Number(headers.get('x-ratelimit-limit'))); + expect(rateLimit.rateLimit).toBeGreaterThan(0); + expect(rateLimit.rateLimitRemaining).toBe( + Number(headers.get('x-ratelimit-remaining')), + ); + expect(rateLimit.rateLimitRemaining).toBeLessThanOrEqual( + rateLimit.rateLimit ?? 0, + ); + expect(rateLimit.rateLimitReset).toBeInstanceOf(Date); + expect(rateLimit.rateLimitReset?.getTime()).toBe( + Number(headers.get('x-ratelimit-reset')) * 1000, + ); + + expect(log.mock.calls).toEqual([ + [rateLimit.rateLimit], + [rateLimit.rateLimitRemaining], + [rateLimit.rateLimitReset], + ]); + }); + + it('inspects the rate limits through the API', async () => { + // #region snippet docs="platform/04-using-the-api/01-rate-limits.md" heading="Inspecting Rate Limits" tab="Node.js" index=1 + // Server-side platform only + const serverSideLimits = await client.getRateLimits({ server_side: true }); + + // All platforms + const allLimits = await client.getRateLimits(); + + // Specific platforms + const platformLimits = await client.getRateLimits({ + ios: true, + android: true, + }); + + // Specific endpoints + const endpointLimits = await client.getRateLimits({ + endpoints: 'QueryUsers,UpdateUsers', + }); + // #endregion snippet + + const platforms = (r: object) => + Object.keys(r) + .filter((k) => !['duration', 'metadata'].includes(k)) + .sort(); + + expect(platforms(serverSideLimits)).toEqual(['server_side']); + expect(serverSideLimits.server_side?.QueryUsers).toMatchObject({ + limit: 1000, + }); + + const every = [ + 'android', + 'ios', + 'server_side', + 'unity', + 'unity_console', + 'unity_desktop', + 'web', + ]; + expect(platforms(allLimits)).toEqual(every); + expect(Object.keys(allLimits.server_side ?? {}).length).toBeGreaterThan(2); + + expect(platforms(platformLimits)).toEqual(['android', 'ios']); + + // Every platform, restricted to the two endpoints. + expect(platforms(endpointLimits)).toEqual(every); + for (const endpoints of [ + endpointLimits.server_side, + endpointLimits.ios, + endpointLimits.web, + ]) { + expect(Object.keys(endpoints ?? {}).sort()).toEqual([ + 'QueryUsers', + 'UpdateUsers', + ]); + } + const queryUsers = endpointLimits.server_side?.QueryUsers; + expect(queryUsers?.limit).toBe(1000); + expect(queryUsers?.remaining).toBeLessThanOrEqual(1000); + expect(typeof queryUsers?.reset).toBe('number'); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/04-using-the-api/03-async-operations.test.ts b/__tests__/docs-snippets/pages/platform/04-using-the-api/03-async-operations.test.ts new file mode 100644 index 0000000..c5d73d1 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/04-using-the-api/03-async-operations.test.ts @@ -0,0 +1,61 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = 'platform/04-using-the-api/03-async-operations.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const userId1 = uniqueId('async-user1'); + const userId2 = uniqueId('async-user2'); + + beforeAll(async () => { + cleanup.users.push(userId1, userId2); + await client.upsertUsers([{ id: userId1 }, { id: userId2 }]); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('polls a task manually', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="platform/04-using-the-api/03-async-operations.md" heading="Polling manually" tab="Node.js" index=1 + // COPY: userId1="", userId2="" + const response = await client.exportUsers({ + user_ids: [userId1, userId2], + }); + + // poll until the status is completed or failed + let taskResponse = await client.getTask({ id: response.task_id }); + while ( + taskResponse.status !== 'completed' && + taskResponse.status !== 'failed' + ) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + taskResponse = await client.getTask({ id: response.task_id }); + } + + console.log(taskResponse.status, taskResponse.result); + // #endregion snippet + + expect(taskResponse.task_id).toBe(response.task_id); + expect(taskResponse.status).toBe('completed'); + expect(log).toHaveBeenCalledWith('completed', taskResponse.result); + const url = taskResponse.result?.url as unknown; + expect(typeof url).toBe('string'); + const contents = await (await fetch(String(url))).text(); + expect(contents).toContain(userId1); + expect(contents).toContain(userId2); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/04-using-the-api/06-connection-pooling.test.ts b/__tests__/docs-snippets/pages/platform/04-using-the-api/06-connection-pooling.test.ts new file mode 100644 index 0000000..42c58e7 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/04-using-the-api/06-connection-pooling.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { StreamClient } from '../../../../../index'; + +// Values the module-level region below uses (it must come after them: TDZ). +const apiKey = process.env.STREAM_API_KEY as string; +const apiSecret = process.env.STREAM_SECRET as string; + +// #region snippet docs="platform/04-using-the-api/06-connection-pooling.md" heading="SDK-specific notes" tab="Node.js" index=1 +import { Agent } from 'undici'; + +const client = new StreamClient(apiKey, apiSecret, { + agent: new Agent({ connections: 100 }), +}); +// #endregion snippet + +describe('platform/04-using-the-api/06-connection-pooling.md', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes the custom undici Agent to fetch as its dispatcher', async () => { + // No network: only check what the SDK hands to the built-in fetch. + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ app: { name: 'app' }, duration: '0' })), + ); + + const response = await client.getApp(); + + expect(response.app.name).toBe('app'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][1]?.dispatcher).toBeInstanceOf(Agent); + }); + + it('calls the API through the custom Agent', async () => { + // Calls go through: the spy only records that the undici Agent handled the request. + const dispatch = vi.spyOn(Agent.prototype, 'dispatch'); + + const response = await client.getApp(); + + expect(dispatch).toHaveBeenCalled(); + expect(typeof response.app.name).toBe('string'); + }); +}); diff --git a/__tests__/docs-snippets/pages/platform/04-using-the-api/07-api-error-codes.test.ts b/__tests__/docs-snippets/pages/platform/04-using-the-api/07-api-error-codes.test.ts new file mode 100644 index 0000000..be1a006 --- /dev/null +++ b/__tests__/docs-snippets/pages/platform/04-using-the-api/07-api-error-codes.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { StreamClient, StreamError } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; + +const apiKey = process.env.STREAM_API_KEY as string; +const apiSecret = process.env.STREAM_SECRET as string; + +describe('platform/04-using-the-api/07-api-error-codes.md', () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const creatorId = uniqueId('creator'); + const missingChannelId = uniqueId('missing'); + const channelId = uniqueId('channel'); + + beforeAll(async () => { + cleanup.users.push(creatorId); + // Registered in case a run creates them despite the error. + cleanup.channels.push( + `messaging:${missingChannelId}`, + `messaging:${channelId}`, + ); + await serverClient.upsertUsers([{ id: creatorId }]); + }); + + afterAll(() => cleanup.run()); + + it('fails to get a channel that does not exist without created_by', async () => { + const run = async () => { + // #region snippet docs="platform/04-using-the-api/07-api-error-codes.md" heading="GetOrCreateChannel failed" tab="Node.js" index=1 + // COPY: apiKey="key", apiSecret="secret", missingChannelId="123" + const client = new StreamClient(apiKey, apiSecret); + const channel = client.chat.channel('messaging', missingChannelId); + await channel.getOrCreate(); + // #endregion snippet + }; + + const error: unknown = await run().catch((e: unknown) => e); + expect(error).toBeInstanceOf(StreamError); + expect(error).toMatchObject({ + code: 4, + metadata: { responseCode: 400 }, + }); + expect((error as StreamError).message).toMatch( + /GetOrCreateChannel failed with error: "either data\.created_by or data\.created_by_id must be provided when using server side auth\."/, + ); + + // Nothing was created. + const { channels } = await serverClient.chat.queryChannels({ + filter_conditions: { cid: `messaging:${missingChannelId}` }, + }); + expect(channels).toEqual([]); + }); + + it('creates the channel with created_by_id', async () => { + // #region snippet docs="platform/04-using-the-api/07-api-error-codes.md" heading="GetOrCreateChannel failed" tab="Node.js" index=2 + // COPY: apiKey="key", apiSecret="secret", channelId="123", creatorId="4645" + const client = new StreamClient(apiKey, apiSecret); + // create the channel and set created_by to user id 4645 + const channel = client.chat.channel('messaging', channelId); + await channel.getOrCreate({ + data: { created_by_id: creatorId }, + }); + // #endregion snippet + + const created = await getServerChannel( + serverClient, + `messaging:${channelId}`, + ); + expect(created.channel?.created_by?.id).toBe(creatorId); + + // Once the channel exists, the first snippet's call works without created_by. + const response = await channel.getOrCreate(); + expect(response.channel?.cid).toBe(`messaging:${channelId}`); + }); +}); diff --git a/__tests__/docs-snippets/sync/docs-sync.test.ts b/__tests__/docs-snippets/sync/docs-sync.test.ts index 9a835ae..865bec8 100644 --- a/__tests__/docs-snippets/sync/docs-sync.test.ts +++ b/__tests__/docs-snippets/sync/docs-sync.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { ALLOWED_JS_LABELS, applyCopy, - DOCS_CHAT_DIR, + DOCS_DIR, EXCLUDED_PAGES, fenceId, findSidebarPages, @@ -20,15 +20,17 @@ import type { DocsFence, TestRegion } from './parse'; // `yarn test:docs:sync`, in this order: // 1. Every `Node.js` fence (except comment-only fences) on the pages of the chat Node -// sidebar has a test region. `JavaScript` (client-side) fences are out of scope. -// 2. Every JS fence on those pages is labelled `JavaScript` or `Node.js`. +// sidebar and the platform sidebar has a test region. `JavaScript`, `React` and +// `React Native` (client-side) fences, and fences inside a +// `` block, are out of scope. +// 2. Every JS fence on those pages is labelled with one of `ALLOWED_JS_LABELS`. // 3. Every marked test region points at an existing fence and matches it (after // applying COPY values and formatting both sides the same way). // Pages in `EXCLUDED_PAGES` (parse.ts) are skipped. // DOCS_SYNC_WRITE= overwrite that page's fences with the test code instead of // comparing (page path as in the markers, e.g. -// _default/04-messages/01-send_message.md; `1` = all pages) -// DOCS_CHAT_DIR=... path to getstream.io/content/docs/chat (default: sibling checkout) +// chat/_default/04-messages/01-send_message.md; `1` = all pages) +// DOCS_DIR=... path to getstream.io/content/docs (default: sibling checkout) const writeTarget = process.env.DOCS_SYNC_WRITE; const shouldWrite = (docs: string) => @@ -52,9 +54,9 @@ it('collects the test regions', () => { // Without a docs checkout there is nothing to compare against: skip (vitest still runs // the describe body to collect tests, so it must not read the docs either). -const hasDocs = fs.existsSync(DOCS_CHAT_DIR); +const hasDocs = fs.existsSync(DOCS_DIR); -describe.skipIf(!hasDocs)(`docs sync (${DOCS_CHAT_DIR})`, () => { +describe.skipIf(!hasDocs)(`docs sync (${DOCS_DIR})`, () => { // Pages with tests count even if they are no longer in the sidebar. const pages = [ ...new Set([...(hasDocs ? findSidebarPages() : []), ...regionPages]), @@ -76,6 +78,7 @@ describe.skipIf(!hasDocs)(`docs sync (${DOCS_CHAT_DIR})`, () => { (fence) => fence.tab === NODE_TAB && !fence.commentOnly && + !fence.noSdk && !covered.has(fenceId(fence)), ) .map((fence) => where(file, fence)), @@ -139,7 +142,7 @@ describe.skipIf(!hasDocs)(`docs sync (${DOCS_CHAT_DIR})`, () => { /** Replaces one fence body; re-reads the file so several writes to one page compose. */ const writeFence = (docsFile: string, fence: DocsFence, code: string) => { - const docs = path.relative(DOCS_CHAT_DIR, docsFile); + const docs = path.relative(DOCS_DIR, docsFile); const current = parseDocsFences(docs, fs.readFileSync(docsFile, 'utf8')).find( (candidate) => fenceId(candidate) === fenceId(fence), ); diff --git a/__tests__/docs-snippets/sync/parse.ts b/__tests__/docs-snippets/sync/parse.ts index af2c160..834356c 100644 --- a/__tests__/docs-snippets/sync/parse.ts +++ b/__tests__/docs-snippets/sync/parse.ts @@ -3,15 +3,15 @@ import path from 'node:path'; import { format } from 'prettier'; import type { Options } from 'prettier'; -/** `content/docs/chat` in a getstream.io checkout (override with DOCS_CHAT_DIR). */ -export const DOCS_CHAT_DIR = path.resolve( - process.env.DOCS_CHAT_DIR ?? - path.join(__dirname, '../../../../getstream.io/content/docs/chat'), +/** `content/docs` in a getstream.io checkout (override with DOCS_DIR). */ +export const DOCS_DIR = path.resolve( + process.env.DOCS_DIR ?? + path.join(__dirname, '../../../../getstream.io/content/docs'), ); /** Identifies one code fence on a docs page. */ export type FenceKey = { - /** Page path relative to `content/docs/chat`, e.g. `_default/04-messages/01-send_message.md`. */ + /** Page path relative to `content/docs`, e.g. `chat/_default/04-messages/01-send_message.md`. */ docs: string; /** Text of the nearest heading above the fence (`""` before the first heading). */ heading: string; @@ -34,6 +34,8 @@ export type DocsFence = FenceKey & { indent: string; /** The body is only comments (e.g. a "server-side only" placeholder): nothing to test. */ commentOnly: boolean; + /** Inside a `` block: a reference implementation that doesn't use the SDK. */ + noSdk: boolean; }; export type TestRegion = FenceKey & { @@ -53,15 +55,28 @@ const JS_LANGS = new Set(['js', 'javascript', 'ts', 'typescript']); export const JS_TAB = 'JavaScript'; /** The tested tab (`@stream-io/node-sdk` code). */ export const NODE_TAB = 'Node.js'; +/** Out of scope too: client-side React / React Native SDK code. */ +const OUT_OF_SCOPE_TABS = ['React', 'React Native']; +/** + * Opens a block of reference implementations that don't use the SDK (platform pages). + * Its `Node.js` fences are out of scope. + */ +const NO_SDK_DISCLOSURE_RE = + /^\s*]*label="[^"]*\(no SDK\)[^"]*"/i; /** Every JS fence must carry one of these labels. */ -export const ALLOWED_JS_LABELS = [JS_TAB, NODE_TAB]; +export const ALLOWED_JS_LABELS = [JS_TAB, NODE_TAB, ...OUT_OF_SCOPE_TABS]; -/** Node sidebar of the chat docs: the pages whose `Node.js` fences are tested. */ -const SIDEBAR_FILE = '[chat][node].json'; +/** + * Sidebars whose pages' `Node.js` fences are tested: the chat Node sidebar and the + * platform docs (shared by all SDKs). + */ +const SIDEBAR_FILES = ['[chat][node].json', '[platform][default].json']; +/** Sidebar entries outside these docs are ignored. */ +const PAGE_PREFIXES = ['chat/', 'platform/']; /** - * Pages in the Node sidebar that are deliberately not validated (page path - * relative to `content/docs/chat`, reason). + * Sidebar pages that are deliberately not validated (page path relative to + * `content/docs`, reason). */ export const EXCLUDED_PAGES: Record = {}; @@ -87,7 +102,7 @@ export const isCommentOnly = (code: string) => /** * All JS fences (`js`/`javascript`/`ts`/`typescript`) of a docs page, keyed the same way * as the test markers. Includes `JavaScript` and unlabelled fences (for the label check); - * only `Node.js` fences are tested. + * only `Node.js` fences outside no-SDK disclosures are tested. */ export const parseDocsFences = ( docs: string, @@ -97,11 +112,14 @@ export const parseDocsFences = ( const fences: DocsFence[] = []; const counts = new Map(); let heading = ''; + let noSdk = false; for (let i = 0; i < lines.length; i++) { const open = /^(\s*)(`{3,})(\S*)(.*)$/.exec(lines[i]); if (!open) { const h = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(lines[i]); if (h) heading = h[1]; + if (NO_SDK_DISCLOSURE_RE.test(lines[i])) noSdk = true; + else if (/^\s*<\/disclosure>/i.test(lines[i])) noSdk = false; continue; } const [, indent, ticks, lang, rest] = open; @@ -125,6 +143,7 @@ export const parseDocsFences = ( lang, code, commentOnly: isCommentOnly(code), + noSdk, line: i + 1, bodyStart: i + 1, bodyEnd: end, @@ -178,7 +197,7 @@ const keyFromAttrs = ( * Snippet regions of a test file: * * ```ts - * // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 + * // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 * // COPY: channelId="general", userId="john" * ...docs code... * // #endregion snippet @@ -281,28 +300,31 @@ export const formatForDocs = async (code: string, markdownFile: string) => { type SidebarItem = { markdown?: string; children?: SidebarItem[] }; /** - * Pages of the chat Node sidebar (paths relative to `content/docs/chat`), minus + * Pages of the tested sidebars (paths relative to `content/docs`), minus * `EXCLUDED_PAGES`. */ export const findSidebarPages = () => { - const file = path.join(DOCS_CHAT_DIR, '..', '_sidebars', SIDEBAR_FILE); - const { items } = JSON.parse(fs.readFileSync(file, 'utf8')) as { - items: SidebarItem[]; - }; const pages = new Set(); const walk = (entries: SidebarItem[] = []) => { for (const entry of entries) { - if (entry.markdown?.startsWith('chat/')) - pages.add(entry.markdown.slice('chat/'.length)); + const page = entry.markdown; + if (page && PAGE_PREFIXES.some((prefix) => page.startsWith(prefix))) + pages.add(page); walk(entry.children); } }; - walk(items); + for (const sidebar of SIDEBAR_FILES) { + const file = path.join(DOCS_DIR, '_sidebars', sidebar); + const { items } = JSON.parse(fs.readFileSync(file, 'utf8')) as { + items: SidebarItem[]; + }; + walk(items); + } return [...pages].filter((page) => !(page in EXCLUDED_PAGES)).sort(); }; export const readDocsPage = (docs: string) => { - const file = path.join(DOCS_CHAT_DIR, docs); + const file = path.join(DOCS_DIR, docs); return { file, markdown: fs.readFileSync(file, 'utf8') }; }; diff --git a/index.ts b/index.ts index 54ba101..969e9ca 100644 --- a/index.ts +++ b/index.ts @@ -9,5 +9,6 @@ export * from './src/StreamFeed'; export { InvalidWebhookError, InvalidWebhookErrorMessages, + verifyAndParseWebhook, } from './src/utils/webhook'; export { StreamError } from './src/types'; diff --git a/package.json b/package.json index 3970f11..2ffdf56 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "prettier": "^3.9.6", "typescript": "~6.0.3", "typescript-eslint": "^8.67.0", + "undici": "^7.30.0", "vite": "^8.2.1", "vitest": "~4.1.10" }, diff --git a/yarn.lock b/yarn.lock index 7750cc0..b886f73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -284,6 +284,7 @@ __metadata: prettier: "npm:^3.9.6" typescript: "npm:~6.0.3" typescript-eslint: "npm:^8.67.0" + undici: "npm:^7.30.0" vite: "npm:^8.2.1" vitest: "npm:~4.1.10" peerDependencies: @@ -1791,6 +1792,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^7.30.0": + version: 7.30.0 + resolution: "undici@npm:7.30.0" + checksum: 10c0/011a321a9198532b0f0504740faa43b9ab8fcc0e4f60104e5c9cb808a61faeb03e4be4aec4f0c65f47913d7c7d9d91591793a6cb9ab843f98ca2c3d711c4a34c + languageName: node + linkType: hard + "undici@npm:^8.4.1": version: 8.10.0 resolution: "undici@npm:8.10.0" From 88638eccc329ae7f94300c2d26c027c3831ba4ad Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 12:11:29 -0500 Subject: [PATCH 7/9] chore: clean up docs files, add sync skill --- .claude/skills/docs-sync/SKILL.md | 155 +++++++++++ .../docs-snippets/{learnings.md => AGENTS.md} | 139 ++++++---- __tests__/docs-snippets/README.md | 26 +- .../docs-snippets-agent-prompt.md | 127 --------- __tests__/docs-snippets/docs-snippets-todo.md | 244 +----------------- 5 files changed, 246 insertions(+), 445 deletions(-) create mode 100644 .claude/skills/docs-sync/SKILL.md rename __tests__/docs-snippets/{learnings.md => AGENTS.md} (86%) delete mode 100644 __tests__/docs-snippets/docs-snippets-agent-prompt.md diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md new file mode 100644 index 0000000..9343d68 --- /dev/null +++ b/.claude/skills/docs-sync/SKILL.md @@ -0,0 +1,155 @@ +--- +name: docs-sync +description: Sync the stream-node docs snippet tests (__tests__/docs-snippets) with a getstream.io docs checkout. Asks for the docs repo, runs `yarn test:docs:sync` against it, brings the tests up to date with docs changes (docs win), adds tests for new Node.js snippets, and writes fixes for broken snippets back to the docs. Use when the user asks to sync, check or update the docs snippets or docs snippet tests. +argument-hint: "[path to the getstream.io checkout or its content/docs folder]" +--- + +# Docs sync + +The tests in `__tests__/docs-snippets/` run the server-side `Node.js` snippets of the Stream docs (the chat Node sidebar and the platform sidebar) against a real Stream app with the `@stream-io/node-sdk` server client. Each docs fence has exactly one marked region in a test, and `yarn test:docs:sync` checks that they still agree. This skill makes them agree again after either side changed. + +**Docs have preference.** When a region and its fence differ, the docs version is the source of truth: update the test to match it. Change the docs only when the docs snippet doesn't work (type error against the node SDK, runtime error that isn't caused by test setup, wrong request or response shape), and then write the fix back from the test. + +All paths below are relative to the stream-node repo root unless they start with `$DOCS_DIR`. + +## 1. Find the docs folder + +If the user passed a path as the argument, use it. Otherwise ask for it with AskUserQuestion: "Where is the getstream.io docs checkout?", offering `../getstream.io` (the default sibling checkout) as the first option, and accepting any path through "Other". + +Resolve it to the `content/docs` folder: + +- A repo root (has `content/docs/_sidebars/`): use `/content/docs`. +- A path that already ends in `content/docs` (has `_sidebars/`): use it as is. +- A bare repo name (e.g. `getstream.io`): try it as a sibling of this repo (`../`). +- Anything else, or a folder without `_sidebars/`: tell the user what was checked and ask again. Never guess a path from another machine. + +Make it absolute and call it `$DOCS_DIR`. Pass it as `DOCS_DIR=$DOCS_DIR` on **every** `yarn test:docs:sync` call: without it, the sync check falls back to the sibling checkout and, if that doesn't exist, silently skips the docs comparison. + +Then show the user, before changing anything: + +- the docs repo's current branch and `git status --short` (run git in `$DOCS_DIR`), +- the stream-node branch and `git status --short`. + +If either repo has uncommitted changes, say so: your edits will mix with them. Don't stash, commit or switch branches in either repo. + +## 2. Read the conventions + +Before editing tests, read: + +1. `__tests__/docs-snippets/AGENTS.md`: test conventions, test app facts and API quirks. Follow it. +2. `__tests__/docs-snippets/README.md`: layout, commands, push credentials and the snippet-marker format (fence key, COPY, `DOCS_SYNC_WRITE`). +3. `__tests__/docs-snippets/docs-snippets-todo.md`: the open issues. A difference may be the docs fixing one of them. +4. `$DOCS_DIR/AGENTS.md`, if it exists: the docs writing rules (e.g. no em-dashes). + +Live tests need `STREAM_API_KEY` and `STREAM_SECRET` of the docs test app in the repo root `.env` (see `.env-example`); `DOCS_PUSH_FIREBASE_CREDENTIALS` is optional (push tests are skipped without it). If the key or secret is missing, ask the user for them. Without them you can still sync, typecheck and lint, but you can't tell whether a changed snippet works: say so in the report, and don't change any docs snippet in that case. + +## 3. Run the sync check + +```sh +DOCS_DIR=$DOCS_DIR yarn test:docs:sync +``` + +It runs without network and reports four kinds of failure. Collect them all, grouped by docs page, before fixing anything. + +| Failure | Meaning | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `snippets without a test region` | A `Node.js` fence on a tested sidebar page (chat Node, platform) has no region: a new snippet, a new page, or a fence whose key changed (see the next row). | +| mislabelled JS fences | A JS fence is unlabelled or has a label other than `JavaScript`, `React`, `React Native` or `Node.js`. | +| `no such fence` | A region's key (`docs`, `heading`, `tab`, `index`) matches no fence: the fence was removed, moved, or its heading was renamed. The failure lists the page's fence keys. | +| ` differs from :` | The region's code and the fence's code differ. | + +**Check that the docs branch matches the SDK first.** Because the docs win, syncing against docs written for another SDK version would rewrite working tests to code this SDK doesn't have. If many regions across many pages differ, or the differences look like an API change rather than a docs edit (renamed methods, different request shapes), stop: show the user both branches and a few example differences, and ask whether this is the right docs checkout before changing anything. + +A renamed heading or a reordered fence shows up twice: as `no such fence` for the old key and as a missing region for the new one. Pair them up by code before treating either as a new or removed snippet. + +## 4. Fix each failure + +Work one docs page at a time. + +### Mislabelled fence + +Label it `Node.js` for `@stream-io/node-sdk` (server-side) code, `JavaScript` for client code. `JavaScript`, `React` and `React Native` fences, and `Node.js` fences inside a `` block (reference implementations without the SDK), are out of scope: never test, add or change them otherwise. + +### Region differs from its fence (docs changed) + +1. Replace the region's code with the fence's code. Keep the region's COPY line: where the docs use a fixed value that a COPY variable stands for (`channelId="general"`), keep the variable in the region. If the docs changed that literal, update the COPY value. If the docs introduced a new fixed value that would clash with other tests (a fixed user, channel or channel type name), add a COPY variable from `uniqueId(...)` for it. +2. Adjust the setup before the region and the assertions after it to the new code (new users acting through `user_id`, a different response shape, a new call to assert). +3. Run the page's test: `yarn test:docs `, then `yarn types:docs`. +4. If it passes, done. If it fails, decide why: + - **Test setup** (missing user, permission, channel config, eventual consistency): fix the setup, not the region. + - **API spec / node SDK issue**: the snippet is correct and the API accepts it, but the OpenAPI spec or the generated SDK is wrong (missing or wrongly typed fields, wrong response types). Don't change the snippet or `src/` (`src/gen` is generated, hand edits are lost): add it to "API spec / node SDK issues" in the TODO and report it. `it.skip` doesn't help with type errors: skipped bodies are still typechecked. + - **API behavior issue**: the API behaves differently from what the docs say. Assert what it actually does, and add it to "API behavior issues" in the TODO with the evidence. + - **The docs snippet is wrong**: fix it as described in "Broken docs snippet" below. + - **Not sure** whether the snippet or the setup is wrong: don't touch the docs. Leave the test failing or put the snippet in `it.skip('BLOCKED: ', ...)` and report it as an open question. + +### Marker points at no fence + +- **Heading renamed or fence moved/reordered**: update the marker's `heading` / `tab` / `index` to the key the failure lists (it matches the fence with the same code). +- **Fence removed from the docs**: remove the region together with its `it` (or the part of the `it` that only exists for it) and any setup only it used. If a whole page was removed or left the sidebar, remove its test file. + +When a fix lands that resolves an entry in `docs-snippets-todo.md` (the docs fixed a recorded docs bug, say), remove that entry. + +### New fence without a region + +Write a test for it in the page's test file (create the file if the page is new). The path mirrors the docs path relative to `content/docs/`, without `/_default`: `chat/_default/04-messages/01-send_message.md` → `__tests__/docs-snippets/pages/chat/04-messages/01-send_message.test.ts`. Follow `__tests__/docs-snippets/AGENTS.md` (isolation with `uniqueId`, `Cleanup`, one `it` per snippet, basic `expect`s after each region). In short: + +- Run the snippet with `const client = getServerClient()` (`helpers/clients.ts`), named `client` as in the docs. Users the snippet acts for (`user_id`, `created_by_id`) must exist first, with `uniqueId` ids. +- Everything between the markers is the docs code; setup goes before, assertions after. Fragments get the minimal surrounding code to compile and run. +- Comment-only fences need no region. +- A fence that isn't code (e.g. a bare data shape) is a docs bug: rewrite it as code that runs (e.g. reading and logging the fields), and report it. Never remove a `Node.js` fence just because it is a fragment or can't be tested: wrap it, or put it in `it.skip`. +- A snippet the test app can't run goes in `it.skip('BLOCKED: ', ...)` so it is still typechecked; report the exact API error. +- **Client-side code in a `Node.js` fence** (needs a connected user or a WebSocket, or a method the node SDK doesn't have: `connectUser`, `channel.watch()`, `client.on(...)`): don't invent a server equivalent. Put it in `it.skip('BLOCKED: client-side code: ...')` if it still typechecks, otherwise make the smallest docs fix that turns it into the server-side call the node SDK has. Report it either way. To check what a server-side endpoint accepts, read the SDK source (`src/StreamClient.ts`, `src/StreamChatClient.ts`, `src/StreamChannel.ts`, `src/gen/`) or the OpenAPI spec (`chat-serverside-api.json` in https://github.com/GetStream/protocol/tree/main/openapi/v2). +- **Push and webhooks**: don't test that notifications or webhook events arrive; assert the response or read the setting back (`getApp()`, `listPushProviders()`, ...). Never touch or use the test app's own push provider (see "Test app" in `AGENTS.md`): only use providers the test creates with `uniqueId` names. Snippets that need real credentials run in `describe.skipIf(!hasFirebaseCredentials)` (`helpers/push.ts`); the README's "Push credentials" section has the details. +- **App-level side effects** (app settings, built-in channel types, grants, webhook URLs, push config, ...): read the original value first and restore it with `cleanup.add(...)`, or the file fails with `DRIFT`. + +### Broken docs snippet + +Only when the docs snippet itself doesn't work (wrong method name or signature, a type error against the node SDK, wrong request or response shape, a runtime API error not caused by setup): + +1. Make the smallest fix in the test region. +2. Write it to the docs: `DOCS_DIR=$DOCS_DIR DOCS_SYNC_WRITE= yarn test:docs:sync`. This writes every region of the page into its fence, with the COPY values applied and the docs prettier style. Never paste snippets into the docs by hand. +3. Check `git diff` in `$DOCS_DIR`: only the intended fences may change. If others changed, a region was out of sync; go back to "Region differs from its fence". +4. Format and lint the page, from `$DOCS_DIR`: + ```sh + npx prettier --write + npx markdownlint-cli2 --config .markdownlint-cli2.cjs + ``` + Never run `npm run ci` or `npm run lint` for the whole docs repo: they rewrite thousands of files, including images. +5. Record the fix for the report: before/after, reason, and evidence (error message or type error). + +Docs bugs you leave unfixed (prose, non-`Node.js` fences, or waiting for a decision) go to "Open docs bugs" in the TODO. + +## 5. Gates + +All must pass before you finish: + +```sh +DOCS_DIR=$DOCS_DIR yarn test:docs:sync # no missing, mislabelled, unknown or differing snippets +yarn test:docs # green, no LEAK or DRIFT error +yarn types:docs # no type errors, no @ts-ignore / @ts-expect-error / `!` +yarn eslint __tests__/docs-snippets # zero warnings +npx prettier --check __tests__/docs-snippets +``` + +Never silence a docs snippet with `@ts-ignore` / `@ts-expect-error`: a type error in a snippet is a docs bug or an SDK issue. Don't use `eslint-disable` either. The one lint-only docs change allowed: a fence that assigns a value it never reads (which the test can't read either, being block-scoped) gets a `console.log` of the field the page talks about; report it as a docs correction. + +Run `yarn test:docs` only on the test files you changed, never two runs at once, and never at the same time as `yarn test`: they share the app, and the drift check and end-of-run sweep would see each other's changes. + +## Rules + +- Don't commit, push, stash or create branches in either repo. +- Never write the API key, secret or push credentials to any file except the repo root `.env`. +- Don't edit `src/`, `__tests__/docs-snippets/AGENTS.md`, the helpers or the configs. Propose changes in the report instead. +- Change docs snippets as little as possible, and only when they don't work. + +## Report + +End with: + +1. **Docs folder** used, and both repos' branches. +2. **Tests updated from the docs**: per page, the regions changed and why the setup/assertions changed. +3. **New and removed tests**: snippets added, regions removed with their fences, renamed markers. +4. **Docs changes**: every fence changed in `$DOCS_DIR` (fixes, labels, client-side code), each with before/after, reason and evidence. These need human review. +5. **Skipped snippets**: new `it.skip` / credential-gated snippets and why. +6. **TODO changes**: entries added to or removed from the three issue lists. +7. **Proposed additions to `__tests__/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. diff --git a/__tests__/docs-snippets/learnings.md b/__tests__/docs-snippets/AGENTS.md similarity index 86% rename from __tests__/docs-snippets/learnings.md rename to __tests__/docs-snippets/AGENTS.md index 14cf3b0..83235df 100644 --- a/__tests__/docs-snippets/learnings.md +++ b/__tests__/docs-snippets/AGENTS.md @@ -1,9 +1,34 @@ -# Docs snippet tests: learnings +# Docs snippet tests: agent notes -Lessons collected from agent runs. Read this before working on a page. -Entries are added only after the user approves them (agents propose them in their final report). +Read this before working on a page. **Test conventions** are the rules every test follows; **API quirks** are API and SDK behavior found while testing. +New entries are added only after the user approves them (agents propose them in their final report). -## Setup and tooling +## Test conventions + +### General + +- Import the SDK from `../../../../index` (relative depth varies) and test helpers from `helpers/`. Use `import type` for types. +- `@stream-io/node-sdk` is aliased to the repo's `index.ts` (`resolve.alias` in `vitest.config.mts`, `paths` in `tsconfig.json`), so a docs fence that starts with `import ... from "@stream-io/node-sdk"` can be a region at module level: declare the values it uses (e.g. `apiKey`) above it. +- The docs call the server client `client`: use `const client = getServerClient()` (`helpers/clients.ts`, 30s timeout) and the same variable names as the docs (`client`, `channel`, ...). `createUserToken(userId)` signs user tokens. +- Snippets act on behalf of users with `user_id` / `created_by_id`: those users must exist (`client.upsertUsers`) and have `uniqueId` ids. +- **Isolation**: the id or name of everything a test creates comes from `uniqueId('name')`: users, channels, channel types, roles, commands, blocklists, segments, campaigns (name), user groups, moderation config keys, predefined filters, push providers. Polls are matched by their creator, so create them as a `uniqueId` user. Never use fixed ids like `"john"` or `"general"`. +- **Cleanup**: register everything you create on a `Cleanup` instance and call `cleanup.run()` in `afterAll`. App-level settings changed by a test must be restored with `cleanup.add(...)`. +- Tests in one file may share data created in `beforeAll`. Nothing may leak across files. +- `cleanup.run()` attempts every step and then throws if anything failed, so leftovers show up as a failed `afterAll` instead of piling up in the app. Server-side hard channel deletion is a background task: `Cleanup` waits for it (`waitForTask`) and retries deleting channel types until their channels are gone. +- **Leak check** (`setup.ts`, runs automatically after each file's own `afterAll`): looks up everything minted by `uniqueId` in that file (users, channels, channel types, commands, roles, custom permissions, blocklists, segments, campaigns, user groups, moderation configs, predefined filters, push providers, polls). Anything still there fails the file with a `LEAK` error listing it, and is then removed. Per-user data (reminders, devices, drafts, ...) goes away with the user. It also snapshots app settings (incl. push and webhook config), pre-existing channel types and retention policies before the file and fails with `DRIFT` if anything changed and wasn't restored. +- **End-of-run sweep** (`global-setup.ts`, after every `yarn test:docs`): a file that throws while loading runs no hooks, so its cleanup and leak check never run. The sweep removes every `docs-test` resource created since the run started (channels are found by listing every channel created since then) and fails the run with a `LEAK` list. It also puts back the retention policies the run started with and fails with `DRIFT` if it had to. Don't run two `yarn test:docs` at once: the sweep would remove the other run's resources. +- Anything created under a non-`uniqueId` name can't be found by the leak check or the sweep. Don't do that. +- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). Background tasks: `waitForTask(client, taskId)`, and `waitForBatchTask` for channel batch updates (their top-level status completes too early). +- **App settings**: pages are run one at a time, so tests may change app settings and built-in channel types, but must restore them (`cleanup.add`). The check after each file fails with `DRIFT` otherwise. +- `updateChannelType(client, name, patch)` (`helpers/channel-types.ts`) fills in the fields the request requires; `grantMessagingMembers` (`helpers/grants.ts`) adds `messaging` member grants and registers the restore. `helpers/server.ts` has repeated lookups: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy`, `waitForAppSetting` and `waitForTeamIdSupport` (multi-tenancy toggles). +- One `it` per docs snippet (or per closely related group of snippets in the same tab group). +- Add basic `expect`s after each snippet to prove it did what the docs claim. + +### Test app + +- App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `firebase:firebase` (not a docs-test one: tests must not touch or use it, e.g. no `upsertPushProvider` / `deletePushProvider` on it and no devices or `checkPush` with `push_provider_name: "firebase"`), permissions v2, multi-tenancy off, `disable_auth_checks` off, auto-translation off, campaigns on (segments, user groups and polls endpoints answer), no webhook / SQS / SNS / custom action handler URLs set (`webhook_url`, `custom_action_handler_url`, `sqs_url`, `sns_topic_arn` empty), one entry in `event_hooks`, no retention policies. Channel types: `livestream`, `messaging`, `team`, `agent`, `commerce`, `gaming`, plus `streamnodetest*` types left by the integration tests. Delivery receipts: `delivery_events: true` is accepted on create/update channel type without support enablement (2026-10-12). Location sharing: enabled (2026-10-12); channels still need `shared_locations` (`config_overrides` or channel type), otherwise `sendMessage` with `shared_location` fails with code 17 "location sharing is not enabled for this channel". Translation: `translateMessage` works with auto-translation off; `updateApp({ auto_translation_enabled: true })` is accepted (restored after, 2026-10-12). Restricted visibility messages: enabled (`restricted_visibility` on `sendMessage` works, 2026-10-12). User response time: `updateApp({ user_response_time_enabled: true })` is accepted and `avg_response_time` appears on the replying user within seconds (restored after, 2026-10-12). Dynamic partitioning: `partition_size` / `partition_ttl` accepted on create/update channel type without support enablement (2026-10-12). Message history (audit logs): enabled; `queryMessageHistory` is rate limited to 6 requests per minute (2026-10-12). Feature availability is probed per page: propose new findings for this list in your report. + +### Setup and tooling - A fence that creates a channel type and uses it right away: spy on `client.chat.createChannelType` with a `mockImplementation` that awaits the bound original and then `waitForChannelTypePropagation()`, so the region runs as written without "channel type does not exist" from another API node. @@ -40,7 +65,7 @@ Entries are added only after the user approves them (agents propose them in thei - A fence before the page's first heading uses `heading=""` in its marker. To COPY-map a docs string that contains a test id (`"/ticket suspicious transaction with id 1234"`), map the whole string to a variable built from the id (`ticketText="..."`). - Two dependent fences that both declare the same `const` (e.g. `response`) can share one `it`: put the second region in its own `{ ... }` block; it still sees the outer variables. -## Server client (`@stream-io/node-sdk`) +### Server client (`@stream-io/node-sdk`) - `createChannelType` doesn't take every field `updateChannelType` does (e.g. `reminders`, `grants`, TS2353): create with the required fields only, assert the default, then update. To restore an app setting (e.g. `reminders_interval`), save `getApp().app.` and `cleanup.add` an `updateApp` with it followed by `waitForAppSetting(client, '', original)`. @@ -65,7 +90,58 @@ Entries are added only after the user approves them (agents propose them in thei - Optional response fields (`GetTaskResponse.result`, `MessageResponse.i18n`) aren't narrowed by a status check: a fence reading `response.result.url` fails TS18048, and the docs fix is `?.`. - `getApp().app.enforce_unique_usernames` is typed `string` but `updateApp` expects `'no' | 'app' | 'team'`: cast the saved value when restoring it. `"team"` is accepted with multi-tenancy off. -## API and SDK behavior +### Docs conventions + +- A Node.js fence that reads `error.metadata` in a `catch` fails TS18046 (`unknown`): the docs fix is `error instanceof StreamError && ...` (`metadata` is `Partial`, so `rateLimit` needs `?.`). Placeholder calls (`client....`, `client.queryUsers(...)`) are syntax errors: replace them with the call the sibling tabs use, with a COPY-mapped literal id. +- Unawaited calls used as statements in a Node.js fence (`client.updateApp(...)`, `client.queryUsers(...)`) fail `@typescript-eslint/no-floating-promises`: the docs fix is to add `await` (to assert on the results, spy on the method and await `mock.results`). A fence that declares the same `const` several times (TS2451): give each its own name; switching to `let` and reassigning fails `no-useless-assignment` when the earlier values are never read. +- A fence that assigns a value it never reads, where the test can't read it either (block-scoped, e.g. `if (page1.next) { const page2 = ... }`), fails `no-unused-vars`. Fix the docs minimally by logging the field the page talks about (`console.log(taskResponse.result?.status)`, `console.log(page2.threads)`). Never use `eslint-disable`. In the test, assert the variables that are in scope after the region directly, and read the block-scoped value from the log: `vi.spyOn(console, 'log').mockImplementation(() => undefined)` before the region, `log.mock.calls[0]` after it (example: `pages/04-messages/03-threads.test.ts`, "Supported Sort Fields"). An unused catch parameter (`catch (err) { // comment }`) fails the same rule (`caughtErrors: "all"`): add `console.error(err)` and, in the test, spy on `console.error`. + +- A fence that runs conflicting operations on one placeholder id (soft- then hard-delete a channel, truncate again with an earlier `truncated_at`, delete a message after hard-deleting it) is a docs bug: give the later call its own id (`otherChannel` / `"random"`, `anotherMessageID`), reusing a name another tab already uses when there is one. + +- A fence that reuses the same placeholder ids for a call that needs a different resource (e.g. `deleteChannels({ cids: [cid1, cid2], skip_truncate: true })` after hard-deleting `cid1`/`cid2`, "distinct channels only") is a docs bug: give that call its own ids (`distinctCid1`, `distinctCid2`). Don't swap request fields in a spy to make it pass. + +- Undefined app-supplied values in a fence (`userId`, `channelId`, ...) aren't docs bugs: define them in the test just before the region and leave the docs as they are. + +- A docs fix is the code change only: don't add explanatory comments to the fence (e.g. why `user_id` is needed). Put the reason in the report. + +### Isolation and cleanup + +- Per-user data (reminders, drafts, ...) is easiest to assert exactly with one `uniqueId` user per test, each COPY-mapped to the docs' `"user-id"` literal. + +- To prove something does NOT happen (e.g. a silent message doesn't raise the unread count), add a positive control: read the member's `unread_messages` from `getServerChannel(client, cid, { user_id })` (`read` array), then send a normal message and `retry` until it goes up by 1, so a 0 can't come from a lagging counter. +- Unread counts: `client.chat.unreadCounts({ user_id }).total_unread_count` is user-wide (across all that user's channels, so give each test its own `uniqueId` user for an exact total), `getServerChannel(client, cid, { user_id }).read` is per channel, and `channel.get({ state: true }).read` lists every member. They update within a second of a send or mark read/unread (`retry` is enough). `markUnread({ message_id, user_id })` marks that message and every later one unread: mark the channel read first, then assert the count equals the messages from that one onwards. + +- A `DRIFT` that lists every built-in channel type with the same change the page never made (e.g. `grants` gaining `anonymous: []`) comes from outside the file: an app-wide change still propagating, or another suite (`yarn test`) on the same app. Check with repeated `listChannelTypes()` calls and rerun once it settles; don't add restore code for settings the page doesn't touch, and report it. + +- Snippets that target every channel of a built-in type (`filter: { types: { $in: ["messaging"] } }`) must not run as written: COPY-map the type name to a `uniqueId` channel type (create it, push it to `cleanup.channelTypes`, `waitForChannelTypePropagation()`). The same applies to snippets that change a built-in type (`updateChannelType({ name: "messaging", ... })`): run them on a `uniqueId` type via COPY (`channelType="messaging"`), so nothing built-in needs restoring. + +- Short-lived state from an earlier test (e.g. the docs' 10-second mute) can expire before a later test uses it: recreate it outside the region in the test that needs it. + +- To prove a sort snippet does the work (e.g. `pinned_at: -1` first), create the item it should put first before the other one: without the sort the newer one comes first, so `toEqual([expected, other])` only passes with the sort. + +- `uniqueId(...)` names must be kebab-case (no spaces or `&`): the leak check passes every minted id to a channel `id: { $in }` query, which fails with "invalid id value" and leaves that scan undone. Put docs literals like `"Design Team"` in a COPY variable. +- Docs pagination cursors (`id_gt: "last-id"`, `name_gt: "abc"`) can filter out the test's own resources: map them to `''` with a COPY variable so the assertions see what the test created. +- Docs segment filters ("all users created after 2020") target the whole app: COPY-map them to something only the test's users match (the test start time, a `uniqueId` custom field value) before a campaign is started. +- Channels the API creates from templates (campaign channels named `{{receiver.id}}-{{sender.id}}`) aren't found by the leak check: find them by member in cleanup and push them to `cleanup.channels`. Keep the `uniqueId` names short so the combined id stays under 64 characters. +- `queryPolls` filters (`is_closed: true`) match every poll in the app. When the fence can't be scoped by COPY, create two matching polls and assert their relative order under the docs sort instead of the full list. +- `event_hooks` changes: `updateApp({ event_hooks })` replaces every hook. Save `getApp().app.event_hooks` before the region and `cleanup.add` an `updateApp({ event_hooks: original })`; passing the saved hooks back keeps their ids (no DRIFT). `waitForAppSetting(client, 'event_hooks', original)` can fail to settle because `updated_at` changes on restore: compare the hooks without `created_at` / `updated_at` in a `retry`. Restore the originals before each snippet that claims to preserve existing hooks, so it can assert their ids are kept. `event_types: []` reads back as every event type plus `"*"`: assert with `arrayContaining`. +- SQS / SNS hooks with placeholder keys are accepted; `checkSQS` / `checkSNS` with fake keys resolve with `status: "error"` and an AWS "InvalidClientTokenId" message instead of rejecting (spy on the method to assert the result). Failover configs are validated on save: placeholder GCS credentials fail with code 4, and `type: "s3"` is not enabled on the test app (code 4). Probe with one `updateApp` call before writing setup. +- `waitForChannelTypePropagation()` waits only for what changed since its last call (`getServerClient()` records channel type creates and updates): a new type ≥10s after creation plus 10 agreeing lookups, an update of a docs-test type 10s, an update of a built-in or pre-existing type 30s (the DRIFT check reads those back). Measured 2026-10-13: updates are enforced everywhere within ~3s, but `getChannelType` / `listChannelTypes` reads can return the old value for over a minute, so read a type back with a retry (`waitForChannelType`-style) or assert the update response. A test with two waits on a built-in type needs an explicit timeout above the 90s default. +- An app setting that changes API behavior (e.g. `enforce_unique_usernames`) can break later tests in the same file: restore it at the end of the test that changes it, and keep the `cleanup.add` restore as a fallback. +- The leak check doesn't track video calls: create them with `uniqueId` ids and `cleanup.add(() => client.video.call(type, id).delete({ hard: true }))`, ignoring "not found". +- Feeds v3 works on the test app (feed groups `user`, `timeline`, `notification`, ...), but the leak check and the end-of-run sweep don't track feeds: create them with `uniqueId` ids (`client.feeds.feed(group, id).getOrCreate({ user_id })`) and `cleanup.add(() => feed.delete({ hard_delete: true }))` (ignore "not found" when a snippet may already have deleted it). After a soft `deleteFeedUserData`, `getActivity` on the user's activity rejects with `metadata.responseCode: 404`. +- Feed groups are app-wide and eventually consistent: create them with a `uniqueId` id and `cleanup.add(() => client.feeds.deleteFeedGroup({ id, hard_delete: true }))`. Calls into a new group can fail with code 16 "Feed group with ID ... not found" for a while: spy on the call with a `mockImplementation` that retries the bound original on "not found". +- `notification` feeds aren't created automatically: `create_notification_activity` silently returns `notification_created: false` unless the target user's `notification` feed exists, so create it in setup. Deleting a feed that doesn't exist fails with "Feed ...: can't be found": match both "not found" and "can't be found" when cleanup ignores missing feeds. +- Feed visibilities (`updateFeedVisibility`) are app-wide and not in the DRIFT snapshot. Save `getFeedVisibility({ name }).feed_visibility.grants`, restore only the roles the snippet changed (an update only touches the roles it names), and `retry` until the whole grants object equals the original. +- App-level resources that the leak and drift checks don't track (e.g. retention policies): save the original list in `beforeAll`, and `cleanup.add` a restore that puts the originals back and deletes anything the file added. Check that the app is back to its original state after the run. +- A role can't be deleted while a user has it: in cleanup, set that user's role back to `'user'` before `deleteRole` (`Cleanup` deletes users only after the custom steps). +- Unset optional app settings (`before_message_send_hook_url`, `..._attempt_timeout_ms`) are absent from `getApp()`; restoring with `''` / `0` makes them absent again, so `waitForAppSetting(client, key, undefined)` settles with no DRIFT. +- Custom commands are app-wide: give them `uniqueId` names (hyphens are accepted). A command used by a channel type can't be deleted (code 4 "command in use by channel type ..."), and `Cleanup` runs custom steps before deleting channel types: set the type's `commands` to `[]` first (`updateChannelType` helper), then delete the command, retrying on "in use". +- `queryReviewQueue` filters by `entity_type` match the whole app: flag a fresh message just before the region, assert it is `items[0]` under the docs sort and that every item has that `entity_type`. + +## API quirks + +### API and SDK behavior - Reminders need `user_message_reminders` on the channel (code 4 "reminders are not enabled for this channel"): `updateChannelPartial({ set: { config_overrides: { user_message_reminders: true } } })` on a `messaging` channel works immediately, no channel type change. `remind_at` must be at least one minute in the future on create and update: for an "overdue" filter, create the reminder ~62s ahead at the start of `beforeAll` and put slow setup after it so the wait overlaps. One reminder per user and message. @@ -148,55 +224,6 @@ Entries are added only after the user approves them (agents propose them in thei - Export tasks (`exportChannels`, `exportUsers`, `feeds.exportFeedUserData`; also `feeds.deleteFeedUserData`) finish within seconds: `waitForTask` is enough, and `result.url` downloads with plain `fetch` (no auth), so assert the export's contents (v2: one JSON object per line). - `client.moderation.flag`: a repeat flag by the same user on the same entity raises `flags_count`, but the review item's `flags` keep only the first one (later `reason` / `custom` are dropped). A fence that flags one placeholder id several times from one user with different reasons is a docs bug: give each call its own entity id. Check flags by `retry`ing `client.moderation.queryReviewQueue({ filter: { entity_id } })` until it returns one item (about a second), then assert `items[0].flags[i]`. -## Server-side vs client-side +### Server-side vs client-side - Client-only setup (e.g. creating a draft) can be done with `fetch` as the user: `POST https://chat.stream-io-api.com/channels/{type}/{id}/draft?api_key=...` with headers `Authorization: createUserToken(userId)`, `stream-auth-type: jwt` and body `{ message: { text, parent_id? } }` (example: `pages/05-features/10-advanced/12-drafts.test.ts`). - -## Docs conventions - -- A Node.js fence that reads `error.metadata` in a `catch` fails TS18046 (`unknown`): the docs fix is `error instanceof StreamError && ...` (`metadata` is `Partial`, so `rateLimit` needs `?.`). Placeholder calls (`client....`, `client.queryUsers(...)`) are syntax errors: replace them with the call the sibling tabs use, with a COPY-mapped literal id. -- Unawaited calls used as statements in a Node.js fence (`client.updateApp(...)`, `client.queryUsers(...)`) fail `@typescript-eslint/no-floating-promises`: the docs fix is to add `await` (to assert on the results, spy on the method and await `mock.results`). A fence that declares the same `const` several times (TS2451): give each its own name; switching to `let` and reassigning fails `no-useless-assignment` when the earlier values are never read. -- A fence that assigns a value it never reads, where the test can't read it either (block-scoped, e.g. `if (page1.next) { const page2 = ... }`), fails `no-unused-vars`. Fix the docs minimally by logging the field the page talks about (`console.log(taskResponse.result?.status)`, `console.log(page2.threads)`). Never use `eslint-disable`. In the test, assert the variables that are in scope after the region directly, and read the block-scoped value from the log: `vi.spyOn(console, 'log').mockImplementation(() => undefined)` before the region, `log.mock.calls[0]` after it (example: `pages/04-messages/03-threads.test.ts`, "Supported Sort Fields"). An unused catch parameter (`catch (err) { // comment }`) fails the same rule (`caughtErrors: "all"`): add `console.error(err)` and, in the test, spy on `console.error`. - -- A fence that runs conflicting operations on one placeholder id (soft- then hard-delete a channel, truncate again with an earlier `truncated_at`, delete a message after hard-deleting it) is a docs bug: give the later call its own id (`otherChannel` / `"random"`, `anotherMessageID`), reusing a name another tab already uses when there is one. - -- A fence that reuses the same placeholder ids for a call that needs a different resource (e.g. `deleteChannels({ cids: [cid1, cid2], skip_truncate: true })` after hard-deleting `cid1`/`cid2`, "distinct channels only") is a docs bug: give that call its own ids (`distinctCid1`, `distinctCid2`). Don't swap request fields in a spy to make it pass. - -- Undefined app-supplied values in a fence (`userId`, `channelId`, ...) aren't docs bugs: define them in the test just before the region and leave the docs as they are. - -- A docs fix is the code change only: don't add explanatory comments to the fence (e.g. why `user_id` is needed). Put the reason in the report. - -## Isolation and cleanup - -- Per-user data (reminders, drafts, ...) is easiest to assert exactly with one `uniqueId` user per test, each COPY-mapped to the docs' `"user-id"` literal. - -- To prove something does NOT happen (e.g. a silent message doesn't raise the unread count), add a positive control: read the member's `unread_messages` from `getServerChannel(client, cid, { user_id })` (`read` array), then send a normal message and `retry` until it goes up by 1, so a 0 can't come from a lagging counter. -- Unread counts: `client.chat.unreadCounts({ user_id }).total_unread_count` is user-wide (across all that user's channels, so give each test its own `uniqueId` user for an exact total), `getServerChannel(client, cid, { user_id }).read` is per channel, and `channel.get({ state: true }).read` lists every member. They update within a second of a send or mark read/unread (`retry` is enough). `markUnread({ message_id, user_id })` marks that message and every later one unread: mark the channel read first, then assert the count equals the messages from that one onwards. - -- A `DRIFT` that lists every built-in channel type with the same change the page never made (e.g. `grants` gaining `anonymous: []`) comes from outside the file: an app-wide change still propagating, or another suite (`yarn test`) on the same app. Check with repeated `listChannelTypes()` calls and rerun once it settles; don't add restore code for settings the page doesn't touch, and report it. - -- Snippets that target every channel of a built-in type (`filter: { types: { $in: ["messaging"] } }`) must not run as written: COPY-map the type name to a `uniqueId` channel type (create it, push it to `cleanup.channelTypes`, `waitForChannelTypePropagation()`). The same applies to snippets that change a built-in type (`updateChannelType({ name: "messaging", ... })`): run them on a `uniqueId` type via COPY (`channelType="messaging"`), so nothing built-in needs restoring. - -- Short-lived state from an earlier test (e.g. the docs' 10-second mute) can expire before a later test uses it: recreate it outside the region in the test that needs it. - -- To prove a sort snippet does the work (e.g. `pinned_at: -1` first), create the item it should put first before the other one: without the sort the newer one comes first, so `toEqual([expected, other])` only passes with the sort. - -- `uniqueId(...)` names must be kebab-case (no spaces or `&`): the leak check passes every minted id to a channel `id: { $in }` query, which fails with "invalid id value" and leaves that scan undone. Put docs literals like `"Design Team"` in a COPY variable. -- Docs pagination cursors (`id_gt: "last-id"`, `name_gt: "abc"`) can filter out the test's own resources: map them to `''` with a COPY variable so the assertions see what the test created. -- Docs segment filters ("all users created after 2020") target the whole app: COPY-map them to something only the test's users match (the test start time, a `uniqueId` custom field value) before a campaign is started. -- Channels the API creates from templates (campaign channels named `{{receiver.id}}-{{sender.id}}`) aren't found by the leak check: find them by member in cleanup and push them to `cleanup.channels`. Keep the `uniqueId` names short so the combined id stays under 64 characters. -- `queryPolls` filters (`is_closed: true`) match every poll in the app. When the fence can't be scoped by COPY, create two matching polls and assert their relative order under the docs sort instead of the full list. -- `event_hooks` changes: `updateApp({ event_hooks })` replaces every hook. Save `getApp().app.event_hooks` before the region and `cleanup.add` an `updateApp({ event_hooks: original })`; passing the saved hooks back keeps their ids (no DRIFT). `waitForAppSetting(client, 'event_hooks', original)` can fail to settle because `updated_at` changes on restore: compare the hooks without `created_at` / `updated_at` in a `retry`. Restore the originals before each snippet that claims to preserve existing hooks, so it can assert their ids are kept. `event_types: []` reads back as every event type plus `"*"`: assert with `arrayContaining`. -- SQS / SNS hooks with placeholder keys are accepted; `checkSQS` / `checkSNS` with fake keys resolve with `status: "error"` and an AWS "InvalidClientTokenId" message instead of rejecting (spy on the method to assert the result). Failover configs are validated on save: placeholder GCS credentials fail with code 4, and `type: "s3"` is not enabled on the test app (code 4). Probe with one `updateApp` call before writing setup. -- `waitForChannelTypePropagation()` waits only for what changed since its last call (`getServerClient()` records channel type creates and updates): a new type ≥10s after creation plus 10 agreeing lookups, an update of a docs-test type 10s, an update of a built-in or pre-existing type 30s (the DRIFT check reads those back). Measured 2026-10-13: updates are enforced everywhere within ~3s, but `getChannelType` / `listChannelTypes` reads can return the old value for over a minute, so read a type back with a retry (`waitForChannelType`-style) or assert the update response. A test with two waits on a built-in type needs an explicit timeout above the 90s default. -- An app setting that changes API behavior (e.g. `enforce_unique_usernames`) can break later tests in the same file: restore it at the end of the test that changes it, and keep the `cleanup.add` restore as a fallback. -- The leak check doesn't track video calls: create them with `uniqueId` ids and `cleanup.add(() => client.video.call(type, id).delete({ hard: true }))`, ignoring "not found". -- Feeds v3 works on the test app (feed groups `user`, `timeline`, `notification`, ...), but the leak check and the end-of-run sweep don't track feeds: create them with `uniqueId` ids (`client.feeds.feed(group, id).getOrCreate({ user_id })`) and `cleanup.add(() => feed.delete({ hard_delete: true }))` (ignore "not found" when a snippet may already have deleted it). After a soft `deleteFeedUserData`, `getActivity` on the user's activity rejects with `metadata.responseCode: 404`. -- Feed groups are app-wide and eventually consistent: create them with a `uniqueId` id and `cleanup.add(() => client.feeds.deleteFeedGroup({ id, hard_delete: true }))`. Calls into a new group can fail with code 16 "Feed group with ID ... not found" for a while: spy on the call with a `mockImplementation` that retries the bound original on "not found". -- `notification` feeds aren't created automatically: `create_notification_activity` silently returns `notification_created: false` unless the target user's `notification` feed exists, so create it in setup. Deleting a feed that doesn't exist fails with "Feed ...: can't be found": match both "not found" and "can't be found" when cleanup ignores missing feeds. -- Feed visibilities (`updateFeedVisibility`) are app-wide and not in the DRIFT snapshot. Save `getFeedVisibility({ name }).feed_visibility.grants`, restore only the roles the snippet changed (an update only touches the roles it names), and `retry` until the whole grants object equals the original. -- App-level resources that the leak and drift checks don't track (e.g. retention policies): save the original list in `beforeAll`, and `cleanup.add` a restore that puts the originals back and deletes anything the file added. Check that the app is back to its original state after the run. -- A role can't be deleted while a user has it: in cleanup, set that user's role back to `'user'` before `deleteRole` (`Cleanup` deletes users only after the custom steps). -- Unset optional app settings (`before_message_send_hook_url`, `..._attempt_timeout_ms`) are absent from `getApp()`; restoring with `''` / `0` makes them absent again, so `waitForAppSetting(client, key, undefined)` settles with no DRIFT. -- Custom commands are app-wide: give them `uniqueId` names (hyphens are accepted). A command used by a channel type can't be deleted (code 4 "command in use by channel type ..."), and `Cleanup` runs custom steps before deleting channel types: set the type's `commands` to `[]` first (`updateChannelType` helper), then delete the command, retrying on "in use". -- `queryReviewQueue` filters by `entity_type` match the whole app: flag a fresh message just before the region, assert it is `items[0]` under the docs sort and that every item has that `entity_type`. diff --git a/__tests__/docs-snippets/README.md b/__tests__/docs-snippets/README.md index ab63d64..6060fe1 100644 --- a/__tests__/docs-snippets/README.md +++ b/__tests__/docs-snippets/README.md @@ -20,6 +20,10 @@ yarn eslint __tests__/docs-snippets && npx prettier --check __tests__/docs-snipp These tests hit the network and are not part of `yarn test` (which runs `__tests__/integration`). Don't run them at the same time as `yarn test`: both use the same app, and the drift check would see the other suite's changes. +## How to add tests? + +Run the `docs-sync` Claude Code skill (`.claude/skills/docs-sync/SKILL.md`): `/docs-sync [path to the getstream.io checkout]`. It runs `yarn test:docs:sync` against that checkout, adds tests for new `Node.js` snippets, updates existing tests to docs changes (the docs win), and writes fixes for snippets that don't work back to the docs. + ## Push credentials (optional) `DOCS_PUSH_FIREBASE_CREDENTIALS` in the repo root `.env`: a Firebase service account JSON, on one line. Snippets @@ -42,9 +46,8 @@ setup.ts # per-file leak check and app-settin global-setup.ts # end-of-run sweep: removes leftovers of files that failed while loading sync/ # `yarn test:docs:sync` (docs fence <-> test region check) maintenance/ # `yarn test:docs:sweep` -docs-snippets-todo.md # pages to cover, their status, blockers and setup notes -docs-snippets-agent-prompt.md # prompt for an agent that covers one page -learnings.md # lessons from earlier agent runs, read before working on a page +docs-snippets-todo.md # open API, API spec / node SDK and docs issues +AGENTS.md # test conventions and API quirks, read before working on a page ``` The path mirrors the docs path (relative to `content/docs`) without `/_default`, e.g. @@ -54,22 +57,7 @@ The path mirrors the docs path (relative to `content/docs`) without `/_default`, ## Conventions -- Import the SDK from `../../../../index` (relative depth varies) and test helpers from `helpers/`. Use `import type` for types. -- `@stream-io/node-sdk` is aliased to the repo's `index.ts` (`resolve.alias` in `vitest.config.mts`, `paths` in `tsconfig.json`), so a docs fence that starts with `import ... from "@stream-io/node-sdk"` can be a region at module level: declare the values it uses (e.g. `apiKey`) above it. -- The docs call the server client `client`: use `const client = getServerClient()` (`helpers/clients.ts`, 30s timeout) and the same variable names as the docs (`client`, `channel`, ...). `createUserToken(userId)` signs user tokens. -- Snippets act on behalf of users with `user_id` / `created_by_id`: those users must exist (`client.upsertUsers`) and have `uniqueId` ids. -- **Isolation**: the id or name of everything a test creates comes from `uniqueId('name')`: users, channels, channel types, roles, commands, blocklists, segments, campaigns (name), user groups, moderation config keys, predefined filters, push providers. Polls are matched by their creator, so create them as a `uniqueId` user. Never use fixed ids like `"john"` or `"general"`. -- **Cleanup**: register everything you create on a `Cleanup` instance and call `cleanup.run()` in `afterAll`. App-level settings changed by a test must be restored with `cleanup.add(...)`. -- Tests in one file may share data created in `beforeAll`. Nothing may leak across files. -- `cleanup.run()` attempts every step and then throws if anything failed, so leftovers show up as a failed `afterAll` instead of piling up in the app. Server-side hard channel deletion is a background task: `Cleanup` waits for it (`waitForTask`) and retries deleting channel types until their channels are gone. -- **Leak check** (`setup.ts`, runs automatically after each file's own `afterAll`): looks up everything minted by `uniqueId` in that file (users, channels, channel types, commands, roles, custom permissions, blocklists, segments, campaigns, user groups, moderation configs, predefined filters, push providers, polls). Anything still there fails the file with a `LEAK` error listing it, and is then removed. Per-user data (reminders, devices, drafts, ...) goes away with the user. It also snapshots app settings (incl. push and webhook config), pre-existing channel types and retention policies before the file and fails with `DRIFT` if anything changed and wasn't restored. -- **End-of-run sweep** (`global-setup.ts`, after every `yarn test:docs`): a file that throws while loading runs no hooks, so its cleanup and leak check never run. The sweep removes every `docs-test` resource created since the run started (channels are found by listing every channel created since then) and fails the run with a `LEAK` list. It also puts back the retention policies the run started with and fails with `DRIFT` if it had to. Don't run two `yarn test:docs` at once: the sweep would remove the other run's resources. -- Anything created under a non-`uniqueId` name can't be found by the leak check or the sweep. Don't do that. -- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). Background tasks: `waitForTask(client, taskId)`, and `waitForBatchTask` for channel batch updates (their top-level status completes too early). -- **App settings**: pages are run one at a time, so tests may change app settings and built-in channel types, but must restore them (`cleanup.add`). The check after each file fails with `DRIFT` otherwise. -- `updateChannelType(client, name, patch)` (`helpers/channel-types.ts`) fills in the fields the request requires; `grantMessagingMembers` (`helpers/grants.ts`) adds `messaging` member grants and registers the restore. `helpers/server.ts` has repeated lookups: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy`, `waitForAppSetting` and `waitForTeamIdSupport` (multi-tenancy toggles). -- One `it` per docs snippet (or per closely related group of snippets in the same tab group). -- Add basic `expect`s after each snippet to prove it did what the docs claim. +The test conventions and the API quirks found so far are in [`AGENTS.md`](AGENTS.md). Read it before working on a page. ## Snippet markers and docs sync diff --git a/__tests__/docs-snippets/docs-snippets-agent-prompt.md b/__tests__/docs-snippets/docs-snippets-agent-prompt.md deleted file mode 100644 index 671fa8c..0000000 --- a/__tests__/docs-snippets/docs-snippets-agent-prompt.md +++ /dev/null @@ -1,127 +0,0 @@ -# Agent prompt: test the docs snippets of one page - -Replace `{{PAGE}}` with a path from `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`, e.g. `chat/_default/04-messages/01-send_message.md` or `platform/02-identity-and-access/02-users.md`. - ---- - -You are verifying the server-side Node.js code snippets of one Stream docs page (chat or platform) by writing typed tests that run them against a real Stream app with the `@stream-io/node-sdk` server client (API key + secret). Only fences labelled `Node.js` are in scope. Docs fences must always have a label: if you find an unlabelled `js` fence, label it (`Node.js` for `@stream-io/node-sdk` code, `JavaScript` for client code) and report it. `JavaScript`, `React` and `React Native` fences (client-side code) and `Node.js` fences inside a `` block (reference implementations that don't use the SDK) are out of scope: don't test, add or change them. - -**Page:** `/Users/zitaszupera/Stream/getstream.io/content/docs/{{PAGE}}` - -## Hard rules - -- **Do not commit, push, stash, or create branches** in any repo. Work only in the local working tree. -- **Do not write to `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`.** Read it, and propose additions in your final report. -- Only touch: - - the test files for this page under `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/pages/` - - the docs page itself - - this page's status row (and its entries in the Blockers detail / Setup notes / API behavior issues / API spec / node SDK issues / Open docs bugs sections). Fixed docs bugs go in the report only; record a docs bug in **Open docs bugs** only if you leave it unfixed in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md` -- Do not edit the helpers, the configs, other pages, or anything in `src/` (`src/gen` is generated from the OpenAPI spec, so hand edits would be lost). If a helper is missing something, write a local helper in your test file and propose the shared helper in your report. If the OpenAPI spec or the generated SDK is wrong, report it under "API spec / node SDK issues". -- Change existing docs snippets as little as possible, and only when they are actually wrong. -- Never put the API key, secret or push credentials in any file. They are loaded from the repo root `.env` (`STREAM_API_KEY`, `STREAM_SECRET`, optional `DOCS_PUSH_FIREBASE_CREDENTIALS`). -- In getstream.io, never run `npm run ci` or `npm run lint` for the whole repo. They rewrite thousands of files, including image assets. Use only the per-file commands listed below. - -## Read first - -1. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`: issues that earlier agents ran into. Follow it. -2. `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/chat/node/01-quick_start/01-backend_quickstart.test.ts` as an example. -3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`. Also read the page's entries in the TODO's **Blockers detail** and **Setup notes** sections, and the **App facts** line. -4. The SDK source for every method a snippet calls: `src/StreamClient.ts`, `src/StreamChatClient.ts`, `src/StreamChannel.ts`, `src/StreamModerationClient.ts` and the generated `src/gen/` clients and models (`src/gen/models/index.ts`), for the method name, its request object and its response type. When it's unclear whether a server-side endpoint exists or what it accepts, check the OpenAPI spec: https://github.com/GetStream/protocol/tree/main/openapi/v2 (`chat-serverside-api.json`). - -## What to build - -- **Test** (`__tests__/docs-snippets/pages/
/.test.ts`): run each `Node.js` snippet with `const client = getServerClient()` (`helpers/clients.ts`), named `client` as in the docs. - - Snippets act on behalf of users through `user_id` / `created_by_id` / `user`: create those users first (`client.upsertUsers`) with `uniqueId` ids. - - If a `Node.js` snippet is client-side code (it needs a connected user or a WebSocket, or calls a method the node SDK doesn't have, e.g. `connectUser`, `channel.watch()`, `client.on(...)`), it doesn't belong in a `Node.js` tab. Don't invent a server equivalent: put it in `it.skip('BLOCKED: client-side code: ...')` if it still typechecks, otherwise make the smallest docs fix that turns it into the server-side call the node SDK has, and report it either way. - - If no `Node.js` fence of the page can be tested, don't write a test file; record why in the report and the TODO. -- **Mirror paths.** The test paths mirror the docs path (relative to `content/docs`) without `/_default`: `chat/_default/04-messages/01-send_message.md` goes to `pages/chat/04-messages/01-send_message.test.ts`, `chat/node/...` to `pages/chat/node/...`, `platform/02-identity-and-access/02-users.md` to `pages/platform/02-identity-and-access/02-users.test.ts`. -- **Test structure:** - - `describe` per page. - - `beforeAll` creates the shared users and channels with `uniqueId(...)` ids and registers them on a `Cleanup`. - - `afterAll` runs `cleanup.run()`. - - One `it` per snippet, or per tab group when the snippets in it depend on each other. -- **Snippet markers.** Every `Node.js` fence on the page needs exactly one marker in a test (`JavaScript` fences are ignored). `yarn test:docs:sync` checks the format and the coverage. - - Wrap the docs code like this: - ```ts - // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="Node.js" index=1 - // COPY: channelId="general", userId="john" - ...docs code... - // #endregion snippet - ``` - - `docs` is the page path relative to `content/docs/` (`chat/...` or `platform/...`), i.e. `{{PAGE}}`. - - `heading` is the exact text of the nearest heading above the fence. - - `tab` is the fence label, or `unlabelled`. - - `index` (default 1) counts fences with the same heading text and tab on the page. If a key is wrong, the sync check lists the page's fence keys. - - Everything between the markers must be the docs code. Setup goes before the region and assertions after it. - - **COPY** (optional, first line(s) of the region): `name="docs literal"` pairs. Where the docs use fixed values ("john", "general", "messageID", `"{{ api_key }}"`), use variables in the test, and the sync check replaces each variable with its literal before comparing. Don't use COPY variables as object keys or in shorthand properties (`{ userId }`). - - Comment-only fences are skipped by the sync check and need no marker. Every other `Node.js` fence gets a real region: blocked or untestable snippets go inside `it.skip('BLOCKED: ...')` so they are still typechecked. A fence that isn't code (e.g. a bare data shape) is a docs bug: rewrite it as code (a TypeScript `type` when the sibling tabs show type definitions, otherwise code that reads and logs the fields), and report it. -- **Assertions.** After each snippet, add basic `expect`s proving that it did what the docs say. For example, check that the response contains the new field, or re-query and check that the state changed. Don't over-assert on volatile fields. -- **Naming**: the id or name of every resource you create comes from `uniqueId(...)` (campaigns: `name`; polls: create them as a `uniqueId` user). The automatic leak check after each file only finds resources named this way. If it reports a `LEAK`, fix your cleanup; don't work around the check. -- **App-level side effects** (app settings, built-in channel types, grants, roles, retention policies, multi-tenancy, push config, webhook URLs, ...): pages are run **one at a time**, so changing them is allowed. - - Read the original value first and restore it with `cleanup.add(...)`. - - The check after each file fails with `DRIFT` if app settings or pre-existing channel types differ from before the file ran. - - Prefer `uniqueId` channel types or `config_overrides` when the snippet allows it. -- **Push notifications and webhooks**: don't test that notifications or webhook events arrive. Calling the API and asserting the response (where there is one) is enough; for a setting, read it back (`getApp()`, `listPushProviders()`, `getPushTemplates`, ...). - - Snippets that need push provider credentials (creating a push provider, `firebase_config`, ...): pass `sampleFirebaseCredentials` (`helpers/push.ts`, the sample service account JSON from the platform push providers page) through a COPY variable that maps to the docs placeholder (e.g. `credentials="my-service-account-information"`). If the API rejects it (e.g. code 4 "firebase credentials are invalid"), the snippet needs real credentials: put it in `describe.skipIf(!hasFirebaseCredentials)(...)` with `firebaseCredentials` instead, so it runs when `DOCS_PUSH_FIREBASE_CREDENTIALS` is set and is skipped otherwise. A snippet that needs another provider's config (APN, Huawei, Xiaomi) can't run: `it.skip('BLOCKED: needs credentials')`. Record the probe result in the TODO and mark the page 🔑. - - Push preferences: assert the response, or read the preference back (the user's / channel member's preferences, the channel config or channel type). Don't check which pushes are sent. - - The test app has its own push provider `firebase:firebase`: never touch or use it (don't update or delete it, register devices on it or run `checkPush` against it). Only use providers your test created with `uniqueId` names; if a snippet can't run without an existing provider, skip it. - - Snippets that work without credentials (listing providers, push templates, `checkPush` with a payload, ...) run unconditionally. - - Webhook settings (URLs, `event_hooks`, ...): set them to an obvious placeholder (e.g. `https://example.com/...`), assert them with `getApp()`, and restore the original value. -- **Channel types are eventually consistent.** After `createChannelType` or `updateChannelType`, API nodes can disagree for up to ~30s. - - Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after the create/update, outside the snippet region. - - Push the name to `cleanup.channelTypes` straight after `createChannelType`. `Cleanup` deletes it after the channels are gone. Never delete channel types in your own cleanup code. -- **Blockers** (the page's Blockers cell in the TODO): - - ⛔ snippets go in `it.skip('BLOCKED: ', ...)` so they are still typechecked. - - If you find a new blocker, use `it.skip('BLOCKED: ')`, mark the page `[~]` and report it, with the exact API error. -- **Partial snippets** (fragments like `filters = {...}` or `...` placeholders): wrap them in the minimal code needed to compile and run. The region still holds exactly the docs text. - -## Editing the docs page - -- Write fixes from the test with `DOCS_SYNC_WRITE= yarn test:docs:sync`. This writes every region of that page into its fence, applying the COPY values and the docs prettier style (double quotes). Don't paste snippets by hand. -- Follow `/Users/zitaszupera/Stream/getstream.io/content/docs/AGENTS.md`. In particular, no em-dashes in prose or comments. -- Then run, from `/Users/zitaszupera/Stream/getstream.io/content/docs`: - - `npx prettier --write ` - - `npx markdownlint-cli2 --config .markdownlint-cli2.cjs ` - -## Faulty docs snippets - -If a docs snippet is wrong (wrong method name or signature, a type error against the node SDK, wrong request or response shape, a runtime API error that isn't caused by test setup): - -1. Make the smallest fix that makes it correct, in the test region. -2. Write it to the docs page with `DOCS_SYNC_WRITE= yarn test:docs:sync`, then check `git diff` in getstream.io: only the intended fence should change. -3. Record it for the report as before/after with the reason and the evidence (error message or type error). - -Tell three kinds of problem apart, and record each in its own list in the todo: - -- **Docs bug**: the snippet (or the page) is wrong. Fix the snippet as above; prose and non-Node.js fences are out of scope, record those under **Open docs bugs**. -- **API spec / node SDK issue**: the snippet is correct and the API accepts it, but the OpenAPI spec or the generated SDK is wrong (missing or wrongly typed fields, wrong response types, a required field typed optional). Don't change the snippet or `src/`; record it under **API spec / node SDK issues** (say whether it looks like the spec or the generator) and report it. If the types reject a correct snippet, stop and report it: `it.skip` doesn't help, skipped bodies are still typechecked. -- **API behavior issue**: the API itself seems faulty, or behaves differently from what the docs say (fields missing from responses, data silently dropped, settings not enforced, inconsistent answers between API nodes). Assert what the API actually does and record it under **API behavior issues** with the probe evidence. It is not a blocker: it doesn't make the page `[~]` on its own. - -If you're not sure whether the snippet or your setup is wrong, don't edit the docs. Mark it as an open question in the report instead. - -## Gates (all must pass before you finish) - -Run these from `/Users/zitaszupera/Stream/stream-node`: - -```sh -yarn test:docs # green, with no `LEAK` or `DRIFT` error -yarn types:docs # no type errors (strict TS, no `any` escapes, no `!` non-null assertions) -yarn eslint __tests__/docs-snippets # zero warnings -npx prettier --check __tests__/docs-snippets -yarn test:docs:sync # your page: every Node.js fence covered, every JS fence labelled JavaScript/Node.js, every region identical to its docs fence -``` - -`yarn test:docs:sync`'s coverage check also lists pages nobody has done yet: only your page must be absent from it. - -Don't use `@ts-ignore`/`@ts-expect-error` or `eslint-disable` to silence a docs snippet. The one lint-only docs change allowed: a fence that assigns a value it never reads, which the test can't read either (block-scoped), gets a `console.log` of the field the page talks about (see `learnings.md`); report it as a docs correction. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (report it). - -Finally, update the page's row in `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-todo.md`: `[x]` when done, `[~]` only when some snippets couldn't run (a blocker); API behavior issues and open docs bugs are recorded in their lists and don't make a page `[~]`. - -## Final report (your last message) - -1. **Files created/changed**: test files, plus the docs page with a one-line summary. -2. **Docs snippet corrections**: for each one, a before/after diff, the reason, and the evidence. These need human review. -3. **Skipped / untestable snippets**: for each one, the snippet and why (including 🔑 push snippets skipped without credentials, and whether you ran them with credentials). -4. **Client-side code in Node.js snippets**: each fence or part of a fence that needs a connected user, with the error or the reason. -5. **API spec / node SDK issues** and **API behavior issues**: two separate lists (see above), each item with its evidence (not fixed). -6. **Proposed learnings** for `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/learnings.md`: a numbered list of short, reusable lessons that would have saved you time. Skip page-specific trivia. The user will approve or reject each one. diff --git a/__tests__/docs-snippets/docs-snippets-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md index 8614efd..02794fe 100644 --- a/__tests__/docs-snippets/docs-snippets-todo.md +++ b/__tests__/docs-snippets/docs-snippets-todo.md @@ -1,227 +1,6 @@ # Docs snippet tests: todo -Pages of the chat Node sidebar (`getstream.io/content/docs/_sidebars/[chat][node].json`) and the platform sidebar (`getstream.io/content/docs/_sidebars/[platform][default].json`) that contain `Node.js` snippets. Chat tests go under `pages/chat/...`, platform tests under `pages/platform/...`. Give each page to an agent with `/Users/zitaszupera/Stream/stream-node/__tests__/docs-snippets/docs-snippets-agent-prompt.md`. **Run pages one at a time**: tests may change app settings (they must restore them). - -- Only server-side snippets are in scope: fences labelled `Node.js`. Unlabelled `js` fences get a label (`JavaScript` or `Node.js`). `JavaScript`, `React` and `React Native` fences (client-side) are out of scope. -- **Node.js / Unlabelled**: number of `js` fences labelled `Node.js` / with no label (before the docs were changed). -- **Test**: `yes` when the page gets a test under `__tests__/docs-snippets/pages/...`; `-` when nothing on it can be tested. - -**Blockers** (details below; empty = fully runnable, maybe with setup notes): - -- ⛔ **Blocked**: the app doesn't have the feature. The snippet goes in `it.skip('BLOCKED: ...')` (still typechecked). -- ◐ **Partial**: only some snippets of the page are affected. -- 🔑 **Push credentials**: needs `DOCS_PUSH_FIREBASE_CREDENTIALS`; the snippets run in `describe.skipIf(!hasFirebaseCredentials)`. -- **Excluded**: not validated, leave as is. - -Status: `[ ]` todo, `[x]` done, `[~]` done but some snippets couldn't run (a blocker, e.g. ⛔ or 🔑). API behavior issues and open docs bugs are not blockers: they are tracked in their lists below and don't make a page `[~]`. - -Issues are kept in three lists below: [API behavior issues](#api-behavior-issues) (the API itself may be faulty), [API spec / node SDK issues](#api-spec--node-sdk-issues) (the OpenAPI spec or the generated node SDK is wrong) and [Open docs bugs](#open-docs-bugs) (the docs are wrong and not fixed yet). - -App facts (node SDK test app, probed 2026-10-12): push v3 with one provider `firebase:firebase` (not a docs-test one: tests must not touch or use it, e.g. no `upsertPushProvider` / `deletePushProvider` on it and no devices or `checkPush` with `push_provider_name: "firebase"`), permissions v2, multi-tenancy off, `disable_auth_checks` off, auto-translation off, campaigns on (segments, user groups and polls endpoints answer), no webhook / SQS / SNS / custom action handler URLs set (`webhook_url`, `custom_action_handler_url`, `sqs_url`, `sns_topic_arn` empty), one entry in `event_hooks`, no retention policies. Channel types: `livestream`, `messaging`, `team`, `agent`, `commerce`, `gaming`, plus `streamnodetest*` types left by the integration tests. Delivery receipts: `delivery_events: true` is accepted on create/update channel type without support enablement (2026-10-12). Location sharing: enabled (2026-10-12); channels still need `shared_locations` (`config_overrides` or channel type), otherwise `sendMessage` with `shared_location` fails with code 17 "location sharing is not enabled for this channel". Translation: `translateMessage` works with auto-translation off; `updateApp({ auto_translation_enabled: true })` is accepted (restored after, 2026-10-12). Restricted visibility messages: enabled (`restricted_visibility` on `sendMessage` works, 2026-10-12). User response time: `updateApp({ user_response_time_enabled: true })` is accepted and `avg_response_time` appears on the replying user within seconds (restored after, 2026-10-12). Dynamic partitioning: `partition_size` / `partition_ttl` accepted on create/update channel type without support enablement (2026-10-12). Message history (audit logs): enabled; `queryMessageHistory` is rate limited to 6 requests per minute (2026-10-12). Not probed yet: feature availability per page (each agent probes what its page needs and records it here). - -## chat/node - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | --------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [x] | `chat/node/01-quick_start/01-backend_quickstart.md` | 5 | 0 | yes | | - -## chat/\_default/02-init_and_users - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | --------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [x] | `chat/_default/02-init_and_users/04-user_groups.md` | 8 | 0 | yes | | - -## chat/\_default/03-channels - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ----------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [x] | `chat/_default/03-channels/01-creating_channels.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/02-query_channels.md` | 11 | 0 | yes | | -| [x] | `chat/_default/03-channels/08-get_channel.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/03-channel_update.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/04-query_members.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/05-channel_pagination.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/06-channel_members.md` | 10 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/08-archiving.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/09-pinning.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/10-muting.md` | 3 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/11-hiding.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/12-disabling.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/13-deleting.md` | 3 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/14-freezing.md` | 3 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/15-truncating.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/16-channel_invites.md` | 6 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/17-batch-updates.md` | 3 | 0 | yes | | - -## chat/\_default/04-messages - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ------------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [x] | `chat/_default/04-messages/01-send_message.md` | 9 | 0 | yes | | -| [x] | `chat/_default/04-messages/02-file_uploads.md` | 4 | 0 | yes | | -| [x] | `chat/_default/04-messages/03-threads.md` | 9 | 0 | yes | | -| [x] | `chat/_default/04-messages/04-send_reaction.md` | 5 | 0 | yes | | -| [x] | `chat/_default/04-messages/05-pinned_messages.md` | 3 | 0 | yes | | -| [x] | `chat/_default/04-messages/06-search.md` | 2 | 0 | yes | | -| [x] | `chat/_default/04-messages/07-silent_messages.md` | 2 | 0 | yes | | -| [x] | `chat/_default/04-messages/08-unread_reminders.md` | 1 | 0 | yes | | -| [x] | `chat/_default/04-messages/09-message_reminders.md` | 7 | 0 | yes | | -| [x] | `chat/_default/04-messages/10-message_receipts.md` | 4 | 0 | yes | | -| [x] | `chat/_default/04-messages/11-ai-message-streaming.md` | 2 | 0 | yes | | - -## chat/\_default/05-features - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ------------------------------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [x] | `chat/_default/05-features/02-events.md` | 2 | 0 | yes | | -| [x] | `chat/_default/05-features/03-unread.md` | 7 | 0 | yes | | -| [x] | `chat/_default/05-features/06-campaign_api.md` | 16 | 0 | yes | | -| [x] | `chat/_default/05-features/07-polls_api.md` | 16 | 0 | yes | | -| [x] | `chat/_default/05-features/08-location_sharing.md` | 5 | 0 | yes | | -| [x] | `chat/_default/05-features/09-translation.md` | 3 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md` | 1 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/12-drafts.md` | 4 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/13-private_messaging.md` | 1 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/14-user_average_response_time.md` | 1 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/15-pending_messages.md` | 6 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/16-dynamic_partitioning.md` | 4 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/17-audit_logs.md` | 5 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/18-data_retention_policy.md` | 4 | 0 | yes | | - -## chat/\_default/06-app_and_channel_settings - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | -------------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [x] | `chat/_default/06-app_and_channel_settings/01-channel_types.md` | 9 | 0 | yes | | -| [x] | `chat/_default/06-app_and_channel_settings/02-app_settings.md` | 3 | 0 | yes | | -| [x] | `chat/_default/06-app_and_channel_settings/03-chat_permission_policies.md` | 11 | 0 | yes | | -| [x] | `chat/_default/06-app_and_channel_settings/06-channel-level_settings.md` | 3 | 0 | yes | | - -## chat/\_default/07-push - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [~] | `chat/_default/07-push/07-legacy_push_system.md` | 2 | 0 | yes | 🔑 | - -## chat/\_default/08-webhooks - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ---------------------------------------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [x] | `chat/_default/08-webhooks/01-webhooks_overview/02-before_message_send_webhook.md` | 3 | 0 | yes | | -| [x] | `chat/_default/08-webhooks/01-webhooks_overview/03-custom_commands_webhook.md` | 8 | 0 | yes | | - -## chat/\_default/10-migrating - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ----------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [x] | `chat/_default/10-migrating/03-exporting_channels.md` | 5 | 0 | yes | | - -## chat/\_default/12-best_practices - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ------------------------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [x] | `chat/_default/12-best_practices/02-moderation.md` | 13 | 0 | yes | | -| [x] | `chat/_default/12-best_practices/05-marketplace_best_practices.md` | 5 | 0 | yes | | - -## platform/01-start-here - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ------------------------------------------- | ------- | ---------- | ---- | -------- | -| [x] | `platform/01-start-here/01-backend-sdks.md` | 1 | 0 | yes | | - -## platform/02-identity-and-access - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ------------------------------------------------------ | ------- | ---------- | ---- | -------- | -| [~] | `platform/02-identity-and-access/01-authentication.md` | 8 | 0 | yes | ◐ | -| [x] | `platform/02-identity-and-access/02-users.md` | 13 | 0 | yes | | -| [x] | `platform/02-identity-and-access/03-permissions.md` | 8 | 0 | yes | | -| [x] | `platform/02-identity-and-access/04-multi-tenancy.md` | 9 | 0 | yes | | -| [x] | `platform/02-identity-and-access/05-gdpr.md` | 4 | 0 | yes | | - -## platform/03-events-and-notifications - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ----------------------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [~] | `platform/03-events-and-notifications/01-webhooks.md` | 15 | 1 | yes | ◐ ⛔ | -| [x] | `platform/03-events-and-notifications/02-push-notifications.md` | 3 | 0 | yes | | -| [~] | `platform/03-events-and-notifications/03-push-providers.md` | 5 | 0 | yes | ◐ 🔑 | -| [~] | `platform/03-events-and-notifications/04-push-devices.md` | 10 | 0 | yes | ◐ 🔑 | -| [x] | `platform/03-events-and-notifications/05-push-preferences.md` | 5 | 0 | yes | | -| [~] | `platform/03-events-and-notifications/06-push-troubleshooting.md` | 2 | 0 | yes | 🔑 | - -## platform/04-using-the-api - -| Status | Page | Node.js | Unlabelled | Test | Blockers | -| ------ | ---------------------------------------------------- | ------- | ---------- | ---- | -------- | -| [x] | `platform/04-using-the-api/01-rate-limits.md` | 3 | 0 | yes | | -| [x] | `platform/04-using-the-api/03-async-operations.md` | 1 | 0 | yes | | -| [x] | `platform/04-using-the-api/06-connection-pooling.md` | 0 | 1 | yes | | -| [x] | `platform/04-using-the-api/07-api-error-codes.md` | 2 | 0 | yes | | - -## Pages without Node.js snippets - -These sidebar pages have no `Node.js` (or unlabelled) JS fences: nothing to test. The sync coverage check still reads them, so a `Node.js` fence added later shows up as uncovered. - -- `chat/_default/01-quick_start/02-architecture_and_benchmark.md` -- `chat/_default/01-quick_start/03-roadmap_and_changelog.md` -- `chat/_default/03-channels/07-channel_management/07-overview.md` -- `chat/_default/05-features/01-overview.md` -- `chat/_default/06-app_and_channel_settings/05-permissions_reference.md` -- `chat/_default/07-push/02-push_template.md` -- `chat/_default/08-webhooks/05-webhook_events.md` -- `chat/_default/10-migrating/01-migration_guide.md` -- `chat/_default/10-migrating/02-import.md` -- `chat/_default/11-debugging_and_cli/09-datadog_integration.md` -- `chat/_default/12-best_practices/09-fair_usage_limits.md` -- `chat/_default/11-debugging_and_cli/08-rate_limits.md` -- `chat/_default/11-debugging_and_cli/10-api_budget.md` -- `chat/_default/12-best_practices/08-storage_and_bandwidth.md` -- `chat/_default/12-best_practices/01-best_practices.md` -- `chat/_default/12-best_practices/06-livestream_best_practices.md` -- `platform/00-index.md` -- `platform/04-using-the-api/02-query-syntax-operators.md` -- `platform/04-using-the-api/04-error-handling.md` -- `platform/04-using-the-api/05-logging.md` -- `platform/04-using-the-api/08-cdn-domains.md` - -## Blockers detail - -- **platform/02-identity-and-access/01-authentication** (◐): "Token Revocation by Application" and its "Undoing the revoke" (Node.js #2) are in `it.skip`: `updateApp({ revoke_tokens_issued_before })` revokes every user token of the shared test app, so it is kept skipped by decision (2026-10-14: other users of the app would get 401s while it is set). To run it later (restore with `revoke_tokens_issued_before: null`; `getApp()` returns `null` for it when unset, so no DRIFT is expected). The undo region also fails `yarn types:docs` (see API spec / node SDK issues). -- **platform/03-events-and-notifications/01-webhooks** (◐ ⛔): (1) "Configuration" Node.js #1 (GCS failover) is in `it.skip`: `updateApp` rejects the placeholder `gcs_credentials` with code 4 "failover_config for hook has invalid gcs_credentials: dialing: auth: email must be provided" (needs a real GCS service account). (2) "Configuration" Node.js #2 (S3 failover) is in `it.skip` (⛔): code 4 "failover_config type 's3' is not enabled for hook" (probed 2026-10-14). The stateless `verifyAndParseWebhook` import fence runs in `01-webhooks.stateless.test.ts` (module-level region); the three "Reference implementation (no SDK)" fences are `Node.js` fences inside the disclosure and out of scope. -- **platform/03-events-and-notifications/03-push-providers** (◐ 🔑): only "List Push Providers" runs. "Upsert a Push Provider", "Delete a Push Provider" and "Linking Devices to Providers" (each on its own `uniqueId` Firebase provider) and "Single-Provider Configuration" (`updateApp({ firebase_config })`, restored like the legacy push page) are in `describe.skipIf(!hasFirebaseCredentials)`: the page's sample service account JSON (`sampleFirebaseCredentials`) is rejected with code 4 "firebase credentials are invalid" by both `upsertPushProvider` and `updateApp({ firebase_config })` (probed 2026-10-14). `DOCS_PUSH_FIREBASE_CREDENTIALS` is not set in `.env`, so these four have never run: their assertions (device `push_provider_name`, `push_notifications.firebase.enabled` turning `true`) and the cleanup are unverified. The "apn or firebase or huawei or xiaomi" placeholders run as `firebase` through COPY. -- **platform/03-events-and-notifications/04-push-devices** (◐ 🔑): only the three list fences and the device type check run (the lists on a new user, so `devices` is `[]`). The three register fences ("Register a Device", "Registering from the chat client SDKs", "Multi-Bundle Device Registration") use `push_provider: "apn"`: `it.skip('BLOCKED: needs APN credentials')`, because `createDevice` checks that the provider exists (code 4 'push provider "production-ios" with type "apn" not found, available push providers for this application: type: "firebase" name: "firebase"', also without `push_provider_name`: 'push provider "" with type "apn" not found'; probed 2026-10-14). The three remove fences need a device, which can only be registered on a `uniqueId` Firebase provider (never the app's own): they are in `describe.skipIf(!hasFirebaseCredentials)` (the sample service account JSON is rejected, see 03-push-providers) and have never run. The "device object" Node.js fence (bare data shape) is now a TypeScript `type Device`, checked at compile time to match `DeviceResponse` exactly. -- **platform/03-events-and-notifications/06-push-troubleshooting** (🔑): both "Testing Push" Node.js fences call `checkPush` without a provider, which would run against the app's own `firebase:firebase` provider (not allowed). The test creates a `uniqueId` Firebase provider and a spy on `client.checkPush` adds `push_provider_type: "firebase"` / `push_provider_name` (both in `CheckPushRequest`), so the regions stay as written. Creating the provider needs a real service account (`sampleFirebaseCredentials` is rejected, see 03-push-providers), so the whole file is in `describe.skipIf(!hasFirebaseCredentials)`; not yet run with credentials (2026-10-14), so the assertions (`skip_devices: true` passed back, `duration` string) are unverified. - -## Setup notes (not blockers) - -- **node/01-quick_start/01-backend_quickstart**: The first fence creates a client from `"{{ api_key }}"` / `"{{ api_secret }}"` template placeholders: COPY them from the real credentials (`apiKey="{{ api_key }}"`). -- **07-push/07-legacy_push_system**: Push provider / `firebase_config` snippets need real credentials (🔑). Name providers with `uniqueId`; `updateApp({ firebase_config })` changes app-level push config: save it with `getApp()` first and restore it (the DRIFT check compares `push_notifications`). Without real credentials both `upsertPushProvider` and `updateApp({ firebase_config })` (even templates only) fail with code 4 "firebase credentials are invalid" (2026-10-12), so both tests are skipped. Not yet run with credentials: the `firebase_config` restore (templates + `disabled: !enabled`, the app starts with legacy Firebase `enabled: false`) and whether the API accepts the placeholder templates (`"your-template"`) are unverified. -- **08-webhooks/01-webhooks_overview/\***: Configure the hook URL (`before_message_send_hook_url`, `custom_action_handler_url`, ...) with `updateApp`, assert it with `getApp()`, and restore the original (empty) value. Don't test that the webhook is called. -- **02-init_and_users/04-user_groups**: The get/list/search/delete snippets pass `team_id`, which the API rejects without multi-tenancy (`team_id is not supported when multi-tenancy is not enabled`) and requires with it. The test enables `multi_tenant_enabled` for those four snippets (on a `uniqueId` team) and restores it. `waitForAppSetting` is not enough: the user group endpoints of some API nodes keep the old setting for a few seconds more, so the test also waits until 20 calls in a row accept (or reject) `team_id`. -- **03-channels/02-query_channels**: The predefined filter snippets query a filter created by the test (`uniqueId` name, template `{ type: "messaging", members: { $in: ["{{user_id}}"] } }`, COPY-mapped to `"user_messaging_channels"`), deleted in cleanup. The "Filter Best Practices" and "Bad Pattern" regions (and the shared setup) run at module level: their one-line `queryChannels({ filter_conditions: { type: "messaging" } })` would be wrapped by prettier at test indentation, and the sync check keeps the wrapping. -- **03-channels/07-channel_management/13-deleting**: `skip_truncate` only works on distinct channels, so the "Preserving Message History" test runs on a distinct channel (its id COPY-mapped to `"general"`) and recreates it to check the message is back. -- **03-channels/07-channel_management/17-batch-updates**: The docs filter on every `messaging` / `team` channel of the app; the test COPY-maps those type names to two `uniqueId` channel types so only its own channels are touched. Wait on the inner `result.status` / `result.finished_at` of the task (the top-level `status` completes first). A batch started right after another one takes ~60s, so the tests have 240s timeouts. -- **04-messages/01-send_message**: Server-side, URL enrichment runs after `sendMessage` returns: the response has no attachment, and it appears on the stored message about a second later. The test polls `getMessage` for it. The "Mentions" snippet's group id maps to a `uniqueId` user group created in `beforeAll`. -- **04-messages/02-file_uploads**: The "Uploading Files to a Channel" and "Uploading Standalone Files" fences start with `import`s, so they run at module level, and in two files (`02-file_uploads.test.ts`, `02-file_uploads.standalone.test.ts`) because both import `readFile` / `File`. The local paths (`./image1.jpg`, `./report.pdf`, `./avatar.jpg`, ...) are COPY-mapped to a 4x4 JPEG and a minimal PDF written to a temp dir. Server-side uploads need `user: { id }` (code 4 "bad user id" without it). -- **04-messages/09-message_reminders**: Reminders need `user_message_reminders` on the channel (`config_overrides`) or channel type; the "Enabling Reminders" snippet runs on a `uniqueId` type (COPY-mapped to `messaging`). `remind_at` must be at least one minute in the future (create and update, code 4), so the "overdue" filter test uses a reminder created at the start of `beforeAll` with `remind_at` ~62s ahead and waits for it to pass. A user can have only one reminder per message. -- **05-features/06-campaign_api**: Campaigns and segments use `uniqueId` ids and names; the docs segment filters ("created after 2020", `country: "USA"`) are COPY-mapped to the test start time and a `uniqueId` country so only the file's users are targeted. A campaign can be started only once (code 4 "cannot be started" after it ran), and one started "now" reads `scheduled` for a moment before `in_progress` / `completed`; `scheduled_for` must be in the future. A running campaign can't be deleted ("is in progress") and a completed one can't be stopped: cleanup retries stop + delete. Campaign channels get template ids (`{{receiver.id}}-{{sender.id}}`, keep user ids short for the 64-char limit), so cleanup finds them by member. `createSegment` rejects `filter: {}` (code 4 "filter must contain at least 1 item"). `team` in `channel_template` works with multi-tenancy off. -- **08-webhooks/01-webhooks_overview/03-custom_commands_webhook**: Commands are app-wide: the docs' `"ticket"` is COPY-mapped to a `uniqueId` name (hyphens are accepted). A command in use by a channel type can't be deleted (code 4 "command in use by channel type"), so cleanup first sets the type's `commands` to `[]`, then deletes the command. The intro fence (sending `/ticket ...`) runs last on a `uniqueId` type with the command (COPY-mapped to `"messaging"`), after the handler URL is set to the example.com placeholder: Stream calls it, it fails, and `sendMessage` rejects with code 45, which the test records through a spy and asserts. -- **All pages that create/update channel types**: Call `waitForChannelTypePropagation()` after `createChannelType` / `updateChannelType` before using the type (it waits ≥10s for a new type, 10s for an update of a docs-test type, 30s for an update of a built-in type). Read a type back with a retry: reads can return old values for over a minute (see API behavior issues). -- **05-features/07-polls_api**: Polls work on the built-in `messaging` type with no config change. All polls are created as one `uniqueId` user (john) and removed with `deletePollsCreatedBy` before the user. Votes need the poll sent in a message first (`sendMessage` with `poll_id`). The "Querying polls" filter (`is_closed: true`) matches every closed poll of the app, so the test asserts only on the order of its own two closed polls. -- **05-features/10-advanced/17-audit_logs**: Message history is enabled on the test app. `queryMessageHistory` allows 6 requests per minute (code 9, 429): the test spies on it to retry on 429 so back-to-back runs pass. -- **05-features/10-advanced/15-pending_messages**: Pending messages are enabled on the test app (2026-10-12). The `mark_messages_pending` snippet runs on a `uniqueId` type (COPY-mapped to `messaging`). Server-side, `pending_messages` is only returned for a user: `queryChannels` needs `user_id`, `channel.get` needs `state: true` and `user_id`. The pending message hook snippet adds a hook with an `https://example.com` URL to `event_hooks`; the test restores the original `event_hooks` array. -- **platform/02-identity-and-access/01-authentication**: Tokens are checked by verifying their claims with `jsonwebtoken` and by a client-side `POST /channels` (query channels) with the token (201 accepted, 401 code 40 "This token has been revoked, please generate a new token" when revoked). Revocation takes effect within a few seconds (the test retries). Tokens to revoke are issued with `iat` two minutes in the past; the SDK's default `iat` is one second in the past, so a token generated right after the revocation date can still be revoked (retry). `revoke_tokens_issued_before` is stored rounded to the millisecond (±1 ms). -- **platform/02-identity-and-access/02-users**: `createGuest` stores the user as `guest--` (register `response.user.id` for cleanup, not the requested id). `last_active` is only set by a WebSocket connection (`wss://chat.stream-io-api.com/connect`, within ~3s), not by REST calls with a user token; the "Querying Inactive Users" test opens one. Bulk `deactivateUsers` / `reactivateUsers` and `deleteUsers` return a `task_id`: `waitForTask` before asserting. Soft-deleted users are not returned by `queryUsers`; deactivated ones only with `include_deactivated_users: true`. The "Unique usernames" test restores `enforce_unique_usernames` (original `""`) at its end, not only in cleanup, because of the reactivation issue under API behavior issues. -- **platform/02-identity-and-access/04-multi-tenancy**: The "Enable Teams" test runs the docs `updateApp({ multi_tenant_enabled: true })` and the rest of the file runs with multi-tenancy on (a nested `describe` turns it on again if needed); the original value is restored in cleanup with `waitForAppSetting` + `waitForTeamIdSupport`, as on the chat user groups page (it settles in ~4s). Teams are plain strings: the docs' `"red"` / `"blue"` are COPY-mapped to `uniqueId` team names so the team queries only see the file's channels, calls and users. The "Team based roles" fence has `teams_role` keys `red` / `blue` (object keys can't be COPY-mapped), so that user (a `uniqueId` id) uses the literal team names. Calls aren't tracked by the leak check: the test hard-deletes its `uniqueId` calls (`call.delete({ hard: true })`) before multi-tenancy is restored. `queryTeamUsageStats` returns `teams: []` on the test app, so the docs' pagination branch (`if (response.next)`) doesn't run (see API behavior issues). -- **platform pages (all)**: The platform docs are shared by Chat, Video and Feeds, so their Node.js snippets may call `client.chat`, `client.video` or `client.feeds`. The same isolation, cleanup and restore rules apply. -- **platform/02-identity-and-access/03-permissions**: Feeds v3 works on the test app (feed groups `user`, `timeline`, ... exist; probed 2026-10-14). The feed snippets run on `uniqueId` feeds (owned by `uniqueId` users) that the test hard-deletes in cleanup: the leak check doesn't track feeds. Feed visibilities are app-wide and not covered by the DRIFT check: the "Feeds visibility grants" test saves the `visible` grants, restores `feed_member` and checks the whole grants object is back. `updateApp({ grants })` (`.app` scope) is restored like on the chat permission policies page. Custom roles need the retry spies from the chat page (`provided but not defined` / `does not exist`). -- **platform/02-identity-and-access/05-gdpr**: `exportUsers` and `feeds.exportFeedUserData` tasks finish within seconds (`waitForTask`); `result.url` downloads without auth and contains the user id, their chat message text and their feed activity (id and text). `deleteFeedUserData({ hard_delete: false })` finishes within seconds; the user's activity is then 404 for `getActivity` and the user account stays. The feeds snippets run on a `uniqueId` `user` feed with one activity, hard-deleted in cleanup (ignoring "not found"). -- **platform/03-events-and-notifications/02-push-notifications**: All three Node.js fences are Feeds v3 calls (`serverClient.feeds.addComment` / `addReaction` / `addActivityReaction` / `addActivity`); no push provider, device or credentials are involved, and pushes aren't checked (the users have no devices). Each test adds its own activity on a `uniqueId` `user` feed; every `uniqueId` user gets a `notification` feed in setup, because `create_notification_activity` only creates the notification when the activity owner's notification feed exists (see API behavior issues). The custom `alerts` feed group is app-wide: the test creates a `uniqueId` group (hyphens replaced by `_`; whether hyphens are allowed wasn't probed), COPY-mapped to `alerts`, and hard-deletes it in cleanup. The leak check doesn't track feeds or feed groups, so cleanup hard-deletes them (ignoring "not found"). -- **platform/03-events-and-notifications/03-push-providers, 04-push-devices**: Use `sampleFirebaseCredentials` (`helpers/push.ts`, the sample service account JSON from the "Upload your credentials" section of the push providers page) for the credentials placeholder (`"my-service-account-information"`) through a COPY variable. If the API rejects it (the chat legacy push page saw code 4 "firebase credentials are invalid" for fake credentials, see `learnings.md`), skip the snippet (`describe.skipIf(!hasFirebaseCredentials)` with `firebaseCredentials`) and mark the page 🔑. Snippets that need another provider config (APN, Huawei, Xiaomi; e.g. the "apn or firebase or huawei or xiaomi" placeholders) are skipped too, unless they can run against a Firebase provider the test created. Name providers with `uniqueId`, and never touch or use the app's own `firebase:firebase` provider (no devices registered on it, no `checkPush` against it). `updateApp({ firebase_config })` changes app-level push config: save it with `getApp()` and restore it (DRIFT). Devices go away with their `uniqueId` user. Probed 2026-10-14 (push-providers): `sampleFirebaseCredentials` is rejected (code 4 "firebase credentials are invalid") by `upsertPushProvider` and `updateApp({ firebase_config })`. `push_provider.name` is limited to 36 characters (code 4 "push_provider.name must be a maximum of 36 characters in length"): keep `uniqueId` names short (`uniqueId('fcm')`). -- **platform/03-events-and-notifications/05-push-preferences**: Don't check which pushes are sent: assert the response, or read the preference back (user / channel member push preferences, channel `config_overrides`, channel type `push_level`). The snippets on `messaging` and `"announcements"` run on a `uniqueId` channel type / channel (COPY-mapped), or the built-in type is restored. `user-1` / `user-2` are `uniqueId` users. -- **platform/03-events-and-notifications/01-webhooks**: Don't check that webhooks arrive. Every `updateApp({ event_hooks })` replaces all hooks: `beforeAll` saves `getApp().app.event_hooks` and cleanup puts them back (same ids); the "preserve existing hooks" tests (webhook, SQS, SNS) restore the originals first so they can assert the original ids are kept. `waitForAppSetting(client, 'event_hooks', original)` never settles after a restore (`updated_at` changes), so compare the hooks without `created_at` / `updated_at` (the DRIFT check ignores them). `event_types: []` reads back as the full list of event types plus `"*"`. SQS / SNS hooks with placeholder keys are accepted; `checkSQS` / `checkSNS` with them return `status: "error"` ("InvalidClientTokenId"), not a rejection. `verifyAndParseWebhook`, `parseSqs` and `parseSns` are tested locally with an HMAC of a sample body (`STREAM_SECRET`) and a gzip + base64 body. -- **platform/04-using-the-api/06-connection-pooling**: The fence imports `undici` (devDependency `undici@^7.30.0`, the major Node.js 22 and 24 bundle). The real call is checked with a pass-through `vi.spyOn(Agent.prototype, 'dispatch')`. +Open issues found while testing the docs snippets, in three lists: [API behavior issues](#api-behavior-issues) (the API itself may be faulty), [API spec / node SDK issues](#api-spec--node-sdk-issues) (the OpenAPI spec or the generated node SDK is wrong) and [Open docs bugs](#open-docs-bugs) (the docs are wrong and not fixed yet). Remove an entry once it is resolved. ## API behavior issues @@ -261,24 +40,3 @@ The OpenAPI spec or the generated node SDK is wrong; the API itself works. Not f - **platform/04-using-the-api/06-connection-pooling** (node SDK): `StreamClientOptions.agent` is passed to the built-in `fetch` as `dispatcher`, which only works with an `undici` Agent of the same major as the one Node bundles. `npm install undici` gives 8.x (latest 8.12.1), and its Agent fails every request on Node 22 (bundled undici 6.21.2) and Node 24 (bundled 7.21.0) with `TypeError: fetch failed`, cause `InvalidArgumentError: invalid onRequestStart method` (`UND_ERR_INVALID_ARG`); an undici 7.21.0 Agent works on both (probed 2026-10-14; Node 26 not checked, no binary available). `agent` is typed `unknown`, so nothing catches this. The SDK could use undici's own `fetch` when an agent is given, or document the version constraint. - **platform/04-using-the-api/06-connection-pooling** (node SDK): `ApiClient.sendRequest` wraps network errors as `new StreamError("The request failed due to an unexpected error", metadata, error as number)`: the original `Error` ends up in `code` (typed `number`) and is not passed as `cause`, so the real reason ("fetch failed", "invalid onRequestStart method") is hidden. - -## Open docs bugs - -The docs are wrong and the fix wasn't made (out of scope, e.g. prose or non-Node.js fences, or it waits for a decision above). Not blockers. Remove an entry once it is fixed. - -- **05-features/10-advanced/17-audit_logs** (`JSON` response fences, not Node.js): both fences are invalid JSON (`"message_updated_at" "..."` lacks a colon; the second lacks a comma after `"is_deleted": false`). The attribution in them depends on the API behavior issue above. -- **05-features/10-advanced/12-drafts** (prose): a stray `"` at the end of "Draft creation is a client-side operation; there is no server-side alternative." -- **platform/02-identity-and-access/01-authentication** (Python, Ruby, PHP, Go, C#, Java fences of the user-level "Undoing the revoke"): they `unset` `revoke_tokens_issued_before`, which the API ignores (see API behavior issues); the Node.js fence was changed to `set: { revoke_tokens_issued_before: null }`. Not probed in those SDKs. The Java "Token Revocation by Application" comment says the date "must be at least 60 seconds in the past"; the user-level revocation accepted `new Date()` (not probed at app level). -- **08-webhooks/01-webhooks_overview/03-custom_commands_webhook** (prose): "Edit a Command" and "Performance considerations" depend on the API behavior issue above. - -- **platform/02-identity-and-access/02-users** (Python and Ruby "Partial updates" fences): the first update sets `field` and unsets `field.unset`, which the API rejects with code 4 "set and unset for same path: [field, field.unset]". The Node.js fence now unsets `field2.unset`. PHP and C# don't set `field`, so they are fine; Go has no unset. -- **platform/02-identity-and-access/02-users** (prose): "Unique usernames" says `enforce_unique_username` (the setting is `enforce_unique_usernames`). "Deleting users" says "deletion is not reversible", while "Restoring deleted users" restores soft-deleted users. The "Deactivating users" comment only calls bulk deactivation asynchronous, but `reactivateUsers` returns a `task_id` and finishes later too. -- **platform/02-identity-and-access/03-permissions** (non-Node.js fences, not probed): the Go "updateFollow" fence has a stray `)` (`}),` closing `Custom`), so it doesn't compile. The Go `.app` grants fence leaves `mute-user` out of `user` and `admin`, unlike every other language. The C# fences use `"user-id"` (Role Management) and `"special_agent"` (delete role) where the others use `"james_bond"` and `"agent_006"`. -- **platform/04-using-the-api/07-api-error-codes** (error table, prose): the "No Access to Channels" row (code 70) gives HTTP status 403 as "Unauthorised"; 403 is "Forbidden" (as in the other 403 rows). The table rows aren't probed. -- **platform/02-identity-and-access/04-multi-tenancy** (prose): "Empty Team" says the API returns a row for `team=""`; on the test app the response has no rows at all (see API behavior issues). Waits for that decision. -- **platform/03-events-and-notifications/01-webhooks** (`Node.js` reference implementations in the no-SDK disclosures, out of scope, not fixed): the SQS and SNS reference fences call `parseEvent`, which no fence defines (ReferenceError when run as plain Node.js; the HTTP reference inlines `JSON.parse`), and the SNS fence also needs `decodeSqsPayload` from the SQS fence. The SQS fence has an em-dash in a comment ("not base64+gzip — fall back"). -- **platform/03-events-and-notifications/06-push-troubleshooting** (prose, "Push Test Parameters" / "Push Test Payload" tables, not probed): the parameter table omits `push_provider_type`, `push_provider_name` and `event_type`, which `CheckPushRequest` has (the provider fields are how to test one provider on a multi-provider app). The payload table types `rendered_message` as a string; the node SDK types it `Record`. The "Pre-requirements" also say "pass `skip_devices=true`" while the Bash fence doesn't. - -## Notes - -- Proposed shared helper (from platform/02-identity-and-access/03-permissions): add feeds (by `docs-test` id) to the leak check / sweep (`helpers/leaks.ts`) and feed visibility grants to the DRIFT snapshot (`helpers/snapshot.ts`), so Feeds snippets get LEAK and DRIFT coverage without hand-written checks. Not done yet. From bd4feee8df56d464ef6f4fe65078c0ef2b92a50f Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 14:21:53 -0500 Subject: [PATCH 8/9] chore: sweep for missing Node.js code snippets in docs; extend skill --- .claude/skills/docs-sync/SKILL.md | 66 +++++++++--- __tests__/docs-snippets/docs-snippets-todo.md | 2 + .../03-channels/02-query_channels.test.ts | 101 ++++++++++++++++++ .../12-disabling.test.ts | 33 ++++++ .../chat/05-features/09-translation.test.ts | 64 ++++++++++- .../01-webhooks.test.ts | 27 +++++ .../05-push-preferences.test.ts | 30 ++++++ 7 files changed, 308 insertions(+), 15 deletions(-) diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md index 9343d68..41d3c4b 100644 --- a/.claude/skills/docs-sync/SKILL.md +++ b/.claude/skills/docs-sync/SKILL.md @@ -1,6 +1,6 @@ --- name: docs-sync -description: Sync the stream-node docs snippet tests (__tests__/docs-snippets) with a getstream.io docs checkout. Asks for the docs repo, runs `yarn test:docs:sync` against it, brings the tests up to date with docs changes (docs win), adds tests for new Node.js snippets, and writes fixes for broken snippets back to the docs. Use when the user asks to sync, check or update the docs snippets or docs snippet tests. +description: Sync the stream-node docs snippet tests (__tests__/docs-snippets) with a getstream.io docs checkout. Asks for the docs repo, first sweeps the docs for missing or mislabelled Node.js snippets (server-side code labelled JavaScript, JavaScript-only snippets the node SDK can run), then runs `yarn test:docs:sync` against it, brings the tests up to date with docs changes (docs win), adds tests for new Node.js snippets, and writes fixes for broken snippets back to the docs. Use when the user asks to sync, check or update the docs snippets or docs snippet tests. argument-hint: "[path to the getstream.io checkout or its content/docs folder]" --- @@ -43,7 +43,43 @@ Before editing tests, read: Live tests need `STREAM_API_KEY` and `STREAM_SECRET` of the docs test app in the repo root `.env` (see `.env-example`); `DOCS_PUSH_FIREBASE_CREDENTIALS` is optional (push tests are skipped without it). If the key or secret is missing, ask the user for them. Without them you can still sync, typecheck and lint, but you can't tell whether a changed snippet works: say so in the report, and don't change any docs snippet in that case. -## 3. Run the sync check +## 3. Sweep for missing Node.js snippets + +Before the sync check, make sure every server-side snippet on the tested pages has a `Node.js` fence. The sync check only compares `Node.js` fences with their regions; it can't tell that a `JavaScript` fence should have been one. + +### Scope + +- Pages: every page of the `[chat][node].json` and `[platform][default].json` sidebars (`$DOCS_DIR/_sidebars/`) under `chat/` or `platform/`, the same list as `findSidebarPages` in `sync/parse.ts`. +- Fences: every JS fence (`js`, `javascript`, `ts`, `typescript`) labelled `JavaScript` or unlabelled, together with its tab group: the other fences in the same `` block, or the fence alone when it isn't in one. +- Out of scope: `React` and `React Native` fences, `Node.js` fences inside a `` block, and comment-only fences. + +List them with a throwaway script in the scratchpad (page, heading, line, the labels in the group, the code). Then classify each group by its tabs first and the prose second: + +| Group | Action | +| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| A `JavaScript` fence with server-only code (secret, `updateAppSettings`, `createToken`, ...) | Relabel it `Node.js` and rewrite it to the node SDK call (`client.updateApp`, ...). | +| Only client SDKs (`JavaScript` plus Kotlin, Swift, Dart, Unity, Unreal, React, ...) | Client-side: no `Node.js` tab, even when a server equivalent exists. | +| `JavaScript` only (or with non-SDK tabs such as `JSON`) | Add a `Node.js` tab when the node SDK can do the same thing, unless the heading or prose hints "client-side only". See below. | +| Other server SDK tabs (Python, Ruby, PHP, Go, C#, Java) but no `Node.js` | Add a `Node.js` tab. If the others are comment-only notes ("client-side only operation"), add the same note for the Node SDK. | +| A client group, and the page has its own server-side section for it ("Server-Side Usage", "Server Side") | No `Node.js` tab: the server-side section covers it. | + +JavaScript-only groups are common: JavaScript is usually the first SDK documented, and the other SDKs follow later. Being alone is no hint that the code is client-side. Client APIs used in passing aren't either: translate them to the server side (`client.user?.language` → `user.language` of a user you have; the message from `client.chat.getMessage`). Skip the group only when: + +- the node SDK has no way to do it (WebSocket events with `client.on` / `channel.on`, `channel.watch()`, `registerSubscriptions` and other local client state, UI code), or +- the prose says so ("the server-side SDKs do not expose custom request headers at all"). + +To check what the node SDK can do, read `src/StreamClient.ts`, `src/StreamChatClient.ts`, `src/StreamChannel.ts` and `src/gen/`. + +### Writing the Node.js fence + +- Mirror the `JavaScript` fence: same comments, structure and placeholder literals (`"user-1"`, `"messaging:general"`). +- Use the node SDK's shapes and types: `client.chat.*` / `client.feeds.*`, request objects (`filter_conditions`, `preferences: [...]`), `Date` where the types say `Date` (not `.toISOString()`), and `user_id` where the client code acts as the connected user (server-side `queryChannels` with `hidden` / `muted`, drafts, devices need it). +- Put it before the group's ``. A fence that isn't in a `` block: wrap it and the new fence in ``. +- From `$DOCS_DIR`, run `npx prettier --write ` and `node scripts/tab-lint.mjs ` (it puts the tabs in the canonical order: client SDKs first, then server SDKs). + +The new fences then show up in the sync check as `snippets without a test region`, and get a test like any other new fence (see "New fence without a region"). Once the test passes, run `DOCS_SYNC_WRITE` for the page and check that it changes nothing or only whitespace in the new fences. Report every added or relabelled fence under "Docs changes". + +## 4. Run the sync check ```sh DOCS_DIR=$DOCS_DIR yarn test:docs:sync @@ -62,13 +98,13 @@ It runs without network and reports four kinds of failure. Collect them all, gro A renamed heading or a reordered fence shows up twice: as `no such fence` for the old key and as a missing region for the new one. Pair them up by code before treating either as a new or removed snippet. -## 4. Fix each failure +## 5. Fix each failure Work one docs page at a time. ### Mislabelled fence -Label it `Node.js` for `@stream-io/node-sdk` (server-side) code, `JavaScript` for client code. `JavaScript`, `React` and `React Native` fences, and `Node.js` fences inside a `` block (reference implementations without the SDK), are out of scope: never test, add or change them otherwise. +Label it `Node.js` for `@stream-io/node-sdk` (server-side) code, `JavaScript` for client code. Code that works on both sides (e.g. a bare filter object) gets `JavaScript`: an unlabelled `js` fence already renders like `label="JavaScript"` (the JavaScript route's own sample, a titled fallback on the Node route), so the page doesn't change. Whether it also needs a `Node.js` tab is decided by the sweep (step 3). Otherwise, `JavaScript`, `React` and `React Native` fences, and `Node.js` fences inside a `` block (reference implementations without the SDK), are out of scope: never test or change them. ### Region differs from its fence (docs changed) @@ -98,7 +134,7 @@ Write a test for it in the page's test file (create the file if the page is new) - Comment-only fences need no region. - A fence that isn't code (e.g. a bare data shape) is a docs bug: rewrite it as code that runs (e.g. reading and logging the fields), and report it. Never remove a `Node.js` fence just because it is a fragment or can't be tested: wrap it, or put it in `it.skip`. - A snippet the test app can't run goes in `it.skip('BLOCKED: ', ...)` so it is still typechecked; report the exact API error. -- **Client-side code in a `Node.js` fence** (needs a connected user or a WebSocket, or a method the node SDK doesn't have: `connectUser`, `channel.watch()`, `client.on(...)`): don't invent a server equivalent. Put it in `it.skip('BLOCKED: client-side code: ...')` if it still typechecks, otherwise make the smallest docs fix that turns it into the server-side call the node SDK has. Report it either way. To check what a server-side endpoint accepts, read the SDK source (`src/StreamClient.ts`, `src/StreamChatClient.ts`, `src/StreamChannel.ts`, `src/gen/`) or the OpenAPI spec (`chat-serverside-api.json` in https://github.com/GetStream/protocol/tree/main/openapi/v2). +- **Client-side code in a `Node.js` fence** (needs a connected user or a WebSocket, or a method the node SDK doesn't have: `connectUser`, `channel.watch()`, `client.on(...)`): never test different code from what the fence shows. If the node SDK can do the same thing, make the smallest docs fix that turns the fence into that call (`channel.watch()` → `channel.getOrCreate()`, `user_id` instead of `connectUser`). If it can't (events, WebSocket, local client state), the fence is mislabelled: label it `JavaScript`. Use `it.skip('BLOCKED: client-side code: ...')` only while that decision is open. Report it either way. To check what a server-side endpoint accepts, read the SDK source (`src/StreamClient.ts`, `src/StreamChatClient.ts`, `src/StreamChannel.ts`, `src/gen/`) or the OpenAPI spec (`chat-serverside-api.json` in https://github.com/GetStream/protocol/tree/main/openapi/v2). - **Push and webhooks**: don't test that notifications or webhook events arrive; assert the response or read the setting back (`getApp()`, `listPushProviders()`, ...). Never touch or use the test app's own push provider (see "Test app" in `AGENTS.md`): only use providers the test creates with `uniqueId` names. Snippets that need real credentials run in `describe.skipIf(!hasFirebaseCredentials)` (`helpers/push.ts`); the README's "Push credentials" section has the details. - **App-level side effects** (app settings, built-in channel types, grants, webhook URLs, push config, ...): read the original value first and restore it with `cleanup.add(...)`, or the file fails with `DRIFT`. @@ -112,14 +148,15 @@ Only when the docs snippet itself doesn't work (wrong method name or signature, 4. Format and lint the page, from `$DOCS_DIR`: ```sh npx prettier --write - npx markdownlint-cli2 --config .markdownlint-cli2.cjs + node scripts/tab-lint.mjs --check ``` + (The docs repo has no markdownlint config.) In zsh, pass several pages as an array (`FILES=(a.md b.md); npx prettier --write $FILES`): a space-separated string stays one argument. Never run `npm run ci` or `npm run lint` for the whole docs repo: they rewrite thousands of files, including images. 5. Record the fix for the report: before/after, reason, and evidence (error message or type error). Docs bugs you leave unfixed (prose, non-`Node.js` fences, or waiting for a decision) go to "Open docs bugs" in the TODO. -## 5. Gates +## 6. Gates All must pass before you finish: @@ -140,16 +177,17 @@ Run `yarn test:docs` only on the test files you changed, never two runs at once, - Don't commit, push, stash or create branches in either repo. - Never write the API key, secret or push credentials to any file except the repo root `.env`. - Don't edit `src/`, `__tests__/docs-snippets/AGENTS.md`, the helpers or the configs. Propose changes in the report instead. -- Change docs snippets as little as possible, and only when they don't work. +- Change docs snippets as little as possible: only when they don't work, or to add or relabel `Node.js` fences in the sweep (step 3). ## Report End with: 1. **Docs folder** used, and both repos' branches. -2. **Tests updated from the docs**: per page, the regions changed and why the setup/assertions changed. -3. **New and removed tests**: snippets added, regions removed with their fences, renamed markers. -4. **Docs changes**: every fence changed in `$DOCS_DIR` (fixes, labels, client-side code), each with before/after, reason and evidence. These need human review. -5. **Skipped snippets**: new `it.skip` / credential-gated snippets and why. -6. **TODO changes**: entries added to or removed from the three issue lists. -7. **Proposed additions to `__tests__/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. +2. **Sweep**: `Node.js` fences added or relabelled, per page, and the groups you left without one and why (client SDKs only, a server-side section on the page, no node SDK equivalent, prose says client-side). +3. **Tests updated from the docs**: per page, the regions changed and why the setup/assertions changed. +4. **New and removed tests**: snippets added, regions removed with their fences, renamed markers. +5. **Docs changes**: every fence changed in `$DOCS_DIR` (fixes, labels, client-side code), each with before/after, reason and evidence. These need human review. +6. **Skipped snippets**: new `it.skip` / credential-gated snippets and why. +7. **TODO changes**: entries added to or removed from the three issue lists. +8. **Proposed additions to `__tests__/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. diff --git a/__tests__/docs-snippets/docs-snippets-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md index 02794fe..75cb4ba 100644 --- a/__tests__/docs-snippets/docs-snippets-todo.md +++ b/__tests__/docs-snippets/docs-snippets-todo.md @@ -23,6 +23,7 @@ The API itself may be faulty, or behaves differently from what the docs say. Not - **platform/03-events-and-notifications/03-push-providers**: `getApp()` returns every push provider's full credentials in `app.push_notifications.providers[].firebase_credentials` (the app's own `firebase` provider: the whole service account JSON, private key included; probed 2026-10-14), while the legacy app-level config never returns credentials. Intended for a server-side response, or should secrets be masked? Tests must not log `getApp().app.push_notifications`. - **platform/03-events-and-notifications/04-push-devices**: server-side `deleteDevice({ id, user_id })` with a `user_id` that doesn't exist fails with code 16 'user ... does not have device with id ...' but creates that user anyway: the leak check found the user afterwards (probed 2026-10-14). A failed delete shouldn't upsert the user. - **platform/03-events-and-notifications/05-push-preferences**: (1) server-side `queryChannels({ filter_conditions: { cid }, user_id })` never returns `push_preferences` (`ChannelStateResponse.push_preferences`), even right after `updatePushNotificationPreferences` set channel-member `chat_preferences` for that member, and still not after 7s of re-queries (the response has `channel, messages, pinned_messages, read, members, membership, threads`; `membership` is also missing, probed 2026-10-14). So channel-member preferences can only be read back from the upsert response server-side. (2) The upsert response's `user_preferences` is documented as "always returned regardless if you edited it", but a call with only a channel-member preference returns `user_preferences: { "": null }` (probed 2026-10-14), although the user has user-level `chat_preferences` from the call before. The test asserts both as observed. +- **03-channels/02-query_channels** ("Listing a User's Channels", "Filtering by Member Data"): the page says adding `$or: [{ hidden: true }, { hidden: false }]` returns hidden and visible channels together, but server-side `queryChannels({ filter_conditions: { members: { $in: [userId] }, $or: [{ hidden: true }, { hidden: false }] }, user_id })` still leaves the hidden channel out (also with `type: "messaging"`, and with `$and: [{ members }, { $or: [...] }]`). It only works with a top-level `cid` filter (`cid: { $in: cids }`, Node.js #1). Inside the nested `$and` of Node.js #2 it has no effect either: a hidden channel in `cids` is left out. `hidden: true` alone returns only the hidden channels; `hidden: { $in: [true, false] }` is rejected (code 4 `operator "$in" is not allowed for field "hidden"`). Probed 2026-10-15; the Node.js tests assert the actual results. Is this an API bug, or should the docs say to query `hidden: true` separately? ## API spec / node SDK issues @@ -37,6 +38,7 @@ The OpenAPI spec or the generated node SDK is wrong; the API itself works. Not f - **platform/03-events-and-notifications/01-webhooks** (node SDK, to check): `client.parseSqs` / `parseSns` always base64-decode and reject a plain JSON body with "invalid base64 encoding", while the docs say uncompressed SQS / SNS bodies are plain JSON ("When compression is on, the message body is gzipped + base64-encoded", and the reference implementation falls back to the raw UTF-8 body) and that "the same call works whether or not compression is on". Check what Stream sends with compression off: either the SDK needs the fallback or the docs are wrong. - **platform/03-events-and-notifications/04-push-devices** (API spec): `deleteDevice` and `listDevices` type `user_id` as optional, but server-side calls without it fail with code 4 "either user or user_id must be provided when using server side auth." (probed 2026-10-14), like the drafts endpoints. - **platform/03-events-and-notifications/05-push-preferences** (API spec): `UpsertPushPreferencesResponse.user_preferences` is typed `Record`, but the API returns `null` values (`{ "": null }` for a call with only a channel-member preference, see API behavior issues): either the value should be nullable in the spec, or the API should return the user's preferences. +- **platform/03-events-and-notifications/05-push-preferences** (node SDK generator): the `UpsertPushPreferencesResponse` decoder maps only `user_preferences`, not the nested `user_channel_preferences` record, so channel-level `disabled_until` (typed `Date`) arrives as a raw nanosecond number (`1792159200000000000`), while user-level `disabled_until` is a `Date`. Probed 2026-10-15 with a channel-level `updatePushNotificationPreferences({ preferences: [{ user_id, channel_cid, disabled_until }] })`; no test covers it yet. The generator needs to decode `Record>` values. - **platform/04-using-the-api/06-connection-pooling** (node SDK): `StreamClientOptions.agent` is passed to the built-in `fetch` as `dispatcher`, which only works with an `undici` Agent of the same major as the one Node bundles. `npm install undici` gives 8.x (latest 8.12.1), and its Agent fails every request on Node 22 (bundled undici 6.21.2) and Node 24 (bundled 7.21.0) with `TypeError: fetch failed`, cause `InvalidArgumentError: invalid onRequestStart method` (`UND_ERR_INVALID_ARG`); an undici 7.21.0 Agent works on both (probed 2026-10-14; Node 26 not checked, no binary available). `agent` is typed `unknown`, so nothing catches this. The SDK could use undici's own `fetch` when an agent is given, or document the version constraint. - **platform/04-using-the-api/06-connection-pooling** (node SDK): `ApiClient.sendRequest` wraps network errors as `new StreamError("The request failed due to an unexpected error", metadata, error as number)`: the original `Error` ends up in `code` (typed `number`) and is not passed as `cause`, so the real reason ("fetch failed", "invalid onRequestStart method") is hidden. diff --git a/__tests__/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts index cf44d74..bd909eb 100644 --- a/__tests__/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts @@ -266,4 +266,105 @@ describe('chat/_default/03-channels/02-query_channels.md', () => { expect(idsOf(response)).toEqual(byLastMessage()); }); + + describe('hidden and muted channels', () => { + // A user of its own with three channels: one hidden, one muted, one plain. + const listerId = uniqueId('lister'); + const hiddenId = uniqueId('hidden-channel'); + const mutedId = uniqueId('muted-channel'); + const plainId = uniqueId('plain-channel'); + const hiddenCid = `messaging:${hiddenId}`; + const mutedCid = `messaging:${mutedId}`; + const plainCid = `messaging:${plainId}`; + + beforeAll(async () => { + cleanup.users.push(listerId); + cleanup.channels.push(hiddenCid, mutedCid, plainCid); + await client.upsertUsers([{ id: listerId }]); + for (const id of [hiddenId, mutedId, plainId]) { + await client.chat.channel('messaging', id).getOrCreate({ + data: { + created_by_id: otherId, + members: [{ user_id: listerId }, { user_id: otherId }], + }, + }); + } + await client.chat + .channel('messaging', hiddenId) + .hide({ user_id: listerId }); + await client.chat.muteChannel({ + channel_cids: [mutedCid], + user_id: listerId, + }); + }); + + const sorted = (ids: string[]) => [...ids].sort(); + + it("lists a user's hidden and visible channels", async () => { + const userID = listerId; + + // Without the hidden filter, the hidden channel is left out. + const visible = await client.chat.queryChannels({ + filter_conditions: { type: 'messaging', members: { $in: [userID] } }, + user_id: userID, + }); + expect(sorted(idsOf(visible))).toEqual(sorted([mutedId, plainId])); + + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Listing a User's Channels" tab="Node.js" index=1 + const response = await client.chat.queryChannels({ + filter_conditions: { + type: 'messaging', + members: { $in: [userID] }, + $or: [{ hidden: true }, { hidden: false }], + }, + user_id: userID, + }); + // #endregion snippet + + // The docs say this also returns the hidden channel, but the API leaves it out + // without a top-level `cid` filter (see the TODO). + expect(sorted(idsOf(response))).toEqual(sorted([mutedId, plainId])); + }); + + it('queries channels by id within the user membership', async () => { + const userID = listerId; + const cids = [hiddenCid, plainCid]; + + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Filtering by Member Data" tab="Node.js" index=1 + const response = await client.chat.queryChannels({ + filter_conditions: { + cid: { $in: cids }, + members: { $in: [userID] }, + $or: [{ hidden: true }, { hidden: false }], + }, + user_id: userID, + }); + // #endregion snippet + + expect(sorted(idsOf(response))).toEqual(sorted([hiddenId, plainId])); + }); + + it('combines the id list with muted channels', async () => { + const userID = listerId; + // The hidden channel is in the list, to check the hidden filter. + const cids = [hiddenCid]; + + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Filtering by Member Data" tab="Node.js" index=2 + const response = await client.chat.queryChannels({ + filter_conditions: { + members: { $in: [userID] }, + $and: [ + { $or: [{ cid: { $in: cids } }, { muted: true }] }, + { $or: [{ hidden: true }, { hidden: false }] }, + ], + }, + user_id: userID, + }); + // #endregion snippet + + // The docs say the hidden channel in `cids` is returned too, but inside `$and` + // the hidden filter has no effect (see the TODO). + expect(sorted(idsOf(response))).toEqual([mutedId]); + }); + }); }); diff --git a/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts index a140144..a6b80e4 100644 --- a/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts +++ b/__tests__/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { + QueryChannelsResponse, UpdateChannelPartialResponse, UpdateChannelResponse, } from '../../../../../../index'; @@ -73,4 +74,36 @@ describe(DOCS, () => { const { channel: current } = await getServerChannel(client, cid); expect(current?.disabled).toBe(false); }); + + it('filters out disabled channels', async () => { + // A disabled channel: server-side queries return it unless the filter excludes it. + const disabledId = uniqueId('disabled'); + const disabledCid = `messaging:${disabledId}`; + cleanup.channels.push(disabledCid); + const disabled = client.chat.channel('messaging', disabledId); + await disabled.getOrCreate({ data: { created_by_id: ownerId } }); + await disabled.updateChannelPartial({ set: { disabled: true } }); + const unfiltered = await client.chat.queryChannels({ + filter_conditions: { cid: disabledCid }, + }); + expect(unfiltered.channels[0]?.channel?.disabled).toBe(true); + const filtered = await client.chat.queryChannels({ + filter_conditions: { cid: disabledCid, disabled: false }, + }); + expect(filtered.channels).toEqual([]); + + const spy = vi.spyOn(client.chat, 'queryChannels'); + + // #region snippet docs="chat/_default/03-channels/07-channel_management/12-disabling.md" heading="" tab="Node.js" index=1 + await client.chat.queryChannels({ filter_conditions: { disabled: false } }); + // #endregion snippet + + const response = await (spy.mock.results[0] + .value as Promise); + spy.mockRestore(); + expect(response.channels.length).toBeGreaterThan(0); + expect(response.channels.map((c) => c.channel?.disabled)).not.toContain( + true, + ); + }); }); diff --git a/__tests__/docs-snippets/pages/chat/05-features/09-translation.test.ts b/__tests__/docs-snippets/pages/chat/05-features/09-translation.test.ts index 6a74662..4f427ce 100644 --- a/__tests__/docs-snippets/pages/chat/05-features/09-translation.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/09-translation.test.ts @@ -2,7 +2,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { getServerClient } from '../../../helpers/clients'; import { Cleanup } from '../../../helpers/cleanup'; import { uniqueId } from '../../../helpers/ids'; -import { getServerUser, waitForAppSetting } from '../../../helpers/server'; +import { + deletePollsCreatedBy, + getServerUser, + waitForAppSetting, +} from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; const DOCS = 'chat/_default/05-features/09-translation.md'; @@ -112,4 +117,61 @@ describe(DOCS, () => { const user = await getServerUser(client, userId); expect(user.language).toBe('en'); }); + + it('Poll translation', async () => { + // John (en) sends a poll to a Spanish-speaking member in an auto-translated channel. + const readerId = uniqueId('reader'); + cleanup.users.push(readerId); + await client.upsertUsers([{ id: readerId, language: 'es' }]); + const channelId = uniqueId('polls'); + const cid = `messaging:${channelId}`; + cleanup.channels.push(cid); + const channel = client.chat.channel('messaging', channelId); + await channel.getOrCreate({ + data: { + created_by_id: johnId, + members: [{ user_id: johnId }, { user_id: readerId }], + auto_translation_enabled: true, + auto_translation_language: 'es', + }, + }); + cleanup.add(() => deletePollsCreatedBy(client, johnId)); + const { poll: created } = await client.createPoll({ + name: "What's for lunch?", + options: [{ text: 'Pizza' }, { text: 'Fish' }], + user_id: johnId, + }); + const messageID = uniqueId('message'); + await channel.sendMessage({ + message: { id: messageID, poll_id: created.id, user_id: johnId }, + }); + // Translation is asynchronous: wait until the poll name has a Spanish text. + await retry(async () => { + const { message } = await client.chat.getMessage({ id: messageID }); + if (!message.poll?.name_i18n?.es_text) { + throw new Error('poll not translated yet'); + } + }); + const user = await getServerUser(client, readerId); + + // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Poll translation" tab="Node.js" index=1 + const { message } = await client.chat.getMessage({ id: messageID }); + const poll = message.poll; + const language = user.language; + + // Fall back to the original text when a field has no translation for the language + const name = poll?.name_i18n?.[`${language}_text`] ?? poll?.name; + const options = poll?.options.map( + (option) => option.text_i18n?.[`${language}_text`] ?? option.text, + ); + // #endregion snippet + + expect(language).toBe('es'); + expect(name).toBe(poll?.name_i18n?.es_text); + expect(name).not.toBe("What's for lunch?"); + expect(options).toHaveLength(2); + expect(options).toEqual( + poll?.options.map((o) => o.text_i18n?.es_text ?? o.text), + ); + }); }); diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts index b37d9d5..c062bbd 100644 --- a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts @@ -481,6 +481,33 @@ describe(DOCS, () => { // #endregion snippet }); + it.skip('BLOCKED: needs a real GCS service account (Pending message hooks)', async () => { + // updateApp rejects the placeholder credentials with code 4 "failover_config for hook + // has invalid gcs_credentials: dialing: auth: email must be provided" (2026-10-15). + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Pending message hooks" tab="Node.js" index=1 + await client.updateApp({ + event_hooks: [ + { + enabled: true, + hook_type: 'pending_message', + webhook_url: 'https://example.com/pending-messages', + timeout_ms: 10000, + callback: { + mode: 'CALLBACK_MODE_REST', + }, + failover_config: { + type: 'gcs', + gcs_bucket: 'my-failover-bucket', + gcs_path: 'stream/failed-pending-messages', + gcs_credentials: '{"type":"service_account","project_id":"..."}', + }, + }, + ], + }); + // #endregion snippet + }); + it('Disabling Failover', async () => { // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Disabling Failover" tab="Node.js" index=1 await client.updateApp({ diff --git a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts index 63fb4f1..3a118da 100644 --- a/__tests__/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts +++ b/__tests__/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts @@ -273,4 +273,34 @@ describe(DOCS, () => { }, ); }); + + it('Setting Call Push Preferences', async () => { + const upsert = vi.spyOn(client, 'updatePushNotificationPreferences'); + + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Setting Call Push Preferences" tab="Node.js" index=1 + // COPY: userId1="user-1" + // Set call-level preferences with temporary disabling + const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000); + + await client.updatePushNotificationPreferences({ + preferences: [ + { + user_id: userId1, + call_level: 'all', + disabled_until: oneHourFromNow, + }, + ], + }); + // #endregion snippet + + const [response] = await upsertResults(upsert); + const preferences = response.user_preferences[userId1]; + expect(preferences?.call_level).toBe('all'); + expect( + Math.abs( + (preferences?.disabled_until?.getTime() ?? 0) - + oneHourFromNow.getTime(), + ), + ).toBeLessThan(1000); + }); }); From b7af1e53a4f416937eca69f687e20cc108f47396 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Fri, 16 Oct 2026 09:40:04 -0500 Subject: [PATCH 9/9] chore: cleanup todo --- .claude/skills/docs-sync/SKILL.md | 9 +-- __tests__/docs-snippets/AGENTS.md | 4 +- __tests__/docs-snippets/README.md | 2 +- __tests__/docs-snippets/docs-snippets-todo.md | 46 +++------------- .../04-multi-tenancy.test.ts | 4 +- __tests__/integration/network-error.test.ts | 55 +++++++++++++++++++ src/ApiClient.ts | 5 +- 7 files changed, 76 insertions(+), 49 deletions(-) create mode 100644 __tests__/integration/network-error.test.ts diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md index 41d3c4b..a167613 100644 --- a/.claude/skills/docs-sync/SKILL.md +++ b/.claude/skills/docs-sync/SKILL.md @@ -113,8 +113,8 @@ Label it `Node.js` for `@stream-io/node-sdk` (server-side) code, `JavaScript` fo 3. Run the page's test: `yarn test:docs `, then `yarn types:docs`. 4. If it passes, done. If it fails, decide why: - **Test setup** (missing user, permission, channel config, eventual consistency): fix the setup, not the region. - - **API spec / node SDK issue**: the snippet is correct and the API accepts it, but the OpenAPI spec or the generated SDK is wrong (missing or wrongly typed fields, wrong response types). Don't change the snippet or `src/` (`src/gen` is generated, hand edits are lost): add it to "API spec / node SDK issues" in the TODO and report it. `it.skip` doesn't help with type errors: skipped bodies are still typechecked. - - **API behavior issue**: the API behaves differently from what the docs say. Assert what it actually does, and add it to "API behavior issues" in the TODO with the evidence. + - **API spec / generator or node SDK issue**: the snippet is correct and the API accepts it, but the OpenAPI spec, the generated SDK code or the hand-written node SDK code is wrong (missing or wrongly typed fields, wrong response types). Don't change the snippet or `src/` (`src/gen` is generated, hand edits are lost): add it to "API spec / generator issues" or "Node SDK issues" in the TODO and report it. `it.skip` doesn't help with type errors: skipped bodies are still typechecked. + - **API behavior issue**: the API behaves differently from what the docs say. Assert what it actually does, and report it with the evidence (see "Report"). Don't add it to the TODO. - **The docs snippet is wrong**: fix it as described in "Broken docs snippet" below. - **Not sure** whether the snippet or the setup is wrong: don't touch the docs. Leave the test failing or put the snippet in `it.skip('BLOCKED: ', ...)` and report it as an open question. @@ -189,5 +189,6 @@ End with: 4. **New and removed tests**: snippets added, regions removed with their fences, renamed markers. 5. **Docs changes**: every fence changed in `$DOCS_DIR` (fixes, labels, client-side code), each with before/after, reason and evidence. These need human review. 6. **Skipped snippets**: new `it.skip` / credential-gated snippets and why. -7. **TODO changes**: entries added to or removed from the three issue lists. -8. **Proposed additions to `__tests__/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. +7. **TODO changes**: entries added to or removed from the TODO lists. +8. **API behavior issues**: where the API behaves differently from what the docs say, each with the request, the observed result, what the docs claim, and how the test handles it. +9. **Proposed additions to `__tests__/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. diff --git a/__tests__/docs-snippets/AGENTS.md b/__tests__/docs-snippets/AGENTS.md index 83235df..2b977c0 100644 --- a/__tests__/docs-snippets/AGENTS.md +++ b/__tests__/docs-snippets/AGENTS.md @@ -85,7 +85,7 @@ New entries are added only after the user approves them (agents propose them in - A fence that builds a request object in a separate `const` and passes it later fails TS2322 when a field is a string-literal union (`callback.mode: "CALLBACK_MODE_REST"` widens to `string`). The plain-JS fix is to move the object inline into the call. - `updateChannelType` returns `commands` as names (`string[]`), while `getChannelType` / `listChannelTypes` return `Command` objects: compare by `.name`. `commands: ["all"]` expands to every built-in command (giphy, ban, unban, mute, unmute). - A `vi.spyOn(client, 'updateApp').mockImplementation(...)` must take `request?: UpdateAppRequest` (optional), or it fails TS2345. -- A correct fence that the SDK types reject can't be parked with `it.skip`: skipped bodies are still typechecked, so `yarn types:docs` fails for everyone. Stop and report it under API spec / node SDK issues. The generator drops `nullable` everywhere (no `| null` in `src/gen/models`), e.g. `grants: null` on `updateChannelType` fails TS2322. +- A correct fence that the SDK types reject can't be parked with `it.skip`: skipped bodies are still typechecked, so `yarn types:docs` fails for everyone. Stop and add it to the TODO's API spec / generator issues. The generator drops `nullable` everywhere (no `| null` in `src/gen/models`), e.g. `grants: null` on `updateChannelType` fails TS2322. - `verifyAndParseWebhook` fences: build `req = { rawBody, headers: { 'x-signature': sig } }` before the region, where `sig` is the HMAC-SHA256 hex of the uncompressed JSON keyed with `STREAM_SECRET` and `rawBody` is the JSON or `zlib.gzipSync(json)` (the signature always covers the decompressed bytes). - Optional response fields (`GetTaskResponse.result`, `MessageResponse.i18n`) aren't narrowed by a status check: a fence reading `response.result.url` fails TS18048, and the docs fix is `?.`. - `getApp().app.enforce_unique_usernames` is typed `string` but `updateApp` expects `'no' | 'app' | 'team'`: cast the saved value when restoring it. `"team"` is accepted with multi-tenancy off. @@ -218,7 +218,7 @@ New entries are added only after the user approves them (agents propose them in - A Node.js fence that calls `checkPush` without a provider would hit the app's own provider: spy on `client.checkPush` with a `mockImplementation` that calls the bound original with `push_provider_type` / `push_provider_name` of a `uniqueId` provider the test created, so the region stays as written (needs real credentials, so `describe.skipIf(!hasFirebaseCredentials)`). - To test 429 handling without flooding the shared app, `vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ code: 9, message: 'Too many requests, check response headers for more information.', StatusCode: 429 }), { status: 429, headers: { 'x-ratelimit-limit': ..., 'x-ratelimit-remaining': ..., 'x-ratelimit-reset': ... } }))`: the SDK's `ApiClient` turns it into a real `StreamError` (`code`, `metadata.responseCode`, `metadata.rateLimit.rateLimitReset` as a `Date`). - To check what the SDK passes to `fetch` (e.g. `dispatcher` from the `agent` option) without the network, `vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({...})))` and read `fetchSpy.mock.calls[0][1]?.dispatcher` (typed, no cast). An undici `Agent` only works as `agent` when its undici major matches `process.versions.undici` (undici 8 fails on Node 22 / 24 with "fetch failed", cause "invalid onRequestStart method"). To prove a real request went through an Agent the region creates inline, use a pass-through `vi.spyOn(Agent.prototype, 'dispatch')`. -- SDK network failures surface as `StreamError` "The request failed due to an unexpected error" with the original error in `.code` (not `.cause`): look in `code.cause` for the reason. +- SDK network failures surface as `StreamError` "The request failed due to an unexpected error" with the original error as `.cause` (`code` is undefined): look in `cause.cause` for the reason (e.g. "invalid onRequestStart method" under "fetch failed"). - A server-side call that takes a `user_id` can create that user even when the call fails (seen with `deleteDevice` on an unknown user): register probe user ids on `cleanup.users` anyway. - Firebase calls validate the credentials on every request: `upsertPushProvider` and `updateApp({ firebase_config })` fail with code 4 "firebase credentials are invalid" without a real service account, even when only a template changes, so the whole test goes in `describe.skipIf(!hasFirebaseCredentials)`. On this push v3 app `getApp().app.push_notifications` legacy Firebase config is just `{ enabled: false }` and credentials are never returned: cleanup can only restore templates and the enabled flag. - Export tasks (`exportChannels`, `exportUsers`, `feeds.exportFeedUserData`; also `feeds.deleteFeedUserData`) finish within seconds: `waitForTask` is enough, and `result.url` downloads with plain `fetch` (no auth), so assert the export's contents (v2: one JSON object per line). diff --git a/__tests__/docs-snippets/README.md b/__tests__/docs-snippets/README.md index 6060fe1..6ce1c11 100644 --- a/__tests__/docs-snippets/README.md +++ b/__tests__/docs-snippets/README.md @@ -46,7 +46,7 @@ setup.ts # per-file leak check and app-settin global-setup.ts # end-of-run sweep: removes leftovers of files that failed while loading sync/ # `yarn test:docs:sync` (docs fence <-> test region check) maintenance/ # `yarn test:docs:sweep` -docs-snippets-todo.md # open API, API spec / node SDK and docs issues +docs-snippets-todo.md # open API spec / generator and node SDK issues AGENTS.md # test conventions and API quirks, read before working on a page ``` diff --git a/__tests__/docs-snippets/docs-snippets-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md index 75cb4ba..e20a45c 100644 --- a/__tests__/docs-snippets/docs-snippets-todo.md +++ b/__tests__/docs-snippets/docs-snippets-todo.md @@ -1,44 +1,14 @@ # Docs snippet tests: todo -Open issues found while testing the docs snippets, in three lists: [API behavior issues](#api-behavior-issues) (the API itself may be faulty), [API spec / node SDK issues](#api-spec--node-sdk-issues) (the OpenAPI spec or the generated node SDK is wrong) and [Open docs bugs](#open-docs-bugs) (the docs are wrong and not fixed yet). Remove an entry once it is resolved. +Open issues found while testing the docs snippets, in two lists: [API spec / generator issues](#api-spec--generator-issues) (the OpenAPI spec is wrong, or the generator turns it into wrong node SDK code) and [Node SDK issues](#node-sdk-issues) (the hand-written node SDK code is wrong). The API itself works. Not fixed by the agents (no `src/` changes). Remove an entry once it is fixed. -## API behavior issues +## API spec / generator issues -The API itself may be faulty, or behaves differently from what the docs say. Not blockers: the tests assert the observed behavior and the pages count as done unless something else is open. Remove an entry once someone has decided whether the API or the docs change (and fix the docs or the test accordingly). +- **06-app_and_channel_settings/03-chat_permission_policies** (node SDK generator): `UpdateChannelTypeRequest.grants` and `UpdateAppRequest.grants` are typed `Record` without `| null`, while the spec marks them `nullable: true` and the docs reset a scope with `grants: null` (TS2322). The API accepts `null` and resets the grants to the defaults. The generator drops `nullable` everywhere (`src/gen/models/index.ts` has no `| null` at all, e.g. `pin_expires?: Date`): a generator change in `../chat` (`tools/openapi`). Not patched (the local `src/gen` patch was reverted on 2026-10-13), so `yarn types:docs` reports TS2322 on the "Granting permissions" Node.js #4 region until the generator emits `| null`. Reported: [Slack thread](https://getstream.slack.com/archives/C07AL9Q0T2T/p1791403182844109). +- **05-features/06-campaign_api** (API spec): `updateCampaign` and `stopCampaign` are typed as returning `CampaignResponse`, but the API returns `{ campaign: CampaignResponse, ... }` (like `StartCampaignResponse`), so `response.campaign` is a TS2339 error and `response.name` etc. are undefined at runtime. The response schemas of `PUT /campaigns/{id}` and `POST /campaigns/{id}/stop`. Reported: [Slack thread](https://getstream.slack.com/archives/C07AL9Q0T2T/p1791403182844109). +- **04-messages/08-unread_reminders** (API spec, to check): `CreateChannelTypeRequest` has no `reminders` field, while `UpdateChannelTypeRequest` has it, so a channel type can't be created with reminders on in one call from typed code. Check whether the spec (`CreateChannelType`) lacks it or the API doesn't accept it. Reported: [Slack thread](https://getstream.slack.com/archives/C07AL9Q0T2T/p1791403182844109). +- **platform/02-identity-and-access/01-authentication** (node SDK generator): `UpdateAppRequest.revoke_tokens_issued_before` is typed `Date` without `| null`, so the docs' `updateApp({ revoke_tokens_issued_before: null })` ("Undoing the revoke", Node.js #2) fails TS2322 and `yarn types:docs` reports it. Same root cause as the `grants: null` entry above (the generator drops `nullable`); check that the spec marks the field nullable. Not run against the API (see Blockers detail). `UpdateUserPartialRequest.set` is `Record`, so the user-level `set: { revoke_tokens_issued_before: null }` typechecks. Reported (as the generator `nullable` issue): [Slack thread](https://getstream.slack.com/archives/C07AL9Q0T2T/p1791403182844109). -- **04-messages/09-message_reminders**: with the default sort (`remind_at`), `queryReminders` pagination skips reminders without a due date: 2 timed + 2 "saved for later" reminders, `limit: 1`, give page 1 and page 2 (timed) and then no `next`. With `sort: [{ field: "created_at", direction: 1 }]` all 4 come back. The "Pagination" test uses timed reminders only. API bug, or a limitation to document? -- **05-features/08-location_sharing**: server-side `getMessage` (with or without `user_id`), `queryChannels` without `user_id` and `getOrCreate({ state: true })` leave out `shared_location`, while `sendMessage`, `queryChannels` with `user_id` and `getUserLiveLocations` return it (probed 2026-10-13; the SDK doesn't drop the field). The docs don't rely on it; the test reads locations from the channel state as a member and from `getUserLiveLocations`. -- **05-features/10-advanced/17-audit_logs**: each history entry holds the text before an update, and its `message_updated_by_id` is the user who wrote that text, not the user of the update that replaced it (probed: v1 alice, v2 alice, v3 admin, v4 alice give entries v1/alice, v2/alice, v3/admin). The docs (JSON response and prose, "the user who made the change") say it's the editor. Should the API record the editor, or the docs change? (The invalid JSON is listed under Open docs bugs.) -- **06-app_and_channel_settings/02-app_settings**: with `disable_auth_checks: false`, a dev token is rejected with 401, but the message differs per API node: "development tokens are not allowed for this application" or "Token signature is invalid", the latter for over a minute on some nodes after re-enabling (2026-10-13). The test accepts both. -- **06-app_and_channel_settings/06-channel-level_settings**: the channel-level blocklist override is set and reads back (`config.blocklist`, `blocklist_behavior: "block"`), but a message with a blocklisted word is not blocked, neither sent server-side nor as the user (60s of retries), and a nonexistent blocklist name is accepted without error (2026-10-12). Possibly because the app has `moderation_enabled: true` and blocklists are enforced through moderation configs; the test only asserts the setting. -- **08-webhooks/01-webhooks_overview/03-custom_commands_webhook**: (1) "Edit a Command" says fields not sent stay unchanged, but `updateCommand({ name, description })` resets `args` and `set` to `""` (the SDK leaves undefined fields out of the body, so it is the API). (2) "Performance considerations" says a failing or slow endpoint lets the message through as usual, but when the custom command handler returns an error `sendMessage` is rejected with code 45 "custom command endpoint returned an error" (code 44 "missing custom command endpoint url" without a handler URL). The paragraph looks copied from the Before Message Send page. Fix the prose or the API? -- **12-best_practices/02-moderation**: `client.moderation.flag` by the same user on the same entity again raises `flags_count`, but the review item's `flags` keep only the first flag, so the later `reason` and `custom` data are silently dropped (probed 2026-10-12). The docs flagged one message several times with different reasons; they now flag separate messages. Intended (one flag per user) or should the later flag update or error? -- **platform/02-identity-and-access/01-authentication**: `updateUsersPartial({ users: [{ id, unset: ["revoke_tokens_issued_before"] }] })` is silently ignored: the response and `queryUsers` still return the date and old tokens stay revoked (also with `set: {}` alongside, probed 2026-10-14). `set: { revoke_tokens_issued_before: null }` clears it (field gone, old tokens work again). The Node.js fence now uses `set: null` (as the prose says); should `unset` work too? -- **platform/02-identity-and-access/02-users**: with `enforce_unique_usernames` set to `"team"` (or `"app"`), `reactivateUser({ user_id, restore_messages: true })` fails with code 4 "user ... is not deactivated" although the user is deactivated (`deactivateUser` response and `queryUsers({ include_deactivated_users: true })` show `deactivated_at`). Without `restore_messages` it works, and with `enforce_unique_usernames: ""` every variant works (probed 2026-10-14, one factor at a time: messages, `mark_messages_deleted`, `created_by_id`, `name` make no difference). The test restores the setting before the deactivation tests. -- **platform/02-identity-and-access/02-users**: with `enforce_unique_usernames: "team"` and multi-tenancy off, users without a team can share a name (a duplicate `upsertUsers` is accepted; with `"app"` it fails with code 6 "username(s) '...' already exist(s)"). Plausible (no team to compare), but the docs don't say what `"team"` does for users without teams. Once (out of ~6 runs) the `"app"` check still accepted a duplicate for 60s after `getApp()` agreed on `"app"` 5 times. -- **platform/02-identity-and-access/04-multi-tenancy**: `queryTeamUsageStats` returns `teams: []` for every query on the test app: no params, `limit: 1`, `month` (`2024-01`, `2026-09`), a date range (`2024-01`, `2026-10-01..13`) and `team: ""`, with multi-tenancy off and on (probed 2026-10-14). The page says "The API returns a row for `team=""` (empty string)" for users and messages without a team, and the app has many of both. Maybe the stats are only aggregated for apps that had multi-tenancy on, or daily with a delay; the docs don't say. The test asserts only the response shape, so the pagination branch is not exercised. -- **All pages that update channel types** (not page-specific, the tests work around it): after `updateChannelType`, the update is enforced on every API node within ~3s, but `getChannelType` / `listChannelTypes` reads flip between the new and the old value for over a minute (probed 2026-10-13: correct for ~15s, then the old value for ~40s, then mixed past 90s). Tests read types back with a retry or assert the update response. -- **platform/03-events-and-notifications/01-webhooks**: `getApp()` never returns `enable_hook_payload_compression` (absent before and after `updateApp({ enable_hook_payload_compression: true })` and `false`, 2026-10-14; `AppResponseFields` has no such field either), so the setting can't be read back, the DRIFT check can't see it, and the original value has to be known from elsewhere: `false` on the test app (confirmed by the user, 2026-10-14), which the test restores. -- **platform/03-events-and-notifications/02-push-notifications**: (1) `feeds.addComment({ ..., create_notification_activity: true })` returns `notification_created: false` with no error when the activity owner has no `notification` feed yet; with the feed created first it returns `true` (probed 2026-10-14). The page says the comment "creates notification activity and sends via notification feed" without mentioning that the feed must exist. Intended (should the API create it, or the docs say so)? (2) A new feed group is eventually consistent: right after `createFeedGroup`, `getOrCreate` of a feed in it and then `addActivity` to it failed on some calls with code 16 "Feed group with ID ... not found", even after a `getOrCreate` in that group had succeeded (2026-10-14). The test retries on "not found". -- **platform/03-events-and-notifications/03-push-providers**: `getApp()` returns every push provider's full credentials in `app.push_notifications.providers[].firebase_credentials` (the app's own `firebase` provider: the whole service account JSON, private key included; probed 2026-10-14), while the legacy app-level config never returns credentials. Intended for a server-side response, or should secrets be masked? Tests must not log `getApp().app.push_notifications`. -- **platform/03-events-and-notifications/04-push-devices**: server-side `deleteDevice({ id, user_id })` with a `user_id` that doesn't exist fails with code 16 'user ... does not have device with id ...' but creates that user anyway: the leak check found the user afterwards (probed 2026-10-14). A failed delete shouldn't upsert the user. -- **platform/03-events-and-notifications/05-push-preferences**: (1) server-side `queryChannels({ filter_conditions: { cid }, user_id })` never returns `push_preferences` (`ChannelStateResponse.push_preferences`), even right after `updatePushNotificationPreferences` set channel-member `chat_preferences` for that member, and still not after 7s of re-queries (the response has `channel, messages, pinned_messages, read, members, membership, threads`; `membership` is also missing, probed 2026-10-14). So channel-member preferences can only be read back from the upsert response server-side. (2) The upsert response's `user_preferences` is documented as "always returned regardless if you edited it", but a call with only a channel-member preference returns `user_preferences: { "": null }` (probed 2026-10-14), although the user has user-level `chat_preferences` from the call before. The test asserts both as observed. -- **03-channels/02-query_channels** ("Listing a User's Channels", "Filtering by Member Data"): the page says adding `$or: [{ hidden: true }, { hidden: false }]` returns hidden and visible channels together, but server-side `queryChannels({ filter_conditions: { members: { $in: [userId] }, $or: [{ hidden: true }, { hidden: false }] }, user_id })` still leaves the hidden channel out (also with `type: "messaging"`, and with `$and: [{ members }, { $or: [...] }]`). It only works with a top-level `cid` filter (`cid: { $in: cids }`, Node.js #1). Inside the nested `$and` of Node.js #2 it has no effect either: a hidden channel in `cids` is left out. `hidden: true` alone returns only the hidden channels; `hidden: { $in: [true, false] }` is rejected (code 4 `operator "$in" is not allowed for field "hidden"`). Probed 2026-10-15; the Node.js tests assert the actual results. Is this an API bug, or should the docs say to query `hidden: true` separately? +## Node SDK issues -## API spec / node SDK issues - -The OpenAPI spec or the generated node SDK is wrong; the API itself works. Not fixed by the agents (no `src/` changes). Remove an entry once it is fixed. - -- **06-app_and_channel_settings/03-chat_permission_policies** (node SDK generator): `UpdateChannelTypeRequest.grants` and `UpdateAppRequest.grants` are typed `Record` without `| null`, while the spec marks them `nullable: true` and the docs reset a scope with `grants: null` (TS2322). The API accepts `null` and resets the grants to the defaults. The generator drops `nullable` everywhere (`src/gen/models/index.ts` has no `| null` at all, e.g. `pin_expires?: Date`): a generator change in `../chat` (`tools/openapi`). Not patched (the local `src/gen` patch was reverted on 2026-10-13), so `yarn types:docs` reports TS2322 on the "Granting permissions" Node.js #4 region until the generator emits `| null`. -- **05-features/06-campaign_api** (API spec): `updateCampaign` and `stopCampaign` are typed as returning `CampaignResponse`, but the API returns `{ campaign: CampaignResponse, ... }` (like `StartCampaignResponse`), so `response.campaign` is a TS2339 error and `response.name` etc. are undefined at runtime. The response schemas of `PUT /campaigns/{id}` and `POST /campaigns/{id}/stop`. -- **04-messages/08-unread_reminders** (API spec, to check): `CreateChannelTypeRequest` has no `reminders` field, while `UpdateChannelTypeRequest` has it, so a channel type can't be created with reminders on in one call from typed code. Check whether the spec (`CreateChannelType`) lacks it or the API doesn't accept it. -- **05-features/10-advanced/12-drafts** (API spec): `getDraft` / `deleteDraft` / `queryDrafts` type `user_id` as optional, but a server-side client must pass it (code 4 "either user or user_id must be provided when using server side auth"). -- **platform/02-identity-and-access/01-authentication** (node SDK generator): `UpdateAppRequest.revoke_tokens_issued_before` is typed `Date` without `| null`, so the docs' `updateApp({ revoke_tokens_issued_before: null })` ("Undoing the revoke", Node.js #2) fails TS2322 and `yarn types:docs` reports it. Same root cause as the `grants: null` entry above (the generator drops `nullable`); check that the spec marks the field nullable. Not run against the API (see Blockers detail). `UpdateUserPartialRequest.set` is `Record`, so the user-level `set: { revoke_tokens_issued_before: null }` typechecks. -- **12-best_practices/02-moderation** (API spec): `getApp().app.enforce_unique_usernames` is typed `string`, while `UpdateAppRequest.enforce_unique_usernames` is `'no' | 'app' | 'team'`, so restoring a saved value needs a cast. -- **platform/03-events-and-notifications/01-webhooks** (node SDK, to check): `client.parseSqs` / `parseSns` always base64-decode and reject a plain JSON body with "invalid base64 encoding", while the docs say uncompressed SQS / SNS bodies are plain JSON ("When compression is on, the message body is gzipped + base64-encoded", and the reference implementation falls back to the raw UTF-8 body) and that "the same call works whether or not compression is on". Check what Stream sends with compression off: either the SDK needs the fallback or the docs are wrong. -- **platform/03-events-and-notifications/04-push-devices** (API spec): `deleteDevice` and `listDevices` type `user_id` as optional, but server-side calls without it fail with code 4 "either user or user_id must be provided when using server side auth." (probed 2026-10-14), like the drafts endpoints. -- **platform/03-events-and-notifications/05-push-preferences** (API spec): `UpsertPushPreferencesResponse.user_preferences` is typed `Record`, but the API returns `null` values (`{ "": null }` for a call with only a channel-member preference, see API behavior issues): either the value should be nullable in the spec, or the API should return the user's preferences. -- **platform/03-events-and-notifications/05-push-preferences** (node SDK generator): the `UpsertPushPreferencesResponse` decoder maps only `user_preferences`, not the nested `user_channel_preferences` record, so channel-level `disabled_until` (typed `Date`) arrives as a raw nanosecond number (`1792159200000000000`), while user-level `disabled_until` is a `Date`. Probed 2026-10-15 with a channel-level `updatePushNotificationPreferences({ preferences: [{ user_id, channel_cid, disabled_until }] })`; no test covers it yet. The generator needs to decode `Record>` values. - -- **platform/04-using-the-api/06-connection-pooling** (node SDK): `StreamClientOptions.agent` is passed to the built-in `fetch` as `dispatcher`, which only works with an `undici` Agent of the same major as the one Node bundles. `npm install undici` gives 8.x (latest 8.12.1), and its Agent fails every request on Node 22 (bundled undici 6.21.2) and Node 24 (bundled 7.21.0) with `TypeError: fetch failed`, cause `InvalidArgumentError: invalid onRequestStart method` (`UND_ERR_INVALID_ARG`); an undici 7.21.0 Agent works on both (probed 2026-10-14; Node 26 not checked, no binary available). `agent` is typed `unknown`, so nothing catches this. The SDK could use undici's own `fetch` when an agent is given, or document the version constraint. -- **platform/04-using-the-api/06-connection-pooling** (node SDK): `ApiClient.sendRequest` wraps network errors as `new StreamError("The request failed due to an unexpected error", metadata, error as number)`: the original `Error` ends up in `code` (typed `number`) and is not passed as `cause`, so the real reason ("fetch failed", "invalid onRequestStart method") is hidden. +None open. diff --git a/__tests__/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts b/__tests__/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts index 5c5b21d..e5cd275 100644 --- a/__tests__/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts +++ b/__tests__/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts @@ -373,8 +373,8 @@ describe(DOCS, () => { } // Monthly mode: totals only, no daily breakdown. expect(january.teams.every((t) => !t.users_daily.daily)).toBe(true); - // The test app returns no rows at all (not even the docs' team "" row, see the - // TODO's API behavior issues), so the pagination branch only runs once it does. + // The test app returns no rows at all (not even the docs' team "" row: an API + // behavior issue), so the pagination branch only runs once it does. expect(response.teams.length).toBeLessThanOrEqual(pageSize); expect(log).toHaveBeenCalledTimes(response.next ? 1 : 0); }); diff --git a/__tests__/integration/network-error.test.ts b/__tests__/integration/network-error.test.ts new file mode 100644 index 0000000..5cd9b41 --- /dev/null +++ b/__tests__/integration/network-error.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient } from '../../src/ApiClient'; +import { StreamError } from '../../src/types'; + +const createApiClient = () => + new ApiClient({ + apiKey: 'test-api-key', + token: 'test-token', + baseUrl: 'https://example.com', + timeout: 3000, + }); + +const sendRequestError = (fetchError: Error) => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(fetchError); + return createApiClient() + .sendRequest('GET', '/test') + .catch((e: unknown) => e); +}; + +describe('network errors', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes the original error as cause', async () => { + const fetchError = new TypeError('fetch failed', { + cause: new Error('invalid onRequestStart method'), + }); + + const error = await sendRequestError(fetchError); + + expect(error).toBeInstanceOf(StreamError); + const streamError = error as StreamError; + expect(streamError.message).toBe( + 'The request failed due to an unexpected error', + ); + expect(streamError.code).toBeUndefined(); + expect(streamError.cause).toBe(fetchError); + }); + + it('passes the timeout error as cause', async () => { + const timeoutError = new DOMException( + 'The operation was aborted due to timeout', + 'TimeoutError', + ); + + const error = await sendRequestError(timeoutError); + + expect(error).toBeInstanceOf(StreamError); + const streamError = error as StreamError; + expect(streamError.message).toContain('3000ms timeout'); + expect(streamError.code).toBeUndefined(); + expect(streamError.cause).toBe(timeoutError); + }); +}); diff --git a/src/ApiClient.ts b/src/ApiClient.ts index 57af75e..d07050f 100644 --- a/src/ApiClient.ts +++ b/src/ApiClient.ts @@ -108,13 +108,14 @@ export class ApiClient { `The request was aborted due to to the ${this.apiConfig.timeout}ms timeout, you can set the timeout in the StreamClient constructor`, metadata, undefined, - error as ErrorOptions, + { cause: error }, ); } else { throw new StreamError( `The request failed due to an unexpected error`, metadata, - error as number, + undefined, + { cause: error }, ); } }